Files
book-tac/tac-qlib/tac_qlib/book_server.py
T

389 lines
13 KiB
Python

"""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()