/* ============================================================ gp-data.jsx — Game Plan Audit LIVE data adapter. Publishes the same `window.GP` contract the prototype's mock did, but sources `DATA.decisions` from the real engine artifact served by GET /api/strategy-rationale (data/daily_briefing.json), recomputing nothing — the verifier (gp-verify.jsx) re-derives every linkage proof client-side from the raw winner/runner_ups field. The live briefing carries only `winner` + `runner_ups` (NOT the mock's full 30-combo sweep grid). We therefore synthesize the minimal shape the views read: - sweep.candidates := [winner, ...runner_ups] (verifier's input) - paramSpace := union of observed candidate params per group - selection := synthetic {rank, fit:null, regime:null, tags:[]} and flag the decision `partial:true` so detail views can show an honest "full sweep grid not in briefing artifact (Phase 2)" note. Radar / Selection / Execution are wired to live endpoints in later commits; here they publish empty-but-shaped placeholders so the tree stays working on every stage. Boot is async (§4.3): `window.GP.load()` returns a Promise that must resolve before React mounts. On 401/missing token it rejects with `err.status` so the shell can render the token-entry view. ============================================================ */ (function () { // ---- deterministic RNG (LCG) — kept as a pure helper for any view jitter ---- function seededRng(s) { let x = (s | 0) || 1; return () => { x = (x * 1664525 + 1013904223) | 0; return ((x >>> 0) % 1e9) / 1e9; }; } const rng = seededRng(20260604); // ---- formatting helpers shared across views (null-guarded for live gaps) ---- const fmt = { pct: (v, d = 1) => (v == null ? "—" : (v * 100).toFixed(d) + "%"), signedPct: (v, d = 1) => (v == null ? "—" : (v > 0 ? "+" : "") + (v * 100).toFixed(d) + "%"), num: (v, d = 2) => (v == null ? "—" : (+v).toFixed(d)), sharpe: (v) => (v == null ? "—" : (+v).toFixed(2)), money: (v) => (v == null ? "—" : "$" + Math.round(v).toLocaleString("en-US")), price: (v) => { if (v == null) return "—"; if (v >= 1000) return "$" + Math.round(v).toLocaleString("en-US"); if (v >= 1) return "$" + v.toFixed(2); return "$" + v.toFixed(4); }, moneyK: (v) => (v == null ? "—" : "$" + (v / 1000).toFixed(1) + "K"), time: (iso) => { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d)) return "—"; const p = (n) => String(n).padStart(2, "0"); return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; }, ago: (iso, now) => { if (!iso) return "—"; const t = new Date(iso).getTime(); if (isNaN(t)) return "—"; const m = Math.round((now - t) / 60000); if (m < 60) return m + "m"; return (m / 60).toFixed(1) + "h"; }, }; // ---- client-side fit heuristics (presentation-time, not engine output) ---- // Kept for Selection (§4.3); they need indicator-bearing assets to run, so // they stay dormant until /api/universe carries indicators (Phase 2). function meanRevFit(a) { const rsiStretch = Math.max(0, Math.abs(a.rsi - 50) - 12) / 30; const bbStretch = Math.max(0, Math.abs(a.bbPctB - 0.5) - 0.25) / 0.7; const choppy = a.adx < 22 ? 1 : Math.max(0, 1 - (a.adx - 22) / 22); return rsiStretch * 0.4 + bbStretch * 0.34 + choppy * 0.26; } function trendFit(a) { const adxStrength = Math.min(1, Math.max(0, (a.adx - 18) / 26)); const diSep = Math.min(1, Math.abs(a.diPlus - a.diMinus) / 22); const mom = Math.min(1, Math.abs(a.chg) / 0.07); return adxStrength * 0.48 + diSep * 0.32 + mom * 0.2; } const REGIMES = ["TRENDING_BULL", "TRENDING_BEAR", "CHOPPY", "RANGE_BOUND", "BREAKOUT"]; // ---- per-group display metadata (labels/blurbs the artifact doesn't carry) ---- // floor / objective / class come live from each decision; this only supplies // human-facing copy and the primary-param axis for the robustness slice. const STRATEGY_META = { MEAN_REV: { label: "Bollinger Mean Reversion", blurb: "Buys statistical extremes, exits toward the mean.", primaryParam: "window", fits: "choppy / range-bound regimes with stretched RSI", }, TREND: { label: "ADX Trend Follower", blurb: "Rides strong directional momentum, ATR-stopped.", primaryParam: "adx_period", fits: "trending regimes with clean DI separation", }, }; // ---- auth-aware fetch helper (§4.1 — salvaged localStorage Bearer contract) ---- const API = { base: () => localStorage.getItem("centaur.baseUrl") || location.origin, token: () => localStorage.getItem("centaur.token") || "", async get(path) { const r = await fetch(this.base() + path, { headers: { "Authorization": "Bearer " + this.token() }, }); if (!r.ok) { const err = new Error(path + " -> " + r.status); err.status = r.status; throw err; } return r.json(); }, }; // collect distinct sorted numeric values for one param across a candidate set function axisValues(cands, key) { const seen = new Set(); for (const c of cands) { const v = c.params ? c.params[key] : undefined; if (v != null) seen.add(v); } return [...seen].sort((a, b) => a - b); } // derive a STRATEGIES entry per group_id observed in the live briefing function buildStrategies(rawDecisions) { const byGroup = new Map(); for (const d of rawDecisions) { const gid = d.group_id; if (!byGroup.has(gid)) byGroup.set(gid, { sample: d, cands: [] }); const bucket = byGroup.get(gid); if (d.winner) bucket.cands.push(d.winner); for (const r of (d.runner_ups || [])) bucket.cands.push(r); } const out = []; for (const [gid, { sample, cands }] of byGroup) { const meta = STRATEGY_META[gid] || {}; const paramKeys = sample.winner && sample.winner.params ? Object.keys(sample.winner.params) : []; const paramSpace = {}; for (const k of paramKeys) paramSpace[k] = axisValues(cands, k); out.push({ id: gid, cls: sample.strategy_class || gid, label: meta.label || gid, blurb: meta.blurb || "", floor: sample.win_rate_floor != null ? sample.win_rate_floor : 0, objective: sample.sweep_objective || "sharpe_x_winrate", primaryParam: meta.primaryParam && paramKeys.includes(meta.primaryParam) ? meta.primaryParam : (paramKeys[0] || null), paramSpace, fits: meta.fits || "", }); } return out; } // map a live briefing decision onto the shape the views read function buildDecisions(rawDecisions) { const groupCount = {}; return rawDecisions.map((d, i) => { const gid = d.group_id; groupCount[gid] = (groupCount[gid] || 0) + 1; const candidates = [d.winner, ...(d.runner_ups || [])].filter(Boolean); return { id: "D" + String(i + 1).padStart(2, "0"), ts_iso: d.ts_iso, strategy_class: d.strategy_class, strategy_id: gid, asset: d.asset, group_id: gid, win_rate_floor: d.win_rate_floor, sweep_objective: d.sweep_objective, bar_count: (d.winner && d.winner.bar_count) || null, // synthetic selection: rank within group; fit/regime/tags absent in the // artifact (engine doesn't serialize them) -> honest nulls, not fabricated. selection: { rank: groupCount[gid], fit: null, regime: null, tags: [], scanned: 0, }, sweep: { started_at: d.ts_iso, finished_at: d.ts_iso, // the artifact carries only the survivor field, not the full grid sampled: candidates.length, coarse_grid_size: candidates.length, partial: true, candidates, }, winner: d.winner, runner_ups: d.runner_ups || [], }; }); } // build the radar universe from /api/universe. The live snapshot is // symbols-only (list[str]) — every indicator (adx/rsi/regime/...) is genuinely // absent, so we publish nulls (rendered as "—"), never fabricated numbers. function buildRadar(universe, decisions) { // /api/universe serializes generated_at as a unix-seconds float; the // rationale feed uses ISO. Normalize to ISO so fmt.time/ago agree. const toIso = (g) => { if (g == null) return null; if (typeof g === "number") return new Date(g * 1000).toISOString(); return g; }; const raw = Array.isArray(universe.assets) ? universe.assets : []; const assets = raw.map((s) => { const sym = typeof s === "string" ? s : (s.symbol || s.sym || ""); const pick = (k) => (s && typeof s === "object" && s[k] != null ? s[k] : null); return { sym, name: (sym.split("/")[0] || sym), px: pick("px"), chg: pick("chg"), adx: pick("adx"), diPlus: pick("diPlus"), diMinus: pick("diMinus"), rsi: pick("rsi"), bbPctB: pick("bbPctB"), regime: pick("regime"), }; }); return { scanned_at: toIso(universe.generated_at), count: assets.length, qualified: null, duration_ms: 0, age_human: universe.age_human || null, fresh: !!universe.fresh, // true only if the snapshot actually carries indicator fields (Phase 2) hasIndicators: assets.length > 0 && assets[0].adx != null, assets, }; } // derive Selection from the committed decisions themselves — the engine's // real picks. A client-side fit ranking (meanRevFit/trendFit) needs indicators // the symbol-only universe lacks, so fit is null (labelled in-UI), not faked. function buildSelections(strategies, decisions, scanned) { return strategies.map((strat) => { const picks = decisions .filter((d) => d.strategy_id === strat.id) .sort((a, b) => a.selection.rank - b.selection.rank) .map((d) => ({ sym: d.asset, rank: d.selection.rank, fit: null, regime: null, selected: true, decisionId: d.id, })); return { strategy: strat.id, picks, alternatives: [], scanned, derived: true }; }); } // build the Execution stage from live broker + ledger reality. The recorded // plan (target_weight / drift / notional_usd / live_price) is persisted by the // engine on each order row (centaur/ledger.py `orders` table) — we surface // those verbatim and NEVER re-derive them (no fabricated plan-vs-fill). // Broker state (positions / open orders) supplies the live fill side. When an // endpoint is unavailable (Alpaca creds absent / empty ledger) the caller has // already degraded it to an empty list, so this just renders fewer rows. function buildExecution(positions, openOrders, trades, decisions) { const decByAsset = new Map(); for (const d of decisions) if (!decByAsset.has(d.asset)) decByAsset.set(d.asset, d); const posBySym = new Map(); for (const p of positions) posBySym.set(p.symbol, p); const openBySym = new Map(); for (const o of openOrders) { if (!openBySym.has(o.symbol)) openBySym.set(o.symbol, []); openBySym.get(o.symbol).push(o); } // `trades` is newest-first (ledger ORDER BY ts_utc DESC) — the first row per // symbol is the most recent recorded order, i.e. today's plan for that asset. const latestBySym = new Map(); for (const t of trades) if (!latestBySym.has(t.symbol)) latestBySym.set(t.symbol, t); const symbols = new Set([ ...posBySym.keys(), ...openBySym.keys(), ...latestBySym.keys(), ...decByAsset.keys(), ]); const rows = []; for (const sym of symbols) { const pos = posBySym.get(sym) || null; const ord = latestBySym.get(sym) || null; const opens = openBySym.get(sym) || []; const dec = decByAsset.get(sym) || null; // recorded plan — present only when the engine logged an order for `sym`. const targetWeight = ord && ord.target_weight != null ? ord.target_weight : null; const drift = ord && ord.drift != null ? ord.drift : null; const plannedUsd = ord && ord.notional_usd != null ? ord.notional_usd : null; const filledUsd = pos ? pos.market_value : null; const currentPx = pos ? pos.current_price : (ord ? ord.live_price : null); // status: read the recorded order outcome + live broker reality. Never // synthesize a verdict the data doesn't support -> UNKNOWN when neither. let status = "UNKNOWN"; if (ord && ord.status === "SKIPPED") status = "SKIPPED"; else if (ord && ord.status === "FAILED") status = "FAILED"; else if (opens.length) status = "PENDING"; else if (ord && ord.status === "SUBMITTED") status = "FILLED"; else if (pos) status = "HELD"; rows.push({ symbol: sym, decisionId: dec ? dec.id : null, isPaper: pos ? pos.is_paper : (ord ? !!ord.is_paper : (opens[0] ? opens[0].is_paper : null)), side: ord ? ord.side : (opens[0] ? opens[0].side : null), status, targetWeight, drift, plannedUsd, filledUsd, currentPx, qty: pos ? pos.qty : (ord ? ord.qty : null), unrealizedPl: pos ? pos.unrealized_pl : null, unrealizedPlpc: pos ? pos.unrealized_plpc : null, orderTs: ord ? ord.ts_iso : null, orderStatus: ord ? ord.status : null, regime: ord ? ord.regime : null, openOrders: opens, }); } rows.sort((a, b) => (b.filledUsd || b.plannedUsd || 0) - (a.filledUsd || a.plannedUsd || 0)); return rows; } // fetch the three execution feeds, each degrading to [] on failure, and report // an honest link state so the view can explain *why* it's empty (broker creds // absent vs. simply no activity). Alpaca-not-configured surfaces as 500. async function loadExecution(decisions) { let positions = [], openOrders = [], trades = []; let brokerUp = true, ledgerUp = true, reason = null; try { positions = await API.get("/api/positions"); } catch (e) { brokerUp = false; reason = e.status === 500 ? "broker not connected" : ("positions → " + (e.status || "error")); } try { openOrders = await API.get("/api/orders/open"); } catch (e) { brokerUp = false; if (!reason) reason = "broker not connected"; } try { trades = await API.get("/api/trades?limit=200"); } catch (e) { ledgerUp = false; } positions = Array.isArray(positions) ? positions : []; openOrders = Array.isArray(openOrders) ? openOrders : []; trades = Array.isArray(trades) ? trades : []; return { rows: buildExecution(positions, openOrders, trades, decisions), meta: { brokerUp, ledgerUp, reason, positions: positions.length, openOrders: openOrders.length, orders: trades.length, }, }; } // safe per-asset lookup — live universe is symbols-only, so price/change are // genuinely unknown here (null), not faked. Detail header renders "—". function makeU(decisions) { const map = new Map(); for (const d of decisions) { if (!map.has(d.asset)) { map.set(d.asset, { sym: d.asset, name: d.asset.split("/")[0], px: null, chg: null }); } } return (sym) => map.get(sym) || { sym, name: (sym || "").split("/")[0], px: null, chg: null }; } // ---- publish the synchronous skeleton; load() fills DATA before React mounts ---- window.GP = { seededRng, rng, fmt, meanRevFit, trendFit, NOW: Date.now(), TODAY: new Date().toISOString().slice(0, 10), STRATEGIES: [], REGIMES, UNIVERSE: [], U: () => ({ sym: "", name: "", px: null, chg: null }), DATA: { generated_at: null, radar: { scanned_at: null, count: 0, qualified: 0, duration_ms: 0, assets: [] }, selections: [], decisions: [], execution: [], execMeta: { brokerUp: false, ledgerUp: false, reason: null, positions: 0, openOrders: 0, orders: 0 }, }, available: false, async load() { const res = await API.get("/api/strategy-rationale?all=true"); // universe is a secondary feed — a failure/empty there must not blank the // Plan core, so it's caught and degraded to an empty snapshot. const universe = await API.get("/api/universe").catch(() => ({ assets: [], generated_at: null, age_human: "unavailable", fresh: false, })); const raw = Array.isArray(res.decisions) ? res.decisions : []; const strategies = buildStrategies(raw); const decisions = buildDecisions(raw); const radar = buildRadar(universe, decisions); const selections = buildSelections(strategies, decisions, radar.count); const exec = await loadExecution(decisions); this.NOW = Date.now(); this.TODAY = (res.today) || new Date().toISOString().slice(0, 10); this.STRATEGIES = strategies; this.UNIVERSE = radar.assets; this.U = makeU(decisions); this.available = !!res.available && decisions.length > 0; this.DATA = { generated_at: res.generated_at || null, radar, selections, decisions, execution: exec.rows, execMeta: exec.meta, }; return this; }, }; })();