Files
momentum-system/plans/012-dashboard-order-limits-declutter.md

12 KiB

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:

    {"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:

    (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 (after the 2026-07-17 Step-3 revision this is the ONLY file that changes)
  • tests/unit/api/ (only if a Python change sneaks back in — it should not)

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

REVISED 2026-07-17 after an executor STOP: the lifecycle EVENTS (order_submitted/order_filled, see query_order_lifecycle_snapshot, storage/projections.py:280) never captured limit_price/stop_price/ type — there is nothing to pass through, and adding fields to the emitters is an out-of-scope write-path change. The rich fields live in the BROKER snapshot the dashboard already receives as d.orders (account_orders_updated, flattened in alpaca_paper/sync.py:135-153), and the join key exists: alpaca_paper/execution.py:116 sends the internal order_id as the broker client_order_id (request.order_id[:48]; the ids are 36-char uuids so the truncation never bites). Enrich CLIENT-SIDE — do NOT modify storage/projections.py at all.

  • In render(d) next to the leg maps from Step 1, build brokerByClientId = {}: scan d.orders snapshots and map o.client_order_id → {type: o.type, limit: +o.limit_price || null, stop: +o.stop_price || null, fill: +o.filled_avg_price || null} (first snapshot wins, same idiom as stopBy).
  • In the Order Lifecycle rows, look up brokerByClientId[o.order_id]; change the table columns to: Symbol, Side, Type, Qty, Limit, Stop, Fill, Status, Updated. Fill falls back to the event-sourced o.fill_price when the broker join misses. Old lifecycle rows without a broker match render em-dashes for Type/Limit/Stop — acceptable (the broker snapshot covers the recent orders the panel shows).
  • 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.

Verify: node --check → exit 0. (No Python change in this step — the pytest stash comparison is unnecessary unless some other step touched storage/projections.py.)

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:

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.