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:
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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
|
||||
Reference in new issue
Block a user