--- name: tac-algo-trade description: Guide agents to run the TradeAC scheduled algo-trading flow end-to-end. First backfill the data lake for all symbols up to the latest completed trading day, then — given a reference MLflow run (experiment_name + run_id) — re-train the same model configuration on a rolling window (4 years up to the latest completed trading day), generate fresh signals, run the selected strategy into a target order list, and place the orders on the Alpaca paper account — chaining every step from the previous one's output. Uses the `tac-engine` lake MCP tools (get_lake_bars, backfill_lake_calendar, get_lake_coverage) for data, the `tac-qlib-rd` MCP tools (rd_train, rd_predict, rd_strategy_targets, rd_exp_*) for the quant side, and the `tac-engine` MCP tools (place_order, list_orders, list_positions, get_news, ...) for execution. --- # tac-algo-trade Scheduled algo trading for the TradeAC paper account. Each scheduled execution (1) backfills the lake so it is current through the latest completed trading day, (2) re-trains the reference run's configuration on the most recent 4 years of lake data, (3) predicts, (4) derives an order list from the strategy, and (5) places orders on Alpaca. This is the skill the app's scheduler invokes (`/dashboard/scheduler`). It chains strict step-to-step outputs: **do not skip ahead, do not fabricate outputs — every step consumes the artifact path returned by the previous one.** ## MCP tools - Data side: `tac-engine` lake tools (see `tac-engine/skills/tradeac-lake/SKILL.md`) — `get_lake_coverage`, `backfill_lake_calendar`, `get_lake_bars` (lazy backfill), `get_lake_status`. - Quant side: `tac-qlib-rd` (see `tradeac-rd/SKILL.md`) — `rd_status`, `rd_exp_get_experiment`, `rd_exp_input`, `rd_train`, `rd_predict`, `rd_strategy_targets`, `rd_exp_get_run`. - Execution side: `tac-engine` (see `tac-engine/skills/tradeac-alpaca/SKILL.md`) — `get_account`, `list_positions`, `list_orders`, `place_order`, `get_stock_snapshot`, `get_stock_latest_quotes`, `get_news`. - Round book: `tac-rd-book` — the execution trail (see the "Round book" section below). `round_create` / `round_update`, `fact_record`, `intent_set`, `decision_record`, `round_sync_fills`, `round_update_status`, `book_reconcile`, `trail_funnel`. All steps use the MCP tools directly. Never hand-compute scores, read mlruns files directly (`mlruns.db` / pickles — the store is Postgres via `DATABASE_URL` when set), or script the MCP servers yourself. This is a **paper** account — trade normally, size each order per the strategy's target weight × live equity (whole shares), capped by available buying power, and skip anything untradeable. ## Inputs - `experiment_name` — MLflow experiment of the reference run. - `run_id` — the reference run inside that experiment (its saved `config` artifact is the source of truth for the whole pipeline). - `strategy` (optional) — a workflow YAML from `tac-qlib/workflows/*.yaml` defining the strategy sizing (e.g. `topk` / `n_drop` / `risk_degree`, benchmark, costs). Default: the reference run's own backtest config. - Time context: today's date in the scheduling city's timezone. ## Step 1 — Backfill the lake (data currency) The retrain must see every symbol up to the latest available bar — do **not** train on stale data. 1. `get_lake_coverage` `{"market":"US","timeframe":"1d"}` → read each symbol's loaded window; the **last loaded date** across the universe is your backfill start. 2. `backfill_lake_calendar` `{"market":"US","symbols":"all","start":,"end":}` → seed the trading-day set first so the 1d completeness check knows which days to expect. 3. `get_lake_bars` `{"market":"US","symbols":"all","timeframe":"1d","start":,"end":,"lazy":true,"quiet":true}` → backfill every symbol's gap (Alpaca historical bars) and persist to the lake. Use `quiet: true` so the tool returns a per-symbol `{count, first_t, last_t}` summary instead of echoing back thousands of bar rows. Alpaca has no bar for today until the session closes, so the latest bar landed is the **latest completed trading day** `D` (for a Monday run this is Friday). Confirm with `rd_status` (calendar range + coverage) that the lake is populated through `D`. **Output: `D`, the latest completed trading day.** ## Step 2 — Inspect the reference run `rd_exp_get_experiment` with `experiment_id` (or `rd_exp_input` with `run_id`) → extract from the run's `config` artifact: - handler config: `universe` (instruments), `features`, `label`, `freq` - model kwargs: `learning_rate`, `num_leaves`, `n_estimators`, `colsample_bytree`, `subsample`, `subsample_freq`, `reg_alpha`, `reg_lambda`, `seed` - strategy sizing: `topk` / `n_drop` / `risk_degree`, costs, benchmark Record these — they define the retrain. **Output: config values above.** ## Step 3 — Open the traced experiment (git lineage) Every scheduled run is a **traced experiment** on the tac-qlib-custom lineage: a row in the `rd_experiments` table plus a per-experiment git branch in the `experiments` submodule, forked from the predecessor's branch. **This is part of the run — do it automatically, do not wait for the user to prompt** (see `tac-qlib/skills/tac-qlib-custom/SKILL.md`, "Experiment traceability", for the full procedure and env vars). 1. Resolve the predecessor: if the reference run (`experiment_name` / `run_id` from Step 2) is itself traced, reuse its traced id as `evolved_from`; otherwise use `--evolved-from auto` (semantic search over existing rationals). 2. Open the trace — this inserts the row, forks the branch from the predecessor and pushes it (via the `rd_trace_*` MCP tools on tac-qlib-rd): ``` rd_trace_init rd_trace_start rational="scheduled algo retrain on : / re-trained on 4y -> live paper orders" \ details="" \ experiment_name= \ evolved_from= \ session_id="" # -> {"experiment_id": N, "branch": "...", "evolved_from": ..., "base_branch": ...} ``` **Experiment naming (unique per run):** every retrain runs into its OWN experiment — `-` (e.g. `tac-basic-short-1786883261`). The scheduler prompt names the exact experiment for you; use that name for `rd_trace_start experiment_name`, `rd_train`'s `experiment_name`, and the round's `experiment_name`. **Never** reuse the reference experiment name for this run's trace node — reusing it creates duplicate lineage entries with the same name and a wrong parent chain (seen with `tac-basic-short`). The tool returns `experiment_id` / `branch` as JSON — record them; every later `rd_trace_*` call uses the id. Commit the run's files (workflow YAML / notes) with `rd_trace_commit experiment_id= message="..."` as you go. 3. **Round window** — the execution trail for `D`: - **If the scheduler pre-created it** (your instructions name a `ROUND_ID` / `target_date` / `source`) — **skip `round_create`** and use that `ROUND_ID`. If the `D` you computed in Step 1 differs from the given `target_date`, correct it first with `round_update {round_id:, target_date:}` (weekday rule can't see NYSE holidays; the agent reconciles). - Otherwise create it yourself (idempotent: a second scheduled run for the same day reuses the open window): ``` round_create {target_date:, signal_date:, source:"scheduled", rd_experiment_id:, experiment_name:} # -> round_id (record it; every round-book call below uses it) ``` **Output: `EXPERIMENT_ID` (and its branch), `ROUND_ID`.** ## Step 4 — Re-train with the rolling window Call `rd_train` with the **exact same configuration** from Step 2, only the dates change: - `train_start` = 4 years before `D` (same day-of-month), `train_end` = `D` - **Validation is optional** — qlib supports omitting it, so omit `valid_start`/`valid_end`/`test_start`/`test_end` (pass them empty). If the tool/your run requires a holdout for sanity, use a short recent `valid` window only; never reserve data the live model needs. - `record_analysis=false` (we only need the model; no SignalRecord/PortAnaRecord on a holdout we don't use) - `wait=false` (recommended) — `rd_train` returns immediately and the fit runs in the background; poll `rd_exp_get_run` (or `rd_exp_list` filtered to the experiment) until the newest run's status is `FINISHED`, then take its `run_id`. With `wait=true` the call blocks until the fit completes — fine when the window is small, but a 4y LightGBM fit can outlive the MCP call timeout, which forced manual recovery in an earlier run. - `out_dir` — the working directory for this run (e.g. `tac-algo-output`) - `experiment_name` — the **run's unique experiment name** (the scheduler prompt names it: `-`). This is the SAME name used for `rd_trace_start experiment_name` and the round's `experiment_name`. Do not reuse the reference experiment name. Keep the same `universe`, `features`, `label`, and every model hyper-parameter. **Output: the new run's `model_path` (and its `run_id`).** > If a 4-year window is slower than the schedule allows, use the largest trailing window you can complete and say so in the summary — never silently shrink the horizon. Pin the new training run to the round window: ``` round_update {round_id:, run_id:, model_path:} ``` ## Step 5 — Generate predictions (the signal) Call `rd_predict` with `model_path` = the path returned by Step 4 (preferred over `run_id` since it is the freshly-trained artifact): - `test_start` = `D`, `test_end` = `D` (the just-completed trading day — this is the signal we trade on) - same `universe` / `features` / `label` as Step 2 - `out_dir` = the same working directory **Output: `pred_path` (pred.pkl) and the score ranking.** The model's predicted score per instrument IS the alpha signal for day `D` — top-scored names are candidates. Record the signal into the round book (one `fact_record` per top-scored name, plus the strategy config and the market snapshot at prediction time): ``` fact_record {round_id:, kind:"signal_score", symbol:, payload:{"pred":, "rank":}, source:"rd_predict"} fact_record {round_id:, kind:"strategy_config", payload:{...strategy sizing...}, source:"reference config"} fact_record {round_id:, kind:"market_snapshot", payload:{: {last:, change_pct:<%>, vol:, updated:}, ...}, source:"get_stock_snapshots / get_stock_latest_quotes"} ``` `market_snapshot` freezes the market state **when the prediction was made** — the latest price / % change / volume per universe name, so the signal can later be judged against what the market looked like at that moment. ## Step 6 — Run the configured strategy, derive the target order list **First pull the current portfolio — it is an input to the strategy step** (the order list is a delta, not a full rebuild): - `get_account` → cash / buying power **and total equity** (equity sizes the positions; buying power caps total buys) - `list_positions` → current holdings and their market value Then run the strategy **exactly as it was configured in the reference run** — this works for any model/strategy, not just TopkDropout. The reference run's saved `config` artifact (from `rd_exp_input`, Step 2) carries the strategy configuration from its backtest/record block (e.g. `TopkDropoutStrategy` kwargs: `topk`, `n_drop`, `risk_degree`, or any custom strategy's own kwargs, plus costs, `account`, `benchmark`). **Use those values — not tool defaults.** The model's score is the signal the strategy consumes; the strategy's config decides allocation. Call `rd_strategy_targets` with: - `pred_path` = the signal from Step 5 - the run-configured `topk` / `n_drop` / `risk_degree` (from the reference run config) - `account` = the **live account equity** from `get_account` (a new account is not a $1M book — sizing against `$1M` when equity is far smaller produces oversized orders) - `prices` = a JSON `{symbol: price}` of latest quotes (from `get_stock_latest_quotes`) so the tool floors each order to whole shares (`qty`) and reports `expected_price` / `invested` - `risk_limits` = the round's risk-limit spec JSON (see below) — the SAME spec that `rd_backtest` uses, so live gating is provable against backtest - `equity` / `peak_equity` = live equity and its trailing peak (from `get_portfolio_history`) when `risk_limits.drawdown_pause_pct` is set The tool applies the exact TopkDropout selection on day `D`: rank the cross-sectional scores, **drop the top `n_drop`**, take the next `topk` as buys, sized at `account × risk_degree / topk` per name. It then applies `risk_limits` as pre-gates — liquidity floor (drops names with avg daily dollar volume below `liquidity_floor_adv`), per-name `size_cap_pct` of equity, `concentration_cap_pct` of equity on total deployed, and `drawdown_pause_pct` (equity ≤ (1−pause)×peak ⇒ no buys). **Output: the deterministic target buy list** (`symbol`, `rank`, `score`, `side`, `notional`, `qty`), the full `ranking`, and `risk_limits_applied` (which limits cut what — record it). **No manual strategy replication** (an earlier run's hand-rolled sizing silently dropped the n_drop and bought the wrong names). > If the strategy in the run/workflow config does not fit TopkDropout's `topk`/`n_drop`/`risk_degree`, apply the strategy's own rules to the Step 5 scores directly to derive the target portfolio, still bounded by `get_account` buying power and today's `list_positions`. Then convert the target portfolio into an order list against the current holdings: - For each target ticker compute the **delta** vs. what the account already holds: buy the shortfall, sell the excess. Do not blindly re-buy names already held, and do not sell names that are not in the portfolio. - **Fresh account (no positions):** the target portfolio is entirely new buys — emit no sell orders, and size each buy from the tool's `qty` (or `notional` ÷ latest quote), capped by buying power. - Skip any ticker whose delta is ~0 (already at target) so you don't churn held names. - Cap total buy size to available buying power. Drop any ticker with no score in Step 5 or no tradable quote. **Output: the explicit order list** (ticker, side, qty, order type). **Write the target into the round book** — this is the intent the round reconciles against (versions auto-increment; a second strategy pass for the same round supersedes the first): ``` fact_record {round_id:, kind:"account_state", payload:{"equity":, "buying_power":}, source:"get_account"} fact_record {round_id:, kind:"position_state", symbol:, payload:{"shares":}, source:"list_positions"} fact_record {round_id:, kind:"risk_check", payload:{"risk_limits":{...spec...}, "applied":{...risk_limits_applied from the tool...}, "equity":, "peak_equity":}, source:"rd_strategy_targets"} round_update {round_id:, account_equity_at_sizing:, strategy_snapshot:{topk, n_drop, risk_degree, costs, benchmark, risk_limits:{liquidity_floor_adv?, size_cap_pct?, concentration_cap_pct?, drawdown_pause_pct?}}} intent_set {round_id:, target_portfolio:[{symbol, side, qty, notional, expected_price, score, rank}...], raw_strategy_output:{...the strategy output as computed...}, reason:"topk from "} ``` **Risk-limit spec (B)**: the round's `risk_limits` (a JSON map with any of `liquidity_floor_adv`, `size_cap_pct`, `concentration_cap_pct`, `drawdown_pause_pct`) is the single source of truth — **the same spec is passed to `rd_backtest` when calibrating** (B2), folded into `rd_train`'s PortAnaRecord via `risk_degree`, and consulted by `rd_strategy_targets` live. Store it verbatim in `strategy_snapshot.risk_limits`. When the tool's `risk_limits_applied` reports a limit that cut targets (dropped liquidity / capped sizing / drawdown pause), record it — the audit trail proves the limit fired live exactly as the calibration predicted. If `drawdown_pause_pct` fired and produced an empty target list, **settle the round as open→settled with no orders** rather than forcing buys (that is the intended behavior). **Record the evidence behind each selected name** — the feature snapshot and the decision rationale, so the fact table can answer *why this symbol was ranked top-K*: - `symbol_features` — the model-input feature values that produced the score on day `D` (the top features by `rd_exp_model` importance, plus the handful most relevant for that name — e.g. trend slopes, RSI, volume/vol ratios, MACD): ``` get_lake_ta {symbol:, timeframe:"1d", start:<~60d before D>, end:, persist:true, quiet:true} # (re)compute TA + sp_* columns up to D get_lake_features {symbol:, timeframe:"1d", start:, end:} # read the D row; if 0 rows, the persisted features are stale -> persist first as above rd_exp_model {run_id:, tree_id:0, max_depth:4} # feature_importances + tree nodes fact_record {round_id:, kind:"symbol_features", symbol:, payload:{"score":, "rank":, "features":{:, ...}}, source:"get_lake_features / rd_exp_model"} ``` `get_lake_features` returns 0 rows for day `D` when the persisted feature files were last written before `D` (they are per-symbol parquet files that only extend to the last time they were computed). In that case **first persist** with `get_lake_ta ... persist:true` (and `get_lake_sp` when the model uses `sp_*` columns — the rd_train feature list from Step 2 tells you which), then read `get_lake_features` for `D` again — it must return a row per ticker. - `decision_justification` — **concise** (under 500 words total, aim for 2–4 sentences per name): why the model ranked the symbol top-K. Ground it in the actual data — the `rd_exp_model` tree path (which feature conditions led the row down the high-score branch) and the `symbol_features` values — not generic commentary: ``` fact_record {round_id:, kind:"decision_justification", symbol:, payload:{"score":, "rank":, "why": "<2-4 sentences, e.g. 'strong 5d trend slope + rising volume ratio put TSLA above $sp_trend_slope_60 threshold, sending it down the high-score branch (leaf value +0.0545); RSI recovering but not overbought.'>"}, source:"rd_exp_model tree + feature snapshot"} ``` ## Step 7 — Execution context + news sentiment gate Before placing anything, per candidate ticker: 1. `get_account` (buying power), `list_orders` (open orders), `list_positions` (current holdings). 2. `get_stock_snapshot` / `get_stock_latest_quotes` → sanity-check each quote: skip tickers with no quote, a stale/illiquid quote (wide spread or near-zero volume), or a halt. Use the latest quote, not just the model score, for sizing and order type. 3. `get_news` with `symbols=`, `limit=20`, `include_content=true` → assign a sentiment score **−3 (strongly negative) … +3 (strongly positive)**. **Sentiment gate:** if sentiment strongly contradicts the signal — a **BUY** with sentiment ≤ −2 or a **SELL** with sentiment ≥ +2 — **cancel** that order and record it as `cancelled: sentiment conflict`. Tickers with no news or neutral sentiment (−1..+1) trade normally. Record the evidence per candidate into the round book (so the reconcile step can explain every skip): ``` fact_record {round_id:, kind:"quote", symbol:, payload:{bid, ask, last, spread_bps}, source:"get_stock_snapshot"} fact_record {round_id:, kind:"news_sentiment", symbol:, payload:{"sentiment":<−3..+3>, "headline":}, source:"get_news"} ``` ## Step 8 — Place orders on Alpaca For each surviving order in the Step 6 list (respecting the gate): call the `tac-engine` `place_order` tool with the ticker, side, qty and order type. Then verify with `list_orders` / `list_positions` that the intended changes went through. **Record every decision in the round book** — placed orders AND deliberate skips, each with its reason (this is what the reconcile / funnel view reads): ``` # each placed order (order id from the place_order response): decision_record {round_id:, symbol:, side:, qty:, order_type:, expected_price:, status:"placed", reason:"placed", intent_id:, alpaca_order_id:, client_order_id:} # each gate cancel / skip (delta≈0, no quote, illiquid, halt, bp cap, sentiment conflict, no score, risk limit): decision_record {round_id:, symbol:, side:, qty:, status:"skipped", reason:"sentiment_conflict"|"illiquid"|"no_quote"|"halt"|"delta_zero"|"bp_cap"|"no_score"|"risk_limit", reason_detail:, intent_id:} ``` **Sync fills** — pull Alpaca's order state into the round (pass the `list_orders` output as `orders` so no API call is needed; unmatched orders are reported back): ``` round_sync_fills {round_id:, orders:[{id, client_order_id, symbol, side, qty, filled_qty, filled_avg_price, status}...]} ``` ## Step 9 — Evidence check, close the traced experiment + summarize **Evidence gate — run this BEFORE committing/closing. Do not skip, do not "summarize only".** Query the round and confirm every evidence kind is present; record anything missing right now, then re-query: ``` fact_query {round_id:} # or per-kind: fact_query {round_id:, kind:""} ``` For each of the per-universe kinds (`signal_score`, `market_snapshot`, `quote`, `news_sentiment`, `symbol_features`, `decision_justification`) count that you recorded one per symbol you processed; `strategy_config`, `account_state`, `position_state` once each. If any kind is missing or any target symbol is missing from a kind, **go back and `fact_record` it now** (use the persist→read recipe in Step 6 for `symbol_features`). Only when every kind above is present, proceed: 1. Commit the run artifacts to the experiment branch: `rd_trace_commit experiment_id= message="algo run : orders placed"`. 2. Close the lineage — re-embeds the rational/details, records metrics/evaluation, commits + pushes: ``` rd_trace_finish experiment_id= \ ref_id= \ evaluation="" \ metrics='{"n_buys":N,"n_sells":M,"n_cancelled":K}' \ mlruns_dir=/mlruns// ``` 3. **Settle the round** — reconcile and close the window: ``` book_reconcile {round_id:} # residual vs target, per-symbol reasons trail_funnel {round_id:} # targets -> decided -> placed -> filled, skips by reason round_update_status {round_id:, status:"settled", summary_metrics:{...funnel + invested...}} ``` 4. **Close the loop** — record the round's execution economics for the next run's tuning: - `round_metrics` → the round's invested notional, turnover, slippage bps, estimated cost, cost-as-% of gross (the `fetchPriorRoundFeedback` in the scheduler injects these into the NEXT run's prompt automatically). - If the round had fills, run `rd_factor_attribution` over the round window (pass the `get_portfolio_history` equity curve as `portfolio_equity`, benchmark e.g. `IVV`, realized slippage+cost bps from `round_metrics`, expected values from the calibration) and record the result: ``` fact_record {round_id:, kind:"attribution", payload:{beta, alpha_annualized_pct, pnl_beta, pnl_alpha, drift_alarm}, source:"rd_factor_attribution"} ``` - A `drift_alarm` in the attribution means live execution cost is deviating from the backtest assumption — re-run `rd_risk_calibrate` before the next round and tighten sizing/limits. 5. End your reply with the compact summary: date `D`, reference run (`experiment_name` / `run_id`), new training run (`run_id` / `model_path`), window (4y → `D`), number of scores, top names, per-ticker sentiment scores, what was bought/sold, which orders were cancelled by the sentiment gate (and why), and any skipped trades (with reasons). ## Example ``` experiment_name=tac-rd run_id= strategy=tune_run1_wider_5d.yaml 1. get_lake_coverage {US,1d} -> last loaded date; backfill_lake_calendar; get_lake_bars lazy -> lake current -> D 2. rd_exp_input run_id= -> universe=all, features=KR..(ta fields), label=Ref($close,-2)/Ref($close,-1)-1, lr=0.05, leaves=15 ... topk/n_drop from the run's backtest config 3. EXP_NEW=- # unique per run (scheduler names it) rd_trace_init && rd_trace_start experiment_name=$EXP_NEW evolved_from=auto -> experiment_id / branch # scheduler usually pre-creates the round (ROUND_ID in the instructions) -> skip round_create, use it round_create {target_date:, signal_date:, source:"scheduled", rd_experiment_id:, experiment_name:$EXP_NEW} -> ROUND_ID 4. rd_train experiment_name=$EXP_NEW train_start= train_end= record_analysis=false wait=false out_dir=tac-algo-output # -> returns immediately; poll rd_exp_get_run until status FINISHED -> run_id , model_path tac-algo-output/params.pkl round_update {round_id:, run_id:, model_path:"tac-algo-output/params.pkl"} 5. rd_predict model_path=tac-algo-output/params.pkl test_start= test_end= # -> pred_path tac-algo-output/pred.pkl, score head ... fact_record {kind:"signal_score", symbol:, payload:{pred, rank}} per top name 6. get_account + list_positions # current portfolio as strategy input; account=live equity get_stock_latest_quotes -> prices JSON for sizing rd_strategy_targets pred_path=tac-algo-output/pred.pkl signal_date= \ topk= n_drop= risk_degree= account= prices='{...}' \ risk_limits='{"liquidity_floor_adv":5000000,"size_cap_pct":8,"concentration_cap_pct":30}' equity= peak_equity= # -> deterministic target buys (symbol/rank/score/notional/qty); delta vs list_positions -> order list (fresh account = all buys) fact_record {kind:"risk_check", payload:{risk_limits:{...}, applied:{...risk_limits_applied...}, equity, peak_equity}} round_update {round_id:, account_equity_at_sizing:, strategy_snapshot:{topk, n_drop, risk_degree, costs, benchmark, risk_limits:{...}}} intent_set {round_id:, target_portfolio:[{symbol, side, qty, expected_price, score, rank}]} -> intent_id 7. get_news per ticker -> sentiment gate; fact_record quote + news_sentiment per ticker 8. place_order ... per surviving delta; decision_record per placed + skipped (with reason) round_sync_fills {round_id:, orders:[...list_orders output...]} 9. rd_trace_commit experiment_id= + rd_trace_finish experiment_id= ref_id= book_reconcile + trail_funnel; round_update_status {status:"settled", summary_metrics:{...}}; summary ``` ## Round book — the execution trail Every scheduled run writes its decision→fill trail to Postgres via the `tac-rd-book` tools, mirroring the `/dashboard/rounds` UI. The round is the link between the scheduler run, the traced experiment, and the actual account activity: ``` scheduler_runs ──► ROUND ──► rd_experiments │ fact_events evidence: signal_score / market_snapshot / quote / news_sentiment / account_state / position_state / symbol_features / decision_justification / risk_check │ round_intents versioned target portfolios (new version supersedes old) │ round_decisions per-symbol: placed OR skipped, each with a reason (incl. risk_limit) └──► round_orders execution rows (Alpaca order id + fills), synced via round_sync_fills ``` `book_reconcile` returns the per-symbol residual (target qty − filled qty, with the reason it did not fill) plus cash/BP impact, slippage bps and estimated cost — that is the answer to "why is the account not at the target portfolio". `round_metrics` reports the round roll-ups (invested notional, turnover, slippage bps, estimated cost, cost-as-% of gross); `trail_funnel` gives the counts (targets → decided → placed → filled, skips by reason). All surface unchanged in the UI. When a round fires `drawdown_pause_pct`, its `round_metrics` will show `invested_notional: 0` — that is the pause working, not a broken round.