Files
book-tac/tac-qlib/skills/tac-algo-trade/SKILL.md
T

29 KiB
Raw Blame History

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-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":<backfill start>,"end":<today>} → 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":<backfill start>,"end":<today>,"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 <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.

  1. 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:<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 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: <reference experiment name>-<epoch seconds>). 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:<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 / 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:<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 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:<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 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:<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_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:<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:

  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=<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:

  1. Commit the run artifacts to the experiment branch: rd_trace_commit experiment_id=<EXPERIMENT_ID> message="algo run <D>: orders placed".
  2. 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>
    
  3. 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...}}
    
  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:<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=<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.