Files
options-pricer/frontend/positions.html

438 lines
23 KiB
HTML
Raw Normal View History

<!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="#sidebar-menu" aria-controls="sidebar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark">
<a href="index.html" class="d-flex align-items-center gap-2 text-decoration-none">
<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" class="text-primary" aria-hidden="true">
<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="sidebar-menu">
<ul class="navbar-nav pt-lg-3">
<li class="nav-item ">
<a class="nav-link" href="index.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"/>
<rect x="4" y="4" width="6" height="5" rx="2"/>
<rect x="4" y="13" width="6" height="7" rx="2"/>
<rect x="14" y="4" width="6" height="11" rx="2"/>
<rect x="14" y="19" width="6" height="1" rx=".5"/>
</svg>
</span>
<span class="nav-link-title">Dashboard</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" 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"/>
<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" 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="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="scanner.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="11" cy="11" r="7"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<path d="M11 8v6"/>
<path d="M8 11h6"/>
</svg>
</span>
<span class="nav-link-title">Scanner</span>
</a>
</li>
<li class="nav-item ">
<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" 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="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="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"/>
<rect x="3" y="7" width="18" height="13" rx="2"/>
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2"/>
<line x1="12" y1="12" x2="12" y2="12.01"/>
<path d="M3 13a20 20 0 0 0 18 0"/>
</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" 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="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" 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="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</span>
<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><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 btn-outline-primary" @click="openInStrategy(row.o)" title="Open this position in the Strategy P/L chart">P/L Chart</button></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/strategy-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 {}
},
// Load this order's legs into the Strategy basket (replacing any existing
// legs for the same symbol), then open the Strategy page.
openInStrategy(o) {
const sym = o.symbol;
let existingLegs = 0;
try {
const doc = JSON.parse(localStorage.getItem('optionsPricer:strategy') || '{}');
existingLegs = doc?.baskets?.[sym]?.legs?.length || 0;
} catch {}
if (existingLegs > 0 && !confirm(`Replace your current ${sym} strategy (${existingLegs} leg${existingLegs===1?'':'s'}) with order #${o.id}?`)) return;
const legs = (o.legs || []).map(l => ({
id: 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2),
symbol: l.symbol,
expiry: l.expiry,
type: l.type,
strike: Number(l.strike),
side: l.side === 'short' ? 'short' : 'long',
qty: Math.max(1, Math.round(Number(l.qty) || 1)),
entryPrice: Number(l.entryPrice) || 0,
iv: Number(l.iv) || 0,
locked: true, // it's an entered position — lock the entry prices
currentMark: null,
}));
StrategyStore.save({ symbol: sym, spotSnapshot: 0, legs });
StrategyStore.setActive(sym);
this.flash('Loaded into Strategy — opening…');
setTimeout(() => { window.location.href = '/strategy.html'; }, 400);
},
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>