diff --git a/frontend/assets/settings-store.js b/frontend/assets/settings-store.js new file mode 100644 index 0000000..9ae8c44 --- /dev/null +++ b/frontend/assets/settings-store.js @@ -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 }; +})(); diff --git a/frontend/chain.html b/frontend/chain.html index dd76fe9..7d5e087 100644 --- a/frontend/chain.html +++ b/frontend/chain.html @@ -129,6 +129,18 @@ + + + + diff --git a/frontend/index.html b/frontend/index.html index 64d22de..975d54c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -213,6 +213,18 @@ + + + + + + diff --git a/frontend/positions.html b/frontend/positions.html new file mode 100644 index 0000000..106172e --- /dev/null +++ b/frontend/positions.html @@ -0,0 +1,298 @@ + + + + + + Positions — Options Pricer + + + + + +
+ + + + + +
+ + +
+
+ + +
+
Open positions
+
Total entered
+
Current value
+
Net P/L (after commissions)
+
+ + +
+
+

No positions yet

+

Go to Strategy, build a position, and click Enter Position.

+
+
+ + +
+
+

+ positions + +

+ + Commission: + · change + +
+
+ + + + + + + + + + + + + + + + + + +
SymStrategyLegsEntered $Current $Comm. (RT)Gross P/LNet P/LP/L %OpenedStatus
+
+
+ +
+
+
+ + +
+
+
+
+ + + + + + + + diff --git a/frontend/settings.html b/frontend/settings.html new file mode 100644 index 0000000..ac057e3 --- /dev/null +++ b/frontend/settings.html @@ -0,0 +1,172 @@ + + + + + + Settings — Options Pricer + + + + + +
+ + + + + +
+ + +
+
+ +
+
+

Options Commission

+
+
+

+ Used when computing Net P/L on the Positions page. + Defaults match Interactive Brokers Fixed / IBKR Lite for US equity options: + $0.65 per contract, $1.00 minimum per order + (IBKR docs). +

+ +
+
+ +
+ + + +
+
+ IBKR Tiered: ~$0.15–$0.65 per contract by volume, plus exchange / regulatory fees. Estimated effective ~$0.55/ct, $1.00 min. +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ +
+ +

Preview

+
+
+
1 single-leg, 1 contract
+
Round-trip:
+
+
+
2-leg vertical, 1 contract each
+
Round-trip:
+
+
+
4-leg iron condor, 1 contract each
+
Round-trip:
+
+
+ +
+ + +
+
+
+ +
+
+
+
+ + + + + + + + diff --git a/frontend/strategy.html b/frontend/strategy.html index beb473a..5998d32 100644 --- a/frontend/strategy.html +++ b/frontend/strategy.html @@ -44,7 +44,9 @@ + + @@ -76,9 +78,13 @@ - + @@ -748,8 +754,20 @@ }, flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); }, - // POST the current active basket to /api/orders for tracking - async saveOrder() { + // Add the current symbol to the Tracker watchlist (localStorage). + 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; if (legs.length === 0 || !this.symbol) return; this.savingOrder = true; @@ -770,9 +788,10 @@ }); const d = await r.json(); 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) { - this.flash('Save failed: ' + e.message); + this.flash('Enter failed: ' + e.message); } finally { this.savingOrder = false; } diff --git a/frontend/surface.html b/frontend/surface.html index a57935a..1d789ab 100644 --- a/frontend/surface.html +++ b/frontend/surface.html @@ -208,6 +208,17 @@ + + + + diff --git a/frontend/tracker.html b/frontend/tracker.html index 9f78621..9e859ee 100644 --- a/frontend/tracker.html +++ b/frontend/tracker.html @@ -168,6 +168,17 @@ + + + + @@ -255,52 +278,15 @@
- -
-
-

- Tracked Orders - -

-
- - - -
-
-
- - - - - - - - - - - - - - - - - -
StrategyLegsEnteredCurrentP/L $P/L %OpenedStatus
-
+ +
+ Watchlist: +
@@ -660,9 +646,7 @@ loading: false, error: '', - orders: [], - ordersLoading: false, - ordersChain: {}, // "SYMBOL@EXPIRY" -> { "strike@type": option } + watchlist: [], _charts: { atmIv: null, rr25: null, fly25: null }, @@ -676,96 +660,24 @@ this.snapshots = vs.snapshots ?? []; 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() { - if (!this.symbol) return; - this.ordersLoading = true; - 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; - } + _loadWatchlist() { + try { this.watchlist = JSON.parse(localStorage.getItem('optionsPricer:watchlist') || '[]'); } + catch { this.watchlist = []; } }, - orderPL(o) { - let value = 0, entered = 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.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 }; + loadSymbol(s) { + this.symbol = s; + this.fetchExpirations(); + this.loadHistory(); }, - async toggleCloseOrder(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 (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 }); + removeWatch(s) { + this.watchlist = this.watchlist.filter(x => x !== s); + try { localStorage.setItem('optionsPricer:watchlist', JSON.stringify(this.watchlist)); } catch {} }, _persist() { @@ -812,7 +724,6 @@ } this._persist(); this.$nextTick(() => this.renderCharts()); - this.loadOrders(); } catch (e) { this.error = e.message; this.snapshots = [];