plans(012-015): dashboard visibility milestone — order limits, symbol charts, decision trace, /backtest replay

Owner-requested UI work, advisor-planned for executor handoff:
- 012: show working limit/stop/target prices on positions + order lifecycle,
  cut dead columns (order-id, approval Mode)
- 013: symbol drill-in — 24h candlestick + live buy/sell fill markers
  (TradingView Lightweight Charts v5.2, vendored, Apache-2.0)
- 014: live decision trace — surface entry_gate_evaluated receipts as a feed
  (value→threshold per gate) + chart reject markers
- 015: /backtest replay page — theoretical entries/exits over past sessions
  under CURRENT settings, assembled strictly from Experiment-OS primitives

All four are read-path only; zero live-loop surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-17 06:00:45 -05:00
1 parent 43a3ace91d
commit 33708ffe6a
5 files changed
+1047

No files matched your search

@@ -0,0 +1,237 @@
# Plan 012: Show the actual buy/sell limit prices everywhere they act, and cut the dashboard fields that carry no decision weight
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving to the
> next step. If anything in the "STOP conditions" section occurs, stop and
> report — do not improvise. When done, update the status row for this plan
> in `plans/README.md` — unless a reviewer dispatched you and told you they
> maintain the index.
>
> **Drift check (run first)**: `git diff --stat 43a3ace..HEAD -- api/static/dashboard.html storage/projections.py api/main.py tests/unit/api/`
> If any in-scope file changed since this plan was written, compare the
> "Current state" excerpts against the live code before proceeding; on a
> mismatch, treat it as a STOP condition.
## Status
- **Priority**: P2
- **Effort**: S
- **Risk**: LOW (read-only UI + one projection field; no trading-path code)
- **Depends on**: none
- **Category**: dx / operator-visibility
- **Planned at**: commit `43a3ace`, 2026-07-16
## Why this matters
The owner watches this dashboard to understand what the bot is DOING with
money, and today the most decision-relevant numbers — the actual limit price a
buy was submitted at, the resting protective stop, and the take-profit limit —
are either hidden (bracket legs appear only as anonymous rows in Order
Lifecycle) or collapsed into a single generic "Price" column. Meanwhile some
columns carry no decision weight (truncated broker order-id, an approval
"Mode" column that always reads `auto`). This plan makes the working orders
legible (limit/stop/fill split out, bracket legs attached to their position)
and removes the dead columns.
## Current state
- `api/static/dashboard.html` (1492 lines) — the ONE dashboard UI (owner
preference: extend this page at :8765; do NOT touch the retired NiceGUI
control room). Single inline `<script>`; tables built with a `table(headers,
rows)` helper; panels are `<details class="panel">` blocks.
- `api/main.py` — stdlib HTTP server. `DashboardState.snapshots()` (line
~247) assembles the payload; `orders` snapshots come from
`storage/projections.py` and each order dict already carries everything
needed — verified against production:
```json
{"symbol": "EHGO", "side": "buy", "quantity": 5102.0, "type": "limit",
"status": "filled", "limit_price": "2.05", "stop_price": null,
"filled_avg_price": "2.05", "submitted_at": "2026-07-16T14:19:16Z"}
```
NOTE: `limit_price`/`stop_price`/`filled_avg_price` are STRINGS from the
broker — coerce with `+x` / `parseFloat` in JS before formatting.
- Positions table today (`dashboard.html:1268-1296`, inside `render(d)`):
columns `Symbol, Chart, Side, Qty, Avg, Last, R / risk, P/L[, Exit]`. A
`stopBy` map (line ~1263) already scans order snapshots for the resting
stop leg:
```js
(d.orders || []).forEach(snap => (snap.orders || []).forEach(o => {
if (o.side === "sell" && (o.type === "stop" || o.type === "stop_limit") && o.stop_price != null && !(o.symbol in stopBy))
stopBy[o.symbol] = +o.stop_price;
}));
```
- Order Lifecycle table (`dashboard.html:1312-1326`): columns `Order, Symbol,
Side, Qty, Price, Status, Updated` where `Price` renders `o.price` — a
single number that hides whether it is a limit, a stop, or a fill.
Its data comes from `query_order_lifecycle_snapshot` in
`storage/projections.py` — check what fields that projection emits; if it
does not pass through `limit_price` / `stop_price` / `filled_avg_price` /
`type`, extend it (read-only projection, safe).
- Approval queue table (`dashboard.html:1229-1246`): has a `Mode` column
rendering `q.approval_mode || "manual"` — the autonomous stack auto-approves,
so this column always reads `auto`; dead weight.
- Repo conventions: no framework, no build step — plain ES5-ish inline JS,
`esc()` for HTML escaping, `fmt()`/`money()` for numbers, `pill` spans for
side badges. Match them. Comments in this file explain WHY a column exists
(see the "show only the ACTIONABLE orders" comment at line ~1312) — keep
that style.
## Commands you will need
| Purpose | Command | Expected on success |
|---------|---------|---------------------|
| JS syntax gate | extract the inline `<script>` and run `node --check` on it (see Step 5) | exit 0 |
| API tests | `python3 -m pytest tests/unit/api/ -q` | pass (NOTE: on some local sandboxes these fail with urllib errors on clean HEAD — if they fail IDENTICALLY before your change, that is pre-existing; compare against a stash run) |
| Full suite | `python3 -m pytest tests/ -q --ignore=tests/unit/api` | all pass |
| Lint | `ruff check --select F api/ storage/` | exit 0 |
## Scope
**In scope** (the only files you should modify):
- `api/static/dashboard.html`
- `storage/projections.py` (ONLY `query_order_lifecycle_snapshot`, only to pass
through fields that already exist in the event payloads)
- `tests/unit/api/` (extend an existing test file if a projection changes)
**Out of scope** (do NOT touch):
- `api/static/admin.html`, anything under `archive/`
- `trading_execution.py`, `run_live_paper.py`, `alpaca_paper/` — no live-path
changes for a UI plan, ever
- The snapshot payload keys consumed by other panels (rename nothing)
- Plan 013's symbol-chart modal (separate plan; don't pre-build hooks for it)
## Git workflow
- Branch: work directly or `advisor/012-dashboard-order-limits` — repo commits
to master with descriptive messages (see `git log --oneline -10` for style,
e.g. `operator-plane: truthful LIVE badge + kill-switch UI + action gating`).
- Do NOT push unless the operator instructed it.
## Steps
### Step 1: Extend the bracket-leg map to capture the take-profit limit and pending entry
In `render(d)` next to the existing `stopBy` map, build two more maps from the
same `d.orders` scan, considering only orders whose `status` is one of
`new/accepted/pending_new/partially_filled` (a filled or cancelled leg is not a
working level):
- `targetBy[symbol]` = `+o.limit_price` where `o.side === "sell"` and
`o.type === "limit"` (the take-profit leg).
- `entryLimitBy[symbol]` = `+o.limit_price` where `o.side === "buy"` and
`o.type === "limit"` (a resting entry not yet filled).
**Verify**: `node --check` on the extracted script → exit 0.
### Step 2: Positions table — show the working levels
Add two columns after `Avg`: `Stop` and `Target`, rendering
`fmt(stopBy[pos.symbol])` / `fmt(targetBy[pos.symbol])` (em-dash when absent —
`fmt` already handles null). Keep the existing ⚠ no-protective-stop marker on
the Symbol cell. Update the `table([...])` header array to match.
**Verify**: `node --check` → exit 0.
### Step 3: Order Lifecycle — split "Price" into Limit / Stop / Fill and drop the order-id column
- If `query_order_lifecycle_snapshot` in `storage/projections.py` does not
already emit `limit_price`, `stop_price`, `filled_avg_price`, and `type`,
add them as pass-throughs from the payload (same pattern as the existing
fields in that function).
- Change the table columns to: `Symbol, Side, Type, Qty, Limit, Stop, Fill,
Status, Updated`. Drop the `Order` (truncated order-id) column — move the
full `order_id` into a `title` attribute on the Symbol cell so it is
hover-recoverable, not gone.
- Coerce string prices (`+o.limit_price` etc.) before `fmt`.
**Verify**: `python3 -m pytest tests/unit/api/ -q` behaves identically to a
pre-change stash run; `node --check` → exit 0.
### Step 4: Approval queue — drop the dead "Mode" column, show explicit numbers
Remove the `Mode` column. In the `riskChip(r)` helper (line ~770), the risk
summary already shows qty/notional/stop%; add the two absolute prices so the
operator sees the exact limits being sent: `entry <b>$X.XX</b>` and
`stop <b>$Y.YY</b>` (from `r.entry_price` / `r.stop_loss_price`, already read
there).
**Verify**: `node --check` → exit 0.
### Step 5: Syntax + render gate
Extract and check the page script:
```bash
python3 - <<'EOF'
import re, pathlib
html = pathlib.Path("api/static/dashboard.html").read_text()
scripts = re.findall(r"<script>(.*?)</script>", html, re.S)
pathlib.Path("/tmp/dash_check.js").write_text("\n;\n".join(scripts))
EOF
node --check /tmp/dash_check.js
```
→ exit 0. If a Playwright headless chromium is available in the environment,
also render `http://127.0.0.1:8765` after `python3 dashboard_api.py` and assert
zero `pageerror` events (this repo has caught blank-page JS bugs this way; see
Suggested executor toolkit).
## Suggested executor toolkit
- Headless render check: launch `chrome-headless-shell` via Playwright with
`args=["--no-sandbox"]`, navigate with `wait_until="load"` (NOT
"networkidle" — the SSE `/api/stream` never idles), capture
`page.on("pageerror")`. A passing API check is NOT a passing page.
## Test plan
- If `query_order_lifecycle_snapshot` changes: extend its existing unit test
(find it with `grep -rn "order_lifecycle" tests/unit/`) with one case
asserting `limit_price`/`stop_price`/`filled_avg_price`/`type` pass through
from a synthetic `account_orders_updated`-style payload. Model after the
neighboring projection tests in the same file.
- UI has no JS test harness — the `node --check` gate plus (optional)
headless render IS the UI verification. Do not introduce a JS test framework
for this plan.
## Done criteria
- [ ] Positions table shows `Stop` and `Target` columns populated from live
bracket legs (em-dash when absent)
- [ ] Order Lifecycle shows `Type, Limit, Stop, Fill` and no order-id column;
order-id recoverable via Symbol-cell `title`
- [ ] Approval queue has no `Mode` column; risk chip shows absolute entry and
stop prices
- [ ] `node --check` on the extracted script exits 0
- [ ] `python3 -m pytest tests/ -q --ignore=tests/unit/api` all pass;
`ruff check --select F api/ storage/` exits 0
- [ ] `git status` shows no modifications outside the in-scope list
- [ ] `plans/README.md` status row updated
## STOP conditions
Stop and report back (do not improvise) if:
- The order snapshot dicts do NOT contain `limit_price`/`stop_price`/`type`
(the excerpt above was verified against production on 2026-07-16 — if absent,
the projection chain changed and this plan is stale).
- Adding pass-through fields to `query_order_lifecycle_snapshot` requires
touching how events are WRITTEN (it must stay a read-side change).
- `node --check` fails on the UNMODIFIED extracted script (pre-existing
breakage — report, don't fix someone else's bug in this plan).
## Maintenance notes
- Plan 013 (symbol drill-in chart) reuses `stopBy`/`targetBy`/`entryLimitBy`
as chart level-lines — keep them top-level in `render(d)`, not inlined.
- Reviewer should scrutinize: string→number coercion on broker prices (the
`"2.05"` strings), and that the leg maps filter by working `status` (a
cancelled stop leg must not render as protection).
- Deferred deliberately: cutting whole panels (Watch States, Account). The
owner said "fields that don't really matter" — start with dead columns;
panel-level cuts need the owner pointing at specific panels.
+306
View File
@@ -0,0 +1,306 @@
# Plan 013: Symbol drill-in — last-24h candlestick chart with the bot's live buy/sell markers
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving to the
> next step. If anything in the "STOP conditions" section occurs, stop and
> report — do not improvise. When done, update the status row for this plan
> in `plans/README.md` — unless a reviewer dispatched you and told you they
> maintain the index.
>
> **Drift check (run first)**: `git diff --stat 43a3ace..HEAD -- api/main.py api/static/ storage/projections.py tests/unit/api/`
> If any in-scope file changed since this plan was written, compare the
> "Current state" excerpts against the live code before proceeding; on a
> mismatch, treat it as a STOP condition.
## Status
- **Priority**: P2
- **Effort**: M
- **Risk**: LOW-MED (new read-only endpoint + vendored JS lib; no trading-path code)
- **Depends on**: plans/012-dashboard-order-limits-declutter.md (reuses its
`stopBy`/`targetBy`/`entryLimitBy` maps for level lines; 013 works without
012 but then must build those maps itself — prefer landing 012 first)
- **Category**: direction / operator-visibility
- **Planned at**: commit `43a3ace`, 2026-07-16
## Why this matters
The owner's ask, verbatim: "look at a symbol and see the last 24 hours and the
marker of buy and sell that the model has on it in real time." Today the
dashboard has only 60-minute canvas sparklines — no premarket context, no
trade markers, no way to see WHERE the bot bought and sold on the tape. The
building blocks all exist (1-min bars incl. premarket in Postgres, buy/sell
fill events, an SSE stream that pushes on every change); this plan wires them
into a click-to-open chart modal.
**Do not hand-roll the chart.** The purpose-built existing solution is
**TradingView Lightweight Charts v5.2** (Apache-2.0, ~45 KB standalone bundle,
`window.LightweightCharts` global — researched 2026-07-16): candlestick series,
`createSeriesMarkers(series, markers)` for buy/sell arrows, `series.update(bar)`
for real-time, `createPriceLine` for entry/stop/target levels. Its license
requires a TradingView attribution notice — Step 1 handles it.
## Current state
- `api/main.py` — stdlib HTTP server, no framework.
- Routes live in `do_GET` (line ~522): flat `if route == "/api/..."` chain.
`/api/bars` (line ~537) is the existing single-session sparkline endpoint.
- `DashboardState.bars()` (line ~326) queries via
`research.query.query_minute_bars(con, symbol, session_date)` under
`self._lock`, returns `{"symbol", "bars": [{t,c,h,l,v,vwap}...]}`. Note it
LACKS the open — a candlestick chart needs `o` too.
- SSE: `_stream()` (line ~481) pushes a full `snapshot` event whenever
`state.change_stamp()` changes; heartbeat ~15 s.
- `STATIC_DIR / "dashboard.html"` served at `/`; static dir is
`api/static/`. NOTE: only `dashboard.html`/`admin.html` are served today —
there is NO generic static-file route; Step 2 adds one for the vendored
lib.
- `storage/projections.py:403` — `query_fills_feed(store, limit, for_date)`:
already extracts exactly the marker fields from `order_filled` /
`order_submitted` events: `{kind, order_id, symbol, side, quantity, price,
timestamp}`. It has no symbol filter (Step 3 adds one).
- `api/static/dashboard.html` — single-page inline-JS UI.
- Symbol cells render as `<td class="sym">SYMBOL</td>` across all tables.
- Sparkline pattern to imitate for fetching: `loadBars(symbol, entry, stop,
canvas)` (line ~759) with an 8-second per-symbol cache.
- SSE hookup: the page listens for the `snapshot` event and calls
`render(d)` — the chart modal must refresh off that same event, not its
own timer (a hidden extra poller is how dashboards rot).
- Plan 012 adds `stopBy` / `targetBy` / `entryLimitBy` maps at the top of
`render(d)` — reuse them for price lines.
- Data (verified against production 2026-07-16):
- `minute_bars(symbol, timestamp, session_date, is_premarket,
is_regular_hours, is_afterhours, open, high, low, close, volume, vwap)` —
1-min bars incl. premarket/after-hours. **Timestamps are NAIVE
US/Eastern** (`2026-07-16T10:33:21`). ~960 bars per symbol per 24 h. The
research connection uses `?` placeholders (facade converts to `%s`).
- `events(event_type, timestamp, payload_json)` — `order_filled` /
`order_submitted` rows carry `symbol/side/price/fill_price/quantity`;
timestamps also naive ET.
- Timezone convention: Lightweight Charts wants UTC epoch **seconds**. Convert
in Python: `datetime(...naive ET...).replace(tzinfo=ZoneInfo("America/New_York")).timestamp()`.
Do NOT ship naive strings to the chart.
- Conventions: no build step, no npm — vendored file + inline JS. Escape all
interpolated strings with the page's `esc()`. Read-only endpoints take no
token (the token gate is POST-only).
## Commands you will need
| Purpose | Command | Expected on success |
|---------|---------|---------------------|
| API tests | `python3 -m pytest tests/unit/api/ -q` | pass (pre-existing local-sandbox urllib failures possible — compare against a stash run of clean HEAD) |
| Full suite | `python3 -m pytest tests/ -q --ignore=tests/unit/api` | all pass |
| Lint (CI gate) | `ruff check --select F api/ storage/ research/` | exit 0 |
| JS syntax gate | extract inline `<script>` blocks, `node --check` (see plan 012 Step 5 for the snippet) | exit 0 |
| Manual serve | `python3 dashboard_api.py` → open :8765 | chart modal opens on symbol click |
## Suggested executor toolkit
- Lightweight Charts docs: https://tradingview.github.io/lightweight-charts/docs
(v5.2). Key APIs: `createChart(el, opts)`,
`chart.addSeries(LightweightCharts.CandlestickSeries)`,
`createSeriesMarkers(series, [{time, position:'belowBar'|'aboveBar',
shape:'arrowUp'|'arrowDown', color, text}])`, `series.createPriceLine({price,
color, title})`, `series.setData(bars)` / `series.update(bar)`.
- Headless render verification (this repo's standard for UI changes): drive
Playwright `chrome-headless-shell` with `--no-sandbox`, `wait_until="load"`
(never "networkidle" — the SSE stream stays open), capture
`page.on("pageerror")`. A JS error that blanks the page is invisible to curl.
## Scope
**In scope** (the only files you may modify/create):
- `api/main.py` (new `/api/chart` route + `DashboardState.chart()` + one
static-file branch for `/static/vendor/...`)
- `api/static/vendor/lightweight-charts.standalone.production.js` (create —
vendored, pinned v5.2)
- `api/static/vendor/NOTICE-lightweight-charts.txt` (create — the attribution
notice from the library's NOTICE file)
- `api/static/dashboard.html`
- `storage/projections.py` (ONLY adding an optional `symbol=` filter to
`query_fills_feed`)
- `tests/unit/api/` (new/extended tests)
**Out of scope** (do NOT touch):
- `trading_execution.py`, `run_live_paper.py`, `alpaca_paper/`, `runtime/` —
zero live-path changes
- `api/static/admin.html`, `archive/`
- No npm/package.json, no build tooling, no CDN `<script src>` (LAN dashboard
must work offline; vendor the file)
- The existing `/api/bars` sparkline endpoint (leave untouched; sparklines
keep working)
## Git workflow
- Match repo style (`git log --oneline -10`), e.g.
`operator-plane: symbol drill-in chart — 24h candles + live trade markers`.
- Do NOT push unless the operator instructed it.
## Steps
### Step 1: Vendor the library, pinned
Download the v5.2 standalone production bundle (from the GitHub release/dist
`lightweight-charts.standalone.production.js`) into
`api/static/vendor/lightweight-charts.standalone.production.js`. Copy the
repository's NOTICE attribution into
`api/static/vendor/NOTICE-lightweight-charts.txt`, and add a one-line HTML
comment near the `<script>` tag in `dashboard.html`: `<!-- Charting by
TradingView Lightweight Charts, https://www.tradingview.com/ (Apache-2.0, see
/static/vendor/NOTICE-lightweight-charts.txt) -->`. Also render the visible
attribution: the chart modal footer must contain a small link
`Charts by TradingView` → `https://www.tradingview.com/` (license requirement).
**Verify**: `node --check api/static/vendor/lightweight-charts.standalone.production.js`
→ exit 0; `head -3` shows the version banner containing `5.2`.
### Step 2: Serve the vendored file
In `do_GET`, add a branch: routes beginning with `/static/vendor/` are served
from `STATIC_DIR / "vendor"` with `Content-Type: application/javascript` and a
path-traversal guard — resolve the requested path and require
`resolved.is_relative_to(STATIC_DIR / "vendor")`, else 404. Follow the shape
of `_send_html` (line ~451) for headers/IO.
**Verify**: `python3 dashboard_api.py` (or the test-server fixture) then
`curl -s http://127.0.0.1:8765/static/vendor/lightweight-charts.standalone.production.js | head -1`
→ the banner line; `curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8765/static/vendor/../dashboard.html"`
→ `404`.
### Step 3: `/api/chart` endpoint — 24h bars + trade markers + levels
`DashboardState.chart(symbol, hours=24)` (new method, hold `self._lock` like
`bars()` does), returning:
```json
{"symbol": "XYZ",
"bars": [{"time": 1752664800, "open": 2.1, "high": 2.2, "low": 2.0, "close": 2.15, "volume": 12345}],
"markers": [{"time": 1752666600, "side": "buy", "kind": "order_filled", "price": 2.16, "quantity": 500}],
"levels": {"avg_entry": 2.16, "stop": 2.05, "target": 2.60, "entry_limit": null}}
```
- Bars: query the research con —
`SELECT timestamp, open, high, low, close, volume FROM minute_bars WHERE
symbol=? AND timestamp >= ? ORDER BY timestamp` with the cutoff = now(ET)
minus `hours` (compute in Python with `ZoneInfo("America/New_York")`, pass a
naive ET datetime to match storage). Convert each naive-ET timestamp to UTC
epoch seconds for `time`. Cap `hours` at 72.
- Markers: `query_fills_feed(store, limit=200, symbol=symbol)` — add an
optional `symbol: str | None = None` parameter to that function in
`storage/projections.py`, filtering `payload.get("symbol") == symbol` before
appending (keep default behavior identical when None). Keep only rows within
the same window; convert timestamps the same way. `side` comes through as
`buy`/`sell`.
- Levels: from the latest `account_positions_updated`/`account_orders_updated`
snapshots the state already loads for `snapshots()` — avg entry price of an
open position in `symbol`, plus working stop leg / target limit / pending
entry limit (same status filter as plan 012: `new/accepted/pending_new/
partially_filled`). All nullable.
- Route: `/api/chart?symbol=X&hours=24` in `do_GET`, mirroring the
`/api/bars` branch. If the research con is absent return
`{"symbol": ..., "bars": [], "reason": "no research db attached"}` (same
degrade pattern as `bars()`).
**Verify**: `python3 -m pytest tests/unit/api/ -q -k chart` → the new tests
pass (written in the Test plan step). Manual:
`curl -s "http://127.0.0.1:8765/api/chart?symbol=SPY&hours=24" | python3 -m json.tool | head`
→ bars array with integer `time` fields.
### Step 4: Chart modal in the UI
- Add a full-screen overlay `<div id="chart-modal">` (hidden by default) with
a header (`symbol`, close button, `Charts by TradingView` link) and a chart
container div. Style consistent with the page's dark palette (reuse CSS
variables already defined at the top of the file).
- Load the vendored script with `<script src="/static/vendor/lightweight-charts.standalone.production.js" defer></script>`
BEFORE the main inline script.
- Delegate clicks: any `<td class="sym">` (positions, ready signals, watch
states, order lifecycle, feed) opens the modal for that symbol. Use ONE
document-level click listener checking `closest("td.sym")` — do not attach
per-row handlers (rows re-render every SSE tick).
- On open: fetch `/api/chart?symbol=S&hours=24`; build
`LightweightCharts.createChart`, add a CandlestickSeries, `setData(bars)`;
`createSeriesMarkers` mapping `side==="buy"` → `{position:"belowBar",
shape:"arrowUp", color:"var green", text:"B "+qty}` and sell → aboveBar /
arrowDown / red; submitted-but-unfilled (`kind==="order_submitted"`) render
in a muted gray so a resting order is visually distinct from a fill. Add
`createPriceLine` for each non-null level (entry blue, stop red, target
green — same colors the sparkline refLines use, line ~742).
- Real-time: inside the existing SSE `snapshot` handler, if the modal is open
and ≥5 s since the last chart fetch, re-fetch `/api/chart` for the open
symbol and `setData`/`setMarkers` again (full replace — 960 rows is cheap;
do NOT build incremental `update()` plumbing in v1). Close on Escape and on
backdrop click; destroy the chart instance (`chart.remove()`) on close.
**Verify**: `node --check` on extracted inline scripts → exit 0. Manual: open
:8765, click a symbol in Positions/Ready Signals → modal with candles; with
the loop running, a new fill appears as an arrow within one SSE tick + ≤5 s.
### Step 5: Render gate
Run the headless render check (toolkit note): zero `pageerror`, and
`document.getElementById("chart-modal")` exists. If Playwright is unavailable
in the environment, state that explicitly in the completion report instead of
skipping silently.
## Test plan
New file `tests/unit/api/test_chart_endpoint.py`, modeled structurally on
`tests/unit/api/test_dashboard_sparklines.py` (same server/state fixtures):
1. `/api/chart?symbol=X` with seeded minute_bars across a session boundary →
bars sorted ascending, `time` is int epoch seconds, bars older than the
window excluded.
2. Seeded `order_filled` + `order_submitted` events for X and for another
symbol → markers contain only X's, with `kind` and `side` intact.
3. No research con attached → `{"bars": [], "reason": ...}`, HTTP 200.
4. `query_fills_feed(symbol=None)` unchanged vs before (regression guard on
the default path — assert same rows as an unfiltered call).
**Verify**: `python3 -m pytest tests/unit/api/test_chart_endpoint.py -q` → all
pass.
## Done criteria
- [ ] Clicking any symbol cell opens a 24-hour candlestick chart (premarket
bars included) with buy/sell arrows at the bot's fills and muted arrows
for submitted-unfilled orders
- [ ] Entry / stop / target price lines drawn when the symbol has working legs
- [ ] Chart refreshes from the existing SSE signal (no new standalone poller;
≥5 s throttle) and cleans up on close
- [ ] Vendored lib pinned at v5.2, served locally; NOTICE file + visible
TradingView attribution present; path-traversal request returns 404
- [ ] `python3 -m pytest tests/unit/api/test_chart_endpoint.py -q` all pass;
full suite (minus known-broken-local api group if pre-existing) passes;
`ruff check --select F api/ storage/ research/` exits 0
- [ ] `node --check` clean on vendored file and extracted inline scripts
- [ ] `git status` clean outside the in-scope list; `plans/README.md` updated
## STOP conditions
Stop and report back (do not improvise) if:
- The vendored v5.2 standalone bundle cannot be obtained (no network in the
executor environment) — report; do NOT substitute a CDN tag or an older
cached version silently.
- `minute_bars` timestamps turn out NOT to be naive US/Eastern (spot-check
`SELECT timestamp FROM minute_bars ORDER BY timestamp DESC LIMIT 1` against
the wall clock) — the epoch conversion in Step 3 would be wrong; report.
- The SSE `snapshot` handler in `dashboard.html` is not a single obvious
hookup point (drift since `43a3ace`).
- Plan 012 has not landed AND building the level maps yourself would duplicate
>20 lines — report the dependency instead of forking the logic.
## Maintenance notes
- The 5-second full-replace refresh is a deliberate ceiling
(`ponytail`-style): if the owner later wants tick-level updates, switch to
`series.update()` fed by a dedicated lightweight endpoint — do not shorten
the throttle on the full re-fetch.
- Marker volume: `query_fills_feed(limit=200)` bounds payload size; a
200-trade day on one symbol would truncate oldest-first — acceptable, noted.
- The library is pinned and vendored; upgrading it is a deliberate act (check
the v5→v6 marker API for breaking changes when that day comes).
- Reviewer scrutiny points: path-traversal guard on the vendor route; ET→UTC
epoch conversion around DST transitions; chart teardown on close (leaked
chart instances accumulate canvases).
+211
View File
@@ -0,0 +1,211 @@
# Plan 014: Live decision trace — show WHY the bot did or didn't enter, per gate, with the config values in play
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving to the
> next step. If anything in the "STOP conditions" section occurs, stop and
> report — do not improvise. When done, update the status row for this plan
> in `plans/README.md` — unless a reviewer dispatched you and told you they
> maintain the index.
>
> **Drift check (run first)**: `git diff --stat 43a3ace..HEAD -- api/main.py api/static/dashboard.html storage/projections.py tests/unit/api/`
> On mismatch with "Current state" excerpts, STOP.
## Status
- **Priority**: P2
- **Effort**: M
- **Risk**: LOW (read-only; new endpoint over existing events + UI panel/overlay)
- **Depends on**: plans/013-symbol-chart-trade-markers.md (the chart modal this
overlays; the Decision Feed panel half works standalone)
- **Category**: direction / operator-visibility
- **Planned at**: commit `43a3ace`, 2026-07-16 (plan written 2026-07-17)
## Why this matters
Owner's ask: "see the logic running live... visually clear... make additional
determinations as to some of the smaller env variables and see their impact on
the trade as to when and where the bot thought it would be." The bot ALREADY
emits exactly this as `entry_gate_evaluated` decision receipts (plan 005) —
one event per entry evaluation with a per-gate breakdown including the value
seen and the threshold applied. Today those receipts are write-only: nothing
on the dashboard reads them. This plan surfaces them two ways: a live
Decision Feed panel (latest gate evaluations, reject reasons, value-vs-
threshold), and reject/entry markers on the plan-013 symbol chart so the
owner sees WHERE on the tape the bot considered and declined.
## Current state
- Receipts: `trading_execution.py:1138` `_emit_gate_receipt` emits
`entry_gate_evaluated` (env `TRADING_GATE_RECEIPTS`, default on). Payload
verified in production 2026-07-16:
```json
{"timestamp": "2026-07-16T10:41:40.255354", "symbol": "ATAI",
"source": "fast", "passed": false, "reject_reason": "already_active",
"message": "ATAI entry gates (fast) — rejected: already_active",
"gates": {"session": {"outcome": "pass"}, "regime": {"outcome": "pass"},
"late_confirmation": {"outcome": "pass"},
"population": {"outcome": "pass"},
"rank": {"value": 5, "threshold": 0, "outcome": "pass"}}}
```
The `gates` map is a soft contract (additive only). Gate keys observed:
`session, regime, late_confirmation, population, rank` (+ others the slow
path adds); entries may carry `value` and `threshold`.
- Volume: ~1,300 receipts/session. **Retention: 14 days** — the nightly
housekeeping in `research/premarket_eval.py` purges `entry_gate_evaluated`
older than 14 days (it is in the spam list). The feature is a LIVE lens, not
a historical archive; say so in the UI hint.
- `api/main.py` — stdlib ThreadingHTTPServer; GET routes in `do_GET`
(line ~522) as a flat if-chain; `_send_json` helper. `DashboardState`
wraps the event store (`self.open_store()`), `store.query_events(
event_type=..., since=..., until=..., limit=...)` is the query interface
(see `storage/projections.py:403` `query_fills_feed` for the exact idiom,
including `json.loads(event.get("payload_json"))`).
- `api/static/dashboard.html` — panel pattern: `<details class="panel">`
blocks (see `p-feed` Activity Feed, line ~528, and its `render` at
line ~1298 for the row style to imitate). Plan 013 adds a chart modal with
`createSeriesMarkers`; its refresh path re-fetches on the SSE `snapshot`
event with a ≥5 s throttle.
- Effective config: the admin page already exposes env; the receipts carry the
numeric threshold per gate — the feed shows `value → threshold` per reject,
which is the "which env var did this" answer without new plumbing.
## Commands you will need
| Purpose | Command | Expected on success |
|---------|---------|---------------------|
| API tests | `python3 -m pytest tests/unit/api/ -q` | pass (pre-existing local urllib failures possible — compare to a clean-HEAD stash run) |
| Full suite | `python3 -m pytest tests/ -q --ignore=tests/unit/api` | all pass |
| Lint (CI gate) | `ruff check --select F api/ storage/` | exit 0 |
| JS syntax gate | extract inline `<script>`, `node --check` (plan 012 Step 5 snippet) | exit 0 |
## Scope
**In scope**:
- `storage/projections.py` (new `query_gate_receipts(store, symbol=None,
limit=100, for_date=None)` — same shape as `query_fills_feed`)
- `api/main.py` (new `/api/decisions` route; add `decisions` to the
`snapshots()` payload ONLY if the SSE-driven panel needs it — prefer the
standalone route + lazy fetch like the Component Attribution panel,
line ~546, to keep the snapshot payload from growing)
- `api/static/dashboard.html`
- `tests/unit/api/` (new test file)
**Out of scope**:
- `trading_execution.py` and anything that WRITES events — the receipt schema
is consumed as-is; if a gate lacks `value`/`threshold`, render an em-dash,
do not add fields to the emitter.
- The 14-day retention policy in `research/premarket_eval.py`.
- `api/static/admin.html`, `archive/`.
## Git workflow
- Match repo style; suggested message:
`operator-plane: live decision trace — gate receipts feed + chart reject markers`.
- Do NOT push unless instructed.
## Steps
### Step 1: `query_gate_receipts` projection
In `storage/projections.py`, next to `query_fills_feed` (line 403), add
`query_gate_receipts(store, symbol=None, limit=100, for_date=None)`:
query `entry_gate_evaluated` via the same `_hist_window(for_date)` +
`store.query_events` idiom; parse payload; keep `{timestamp, symbol, source,
passed, reject_reason, gates}`; filter by `symbol` (case-insensitive upper)
when given; newest first; cap at `limit`.
**Verify**: unit test from Test plan case 1 passes.
### Step 2: `/api/decisions` route
In `do_GET`, add `/api/decisions` accepting `symbol` (optional), `limit`
(default 100, max 500), `date` (optional, same semantics as `/api/snapshots?date=`).
Return `{"decisions": [...]}`.
**Verify**:
`curl -s "http://127.0.0.1:8765/api/decisions?limit=5" | python3 -m json.tool`
→ 5 receipt dicts with `gates` maps (live DB) or empty list (fresh DB).
### Step 3: Decision Feed panel
New `<details class="panel full" id="p-decisions">` titled
`Decision Trace <span class="hint">why each entry was taken or refused ·
value → threshold per gate · receipts kept 14 days</span>`. Lazy-load on
first open + refresh on SSE `snapshot` while open (imitate the Component
Attribution lazy-open pattern at the bottom of the file, and the ≥5 s throttle
idiom from plan 013). Each row:
- time · symbol · source pill (`fast`/`auto`) · PASS (green) or the
`reject_reason` (red)
- the gates map inline, compact: each gate as a chip —
`rank 5→0 ✓`, `population ✗`, `regime ✗ (unfavorable)` — green ✓ / red ✗,
with `value→threshold` only when both present. Full receipt JSON in a
`title` tooltip.
- A symbol filter input at the panel top (client-side param to
`/api/decisions?symbol=`).
**Verify**: `node --check` clean; manual: open panel on the live dashboard →
rows appear; typing a symbol narrows.
### Step 4: Chart overlay — where the bot said no
In the plan-013 chart modal fetch path, also fetch
`/api/decisions?symbol=S&limit=200`. Add markers via the SAME
`createSeriesMarkers` call (merge arrays, sort by time — Lightweight Charts
requires ascending marker time):
- rejected receipts → small gray-red `circle` ABOVE bar, text = short reason
code (first 12 chars of `reject_reason`); collapse consecutive receipts with
the same reason within 5 minutes into one marker (1,300/day would smear the
chart — the collapse rule is load-bearing).
- passed receipts → no marker (the fill arrow from plan 013 already marks the
outcome).
**Verify**: manual — open a symbol the bot rejected today (any
`signal_blocked`-heavy name): gray-red circles at the rejection times;
tooltip/legend shows the reason.
## Test plan
New `tests/unit/api/test_decisions_endpoint.py`, modeled on
`tests/unit/api/test_dashboard_api.py` fixtures:
1. Seed 3 `entry_gate_evaluated` events (2 symbols; one passed, one rejected
with `gates.rank.value/threshold`) → `query_gate_receipts` returns all 3
newest-first; `symbol=` filter returns only that symbol's; `gates` map
survives round-trip.
2. `/api/decisions?symbol=x&limit=1` over the seeded store → HTTP 200, one
row, lower-case symbol matched case-insensitively.
3. `limit=9999` clamps to 500 (no unbounded scan).
**Verify**: `python3 -m pytest tests/unit/api/test_decisions_endpoint.py -q`
→ all pass.
## Done criteria
- [ ] Decision Trace panel shows live receipts with per-gate ✓/✗ chips and
`value→threshold` where present; symbol filter works; 14-day hint shown
- [ ] Chart modal shows collapsed reject markers for the open symbol
- [ ] `/api/decisions` clamped, case-insensitive, date-scoped
- [ ] New endpoint tests pass; full suite passes; `ruff check --select F api/
storage/` exits 0; `node --check` clean
- [ ] `git status` clean outside scope; `plans/README.md` updated
## STOP conditions
- Receipt payloads lack the `gates` map (schema drift since `43a3ace`).
- The `snapshot` SSE payload would need to grow to make the panel work
(the lazy-fetch design above should avoid it; if not, stop and report).
- Marker merge with plan 013's fills breaks chart rendering (ascending-time
requirement) after one fix attempt.
## Maintenance notes
- The gates map is additive-only by contract — the chip renderer must ignore
unknown keys gracefully (render name + outcome, skip value).
- If receipt retention ever changes from 14 days, update the panel hint.
- Deferred: an env-var diff view ("this reject would pass if X were Y") —
that is a simulation, not a display; belongs with plan 015's machinery if
ever built.
+283
View File
@@ -0,0 +1,283 @@
# Plan 015: Backtest replay dashboard — how the bot WOULD have traded past weeks under CURRENT settings
> **Executor instructions**: Follow this plan step by step. Run every
> verification command and confirm the expected result before moving to the
> next step. If anything in the "STOP conditions" section occurs, stop and
> report — do not improvise. When done, update the status row for this plan
> in `plans/README.md` — unless a reviewer dispatched you and told you they
> maintain the index.
>
> **Drift check (run first)**: `git diff --stat 43a3ace..HEAD -- api/main.py api/static/ research/experiments/replay.py strategy/exits.py strategy/population.py tests/unit/api/`
> On mismatch with "Current state" excerpts, STOP.
## Status
- **Priority**: P2
- **Effort**: L
- **Risk**: MED (read-only, but the replay must be HONEST — a leaky or
fidelity-free replay shown as truth is worse than no page; every simulation
primitive here is reused from the adversarially-reviewed Experiment OS, not
reimplemented)
- **Depends on**: plans/013-symbol-chart-trade-markers.md (chart component +
vendored lib reused for per-trade drill-in)
- **Category**: direction / research-tooling
- **Planned at**: commit `43a3ace`, 2026-07-16 (plan written 2026-07-17)
## Why this matters
Owner's ask: "a separate dashboard to show when theoretical entries and exits
would have happened with backtested trades over days or weeks to get a feel of
how the bot would have acted from past data as if it was current with the
current settings." The Experiment OS already contains every piece of an honest
replay: theoretical ORB fires from `setup_events`, the SHARED live exit engine
simulated on real bar paths with adverse-fill fidelity, the SHARED population
gate, and walk-forward regime scores. What's missing is only the assembly — a
`/backtest` page that runs those pieces over a chosen window with the
CURRENTLY-configured settings and shows the resulting trades, per-day P&L, and
equity curve.
## Current state
All primitives exist — REUSE them; write no new simulation logic:
- `research/experiments/replay.py`:
- `reconstruct_fires_from_setups(store, since=None, limit=None)` (line ~164)
— theoretical fires from `setup_events` (entry = `entry_reference_price`,
stop = `invalidation_price`, `sim_baseline=True`). The 18k-strong
backtestable ORB universe.
- `_fire_in_population(con, cfg, fire)` (line ~201) — screens a fire through
the SHARED `strategy/population.py` gate on open-knowable inputs.
`population_config(pop_json)` (line ~196) builds the config from a dict.
- `_sim_baseline_r(fire, bars_after, fidelity)` (line ~306) — the LIVE exit
config (`ExitConfig.from_env()`, cached in `_live_exit_cfg`) simulated on
the bar path with adverse-fill accounting (`_adverse(fidelity)`,
entry fill = entry + adverse ticks). THIS is "current settings".
- Look at `run()` for the batched per-session minute-bar loading pattern
(one query per session, not per fire) — copy that idiom.
- `strategy/exits.py`: `simulate_exit(entry_price, init_stop, bars_after, cfg)
-> ExitResult` (line 229) with `r_multiple`, `reason`, `exit_index` (index
into `bars_after` where the position fully closed — this gives the EXIT
TIMESTAMP for plotting), `fills: list[ExitFill(frac, price, reason)]`.
For per-trade exit markers call `simulate_exit` directly (not
`_sim_baseline_r`, which returns only the scalar R) — but keep the SAME
adverse-fill entry (`entry + _adverse(fidelity)`) and the same
`_live_exit_cfg()` so numbers match the harness.
- Regime gate: `regime_scores` table has walk-forward `favorable` per
(model_version='meta_v1', session_date) — the current live gate's decision
layer. Keep-or-zero per session (see `iv_regime_gate`, replay.py:429:
no score row → KEEP, fail-open).
- Live current settings sources: `ExitConfig.from_env()` (exits),
population from env via `trading_execution.ExecutionSettings.from_env()`
fields `price_band_min/price_band_max/min_entry_gap_pct/min_day_volume`
(see `_population_reject`, trading_execution.py:~895, for the exact
field→PopulationConfig mapping to replicate), regime gate on/off via
`TRADING_REGIME_GATE_ENABLED`.
- Server: `api/main.py`, stdlib **ThreadingHTTPServer** (each request gets a
thread — a slow backtest request does NOT block the live SSE stream).
Static HTML served via `_send_html(STATIC_DIR / "dashboard.html")`;
admin.html shows the two-page pattern.
- Cost estimate (measured on this repo): batched replay ≈ sub-second per
session; a 30-session window lands in the ~10–30 s range. Synchronous
request + client-side loading state is acceptable at v1 WITH the session
cap below; no job queue.
- Fidelity defaults: the pre-registered experiments use
`{"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1}` — use the same
(see `experiments/regime_meta_v1.json`).
## Commands you will need
| Purpose | Command | Expected on success |
|---------|---------|---------------------|
| API tests | `python3 -m pytest tests/unit/api/ -q` | pass (pre-existing local urllib failures possible — compare to clean-HEAD stash run) |
| Replay tests | `python3 -m pytest tests/unit/experiments/ -q` | all pass (must stay green — you're consuming, not changing, replay) |
| Full suite | `python3 -m pytest tests/ -q --ignore=tests/unit/api` | all pass |
| Lint (CI gate) | `ruff check --select F api/ storage/ research/` | exit 0 |
| JS syntax gate | plan 012 Step 5 snippet + `node --check` | exit 0 |
## Scope
**In scope**:
- `api/static/backtest.html` (create — the separate dashboard page)
- `api/main.py` (route `/backtest` → the page; route `/api/backtest` → the
replay; a small in-memory result cache on `DashboardState`)
- `research/experiments/replay.py` — ONLY if a tiny public helper is needed to
avoid importing privates from api/ (e.g. `def replay_window(store, since,
until, *, population_cfg, regime_gate, fidelity) -> list[dict]`); simulation
internals unchanged.
- `tests/unit/api/` (new test file)
**Out of scope**:
- ANY change to simulation semantics (`simulate_exit`, `_sim_baseline_r`,
population logic, fidelity math) — consumers only.
- `trading_execution.py`, `run_live_paper.py`, live loop config.
- Writing anything to the DB (results are computed + cached in memory only).
- Parameter sweeps / what-if config editing in the UI — v1 replays the
CURRENT env settings only (see Maintenance notes).
## Git workflow
- Match repo style; suggested:
`research-tooling: /backtest replay dashboard — theoretical trades under current settings`.
- Do NOT push unless instructed.
## Steps
### Step 1: `replay_window` helper in replay.py
Public function assembling existing pieces (no new math):
```python
def replay_window(store, since, until, *, population_cfg=None,
regime_gate=False, fidelity=None) -> dict:
"""Theoretical trades for [since, until] under the CURRENT live exit
config. Reuses reconstruct_fires_from_setups + _fire_in_population +
simulate_exit with the harness's adverse-fill accounting."""
```
- fires = `reconstruct_fires_from_setups(store, since=...)` filtered to
`session_date <= until`.
- population screen when `population_cfg` given (`_fire_in_population`).
- regime screen when `regime_gate`: drop fires whose session has a
`regime_scores` row (model `meta_v1`) with `favorable = false`; sessions
without a row are KEPT (fail-open, mirrors `iv_regime_gate`).
- per session, batch-load minute bars once (copy `run()`'s idiom); per fire:
`bars_after` = bars strictly after `fill_ts`; entry_fill =
`entry_price + _adverse(fidelity)`; `res = simulate_exit(entry_fill,
stop_price, bars_after, _live_exit_cfg())`; skip fires with no bars or
`risk <= 0` (count them as `skipped`).
- returns `{"trades": [...], "daily": [...], "summary": {...}, "skipped": N}`
where each trade carries `{symbol, session_date, entry_ts, entry_price,
entry_fill, stop_price, exit_ts (bars_after.iloc[exit_index].timestamp),
exit_reason, r_multiple, fills: [{frac, price, reason}]}` — timestamps as
ISO strings (naive ET, converted by the API layer like plan 013 does),
`daily` = per-session `{date, n, sum_r, cum_r}`, `summary` = `{n, sum_r,
avg_r, hit_rate, sessions}`.
**Verify**: `python3 -m pytest tests/unit/experiments/ -q` still green (no
behavior change to existing paths); new unit test (Test plan case 1) passes.
### Step 2: `/api/backtest` route + cache
- GET `/api/backtest?days=10` (alternatively `since`/`until` ISO dates).
**Cap: 45 sessions** — clamp and report `{"clamped": true}` beyond it.
- Build inputs from CURRENT env exactly as live does: population cfg from
`ExecutionSettings.from_env()` fields (the `_population_reject` mapping),
`regime_gate` from `TRADING_REGIME_GATE_ENABLED`, fidelity =
`{"atr_frac": 0.25, "spread_mult": 3, "adverse_ticks": 1}`.
- Echo the effective settings in the response (`"settings": {...}`) — the
page must SHOW what config produced the numbers, or the owner can't trust a
stale tab.
- Cache on `DashboardState`: key = (since, until, repr(settings)); value +
monotonic timestamp; TTL 10 min; lock around compute so two concurrent
requests don't both run the replay (`threading.Lock`, compute-once).
- Convert naive-ET timestamps to UTC epoch seconds for chart use (same
conversion as plan 013 Step 3).
**Verify**: `curl -s "http://127.0.0.1:8765/api/backtest?days=5" | python3 -m json.tool | head -30`
→ trades + daily + summary + settings; second call returns in <1 s (cache).
### Step 3: the `/backtest` page
`api/static/backtest.html`, served at `/backtest` (add the route next to the
dashboard's). Reuse the dashboard's CSS variables/panel styles (copy the
`:root` block; a shared stylesheet refactor is out of scope). Layout:
- Header: window picker (5/10/20/45 sessions buttons + custom date range),
the echoed settings rendered as chips (`target_r=2.0`, `gap≥10%`,
`regime gate ON`, fidelity), and a bold banner: **"THEORETICAL — simulated
fills (adverse-tick fidelity), setup-sourced entries; not live P&L."**
- Summary strip: n trades, sum R, avg R, hit rate, sessions, skipped count.
- Equity curve: cumulative R per session (reuse the dashboard's existing
canvas equity-spark drawing approach — see `renderEquity` /
`renderDaily` in dashboard.html — or a Lightweight Charts line series;
either is fine, pick ONE).
- Per-day bars: like the dashboard's Daily Performance panel; click a day →
filters the trade table.
- Trade table: date, symbol, entry ts/price, exit ts/price, exit reason, R
(green/red). Click a row → chart modal (plan 013's component pattern:
vendored Lightweight Charts, candles for that session via `/api/chart`
scoped hours, plus THEORETICAL entry (blue arrowUp) and exit (purple
arrowDown, text = exit reason) markers from the trade row — purple
distinguishes simulated from the live-fill green/red of plan 013).
- Loading state while `/api/backtest` runs (10–30 s for big windows): spinner
+ "replaying N sessions…" — no silent hang.
**Verify**: `node --check` on extracted scripts → exit 0; manual: open
`http://127.0.0.1:8765/backtest`, pick 5 sessions → summary + curve + table
render; row click opens chart with theoretical markers.
### Step 4: honesty checks in the page
Two footer lines, always visible:
- entries are `setup_events`-sourced ORB triggers (the labeler's population),
entry fill = trigger + adverse ticks — optimistic vs real slippage on thin
names;
- sessions missing from `setup_events` (nightly build gaps) are silently
absent — show `sessions replayed: X of Y calendar trading days` computed
from the daily array vs the requested window.
**Verify**: visual — both lines present; the X-of-Y count is right for a
window containing a known gap (e.g. a weekend).
## Test plan
New `tests/unit/api/test_backtest_endpoint.py` (fixtures modeled on
`tests/unit/experiments/` seeding + `tests/unit/api/` server fixtures):
1. `replay_window`: seed 2 sessions × 2 setups with synthetic minute bars
(reuse the `_seed_session` idiom from `tests/unit/research/test_regime.py:
~130`) → returns 4 trades with `exit_ts` inside the session, `daily` has 2
rows, `cum_r` monotone-consistent with `sum_r`.
2. Population screen: one setup priced outside the band → excluded, counted
in the response, not in trades.
3. Regime gate: seed `regime_scores` meta_v1 unfavorable for session 1 →
its trades drop when `regime_gate=True`; session without a score row KEPT.
4. `/api/backtest?days=9999` → clamped, `clamped: true`.
5. Cache: two identical calls → second returns the identical object (mock or
count compute invocations via monkeypatch on `replay_window`).
**Verify**: `python3 -m pytest tests/unit/api/test_backtest_endpoint.py -q` →
all pass.
## Done criteria
- [ ] `/backtest` renders: settings chips, THEORETICAL banner, summary strip,
equity curve, per-day bars, trade table, chart drill-in with
entry/exit markers
- [ ] Numbers come from `simulate_exit` + `_live_exit_cfg` + harness fidelity
— `grep -n "def simulate\|risk\s*=" api/` shows NO simulation math in
api/ (assembly only)
- [ ] 45-session clamp, 10-min cache, compute-once lock all in place
- [ ] All 5 new tests pass; `tests/unit/experiments/` untouched and green;
full suite passes; CI ruff gate clean; `node --check` clean
- [ ] `git status` clean outside scope; `plans/README.md` updated
## STOP conditions
- Any step tempts you to modify `simulate_exit`, `_sim_baseline_r`,
population logic, or fidelity math — STOP; this plan consumes them.
- `reconstruct_fires_from_setups` coverage for the chosen window is near-zero
(nightly labeler gap — see plans/README 009 note about the 07-15 backfill):
report the data gap, don't pad with a different fire source.
- A 5-session replay exceeds ~60 s on the dev machine — the batching idiom
wasn't copied correctly; stop after one fix attempt.
- Plan 013's vendored chart lib is absent (013 not landed) — report; don't
CDN it.
## Maintenance notes
- v1 replays CURRENT env only. The obvious next step ("what if I change env X
and replay") is a parameter-sweep UI — it belongs on top of the Experiment
OS's `iv_exit_config` (which already takes an arbitrary exit config), and
should be a new plan; do not bolt query-param config overrides onto
`/api/backtest` ad hoc, that becomes an unregistered experiment lab with no
pre-registration discipline.
- The equity curve is R-based, not dollar-based — sizing is deliberately out
(fixed-fractional sizing depends on live equity; mixing it in would fake
precision).
- Reviewer scrutiny: the fires' `bars_after` slicing (strictly AFTER fill_ts —
off-by-one here silently peeks); ET→UTC conversion; the compute lock (a
stampede of tabs must not run N replays).
- The nightly `setup_events` build is this page's data supply — if the
scheduler misses a night (see plan 009 note), yesterday will be missing
here too; the X-of-Y sessions line surfaces that.
+10
View File
@@ -28,6 +28,11 @@ your row when done.
| 009 | Feature-population prerequisite — instrument v1/pm1 (market context, RVOL fallback, catalyst-on), then re-run 008's probe | P2 | L | 006, 008 | code DONE, awaiting supervised rebuild+probe (market context + thin-name RVOL fallback + SPY/QQQ ingest + enrichment-on + committed probe all landed & tested; Steps 4-5 rebuild/AUC run supervised). **2026-07-16: the 07-15 nightly job never fired (docker outage overlapped 20:00 ET) — 07-15 was a data hole (0 setup_events, no SPY ingest, no regime row). BACKFILLED supervised: SIP refetch + 50 v1 setups + 6 pm1 fires + SPY/QQQ ingest + regime row for 07-15. Accrual clock unbroken; verify tonight's 20:00 scheduler fires.** |
| 011 | Regime gate — a FREE-data daily signal deciding whether the ORB trades today; metalabel `P(our ORB profits this session)`, proven in the harness (walk-forward) before any live gate | P1 | L | 006, 004, 009 | **BACKTESTED ON PROD (2026-07-15) — VIABLE as a SHADOW damage-control signal, NOT yet a live gate. 266 walk-forward sessions: trade-always −0.111R/session (36% hit) → meta_v1 GATED −0.004R/session (48% hit), trading only the best ~32% of days; delta +0.107R in-sample, +0.128R OOS (recent 80 sessions), improves across 3 folds. threshold_v1 rule gates too little to matter. Leaky full-fit +0.61R ≫ walk-forward +0.078R confirms the walk-forward discipline was load-bearing; OOS holding = the edge is real. Verdict: turns a bleeding strategy roughly FLAT by sitting out bad regimes. FIRE-WEIGHTED harness (the LIVE v1 population — gap≥10%, vol≥300k — 2041 fires): BOTH experiments PASS their pre-registered bars (first PASS of the milestone) — regime_meta_v1 expR_delta +0.0965R, baseline −0.085R/fire → GATED +0.0117R/fire (POSITIVE on the real population, keeps ~33%); regime_threshold_v1 +0.083R but keeps only 26 fires (its pass = 'sit out 99%', thin/ignore). So on the actual tradeable population the gate is mildly POSITIVE, not just flat. DEPLOYED (shadow): scheduler runs build-features+score nightly; admin `/api/regime/run` button triggers on demand. LIVE-GATED IN PAPER (Step 5 shipped 2026-07-15, commit `0ac09d3`): `_regime_reject` blocks fresh entries on unfavorable sessions, `TRADING_REGIME_GATE_ENABLED=1` in both compose, fail-open everywhere. Blocker-1 (tape_regime not pre-open) resolved by DELETING tape_regime — measured 0.000 importance; LIVE feature set = breadth_rvol(vol_upto/avg, non-look-ahead) + spy_lagged + trailing. Takes effect on next deploy. Real-money promotion still a separate owner decision. **2026-07-16 AUDIT + FIX: the shipped live gate was structurally INERT — `live_session_favorable` read today's breadth_rvol from today's OWN setup_events rows, which the labeler only writes at 20:00 ET, so the fail-open guard returned None every live session (verified: no meta_live score + 1288 all-pass gate receipts on 07-16; the 07-15 'validation block' p=0.080 was computed PRE-guard on a zero-filled feature — an artifact, not a validation). Also found breadth_rvol was UNIT-CONTAMINATED: `_session_context` pooled all setup versions, and pm1 stores RAW cumulative premarket volume in relative_volume (median 261k) vs ratio tracks (~0.8) — ~240/267 historical sessions were pm1-raw-volume, and plan 009's v1 RVOL fallback was about to flip forward sessions to ratio units (07-15 rebuilt = 0.18), silently breaking the feature. FIX (same-day): breadth_rvol redefined pm1-ONLY (median premarket volume — the dominant historical source, unit-consistent, strictly pre-open knowable); live gate recomputes today's pm1 fires IN MEMORY from premarket minute_bars (pure compute_premarket_setups, no insert); _regime_reject retries fail-open verdicts every ~5 min instead of caching None all day (definite verdicts still once/day). regime_features rebuilt (267 sessions, labels unchanged), scores rewritten, fire-weighted re-validation vs the PRE-REGISTERED bars: regime_meta_v1 expR_delta +0.0570 (bar +0.05, n=2058, PASS — weaker than the contaminated +0.0965 but real), keeps 37%, baseline −0.078→gated −0.021; recent-80-session delta +0.149R/session (always −0.141 → gated +0.008, trading 29% of days). threshold_v1 +0.0766 but keeps 1% (sit-out-everything, ignore as before). Live check 07-16 on fixed code: breadth 612,819, verdict UNFAVORABLE — first genuine live decision. Adversarial review (19-agent, 9 findings) hardened the live path: previous_close falls back to the latest prior session's close when today's daily_bars row is absent intraday (else the gap basis silently flips to the premarket open = different fire population than training); verdicts before 9:50 ET are PROVISIONAL (re-scored ~5 min — ingest lag can truncate the premarket frame) and the first verdict at/after 9:50 is FINAL even when None (caps the scorer at a handful of runs/day instead of ~78 on quiet days — hot-path stall guard); NULL-breadth training rows excluded from the live fit (zero-fill taught a 'zero premarket volume' class the serve path can never produce); throttle datetime arithmetic moved inside the fail-open try. KNOWN ACCEPTED SKEW: live premarket volumes are scaled-IEX estimates vs the nightly SIP tape (up to ~25-50x low on symbols without a volume_ratio row) — regime_scores['meta_live'] vs the EOD label is the standing forward measure of whether this skew flips verdicts; revisit (intraday SIP refetch at 9:46+, 16-min embargo) if agreement is poor. Takes effect on next image deploy.** Prior status below. CODE DONE + REVIEW-APPROVED (commit `5cef4aa`), deploy-wired + backtested at `092d607`. Built: `research/regime.py` (probe + walk-forward `build-features`/`score`), `iv_regime_gate`Line truncated
| 012 | Dashboard order-limits verbosity + dead-column cuts (owner request 2026-07-16) | P2 | S | — | TODO |
| 013 | Symbol drill-in: 24h candlestick + live buy/sell trade markers (TradingView Lightweight Charts v5.2, vendored) | P2 | M | 012 (soft — reuses its bracket-leg maps) | TODO |
| 014 | Live decision trace — gate-receipt feed (value→threshold per gate) + chart reject markers | P2 | M | 013 (chart overlay half; feed panel standalone) | TODO |
| 015 | Backtest replay dashboard (/backtest) — theoretical entries/exits over past sessions under CURRENT settings, via Experiment-OS replay | P2 | L | 013 (chart component) | TODO |
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
## Expected drift (read before running any plan's drift check)
@@ -49,6 +54,11 @@ plan in this series (check the status table above).
- 005 before 006 is recommended (receipts enrich replay + rank_one_bullet needs rank), but 006's bar-path replay works without it — the rank experiment just starts underpowered.
- **Postgres provisioning (owner)**: the only new DDL in the milestone is plan 006's `experiments` / `experiment_runs` / `experiment_verdicts` tables (full DDL in `plans/006-experiment-os.md`). Plans 001–005/007 add no tables. The owner said they will provision the database once the plan is solid — that means: apply 006's DDL (or let the harness's idempotent schema-apply do it on first run), and after 002 lands, optionally run its operator `DROP TABLE` list post-backup.
- 013 soft-depends on 012: the chart's entry/stop/target price lines reuse the bracket-leg maps (`stopBy`/`targetBy`/`entryLimitBy`) 012 builds in `render(d)`. 013 alone must build them itself (STOP condition covers it).
- 012/013/014/015 are UI/read-path only — zero overlap with plans 008-011's research/live-gate surface; safe to execute in parallel with any of them.
- Recommended UI execution order: 012 → 013 → 014 → 015. 014's Decision Feed panel half can land before 013; its chart-overlay half can't. 015 consumes replay.py as-is — any plan that changes exit-sim semantics invalidates its honesty claim and must re-verify it.
- 015's data supply is the nightly setup_events build (plan 009's accrual) — a missed scheduler night is a missing day on /backtest; the page surfaces it via the "sessions replayed X of Y" line.
## Findings considered and rejected (do not re-audit)
- **Retest/pullback entry logic, scale-out partials, close-above-trigger confirmation from folklore** — refuted in `docs/research/entry_conversion_research.md` (external evidence + in-house tests); the research doc's "explicit do-nots".