From c1520d796220754649be9ff88c7e76dd4d998432 Mon Sep 17 00:00:00 2001 From: ojy Date: Wed, 13 May 2026 06:54:40 +0000 Subject: [PATCH] =?UTF-8?q?Tracked=20orders=20=E2=80=94=20persist=20strate?= =?UTF-8?q?gy=20positions=20in=20SQLite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - New orders table (symbol, name, legs_json, entry_cost, created_at, closed_at, status, note) sharing the snapshots.db - Endpoints: POST /api/orders save a position GET /api/orders?symbol= list (most recent first) GET /api/orders/:id single PATCH /api/orders/:id { status:'open'|'closed', note? } DELETE /api/orders/:id remove Strategy page: - "Save to Tracker" button posts the currently-active legs (with name, net cost, side, qty, entry, IV, lock) as an order Tracker page: - New "Tracked Orders" section above the IV history charts: lists each saved position for the current symbol with strategy name, leg summary, entered cost, current value, P/L $ and %, opened date, status, and Close/Reopen + Remove actions. Live P/L uses /api/chain mids for each unique leg expiry. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db/orders.ts | 134 +++++++++++++++++++++++++++++++ backend/src/routes/options.ts | 110 ++++++++++++++++++++++++++ frontend/strategy.html | 36 ++++++++- frontend/tracker.html | 143 ++++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/orders.ts diff --git a/backend/src/db/orders.ts b/backend/src/db/orders.ts new file mode 100644 index 0000000..7531775 --- /dev/null +++ b/backend/src/db/orders.ts @@ -0,0 +1,134 @@ +/** + * Tracked-order persistence — strategy positions the user wants to follow + * over time. Single source of truth across browsers/devices. + * + * Schema: + * orders(id, symbol, name, legs_json, entry_cost, created_at, closed_at, status, note) + * legs_json holds the leg array exactly as the Strategy page knows it + * (id, symbol, expiry, type, strike, side, qty, entryPrice, iv, locked). + */ +import { getDb } from "./snapshots.js"; + +const db = getDb(); + +db.exec(` + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + name TEXT, + legs_json TEXT NOT NULL, + entry_cost REAL, + created_at TEXT NOT NULL, + closed_at TEXT, + status TEXT NOT NULL DEFAULT 'open', + note TEXT + ); + CREATE INDEX IF NOT EXISTS idx_orders_symbol_created + ON orders(symbol, created_at); + CREATE INDEX IF NOT EXISTS idx_orders_status + ON orders(status); +`); + +export type OrderLeg = { + symbol: string; + expiry: string; + type: "call" | "put"; + strike: number; + side: "long" | "short"; + qty: number; + entryPrice: number; + iv: number; + locked?: boolean; +}; + +export type OrderRow = { + id: number; + symbol: string; + name: string | null; + legs_json: string; + entry_cost: number | null; + created_at: string; + closed_at: string | null; + status: "open" | "closed"; + note: string | null; +}; + +export type Order = Omit & { legs: OrderLeg[] }; + +const stmtInsert = db.prepare(` + INSERT INTO orders (symbol, name, legs_json, entry_cost, created_at, status, note) + VALUES (?, ?, ?, ?, ?, 'open', ?) +`); +const stmtListBySymbol = db.prepare(` + SELECT * FROM orders WHERE symbol = ? ORDER BY created_at DESC LIMIT ? +`); +const stmtListAll = db.prepare(` + SELECT * FROM orders ORDER BY created_at DESC LIMIT ? +`); +const stmtGet = db.prepare(`SELECT * FROM orders WHERE id = ?`); +const stmtDelete = db.prepare(`DELETE FROM orders WHERE id = ?`); +const stmtClose = db.prepare(` + UPDATE orders SET status = 'closed', closed_at = ? WHERE id = ? +`); +const stmtReopen = db.prepare(` + UPDATE orders SET status = 'open', closed_at = NULL WHERE id = ? +`); +const stmtNote = db.prepare(`UPDATE orders SET note = ? WHERE id = ?`); + +function rowToOrder(row: OrderRow): Order { + let legs: OrderLeg[] = []; + try { legs = JSON.parse(row.legs_json) as OrderLeg[]; } catch { /* ignore */ } + const { legs_json: _legs, ...rest } = row; + return { ...rest, legs }; +} + +export function saveOrder( + symbol: string, + name: string | null, + legs: OrderLeg[], + entryCost: number | null, + note: string | null = null +): Order { + const info = stmtInsert.run( + symbol, + name, + JSON.stringify(legs), + entryCost, + new Date().toISOString(), + note + ); + const row = stmtGet.get(info.lastInsertRowid) as OrderRow; + return rowToOrder(row); +} + +export function listOrders(symbol?: string, limit = 200): Order[] { + const rows = (symbol + ? (stmtListBySymbol.all(symbol, limit) as OrderRow[]) + : (stmtListAll.all(limit) as OrderRow[])); + return rows.map(rowToOrder); +} + +export function getOrder(id: number): Order | null { + const row = stmtGet.get(id) as OrderRow | undefined; + return row ? rowToOrder(row) : null; +} + +export function deleteOrder(id: number): boolean { + const info = stmtDelete.run(id); + return info.changes > 0; +} + +export function closeOrder(id: number): Order | null { + stmtClose.run(new Date().toISOString(), id); + return getOrder(id); +} + +export function reopenOrder(id: number): Order | null { + stmtReopen.run(id); + return getOrder(id); +} + +export function updateOrderNote(id: number, note: string | null): Order | null { + stmtNote.run(note, id); + return getOrder(id); +} diff --git a/backend/src/routes/options.ts b/backend/src/routes/options.ts index 28c5c16..d1083e6 100644 --- a/backend/src/routes/options.ts +++ b/backend/src/routes/options.ts @@ -22,6 +22,16 @@ import { getSnapshots, getLatestSnapshot, } from "../db/snapshots.js"; +import { + saveOrder, + listOrders, + getOrder, + deleteOrder, + closeOrder, + reopenOrder, + updateOrderNote, + type OrderLeg, +} from "../db/orders.js"; // --------------------------------------------------------------------------- // Helpers @@ -425,3 +435,103 @@ optionsRouter.post("/refresh", async (c) => { return c.json(fail(msg), 502); } }); + +// --------------------------------------------------------------------------- +// Tracked orders — strategy positions persisted in SQLite +// --------------------------------------------------------------------------- + +function validLegs(raw: unknown): OrderLeg[] | null { + if (!Array.isArray(raw)) return null; + const out: OrderLeg[] = []; + for (const l of raw) { + if (!l || typeof l !== "object") return null; + const o = l as Record; + const strike = Number(o.strike); + const qty = Math.max(1, Math.round(Number(o.qty) || 1)); + if (!Number.isFinite(strike) || strike <= 0) return null; + if (o.type !== "call" && o.type !== "put") return null; + if (typeof o.symbol !== "string" || !o.symbol) return null; + if (typeof o.expiry !== "string" || !o.expiry) return null; + out.push({ + symbol: o.symbol, + expiry: o.expiry, + type: o.type, + strike, + side: o.side === "short" ? "short" : "long", + qty, + entryPrice: Number(o.entryPrice) || 0, + iv: Number(o.iv) || 0, + locked: o.locked === true, + }); + } + return out; +} + +// POST /api/orders { symbol, name?, legs:[...], entryCost?, note? } +optionsRouter.post("/orders", async (c) => { + let body: any; + try { body = await c.req.json(); } catch { return c.json(fail("Invalid JSON body"), 400); } + const symbol = typeof body.symbol === "string" ? body.symbol.toUpperCase().trim() : ""; + const legs = validLegs(body.legs); + if (!symbol) return c.json(fail("Missing symbol"), 400); + if (!legs || legs.length === 0) return c.json(fail("legs must be a non-empty array of {symbol,expiry,type,strike,side,qty,entryPrice,iv}"), 400); + const name = typeof body.name === "string" ? body.name.slice(0, 80) : null; + const note = typeof body.note === "string" ? body.note.slice(0, 500) : null; + const entryCost = (typeof body.entryCost === "number" && Number.isFinite(body.entryCost)) ? body.entryCost : null; + try { + const order = saveOrder(symbol, name, legs, entryCost, note); + return c.json(ok(order), 201); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[POST /api/orders]", msg); + return c.json(fail(msg), 500); + } +}); + +// GET /api/orders?symbol=...&limit=... +optionsRouter.get("/orders", (c) => { + const sym = c.req.query("symbol")?.toUpperCase(); + const limit = Math.min(parseInt(c.req.query("limit") ?? "200", 10) || 200, 1000); + try { + const orders = listOrders(sym, limit); + return c.json(ok({ count: orders.length, orders })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json(fail(msg), 500); + } +}); + +// GET /api/orders/:id +optionsRouter.get("/orders/:id", (c) => { + const id = Number(c.req.param("id")); + if (!Number.isFinite(id)) return c.json(fail("Invalid id"), 400); + const o = getOrder(id); + return o ? c.json(ok(o)) : c.json(fail("Order not found"), 404); +}); + +// DELETE /api/orders/:id +optionsRouter.delete("/orders/:id", (c) => { + const id = Number(c.req.param("id")); + if (!Number.isFinite(id)) return c.json(fail("Invalid id"), 400); + const removed = deleteOrder(id); + return removed ? c.json(ok({ id, removed: true })) : c.json(fail("Order not found"), 404); +}); + +// PATCH /api/orders/:id { status:'open'|'closed', note?:string } +optionsRouter.patch("/orders/:id", async (c) => { + const id = Number(c.req.param("id")); + if (!Number.isFinite(id)) return c.json(fail("Invalid id"), 400); + let body: any = {}; + try { body = await c.req.json(); } catch { /* allow empty patch */ } + try { + let order = getOrder(id); + if (!order) return c.json(fail("Order not found"), 404); + if (body.status === "closed" && order.status !== "closed") order = closeOrder(id) ?? order; + else if (body.status === "open" && order.status !== "open") order = reopenOrder(id) ?? order; + if (typeof body.note === "string") order = updateOrderNote(id, body.note.slice(0, 500)) ?? order; + return c.json(ok(order)); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json(fail(msg), 500); + } +}); diff --git a/frontend/strategy.html b/frontend/strategy.html index 8429468..beb473a 100644 --- a/frontend/strategy.html +++ b/frontend/strategy.html @@ -76,6 +76,10 @@ + @@ -341,7 +345,7 @@ dteOffset: 0, xZoom: 1, xPan: 0, lastHalfPct: 0, expiryLocked: false, masterExpiry: '', - refreshing: false, showManual: false, toast: '', + refreshing: false, savingOrder: false, showManual: false, toast: '', manual: { side:'long', type:'call', qty:1, strike:null, expiry:'', entryPrice:null, ivPct:null }, chart: null, _renderTimer: null, _chainCache: {}, // "SYMBOL@EXPIRY" -> { "strike@type": optionRow } @@ -744,6 +748,36 @@ }, flash(msg) { this.toast = msg; setTimeout(()=>{ this.toast=''; }, 2500); }, + // POST the current active basket to /api/orders for tracking + async saveOrder() { + const legs = this.activeLegs; + if (legs.length === 0 || !this.symbol) return; + this.savingOrder = true; + try { + const body = { + symbol: this.symbol, + name: this.strategyName, + entryCost: this.netCost, + legs: legs.map(l => ({ + symbol: l.symbol, expiry: l.expiry, type: l.type, strike: l.strike, + side: l.side, qty: l.qty, entryPrice: l.entryPrice, iv: l.iv, locked: !!l.locked, + })), + }; + const r = await fetch('/api/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + 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 + ')'); + } catch (e) { + this.flash('Save failed: ' + e.message); + } finally { + this.savingOrder = false; + } + }, + scheduleRender() { clearTimeout(this._renderTimer); this._renderTimer = setTimeout(() => this.renderChart(), 40); diff --git a/frontend/tracker.html b/frontend/tracker.html index 52e8e20..9f78621 100644 --- a/frontend/tracker.html +++ b/frontend/tracker.html @@ -255,6 +255,54 @@
+ +
+
+

+ Tracked Orders + +

+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + +
StrategyLegsEnteredCurrentP/L $P/L %OpenedStatus
+
+
+
{ "strike@type": option } + _charts: { atmIv: null, rr25: null, fly25: null }, async init() { @@ -624,6 +676,96 @@ this.snapshots = vs.snapshots ?? []; if (this.snapshots.length) this.$nextTick(() => this.renderCharts()); } + if (this.symbol) this.loadOrders(); + }, + + 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; + } + }, + + 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 }; + }, + + 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 }); }, _persist() { @@ -670,6 +812,7 @@ } this._persist(); this.$nextTick(() => this.renderCharts()); + this.loadOrders(); } catch (e) { this.error = e.message; this.snapshots = [];