Split Positions from Tracker; add Settings (commission)

Tracker is for symbol price/IV history, not positions. The "Save to
Tracker" button now adds the symbol to a watchlist (localStorage); the
Tracker page shows the watchlist as clickable chips.

New "Enter Position" button on the Strategy page posts the active legs
to /api/orders, then opens the new Positions page.

New Positions page (positions.html): lists entered positions with live
mid value, round-trip commission, gross & net P/L (Net = Gross − round-
trip commission), per-symbol filter, summary totals, close/reopen and
remove actions.

New Settings page (settings.html) configures the commission used on
Positions. Defaults to Interactive Brokers Fixed / IBKR Lite: $0.65
per contract, $1.00 minimum per order
(https://www.interactivebrokers.com/en/pricing/commissions-options.php).
Per-leg vs per-order toggle for complex orders.

Sidebar nav now: Dashboard · Options Chain · Vol Surface · Strategy P/L
· Positions · Tracker · Settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ojy
2026-05-13 07:04:52 +00:00
parent c1520d7962
commit 9de7f14573
8 changed files with 696 additions and 141 deletions

View File

@@ -0,0 +1,82 @@
/**
* App-wide settings — commission config, etc.
* Exposed as window.SettingsStore. localStorage key "optionsPricer:settings".
*
* Default plan = IBKR Fixed / Lite:
* $0.65 per contract, $1.00 per-order minimum, no per-order maximum.
* Source: https://www.interactivebrokers.com/en/pricing/commissions-options.php
*/
(function () {
"use strict";
const KEY = "optionsPricer:settings";
const VERSION = 1;
const DEFAULTS = {
v: VERSION,
commission: {
plan: "ibkr-fixed", // ibkr-fixed | ibkr-tiered | custom
perContract: 0.65, // $ per option contract
perOrderMin: 1.00, // $ minimum per order
perOrderMax: 0, // $ cap per order; 0 = none
applyPerLeg: false, // true = each leg charged separately; false = whole position counts as one order
},
};
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function load() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return clone(DEFAULTS);
const obj = JSON.parse(raw);
if (!obj || obj.v !== VERSION) return clone(DEFAULTS);
// merge over defaults so missing fields fall back
const out = clone(DEFAULTS);
if (obj.commission && typeof obj.commission === "object") {
Object.assign(out.commission, obj.commission);
}
return out;
} catch { return clone(DEFAULTS); }
}
function save(s) {
const out = clone(DEFAULTS);
if (s && s.commission) Object.assign(out.commission, s.commission);
try { localStorage.setItem(KEY, JSON.stringify(out)); } catch (e) { console.warn("[SettingsStore] save failed:", e); }
return out;
}
function reset() {
try { localStorage.removeItem(KEY); } catch {}
return clone(DEFAULTS);
}
/**
* Estimate commission for a position.
* legs: [{ qty }, ...] (only qty matters)
* Returns { entry, exit, roundTrip } in dollars.
*/
function estimate(legs) {
const c = load().commission;
if (!Array.isArray(legs) || legs.length === 0) return { entry: 0, exit: 0, roundTrip: 0 };
const orderCost = (contracts) => {
let v = contracts * c.perContract;
if (c.perOrderMin > 0) v = Math.max(v, c.perOrderMin);
if (c.perOrderMax > 0) v = Math.min(v, c.perOrderMax);
return v;
};
let entry;
if (c.applyPerLeg) {
entry = legs.reduce((s, l) => s + orderCost(Math.max(1, Math.round(l.qty || 1))), 0);
} else {
const total = legs.reduce((s, l) => s + Math.max(1, Math.round(l.qty || 1)), 0);
entry = orderCost(total);
}
const exit = entry; // assume same fill style on close
return { entry, exit, roundTrip: entry + exit };
}
window.SettingsStore = { KEY, VERSION, DEFAULTS: clone(DEFAULTS), load, save, reset, estimate };
})();

View File

@@ -129,6 +129,18 @@
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/>
</svg>
</span>
<span class="nav-link-title">Positions</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="tracker.html"> <a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block"> <span class="nav-link-icon d-md-none d-lg-inline-block">
@@ -145,6 +157,19 @@
<span class="nav-link-title">Tracker</span> <span class="nav-link-title">Tracker</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nav-link-title">Settings</span>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -213,6 +213,18 @@
</a> </a>
</li> </li>
<!-- Positions -->
<li class="nav-item">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/>
</svg>
</span>
<span class="nav-link-title">Positions</span>
</a>
</li>
<!-- Tracker --> <!-- Tracker -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="tracker.html"> <a class="nav-link" href="tracker.html">
@@ -226,6 +238,19 @@
</a> </a>
</li> </li>
<!-- Settings -->
<li class="nav-item">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nav-link-title">Settings</span>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

298
frontend/positions.html Normal file
View File

@@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Positions — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css" />
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
<style>
.mono { font-family:'JetBrains Mono','Fira Code',monospace; }
[x-cloak] { display:none !important; }
.pos-table th { background:#1a1c2e; color:#8b95a7; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; border-bottom:1px solid #2d3045; }
.pos-table td { border-bottom:1px solid #1e2030; color:#d0d5e0; font-size:.85rem; vertical-align:middle; }
.summary-card { background:#1e2030; border:1px solid #2d3045; border-radius:.5rem; padding:.75rem 1rem; }
.summary-card .lbl { color:#8b95a7; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
.summary-card .val { font-size:1.15rem; font-weight:700; color:#fff; }
.summary-card .val.pos { color:#51cf66; }
.summary-card .val.neg { color:#ff6b6b; }
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="positionsApp()" x-init="init()">
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sb-menu" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="4" y="8" width="4" height="8" rx="1"/><line x1="6" y1="4" x2="6" y2="8"/><line x1="6" y1="16" x2="6" y2="20"/><rect x="16" y="6" width="4" height="10" rx="1"/><line x1="18" y1="2" x2="18" y2="6"/><line x1="18" y1="16" x2="18" y2="22"/></svg>
<span class="fw-bold">Options Pricer</span>
</a>
</h1>
<div class="collapse navbar-collapse" id="sb-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item"><a class="nav-link" href="index.html"><span class="nav-link-title">Dashboard</span></a></li>
<li class="nav-item"><a class="nav-link" href="chain.html"><span class="nav-link-title">Options Chain</span></a></li>
<li class="nav-item"><a class="nav-link" href="surface.html"><span class="nav-link-title">Vol Surface</span></a></li>
<li class="nav-item"><a class="nav-link" href="strategy.html"><span class="nav-link-title">Strategy P/L</span></a></li>
<li class="nav-item active"><a class="nav-link" href="positions.html"><span class="nav-link-title">Positions</span></a></li>
<li class="nav-item"><a class="nav-link" href="tracker.html"><span class="nav-link-title">Tracker</span></a></li>
<li class="nav-item"><a class="nav-link" href="settings.html"><span class="nav-link-title">Settings</span></a></li>
</ul>
</div>
</div>
</aside>
<!-- Page -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">Positions</h2>
<div class="text-secondary mt-1">Strategies you've entered — live P/L with commissions.</div>
</div>
<div class="col-auto d-flex gap-2 align-items-end">
<div>
<label class="form-label text-secondary mb-1 small" for="filterSym">Symbol</label>
<select id="filterSym" class="form-select form-select-sm" x-model="filterSymbol" @change="applyFilter()" style="min-width:8rem;">
<option value="">All symbols</option>
<template x-for="s in symbolList" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<button class="btn btn-outline-primary btn-sm" @click="reload()" :disabled="loading">
<span x-show="loading" class="spinner-border spinner-border-sm me-1"></span>Refresh
</button>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<!-- Summary cards -->
<div class="row g-2 mb-3" x-show="visible.length > 0" x-cloak>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Open positions</div><div class="val" x-text="openCount"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Total entered</div><div class="val mono" x-text="fmtMoney(totals.entered)"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Current value</div><div class="val mono" x-text="fmtMoney(totals.current)"></div></div></div>
<div class="col-6 col-md-3"><div class="summary-card"><div class="lbl">Net P/L (after commissions)</div><div class="val mono" :class="totals.netPL >= 0 ? 'pos' : 'neg'" x-text="fmtMoney(totals.netPL)"></div></div></div>
</div>
<!-- Empty state -->
<div class="card text-center py-5" x-show="!loading && visible.length === 0" x-cloak>
<div class="card-body">
<h3 class="text-secondary">No positions yet</h3>
<p class="text-muted">Go to <a href="strategy.html">Strategy</a>, build a position, and click <span class="badge bg-success">Enter Position</span>.</p>
</div>
</div>
<!-- Positions table -->
<div class="card" x-show="visible.length > 0" x-cloak style="background:#161824; border:1px solid #2d3045;">
<div class="card-header d-flex justify-content-between align-items-center" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">
<span x-text="visible.length"></span> position<span x-show="visible.length !== 1">s</span>
<span class="text-secondary small fw-normal ms-2" x-show="filterSymbol" x-text="'· ' + filterSymbol"></span>
</h3>
<span class="text-secondary small">
Commission: <span class="mono" x-text="commissionLabel"></span>
· <a href="settings.html" class="text-decoration-none">change</a>
</span>
</div>
<div class="table-responsive">
<table class="table table-sm pos-table mb-0">
<thead>
<tr>
<th>Sym</th><th>Strategy</th><th>Legs</th>
<th class="text-end">Entered $</th>
<th class="text-end">Current $</th>
<th class="text-end">Comm. (RT)</th>
<th class="text-end">Gross P/L</th>
<th class="text-end">Net P/L</th>
<th class="text-end">P/L %</th>
<th>Opened</th>
<th>Status</th>
<th></th><th></th>
</tr>
</thead>
<tbody>
<template x-for="row in visible" :key="row.o.id">
<tr :style="row.o.status === 'closed' ? 'opacity:.55' : ''">
<td class="mono fw-semibold" x-text="row.o.symbol"></td>
<td><span class="badge bg-purple-lt" x-text="row.o.name || ('Custom (' + row.o.legs.length + ' legs)')"></span></td>
<td class="mono small text-secondary" x-text="row.legsSummary"></td>
<td class="text-end mono" x-text="fmtMoney(row.entered)"></td>
<td class="text-end mono" x-text="fmtMoney(row.value)"></td>
<td class="text-end mono text-warning" x-text="'-' + fmtMoney(row.commission)"></td>
<td class="text-end mono" :class="row.grossPL >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.grossPL)"></td>
<td class="text-end mono fw-bold" :class="row.netPL >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(row.netPL)"></td>
<td class="text-end mono small" :class="row.netPL >= 0 ? 'text-success' : 'text-danger'" x-text="row.plPct.toFixed(1) + '%'"></td>
<td class="small text-secondary mono" x-text="(row.o.created_at||'').slice(0,10)"></td>
<td><span class="badge" :class="row.o.status === 'open' ? 'bg-success-lt text-success' : 'bg-secondary-lt text-secondary'" x-text="row.o.status"></span></td>
<td><button class="btn btn-sm" :class="row.o.status === 'open' ? 'btn-outline-warning' : 'btn-outline-success'" @click="toggleClose(row.o)" x-text="row.o.status === 'open' ? 'Close' : 'Reopen'"></button></td>
<td><button class="btn btn-sm btn-ghost-danger" @click="remove(row.o.id)" aria-label="Remove position"></button></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div x-show="toast" x-transition x-cloak style="position:fixed;bottom:1rem;right:1rem;z-index:1080;">
<div class="alert alert-success py-2 px-3 mb-0" x-text="toast"></div>
</div>
</div>
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/settings-store.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script>
function positionsApp() {
return {
orders: [],
chains: {}, // "SYMBOL@EXPIRY" -> { "strike@type": option }
loading: false,
toast: '',
filterSymbol: '',
async init() { await this.reload(); },
async reload() {
this.loading = true;
try {
const r = await fetch('/api/orders');
const d = await r.json();
this.orders = d.data?.orders || [];
await this.refreshChains();
} catch (e) {
this.flash('Load failed: ' + e.message);
} finally {
this.loading = false;
}
},
async refreshChains() {
// fetch one chain per unique (symbol, expiry) used by the listed orders
const need = new Set();
for (const o of this.orders) for (const l of (o.legs||[])) {
if (l.symbol && l.expiry) need.add(l.symbol + '@' + l.expiry);
}
const cache = { ...this.chains };
for (const key of need) {
if (cache[key]) continue;
const [sym, exp] = key.split('@');
try {
const r = await fetch('/api/chain?symbol=' + encodeURIComponent(sym) + '&expiry=' + encodeURIComponent(exp));
if (!r.ok) continue;
const d = await r.json();
const snap = d.data?.snapshots?.[0];
if (!snap) continue;
const map = {};
for (const o of (snap.chain || [])) {
const t = (o.type || o.optionType || '').toLowerCase();
map[Number(o.strike) + '@' + t] = o;
}
cache[key] = map;
} catch {}
}
this.chains = cache;
},
get symbolList() {
return [...new Set(this.orders.map(o => o.symbol))].sort();
},
rowFor(o) {
let entered = 0, value = 0;
for (const l of (o.legs||[])) {
const sign = l.side === 'short' ? -1 : 1;
entered += sign * l.qty * 100 * (l.entryPrice || 0);
const m = this.chains[l.symbol + '@' + l.expiry];
const oo = m && m[Number(l.strike) + '@' + l.type];
const mid = oo ? Number(oo.midPrice ?? oo.mid ?? oo.bsPrice ?? 0) : 0;
value += sign * l.qty * 100 * mid;
}
const baseEntered = (o.entry_cost != null) ? Number(o.entry_cost) : entered;
const grossPL = value - baseEntered;
const commission = SettingsStore.estimate(o.legs || []).roundTrip;
const netPL = grossPL - commission;
const denom = Math.max(Math.abs(baseEntered), 1);
return {
o, entered: baseEntered, value, grossPL, commission, netPL,
plPct: netPL / denom * 100,
legsSummary: (o.legs||[]).map(l =>
(l.side === 'long' ? '+' : '-') + l.qty + ' ' + l.strike + (l.type === 'call' ? 'C' : 'P') + '·' + (l.expiry||'').slice(5)
).join(' / '),
};
},
get visible() {
const list = this.filterSymbol ? this.orders.filter(o => o.symbol === this.filterSymbol) : this.orders;
return list.map(o => this.rowFor(o));
},
get openCount() { return this.visible.filter(r => r.o.status === 'open').length; },
get totals() {
let entered = 0, current = 0, comm = 0, gross = 0;
for (const r of this.visible) {
if (r.o.status !== 'open') continue;
entered += r.entered; current += r.value; comm += r.commission; gross += r.grossPL;
}
return { entered, current, commission: comm, grossPL: gross, netPL: gross - comm };
},
get commissionLabel() {
const c = SettingsStore.load().commission;
const planName = c.plan === 'ibkr-fixed' ? 'IBKR Fixed'
: c.plan === 'ibkr-tiered' ? 'IBKR Tiered'
: 'Custom';
return planName + ' · $' + c.perContract.toFixed(2) + '/ct, min $' + c.perOrderMin.toFixed(2);
},
applyFilter() {/* x-model already drives `visible` */},
async toggleClose(o) {
const newStatus = o.status === 'open' ? 'closed' : 'open';
try {
const r = await fetch('/api/orders/' + o.id, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: newStatus }),
});
const d = await r.json();
if (d.ok) {
const idx = this.orders.findIndex(x => x.id === o.id);
if (idx >= 0) this.orders[idx] = d.data;
}
} catch {}
},
async remove(id) {
if (!confirm('Remove this position?')) return;
try {
await fetch('/api/orders/' + id, { method:'DELETE' });
this.orders = this.orders.filter(o => o.id !== id);
this.flash('Removed');
} catch {}
},
flash(msg) { this.toast = msg; setTimeout(() => { this.toast = ''; }, 2500); },
fmtMoney(v) {
if (v == null || !isFinite(v)) return '—';
const a = Math.abs(v), sign = v < 0 ? '-' : '';
return sign + '$' + a.toLocaleString('en-US', { maximumFractionDigits: a < 100 ? 2 : 0 });
},
};
}
</script>
</body>
</html>

172
frontend/settings.html Normal file
View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings — Options Pricer</title>
<link rel="stylesheet" href="/assets/tabler.min.css" />
<link rel="stylesheet" href="/assets/tabler-vendors.min.css" />
<style>
.mono { font-family:'JetBrains Mono','Fira Code',monospace; }
[x-cloak] { display:none !important; }
</style>
</head>
<body class="antialiased">
<div class="wrapper" x-data="settingsApp()" x-init="init()">
<!-- Sidebar -->
<aside class="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#sb-menu"><span class="navbar-toggler-icon"></span></button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="text-decoration-none d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="4" y="8" width="4" height="8" rx="1"/><line x1="6" y1="4" x2="6" y2="8"/><line x1="6" y1="16" x2="6" y2="20"/><rect x="16" y="6" width="4" height="10" rx="1"/><line x1="18" y1="2" x2="18" y2="6"/><line x1="18" y1="16" x2="18" y2="22"/></svg>
<span class="fw-bold">Options Pricer</span>
</a>
</h1>
<div class="collapse navbar-collapse" id="sb-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item"><a class="nav-link" href="index.html"><span class="nav-link-title">Dashboard</span></a></li>
<li class="nav-item"><a class="nav-link" href="chain.html"><span class="nav-link-title">Options Chain</span></a></li>
<li class="nav-item"><a class="nav-link" href="surface.html"><span class="nav-link-title">Vol Surface</span></a></li>
<li class="nav-item"><a class="nav-link" href="strategy.html"><span class="nav-link-title">Strategy P/L</span></a></li>
<li class="nav-item"><a class="nav-link" href="positions.html"><span class="nav-link-title">Positions</span></a></li>
<li class="nav-item"><a class="nav-link" href="tracker.html"><span class="nav-link-title">Tracker</span></a></li>
<li class="nav-item active"><a class="nav-link" href="settings.html"><span class="nav-link-title">Settings</span></a></li>
</ul>
</div>
</div>
</aside>
<!-- Page -->
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Settings</h2></div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="card mb-3" style="background:#161824; border:1px solid #2d3045;">
<div class="card-header" style="border-bottom:1px solid #2d3045;">
<h3 class="card-title text-white mb-0">Options Commission</h3>
</div>
<div class="card-body">
<p class="text-secondary small mb-3">
Used when computing Net P/L on the <a href="positions.html">Positions</a> page.
Defaults match <strong>Interactive Brokers Fixed / IBKR Lite</strong> for US equity options:
<span class="mono">$0.65 per contract, $1.00 minimum per order</span>
(<a href="https://www.interactivebrokers.com/en/pricing/commissions-options.php" target="_blank" rel="noopener">IBKR docs</a>).
</p>
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-secondary">Plan</label>
<div class="btn-group" role="group" aria-label="Commission plan preset">
<button class="btn btn-sm" :class="c.plan === 'ibkr-fixed' ? 'btn-primary' : 'btn-outline-secondary'" @click="setPlan('ibkr-fixed')">IBKR Fixed / Lite</button>
<button class="btn btn-sm" :class="c.plan === 'ibkr-tiered' ? 'btn-primary' : 'btn-outline-secondary'" @click="setPlan('ibkr-tiered')">IBKR Tiered</button>
<button class="btn btn-sm" :class="c.plan === 'custom' ? 'btn-primary' : 'btn-outline-secondary'" @click="setPlan('custom')">Custom</button>
</div>
<div class="text-secondary small mt-2" x-show="c.plan === 'ibkr-tiered'">
IBKR Tiered: ~$0.15$0.65 per contract by volume, plus exchange / regulatory fees. Estimated effective ~$0.55/ct, $1.00 min.
</div>
</div>
<div class="col-md-4">
<label class="form-label small text-secondary" for="cm-pc">$ per contract</label>
<input id="cm-pc" type="number" min="0" step="0.01" class="form-control" :value="c.perContract" @change="update('perContract', +$event.target.value || 0)" />
</div>
<div class="col-md-4">
<label class="form-label small text-secondary" for="cm-min">$ min per order</label>
<input id="cm-min" type="number" min="0" step="0.01" class="form-control" :value="c.perOrderMin" @change="update('perOrderMin', +$event.target.value || 0)" />
</div>
<div class="col-md-4">
<label class="form-label small text-secondary" for="cm-max">$ max per order <span class="text-secondary">(0 = none)</span></label>
<input id="cm-max" type="number" min="0" step="0.01" class="form-control" :value="c.perOrderMax" @change="update('perOrderMax', +$event.target.value || 0)" />
</div>
<div class="col-12">
<div class="form-check form-switch">
<input id="cm-leg" type="checkbox" class="form-check-input" :checked="c.applyPerLeg" @change="update('applyPerLeg', $event.target.checked)" />
<label class="form-check-label small" for="cm-leg">Charge each leg as a separate order
<span class="text-secondary">— off (default): multi-leg complex orders count as one, so the per-order minimum applies once.</span>
</label>
</div>
</div>
</div>
<hr class="my-4" style="border-color:#2d3045;">
<h4 class="text-secondary text-uppercase small mb-2" style="letter-spacing:.05em;">Preview</h4>
<div class="row g-3">
<div class="col-md-4"><div class="p-3" style="background:#1e2030; border:1px solid #2d3045; border-radius:.5rem;">
<div class="text-secondary small">1 single-leg, 1 contract</div>
<div class="mono fs-5">Round-trip: <span x-text="'$' + preview([{qty:1}]).toFixed(2)"></span></div>
</div></div>
<div class="col-md-4"><div class="p-3" style="background:#1e2030; border:1px solid #2d3045; border-radius:.5rem;">
<div class="text-secondary small">2-leg vertical, 1 contract each</div>
<div class="mono fs-5">Round-trip: <span x-text="'$' + preview([{qty:1},{qty:1}]).toFixed(2)"></span></div>
</div></div>
<div class="col-md-4"><div class="p-3" style="background:#1e2030; border:1px solid #2d3045; border-radius:.5rem;">
<div class="text-secondary small">4-leg iron condor, 1 contract each</div>
<div class="mono fs-5">Round-trip: <span x-text="'$' + preview([{qty:1},{qty:1},{qty:1},{qty:1}]).toFixed(2)"></span></div>
</div></div>
</div>
<div class="mt-4 d-flex justify-content-between align-items-center">
<button class="btn btn-outline-danger btn-sm" @click="resetDefaults()">Reset to IBKR Fixed defaults</button>
<span class="text-secondary small" x-show="savedFlash" x-cloak x-text="savedFlash"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/assets/tabler.min.js" defer></script>
<script src="/assets/settings-store.js"></script>
<script src="/assets/alpine.min.js" defer></script>
<script>
function settingsApp() {
return {
c: { plan:'ibkr-fixed', perContract:0.65, perOrderMin:1.00, perOrderMax:0, applyPerLeg:false },
savedFlash: '',
init() { this.c = SettingsStore.load().commission; },
update(key, val) {
this.c[key] = val;
// changing numeric fields manually -> mark as custom
if (['perContract','perOrderMin','perOrderMax','applyPerLeg'].includes(key)) this.c.plan = 'custom';
this._save();
},
setPlan(plan) {
this.c.plan = plan;
if (plan === 'ibkr-fixed') { this.c.perContract = 0.65; this.c.perOrderMin = 1.00; this.c.perOrderMax = 0; this.c.applyPerLeg = false; }
if (plan === 'ibkr-tiered') { this.c.perContract = 0.55; this.c.perOrderMin = 1.00; this.c.perOrderMax = 0; this.c.applyPerLeg = false; }
this._save();
},
resetDefaults() {
this.c = SettingsStore.reset().commission;
this._save(true);
},
_save(silent) {
SettingsStore.save({ commission: this.c });
if (!silent) {
this.savedFlash = 'Saved · ' + new Date().toLocaleTimeString();
clearTimeout(this._t); this._t = setTimeout(() => { this.savedFlash = ''; }, 1500);
}
},
preview(legs) { return SettingsStore.estimate(legs).roundTrip; },
};
}
</script>
</body>
</html>

View File

@@ -44,7 +44,9 @@
<li class="nav-item"><a class="nav-link" href="chain.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10l18 0"/><path d="M10 5v14"/></svg></span><span class="nav-link-title">Options Chain</span></a></li> <li class="nav-item"><a class="nav-link" href="chain.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10l18 0"/><path d="M10 5v14"/></svg></span><span class="nav-link-title">Options Chain</span></a></li>
<li class="nav-item"><a class="nav-link" href="surface.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12c1.333 -4.667 2.667 -7 4 -7s2.667 2.333 4 7s2.667 7 4 7s2.667 -2.333 4 -7"/></svg></span><span class="nav-link-title">Vol Surface</span></a></li> <li class="nav-item"><a class="nav-link" href="surface.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12c1.333 -4.667 2.667 -7 4 -7s2.667 2.333 4 7s2.667 7 4 7s2.667 -2.333 4 -7"/></svg></span><span class="nav-link-title">Vol Surface</span></a></li>
<li class="nav-item active"><a class="nav-link" href="strategy.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -6l4 2l4 -8l4 5"/><path d="M4 4v16h16"/></svg></span><span class="nav-link-title">Strategy P/L</span></a></li> <li class="nav-item active"><a class="nav-link" href="strategy.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 19l4 -6l4 2l4 -8l4 5"/><path d="M4 4v16h16"/></svg></span><span class="nav-link-title">Strategy P/L</span></a></li>
<li class="nav-item"><a class="nav-link" href="positions.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/></svg></span><span class="nav-link-title">Positions</span></a></li>
<li class="nav-item"><a class="nav-link" href="tracker.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M15 12l-3 -3"/></svg></span><span class="nav-link-title">Tracker</span></a></li> <li class="nav-item"><a class="nav-link" href="tracker.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M12 12m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M15 12l-3 -3"/></svg></span><span class="nav-link-title">Tracker</span></a></li>
<li class="nav-item"><a class="nav-link" href="settings.html"><span class="nav-link-icon d-md-none d-lg-inline-block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span><span class="nav-link-title">Settings</span></a></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -76,9 +78,13 @@
<button class="btn btn-outline-primary btn-sm me-1" @click="reloadMarket()" :disabled="refreshing" title="Re-fetch spot, marks &amp; IVs; refresh entry price on unlocked legs"> <button class="btn btn-outline-primary btn-sm me-1" @click="reloadMarket()" :disabled="refreshing" title="Re-fetch spot, marks &amp; IVs; refresh entry price on unlocked legs">
<span x-show="refreshing" class="spinner-border spinner-border-sm me-1" role="status"></span>Reload <span x-show="refreshing" class="spinner-border spinner-border-sm me-1" role="status"></span>Reload
</button> </button>
<button class="btn btn-success btn-sm me-1" @click="saveOrder()" :disabled="savingOrder || activeLegs.length === 0" <button class="btn btn-outline-info btn-sm me-1" @click="saveToTracker()" :disabled="!symbol"
title="Save this position to the Tracker (uses currently-checked legs)"> title="Add this symbol to the Tracker watchlist for IV / price-history monitoring">
<span x-show="savingOrder" class="spinner-border spinner-border-sm me-1" role="status"></span>Save to Tracker Save to Tracker
</button>
<button class="btn btn-success btn-sm me-1" @click="enterPosition()" :disabled="savingOrder || activeLegs.length === 0"
title="Save this position (entered legs) — opens the Positions page">
<span x-show="savingOrder" class="spinner-border spinner-border-sm me-1" role="status"></span>Enter Position
</button> </button>
<button class="btn btn-outline-danger btn-sm" @click="clearAll()">Clear all</button> <button class="btn btn-outline-danger btn-sm" @click="clearAll()">Clear all</button>
</div> </div>
@@ -748,8 +754,20 @@
}, },
flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); }, flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); },
// POST the current active basket to /api/orders for tracking // Add the current symbol to the Tracker watchlist (localStorage).
async saveOrder() { saveToTracker() {
if (!this.symbol) return;
const sym = this.symbol.toUpperCase().trim();
let wl = [];
try { wl = JSON.parse(localStorage.getItem('optionsPricer:watchlist') || '[]'); } catch {}
if (!wl.includes(sym)) wl.push(sym);
wl.sort();
try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(wl)); } catch {}
this.flash(sym + ' added to Tracker watchlist (' + wl.length + ' symbol' + (wl.length===1?'':'s') + ')');
},
// POST the current active basket to /api/orders, then navigate to Positions.
async enterPosition() {
const legs = this.activeLegs; const legs = this.activeLegs;
if (legs.length === 0 || !this.symbol) return; if (legs.length === 0 || !this.symbol) return;
this.savingOrder = true; this.savingOrder = true;
@@ -770,9 +788,10 @@
}); });
const d = await r.json(); const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || ('HTTP ' + r.status)); if (!r.ok || !d.ok) throw new Error(d.error || ('HTTP ' + r.status));
this.flash('Saved to Tracker · order #' + d.data.id + ' (' + this.strategyName + ')'); this.flash('Position #' + d.data.id + ' entered — opening Positions…');
setTimeout(() => { window.location.href = '/positions.html'; }, 700);
} catch (e) { } catch (e) {
this.flash('Save failed: ' + e.message); this.flash('Enter failed: ' + e.message);
} finally { } finally {
this.savingOrder = false; this.savingOrder = false;
} }

View File

@@ -208,6 +208,17 @@
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/>
</svg>
</span>
<span class="nav-link-title">Positions</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="tracker.html"> <a class="nav-link" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block"> <span class="nav-link-icon d-md-none d-lg-inline-block">
@@ -221,6 +232,18 @@
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nav-link-title">Settings</span>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -168,6 +168,17 @@
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="positions.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 4h4v4h-4z"/><path d="M5 12h4v8h-4z"/><path d="M13 4h4v12h-4z"/><path d="M13 18h4v2h-4z"/>
</svg>
</span>
<span class="nav-link-title">Positions</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active-page" href="tracker.html"> <a class="nav-link active-page" href="tracker.html">
<span class="nav-link-icon d-md-none d-lg-inline-block"> <span class="nav-link-icon d-md-none d-lg-inline-block">
@@ -181,6 +192,18 @@
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="settings.html">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nav-link-title">Settings</span>
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -255,52 +278,15 @@
<div class="page-body"> <div class="page-body">
<div class="container-xl"> <div class="container-xl">
<!-- Tracked Orders (persisted strategy positions) --> <!-- Watchlist (symbols saved from Strategy page) -->
<div class="card mb-3" x-show="orders.length > 0 || ordersLoading" x-cloak style="background:#161824; border:1px solid #2d3045;"> <div class="mb-3 d-flex align-items-center flex-wrap gap-2" x-show="watchlist.length > 0" x-cloak>
<div class="card-header d-flex align-items-center justify-content-between" style="border-bottom:1px solid #2d3045;"> <span class="text-secondary small me-1">Watchlist:</span>
<h3 class="card-title text-white mb-0"> <template x-for="s in watchlist" :key="s">
Tracked Orders <span class="badge bg-blue-lt" style="cursor:pointer; padding:.45rem .6rem;">
<span class="text-secondary small fw-normal" x-show="symbol"><span x-text="symbol"></span></span> <span @click="loadSymbol(s)" x-text="s" :class="s === symbol.toUpperCase().trim() ? 'fw-bold' : ''"></span>
</h3> <span class="text-danger ms-1" style="cursor:pointer;" @click="removeWatch(s)" :title="'Remove ' + s + ' from watchlist'"></span>
<div class="d-flex align-items-center gap-2"> </span>
<span x-show="ordersLoading" class="spinner-border spinner-border-sm text-secondary"></span>
<button class="btn btn-sm btn-outline-secondary" @click="loadOrders()" :disabled="ordersLoading">Refresh</button>
<span class="text-secondary small" x-text="orders.length + ' order' + (orders.length===1?'':'s')"></span>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="color:#d0d5e0;">
<thead style="background:#1a1c2e;color:#8b95a7;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;">
<tr>
<th>Strategy</th>
<th>Legs</th>
<th class="text-end">Entered</th>
<th class="text-end">Current</th>
<th class="text-end">P/L $</th>
<th class="text-end">P/L %</th>
<th>Opened</th>
<th>Status</th>
<th></th><th></th>
</tr>
</thead>
<tbody>
<template x-for="o in orders" :key="o.id">
<tr :style="o.status === 'closed' ? 'opacity:.55' : ''" style="font-size:.85rem;">
<td><span class="badge bg-purple-lt" x-text="o.name || ('Custom (' + o.legs.length + ' legs)')"></span></td>
<td class="text-secondary" style="font-family:'JetBrains Mono',monospace;font-size:.75rem;" x-text="legsSummary(o)"></td>
<td class="text-end" style="font-family:'JetBrains Mono',monospace;" x-text="fmtMoney(orderPL(o).entered)"></td>
<td class="text-end" style="font-family:'JetBrains Mono',monospace;" x-text="fmtMoney(orderPL(o).value)"></td>
<td class="text-end" style="font-family:'JetBrains Mono',monospace;" :class="orderPL(o).pl >= 0 ? 'text-success' : 'text-danger'" x-text="fmtMoney(orderPL(o).pl)"></td>
<td class="text-end" style="font-family:'JetBrains Mono',monospace;" :class="orderPL(o).pl >= 0 ? 'text-success' : 'text-danger'" x-text="orderPL(o).plPct.toFixed(1) + '%'"></td>
<td class="small text-secondary" x-text="(o.created_at||'').slice(0,10)"></td>
<td><span class="badge" :class="o.status === 'open' ? 'bg-success-lt text-success' : 'bg-secondary-lt text-secondary'" x-text="o.status"></span></td>
<td><button class="btn btn-sm" :class="o.status === 'open' ? 'btn-outline-warning' : 'btn-outline-success'" @click="toggleCloseOrder(o)" x-text="o.status === 'open' ? 'Close' : 'Reopen'"></button></td>
<td><button class="btn btn-sm btn-ghost-danger" @click="removeOrder(o.id)" aria-label="Remove order"></button></td>
</tr>
</template> </template>
</tbody>
</table>
</div>
</div> </div>
<!-- Empty state --> <!-- Empty state -->
@@ -660,9 +646,7 @@
loading: false, loading: false,
error: '', error: '',
orders: [], watchlist: [],
ordersLoading: false,
ordersChain: {}, // "SYMBOL@EXPIRY" -> { "strike@type": option }
_charts: { atmIv: null, rr25: null, fly25: null }, _charts: { atmIv: null, rr25: null, fly25: null },
@@ -676,96 +660,24 @@
this.snapshots = vs.snapshots ?? []; this.snapshots = vs.snapshots ?? [];
if (this.snapshots.length) this.$nextTick(() => this.renderCharts()); if (this.snapshots.length) this.$nextTick(() => this.renderCharts());
} }
if (this.symbol) this.loadOrders(); this._loadWatchlist();
window.addEventListener('storage', (e) => { if (e.key === 'optionsPricer:watchlist') this._loadWatchlist(); });
}, },
async loadOrders() { _loadWatchlist() {
if (!this.symbol) return; try { this.watchlist = JSON.parse(localStorage.getItem('optionsPricer:watchlist') || '[]'); }
this.ordersLoading = true; catch { this.watchlist = []; }
try {
const r = await fetch('/api/orders?symbol=' + encodeURIComponent(this.symbol.trim().toUpperCase()));
const d = await r.json();
this.orders = d.data?.orders || [];
// fetch live chains for the unique expiries (one /api/chain call each)
const need = new Set();
for (const o of this.orders) for (const l of (o.legs||[])) if (l.expiry) need.add(o.symbol + '@' + l.expiry);
const cache = { ...this.ordersChain };
for (const key of need) {
if (cache[key]) continue;
const [sym, exp] = key.split('@');
try {
const cr = await fetch('/api/chain?symbol=' + encodeURIComponent(sym) + '&expiry=' + encodeURIComponent(exp));
if (!cr.ok) continue;
const ce = await cr.json();
const snap = ce.data?.snapshots?.[0];
if (!snap) continue;
const map = {};
for (const o of (snap.chain || [])) {
const t = (o.type || o.optionType || '').toLowerCase();
map[Number(o.strike) + '@' + t] = o;
}
cache[key] = map;
} catch {}
}
this.ordersChain = cache;
} catch (e) {
console.warn('loadOrders failed:', e);
} finally {
this.ordersLoading = false;
}
}, },
orderPL(o) { loadSymbol(s) {
let value = 0, entered = 0; this.symbol = s;
for (const l of (o.legs||[])) { this.fetchExpirations();
const sign = l.side === 'short' ? -1 : 1; this.loadHistory();
entered += sign * l.qty * 100 * (l.entryPrice || 0);
const m = this.ordersChain[o.symbol + '@' + l.expiry];
const oo = m && m[Number(l.strike) + '@' + l.type];
const mid = oo ? Number(oo.midPrice ?? oo.mid ?? oo.bsPrice ?? 0) : 0;
value += sign * l.qty * 100 * mid;
}
const baseCost = (o.entry_cost != null) ? Number(o.entry_cost) : entered;
const pl = value - baseCost;
const plPct = Math.abs(baseCost) > 1e-6 ? (pl / Math.abs(baseCost)) * 100 : 0;
return { value, entered: baseCost, pl, plPct };
}, },
async toggleCloseOrder(o) { removeWatch(s) {
const newStatus = o.status === 'open' ? 'closed' : 'open'; this.watchlist = this.watchlist.filter(x => x !== s);
try { try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(this.watchlist)); } catch {}
const r = await fetch('/api/orders/' + o.id, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status: newStatus }),
});
const d = await r.json();
if (d.ok) {
const idx = this.orders.findIndex(x => x.id === o.id);
if (idx >= 0) this.orders[idx] = d.data;
}
} catch (e) { /* silent */ }
},
async removeOrder(id) {
if (!confirm('Remove this tracked order?')) return;
try {
await fetch('/api/orders/' + id, { method:'DELETE' });
this.orders = this.orders.filter(o => o.id !== id);
} catch {}
},
legsSummary(o) {
return (o.legs||[]).map(l =>
(l.side === 'long' ? '+' : '-') + l.qty +
' ' + l.strike + (l.type === 'call' ? 'C' : 'P') +
'·' + (l.expiry || '').slice(5)
).join(' / ');
},
fmtMoney(v) {
if (v == null || !isFinite(v)) return '—';
const a = Math.abs(v), sign = v < 0 ? '-' : '';
return sign + '$' + a.toLocaleString('en-US', { maximumFractionDigits: a < 100 ? 2 : 0 });
}, },
_persist() { _persist() {
@@ -812,7 +724,6 @@
} }
this._persist(); this._persist();
this.$nextTick(() => this.renderCharts()); this.$nextTick(() => this.renderCharts());
this.loadOrders();
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
this.snapshots = []; this.snapshots = [];