29 KiB
name, description
| name | description |
|---|---|
| tac-algo-trade | 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-enginelake tools (seetac-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(seetradeac-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(seetac-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 savedconfigartifact is the source of truth for the whole pipeline).strategy(optional) — a workflow YAML fromtac-qlib/workflows/*.yamldefining 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.
get_lake_coverage{"market":"US","timeframe":"1d"}→ read each symbol's loaded window; the last loaded date across the universe is your backfill start.backfill_lake_calendar{"market":"US","symbols":"all","start":<backfill start>,"end":<today>}→ seed the trading-day set first so the 1d completeness check knows which days to expect.get_lake_bars{"market":"US","symbols":"all","timeframe":"1d","start":<backfill start>,"end":<today>,"lazy":true,"quiet":true}→ backfill every symbol's gap (Alpaca historical bars) and persist to the lake. Usequiet: trueso 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 dayD(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).
- Resolve the predecessor: if the reference run (
experiment_name/run_idfrom Step 2) is itself traced, reuse its traced id asevolved_from; otherwise use--evolved-from auto(semantic search over existing rationals). - 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 <D>: <ref exp>/<ref run> re-trained on 4y -> live paper orders" \
details="<universe / features / label / model / strategy sizing from the reference run config>" \
experiment_name=<THE RUN'S experiment name — see naming below> \
evolved_from=<predecessor id or auto> \
session_id="<this chat's opencode session id>"
# -> {"experiment_id": N, "branch": "...", "evolved_from": ..., "base_branch": ...}
Experiment naming (unique per run): every retrain runs into its OWN
experiment — <reference experiment name>-<epoch seconds> (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=<N> message="..." as you go.
- Round window — the execution trail for
D:
- If the scheduler pre-created it (your instructions name a
ROUND_ID/target_date/source) — skipround_createand use thatROUND_ID. If theDyou computed in Step 1 differs from the giventarget_date, correct it first withround_update {round_id:<ROUND_ID>, target_date:<D>}(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:<D>, signal_date:<D>, source:"scheduled",
rd_experiment_id:<EXPERIMENT_ID>, experiment_name:<the run's unique 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 beforeD(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 recentvalidwindow 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_trainreturns immediately and the fit runs in the background; pollrd_exp_get_run(orrd_exp_listfiltered to the experiment) until the newest run's status isFINISHED, then take itsrun_id. Withwait=truethe 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:<reference experiment name>-<epoch seconds>). This is the SAME name used forrd_trace_start experiment_nameand the round'sexperiment_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:<ROUND_ID>, run_id:<new run_id>, model_path:<params.pkl 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/labelas 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:<ROUND_ID>, kind:"signal_score", symbol:<ticker>, payload:{"pred":<score>, "rank":<rank>}, source:"rd_predict"}
fact_record {round_id:<ROUND_ID>, kind:"strategy_config", payload:{...strategy sizing...}, source:"reference config"}
fact_record {round_id:<ROUND_ID>, kind:"market_snapshot", payload:{<ticker>: {last:<px>, change_pct:<%>, vol:<vol>, updated:<ts>}, ...}, 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 fromget_account(a new account is not a $1M book — sizing against$1Mwhen equity is far smaller produces oversized orders)prices= a JSON{symbol: price}of latest quotes (fromget_stock_latest_quotes) so the tool floors each order to whole shares (qty) and reportsexpected_price/investedrisk_limits= the round's risk-limit spec JSON (see below) — the SAME spec thatrd_backtestuses, so live gating is provable against backtestequity/peak_equity= live equity and its trailing peak (fromget_portfolio_history) whenrisk_limits.drawdown_pause_pctis 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 byget_accountbuying power and today'slist_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(ornotional÷ 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:<ROUND_ID>, kind:"account_state", payload:{"equity":<live equity>, "buying_power":<bp>}, source:"get_account"}
fact_record {round_id:<ROUND_ID>, kind:"position_state", symbol:<ticker>, payload:{"shares":<held>}, source:"list_positions"}
fact_record {round_id:<ROUND_ID>, kind:"risk_check", payload:{"risk_limits":{...spec...}, "applied":{...risk_limits_applied from the tool...}, "equity":<equity>, "peak_equity":<peak>}, source:"rd_strategy_targets"}
round_update {round_id:<ROUND_ID>, account_equity_at_sizing:<live equity>, 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:<ROUND_ID>, target_portfolio:[{symbol, side, qty, notional, expected_price, score, rank}...],
raw_strategy_output:{...the strategy output as computed...}, reason:"topk<N> from <ref run>"}
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 dayD(the top features byrd_exp_modelimportance, plus the handful most relevant for that name — e.g. trend slopes, RSI, volume/vol ratios, MACD):get_lake_ta {symbol:<ticker>, timeframe:"1d", start:<~60d before D>, end:<D>, persist:true, quiet:true} # (re)compute TA + sp_* columns up to D get_lake_features {symbol:<ticker>, timeframe:"1d", start:<D>, end:<D>} # read the D row; if 0 rows, the persisted features are stale -> persist first as above rd_exp_model {run_id:<new training run_id>, tree_id:0, max_depth:4} # feature_importances + tree nodes fact_record {round_id:<ROUND_ID>, kind:"symbol_features", symbol:<ticker>, payload:{"score":<score>, "rank":<rank>, "features":{<top feature>:<value>, ...}}, source:"get_lake_features / rd_exp_model"}get_lake_featuresreturns 0 rows for dayDwhen the persisted feature files were last written beforeD(they are per-symbol parquet files that only extend to the last time they were computed). In that case first persist withget_lake_ta ... persist:true(andget_lake_spwhen the model usessp_*columns — the rd_train feature list from Step 2 tells you which), then readget_lake_featuresforDagain — 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 — therd_exp_modeltree path (which feature conditions led the row down the high-score branch) and thesymbol_featuresvalues — not generic commentary:fact_record {round_id:<ROUND_ID>, kind:"decision_justification", symbol:<ticker>, payload:{"score":<score>, "rank":<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:
get_account(buying power),list_orders(open orders),list_positions(current holdings).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.get_newswithsymbols=<ticker>,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:<ROUND_ID>, kind:"quote", symbol:<ticker>, payload:{bid, ask, last, spread_bps}, source:"get_stock_snapshot"}
fact_record {round_id:<ROUND_ID>, kind:"news_sentiment", symbol:<ticker>, payload:{"sentiment":<−3..+3>, "headline":<top 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:<ROUND_ID>, symbol:<ticker>, side:<buy|sell>, qty:<qty>, order_type:<type>,
expected_price:<last quote px>, status:"placed", reason:"placed",
intent_id:<intent id from intent_set>, alpaca_order_id:<alpaca order id>, client_order_id:<cl id>}
# each gate cancel / skip (delta≈0, no quote, illiquid, halt, bp cap, sentiment conflict, no score, risk limit):
decision_record {round_id:<ROUND_ID>, symbol:<ticker>, side:<side>, qty:<qty>, status:"skipped",
reason:"sentiment_conflict"|"illiquid"|"no_quote"|"halt"|"delta_zero"|"bp_cap"|"no_score"|"risk_limit",
reason_detail:<short why>, intent_id:<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:<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:<ROUND_ID>} # or per-kind: fact_query {round_id:<ROUND_ID>, kind:"<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:
- Commit the run artifacts to the experiment branch:
rd_trace_commit experiment_id=<EXPERIMENT_ID> message="algo run <D>: orders placed". - Close the lineage — re-embeds the rational/details, records metrics/evaluation, commits + pushes:
rd_trace_finish experiment_id=<EXPERIMENT_ID> \ ref_id=<new training run_id from Step 4> \ evaluation="<outcome of today's trade: target vs placed, cancellations>" \ metrics='{"n_buys":N,"n_sells":M,"n_cancelled":K}' \ mlruns_dir=<lake>/mlruns/<exp_id>/<run_id> - Settle the round — reconcile and close the window:
book_reconcile {round_id:<ROUND_ID>} # residual vs target, per-symbol reasons trail_funnel {round_id:<ROUND_ID>} # targets -> decided -> placed -> filled, skips by reason round_update_status {round_id:<ROUND_ID>, status:"settled", summary_metrics:{...funnel + invested...}} - 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 (thefetchPriorRoundFeedbackin the scheduler injects these into the NEXT run's prompt automatically).- If the round had fills, run
rd_factor_attributionover the round window (pass theget_portfolio_historyequity curve asportfolio_equity, benchmark e.g.IVV, realized slippage+cost bps fromround_metrics, expected values from the calibration) and record the result:fact_record {round_id:<ROUND_ID>, kind:"attribution", payload:{beta, alpha_annualized_pct, pnl_beta, pnl_alpha, drift_alarm}, source:"rd_factor_attribution"} - A
drift_alarmin the attribution means live execution cost is deviating from the backtest assumption — re-runrd_risk_calibratebefore the next round and tighten sizing/limits.
- 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=<ref-uuid> 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=<ref-uuid> -> 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=<ref exp>-<epoch seconds> # 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:<D>, signal_date:<D>, source:"scheduled", rd_experiment_id:<EXPERIMENT_ID>, experiment_name:$EXP_NEW} -> ROUND_ID
4. rd_train experiment_name=$EXP_NEW train_start=<D-4y> train_end=<D> record_analysis=false wait=false out_dir=tac-algo-output
# -> returns immediately; poll rd_exp_get_run until status FINISHED -> run_id <new-uuid>, model_path tac-algo-output/params.pkl
round_update {round_id:<ROUND_ID>, run_id:<new-uuid>, model_path:"tac-algo-output/params.pkl"}
5. rd_predict model_path=tac-algo-output/params.pkl test_start=<D> test_end=<D>
# -> pred_path tac-algo-output/pred.pkl, score head ...
fact_record {kind:"signal_score", symbol:<ticker>, 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=<D> \
topk=<from run config> n_drop=<from run config> risk_degree=<from run config> account=<live equity> prices='{...}' \
risk_limits='{"liquidity_floor_adv":5000000,"size_cap_pct":8,"concentration_cap_pct":30}' equity=<equity> peak_equity=<peak>
# -> 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:<ROUND_ID>, account_equity_at_sizing:<equity>, strategy_snapshot:{topk, n_drop, risk_degree, costs, benchmark, risk_limits:{...}}}
intent_set {round_id:<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:<ROUND_ID>, orders:[...list_orders output...]}
9. rd_trace_commit experiment_id=<EXPERIMENT_ID> + rd_trace_finish experiment_id=<EXPERIMENT_ID> ref_id=<new-uuid>
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.