book: scaffold + ch00 (execution trail as spine) — evidence exp 8-31, round 3
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
"""Round book MCP server (stdio transport) — the execution trail for algo rounds.
|
||||
|
||||
Exposes the round-book tools over MCP so the agent (tac-algo-trade skill) and
|
||||
the R&D UI can read AND write the same execution trail in Postgres:
|
||||
|
||||
scheduler_runs ──► ROUND ──► rd_experiments
|
||||
|
||||
round_create / round_update / round_update_status / round_list / round_get windows
|
||||
fact_record / fact_query evidence
|
||||
intent_set / intent_get / intent_list target portfolios
|
||||
decision_record / decision_query / order_link gates + orders
|
||||
round_sync_fills Alpaca fills
|
||||
book_reconcile / trail_query / trail_funnel / round_metrics investigation
|
||||
|
||||
Run::
|
||||
|
||||
.venv/bin/python -m tac_qlib.book_server # stdio MCP server
|
||||
|
||||
All tools return JSON-safe dicts. Logging goes to stderr; stdout is reserved
|
||||
for the MCP protocol. DB access is via tac_qlib.book_db (psycopg + DATABASE_URL);
|
||||
fill sync additionally needs APCA_API_KEY_ID / APCA_API_SECRET_KEY when the
|
||||
agent does not pass `orders` explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from tac_qlib import book_db
|
||||
|
||||
book_db._load_repo_env()
|
||||
|
||||
server = MCPServer(
|
||||
name="tac-rd-book",
|
||||
title="TradeAC round book",
|
||||
instructions=(
|
||||
"Execution trail for algo trading rounds on the TradeAC stack: create "
|
||||
"round windows, record evidence facts, set versioned target intents, "
|
||||
"record placed/skipped decisions, link Alpaca orders, sync fills, and "
|
||||
"reconcile / trace the funnel. Backed by Postgres (DATABASE_URL)."
|
||||
),
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[tac-rd-book] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _as_obj(value: Any) -> Any:
|
||||
"""Accept structured MCP input as JSON strings or as already-parsed dicts/lists."""
|
||||
if isinstance(value, str):
|
||||
if not value.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _obj(value: Any) -> Optional[Dict[str, Any]]:
|
||||
parsed = _as_obj(value)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _arr(value: Any) -> Optional[List[Any]]:
|
||||
parsed = _as_obj(value)
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
|
||||
|
||||
def _open_round(func):
|
||||
"""Ensure the round tables exist before any round-book operation.
|
||||
Uses ``functools.wraps`` so ``inspect.signature`` follows ``__wrapped__``
|
||||
and the MCP tool schema keeps the real typed parameters."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
book_db.ensure_schema()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log(f"schema check failed: {exc}")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- windows
|
||||
|
||||
@_open_round
|
||||
def round_create(
|
||||
target_date: str,
|
||||
source: str = "scheduled",
|
||||
signal_date: str = "",
|
||||
scheduler_run_id: int = 0,
|
||||
rd_experiment_id: int = 0,
|
||||
experiment_name: str = "",
|
||||
run_id: str = "",
|
||||
model_path: str = "",
|
||||
strategy_snapshot: str = "{}",
|
||||
account_equity_at_sizing: float = 0.0,
|
||||
) -> dict:
|
||||
"""Open a round window for a target trading date. Idempotent per
|
||||
(source, target_date): an already-open round for the same window is
|
||||
returned unchanged (``reused=True``). Returns the full round row."""
|
||||
snap = _obj(strategy_snapshot) or {}
|
||||
return book_db.create_round(
|
||||
source=source,
|
||||
target_date=target_date,
|
||||
signal_date=signal_date or None,
|
||||
scheduler_run_id=scheduler_run_id or None,
|
||||
rd_experiment_id=rd_experiment_id or None,
|
||||
experiment_name=experiment_name or None,
|
||||
run_id=run_id or None,
|
||||
model_path=model_path or None,
|
||||
strategy_snapshot=snap,
|
||||
account_equity_at_sizing=account_equity_at_sizing or None,
|
||||
)
|
||||
|
||||
|
||||
def round_update(
|
||||
round_id: int,
|
||||
source: str = "",
|
||||
target_date: str = "",
|
||||
signal_date: str = "",
|
||||
scheduler_run_id: int = 0,
|
||||
rd_experiment_id: int = 0,
|
||||
experiment_name: str = "",
|
||||
run_id: str = "",
|
||||
model_path: str = "",
|
||||
strategy_snapshot: str = "",
|
||||
account_equity_at_sizing: float = 0.0,
|
||||
) -> dict:
|
||||
"""Update a round window's metadata — e.g. pin the new training run
|
||||
(``run_id`` / ``model_path``) and strategy snapshot after the retrain.
|
||||
Empty / zero values leave the field unchanged."""
|
||||
return book_db.update_round(
|
||||
round_id,
|
||||
source=source or None,
|
||||
target_date=target_date or None,
|
||||
signal_date=signal_date or None,
|
||||
scheduler_run_id=scheduler_run_id or None,
|
||||
rd_experiment_id=rd_experiment_id or None,
|
||||
experiment_name=experiment_name or None,
|
||||
run_id=run_id or None,
|
||||
model_path=model_path or None,
|
||||
strategy_snapshot=_obj(strategy_snapshot) if strategy_snapshot else None,
|
||||
account_equity_at_sizing=account_equity_at_sizing or None,
|
||||
)
|
||||
|
||||
|
||||
def round_update_status(
|
||||
round_id: int,
|
||||
status: str = "",
|
||||
locked_intent_id: int = 0,
|
||||
summary_metrics: str = "{}",
|
||||
feedback_note: str = "",
|
||||
) -> dict:
|
||||
"""Advance a round (open → locked → settled | aborted). ``locked_intent_id``
|
||||
pins the intent reconciliation uses. ``summary_metrics`` / ``feedback_note``
|
||||
update the round summary."""
|
||||
return book_db.update_round_status(
|
||||
round_id,
|
||||
status=status or None,
|
||||
locked_intent_id=locked_intent_id or None,
|
||||
summary_metrics=_obj(summary_metrics),
|
||||
feedback_note=feedback_note or None,
|
||||
)
|
||||
|
||||
|
||||
def round_list(
|
||||
source: str = "",
|
||||
target_date: str = "",
|
||||
status: str = "",
|
||||
limit: int = 20,
|
||||
include_detail: bool = False,
|
||||
) -> dict:
|
||||
"""List round windows (newest first), optionally filtered by source /
|
||||
target_date / status. ``include_detail`` attaches each round's funnel
|
||||
counts + roll-up metrics (used by the /dashboard/rounds list)."""
|
||||
return {
|
||||
"rounds": book_db.list_rounds(
|
||||
source=source or None,
|
||||
target_date=target_date or None,
|
||||
status=status or None,
|
||||
limit=limit,
|
||||
with_detail=bool(include_detail),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def round_get(round_id: int) -> dict:
|
||||
"""Full detail of one round: window row + intents, decisions, orders, facts,
|
||||
funnel and reconciliation — everything the UI's round detail page needs."""
|
||||
round_row = book_db.get_round(round_id)
|
||||
if round_row is None:
|
||||
return {"error": f"round {round_id} not found"}
|
||||
return {
|
||||
"round": round_row,
|
||||
"intents": book_db.list_intents(round_id),
|
||||
"decisions": book_db.query_decisions(round_id),
|
||||
"orders": book_db.list_orders(round_id),
|
||||
"facts": book_db.query_facts(round_id, limit=500),
|
||||
"funnel": book_db.funnel(round_id),
|
||||
"reconcile": book_db.reconcile(round_id),
|
||||
"metrics": book_db.metrics(round_id),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- facts
|
||||
|
||||
def fact_record(round_id: int, kind: str, payload: str = "{}", symbol: str = "", source: str = "") -> dict:
|
||||
"""Append an evidence event (signal_score, quote, news_sentiment,
|
||||
account_state, position_state, strategy_config, ...) to a round."""
|
||||
return book_db.record_fact(
|
||||
round_id,
|
||||
kind=kind,
|
||||
payload=_obj(payload),
|
||||
symbol=symbol or None,
|
||||
source=source or None,
|
||||
)
|
||||
|
||||
|
||||
def fact_query(round_id: int, kind: str = "", symbol: str = "", limit: int = 200) -> dict:
|
||||
"""Query a round's recorded facts (newest first), optionally filtered by kind/symbol."""
|
||||
return {"facts": book_db.query_facts(round_id, kind=kind or None, symbol=symbol or None, limit=limit)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- intents
|
||||
|
||||
def intent_set(round_id: int, target_portfolio: str, raw_strategy_output: str = "{}", reason: str = "") -> dict:
|
||||
"""Write the next target-portfolio version for a round (auto-increments and
|
||||
supersedes the previous active version). ``target_portfolio`` is a JSON
|
||||
array of {symbol, side, qty, notional, expected_price, score, rank, weight}."""
|
||||
return book_db.set_intent(
|
||||
round_id,
|
||||
target_portfolio=_arr(target_portfolio) or [],
|
||||
raw_strategy_output=_obj(raw_strategy_output),
|
||||
reason=reason or None,
|
||||
)
|
||||
|
||||
|
||||
def intent_get(round_id: int, version: int = 0) -> dict:
|
||||
"""Get a round's intent — the given version, or the active (max) version
|
||||
when ``version`` is omitted."""
|
||||
intent = book_db.get_intent(round_id, version=version or None)
|
||||
return {"intent": intent} if intent else {"intent": None, "error": f"no intent for round {round_id}"}
|
||||
|
||||
|
||||
def intent_list(round_id: int) -> dict:
|
||||
"""List every target-portfolio version for a round (oldest first)."""
|
||||
return {"intents": book_db.list_intents(round_id)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- decisions / orders
|
||||
|
||||
def decision_record(
|
||||
round_id: int,
|
||||
symbol: str,
|
||||
side: str,
|
||||
qty: float = 0.0,
|
||||
order_type: str = "",
|
||||
expected_price: float = 0.0,
|
||||
status: str = "intended",
|
||||
reason: str = "",
|
||||
reason_detail: str = "",
|
||||
intent_id: int = 0,
|
||||
supersedes_decision_id: int = 0,
|
||||
alpaca_order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
) -> dict:
|
||||
"""Record one per-symbol decision by the gates. Use status ``skipped`` /
|
||||
``rejected`` with a ``reason`` for deliberate skips; placed orders carry
|
||||
``alpaca_order_id`` / ``client_order_id`` (an execution row is created).
|
||||
Passing ``supersedes_decision_id`` marks the previous decision superseded."""
|
||||
return book_db.record_decision(
|
||||
round_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=qty or None,
|
||||
order_type=order_type or None,
|
||||
expected_price=expected_price or None,
|
||||
status=status,
|
||||
reason=reason or None,
|
||||
reason_detail=reason_detail or None,
|
||||
intent_id=intent_id or None,
|
||||
supersedes_decision_id=supersedes_decision_id or None,
|
||||
alpaca_order_id=alpaca_order_id or None,
|
||||
client_order_id=client_order_id or None,
|
||||
)
|
||||
|
||||
|
||||
def decision_query(round_id: int, symbol: str = "", include_superseded: bool = True) -> dict:
|
||||
"""List a round's decisions, optionally filtered by symbol."""
|
||||
return {"decisions": book_db.query_decisions(round_id, symbol=symbol or None, include_superseded=include_superseded)}
|
||||
|
||||
|
||||
def order_link(
|
||||
round_id: int,
|
||||
decision_id: int,
|
||||
alpaca_order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
qty_filled: float = -1.0,
|
||||
avg_fill_price: float = -1.0,
|
||||
status: str = "",
|
||||
) -> dict:
|
||||
"""Create or update the execution row for a placed decision (idempotent per
|
||||
decision). Use ``qty_filled=-1`` to leave the value unchanged."""
|
||||
return book_db.link_order(
|
||||
round_id,
|
||||
decision_id=decision_id,
|
||||
alpaca_order_id=alpaca_order_id or None,
|
||||
client_order_id=client_order_id or None,
|
||||
qty_filled=qty_filled if qty_filled >= 0 else None,
|
||||
avg_fill_price=avg_fill_price if avg_fill_price >= 0 else None,
|
||||
status=status or None,
|
||||
)
|
||||
|
||||
|
||||
def round_sync_fills(round_id: int, orders: str = "", feed: str = "iex") -> dict:
|
||||
"""Pull Alpaca order state into the round. Pass ``orders`` as a JSON array
|
||||
(tac-engine ``list_orders`` output) or omit it to fetch from Alpaca with
|
||||
APCA_API_* env vars. Marks orders superseded when the effective intent no
|
||||
longer targets their symbol+side. Returns updated / superseded / unmatched."""
|
||||
parsed = _arr(orders) if isinstance(orders, str) else orders
|
||||
return book_db.sync_fills(round_id, orders=parsed if isinstance(parsed, list) else None, feed=feed or "iex")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- investigation
|
||||
|
||||
def book_reconcile(round_id: int) -> dict:
|
||||
"""Reconcile the round: effective intent targets vs decisions vs fills, with
|
||||
per-symbol residuals and roll-ups (cash/BP impact, slippage bps, cost)."""
|
||||
return book_db.reconcile(round_id)
|
||||
|
||||
|
||||
def trail_query(round_id: int, symbol: str = "") -> dict:
|
||||
"""Per-symbol waterfall: intent target → decision → order → fill."""
|
||||
return {"trail": book_db.trail(round_id, symbol=symbol or None)}
|
||||
|
||||
|
||||
def trail_funnel(round_id: int) -> dict:
|
||||
"""Decision funnel counts for a round: targets → decided → placed → filled,
|
||||
plus skipped-reason breakdown and superseded count."""
|
||||
return book_db.funnel(round_id)
|
||||
|
||||
|
||||
def round_metrics(round_id: int) -> dict:
|
||||
"""Roll-up metrics: placed/filled order counts, invested notional, turnover."""
|
||||
return book_db.metrics(round_id)
|
||||
|
||||
|
||||
def register_tools(mcp_server: MCPServer) -> None:
|
||||
"""Attach all round-book tools to an ``MCPServer`` instance."""
|
||||
for fn in (
|
||||
round_create,
|
||||
round_update,
|
||||
round_update_status,
|
||||
round_list,
|
||||
round_get,
|
||||
fact_record,
|
||||
fact_query,
|
||||
intent_set,
|
||||
intent_get,
|
||||
intent_list,
|
||||
decision_record,
|
||||
decision_query,
|
||||
order_link,
|
||||
round_sync_fills,
|
||||
book_reconcile,
|
||||
trail_query,
|
||||
trail_funnel,
|
||||
round_metrics,
|
||||
):
|
||||
mcp_server.tool(structured_output=False)(fn)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
register_tools(server)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user