diff --git a/agent/backtest/validation.py b/agent/backtest/validation.py index 073bef8e..294c2f10 100644 --- a/agent/backtest/validation.py +++ b/agent/backtest/validation.py @@ -319,6 +319,132 @@ def walk_forward_analysis( } +def purged_walk_forward_analysis( + equity_curve: pd.Series, + trades: List[TradeRecord], + n_windows: int = 5, + purge_bars: int = 2, + embargo_bars: int = 1, + bars_per_year: int = 252, +) -> Dict[str, Any]: + """Purged walk-forward analysis with purge and embargo gaps between windows. + + Prevents holding-period and serial-correlation leakage across sequential + walk-forward folds by: + 1. Dropping ``purge_bars`` from the tail of each fold before a boundary. + 2. Dropping ``embargo_bars`` from the head of each subsequent fold after a boundary. + 3. Excluding any trade whose ``[entry_time, exit_time]`` straddles outside + the purged fold's ``[win_start, win_end]`` interval. + + Args: + equity_curve: Equity time series. + trades: Completed trades. + n_windows: Number of sequential evaluation folds. + purge_bars: Bars removed before each inter-fold boundary (>= 0). + embargo_bars: Bars removed after each inter-fold boundary (>= 0). + bars_per_year: Annualisation factor. + + Returns: + Dict with per-window stats, purged bar counts, and consistency metrics. + """ + if isinstance(n_windows, bool) or not isinstance(n_windows, Integral) or n_windows < 1: + return {"error": f"n_windows must be >= 1, got {n_windows}"} + if isinstance(purge_bars, bool) or not isinstance(purge_bars, Integral) or purge_bars < 0: + return {"error": f"purge_bars must be >= 0, got {purge_bars}"} + if isinstance(embargo_bars, bool) or not isinstance(embargo_bars, Integral) or embargo_bars < 0: + return {"error": f"embargo_bars must be >= 0, got {embargo_bars}"} + + min_bars_per_win = int(purge_bars) + int(embargo_bars) + 3 + if len(equity_curve) < n_windows * min_bars_per_win: + return { + "error": ( + f"need at least {n_windows * min_bars_per_win} bars for " + f"{n_windows} purged windows (purge={purge_bars}, embargo={embargo_bars})" + ) + } + + indices = equity_curve.index + raw_window_size = len(indices) // n_windows + windows: List[Dict[str, Any]] = [] + purged_bars_total = 0 + + for i in range(n_windows): + raw_start = i * raw_window_size + raw_end = (i + 1) * raw_window_size if i < n_windows - 1 else len(indices) + + eff_start = raw_start + (int(embargo_bars) if i > 0 else 0) + eff_end = raw_end - (int(purge_bars) if i < n_windows - 1 else 0) + purged_in_fold = (eff_start - raw_start) + (raw_end - eff_end) + purged_bars_total += purged_in_fold + + if eff_end - eff_start < 2: + return {"error": f"window {i + 1} has fewer than 2 bars after purge/embargo"} + + win_eq = equity_curve.iloc[eff_start:eff_end] + win_start = indices[eff_start] + win_end = indices[eff_end - 1] + if not np.isfinite(win_eq.iloc[[0, -1]]).all(): + return {"error": f"window {i + 1} requires observed start and end equity"} + + # Strict purge on trades: both entry_time and exit_time must fall inside [win_start, win_end] + win_trades = [ + t + for t in trades + if win_start <= t.entry_time <= win_end and win_start <= t.exit_time <= win_end + ] + + ret = float(win_eq.iloc[-1] / win_eq.iloc[0] - 1) if win_eq.iloc[0] > 0 else 0.0 + win_returns = ( + win_eq.pct_change(fill_method=None) + .replace([np.inf, -np.inf], 0.0) + .dropna() + .values + ) + if not len(win_returns): + return {"error": f"window {i + 1} has no observed adjacent equity returns"} + sharpe = _sharpe(win_returns, bars_per_year) if len(win_returns) > 1 else 0.0 + + peak = win_eq.cummax() + dd = (win_eq - peak) / peak.replace(0, 1) + max_dd = float(dd.min()) + + win_pnls = [t.pnl for t in win_trades] + win_rate = len([p for p in win_pnls if p > 0]) / len(win_pnls) if win_pnls else 0.0 + + windows.append( + { + "window": i + 1, + "start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start), + "end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end), + "bars": int(eff_end - eff_start), + "purged_bars": int(purged_in_fold), + "return": round(ret, 6), + "sharpe": round(sharpe, 4), + "max_dd": round(max_dd, 6), + "trades": len(win_trades), + "win_rate": round(win_rate, 4), + } + ) + + returns_list = [w["return"] for w in windows] + sharpes_list = [w["sharpe"] for w in windows] + profitable_windows = sum(1 for r in returns_list if r > 0) + + return { + "n_windows": int(n_windows), + "purge_bars": int(purge_bars), + "embargo_bars": int(embargo_bars), + "purged_bars_total": int(purged_bars_total), + "windows": windows, + "profitable_windows": profitable_windows, + "consistency_rate": round(profitable_windows / n_windows, 4), + "return_mean": round(float(np.mean(returns_list)), 6), + "return_std": round(float(np.std(returns_list)), 6), + "sharpe_mean": round(float(np.mean(sharpes_list)), 4), + "sharpe_std": round(float(np.std(sharpes_list)), 4), + } + + # ─── Runner integration ─── @@ -335,6 +461,7 @@ def run_validation( - monte_carlo: {n_simulations, seed} - bootstrap: {n_bootstrap, confidence, seed} - walk_forward: {n_windows} + - purged_walk_forward: {n_windows, purge_bars, embargo_bars} Args: config: Backtest config (must contain "validation" key). @@ -386,6 +513,21 @@ def run_validation( bars_per_year=bars_per_year, ) + if "purged_walk_forward" in v_cfg: + pwf_cfg = ( + v_cfg["purged_walk_forward"] + if isinstance(v_cfg["purged_walk_forward"], dict) + else {} + ) + results["purged_walk_forward"] = purged_walk_forward_analysis( + equity_curve, + trades, + n_windows=pwf_cfg.get("n_windows", 5), + purge_bars=pwf_cfg.get("purge_bars", 2), + embargo_bars=pwf_cfg.get("embargo_bars", 1), + bars_per_year=bars_per_year, + ) + return results @@ -558,6 +700,267 @@ def main(run_dir: Path) -> Dict[str, Any]: return safe_results +# ─── Phase 1 Schedule Promotion Gate & Tiny-Universe Multi-Market Proof ─── + +TINY_UNIVERSE_BY_MARKET: Dict[str, tuple[str, ...]] = { + "US": ("NVDA.US", "AAPL.US"), + "HK": ("0700.HK", "9988.HK"), + "CRYPTO": ("BTC-USD", "ETH-USD"), +} + + +class _InMemoryFrameLoader: + """Minimal DataLoader adapter wrapping pre-loaded OHLCV DataFrames.""" + + name = "in_memory_frame_loader" + + def __init__(self, frames: Dict[str, pd.DataFrame]) -> None: + self._frames = dict(frames) + + def fetch( + self, + codes: List[str], + start_date: str = "", + end_date: str = "", + fields: List[str] | None = None, + interval: str = "1D", + ) -> Dict[str, pd.DataFrame]: + del start_date, end_date, fields, interval + return {code: self._frames[code].copy() for code in codes if code in self._frames} + + +class _TrendCrossoverSignalEngine: + """Deterministic fast/slow MA crossover signal engine for tiny-universe proof.""" + + def __init__(self, fast_window: int = 5, slow_window: int = 15) -> None: + self.fast_window = fast_window + self.slow_window = slow_window + + def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]: + signals: Dict[str, pd.Series] = {} + for code, df in data_map.items(): + close = df["close"].astype(float) + fast = close.rolling(self.fast_window, min_periods=1).mean() + slow = close.rolling(self.slow_window, min_periods=1).mean() + sig = pd.Series(np.where(fast > slow, 1.0, 0.0), index=close.index, dtype=float) + signals[code] = sig + return signals + + +def evaluate_promotion_gate( + market_reports: Dict[str, Dict[str, Any]], + *, + required_markets: tuple[str, ...] = ("US", "HK", "CRYPTO"), + min_bootstrap_ci_lower: float = -0.25, + min_prob_positive: float = 0.55, + min_wf_consistency: float = 0.40, +) -> Dict[str, Any]: + """Evaluate whether US + HK + Crypto backtest validation gates pass for schedule promotion. + + No paper automation schedule may promote or dispatch proposals unless every + required market passes both Purged Walk-Forward consistency and Bootstrap + Sharpe CI thresholds (fail-closed). + + Args: + market_reports: Mapping of market key (``"US"``, ``"HK"``, ``"CRYPTO"``) + to backtest metrics/validation dicts. + required_markets: Markets that must all pass before promotion. + min_bootstrap_ci_lower: Minimum lower bound for bootstrap Sharpe CI. + min_prob_positive: Minimum bootstrap probability of positive Sharpe. + min_wf_consistency: Minimum fraction of profitable purged walk-forward folds. + + Returns: + Dict with ``passed`` (bool), ``markets_checked``, ``per_market``, and + ``failed_reasons``. + """ + from datetime import datetime, timezone + + failed_reasons: List[str] = [] + per_market: Dict[str, Dict[str, Any]] = {} + normalized_reports = { + str(k).strip().upper(): v for k, v in (market_reports or {}).items() if isinstance(v, dict) + } + + for market in required_markets: + mkt = market.strip().upper() + report = normalized_reports.get(mkt) + if report is None: + failed_reasons.append(f"{mkt}: missing backtest validation report") + per_market[mkt] = {"passed": False, "reason": "missing_report"} + continue + + val = report.get("validation") if isinstance(report.get("validation"), dict) else report + bs = val.get("bootstrap") if isinstance(val.get("bootstrap"), dict) else None + pwf = ( + val.get("purged_walk_forward") + if isinstance(val.get("purged_walk_forward"), dict) + else val.get("walk_forward") + if isinstance(val.get("walk_forward"), dict) + else None + ) + + mkt_reasons: List[str] = [] + if not bs or "error" in bs: + mkt_reasons.append( + f"{mkt}: bootstrap validation missing or failed ({(bs or {}).get('error', 'missing')})" + ) + else: + ci_lower = float(bs.get("ci_lower", float("-inf"))) + prob_pos = float(bs.get("prob_positive", 0.0)) + if ci_lower < min_bootstrap_ci_lower: + mkt_reasons.append( + f"{mkt}: bootstrap ci_lower {ci_lower:.4f} < {min_bootstrap_ci_lower:.4f}" + ) + if prob_pos < min_prob_positive: + mkt_reasons.append( + f"{mkt}: bootstrap prob_positive {prob_pos:.4f} < {min_prob_positive:.4f}" + ) + + if not pwf or "error" in pwf: + mkt_reasons.append( + f"{mkt}: purged_walk_forward missing or failed ({(pwf or {}).get('error', 'missing')})" + ) + else: + consistency = float(pwf.get("consistency_rate", 0.0)) + if consistency < min_wf_consistency: + mkt_reasons.append( + f"{mkt}: purged_walk_forward consistency_rate {consistency:.4f} < {min_wf_consistency:.4f}" + ) + if "purge_bars" in pwf and int(pwf.get("purge_bars", 0)) < 1: + mkt_reasons.append(f"{mkt}: purged_walk_forward requires purge_bars >= 1") + + mkt_passed = len(mkt_reasons) == 0 + failed_reasons.extend(mkt_reasons) + per_market[mkt] = { + "passed": mkt_passed, + "bootstrap": bs, + "purged_walk_forward": pwf, + "reasons": mkt_reasons, + } + + return { + "passed": len(failed_reasons) == 0 and len(required_markets) > 0, + "markets_checked": [m.strip().upper() for m in required_markets], + "per_market": per_market, + "failed_reasons": failed_reasons, + "evaluated_at_utc": datetime.now(timezone.utc).isoformat(), + } + + +def run_tiny_universe_backtest_proof( + *, + bar_data_by_market: Dict[str, Dict[str, pd.DataFrame]] | None = None, + work_dir: Path | None = None, + n_bootstrap: int = 300, + n_windows: int = 4, + purge_bars: int = 2, + embargo_bars: int = 1, + seed: int = 42, + min_bootstrap_ci_lower: float = -0.25, + min_prob_positive: float = 0.55, + min_wf_consistency: float = 0.40, +) -> Dict[str, Any]: + """Run US + HK + Crypto backtest engines on a tiny universe with purged walk-forward + bootstrap. + + Executes: + - ``GlobalEquityEngine(market="us")`` on ``("NVDA.US", "AAPL.US")`` + - ``GlobalEquityEngine(market="hk")`` on ``("0700.HK", "9988.HK")`` + - ``CryptoEngine`` on ``("BTC-USD", "ETH-USD")`` + and evaluates the schedule promotion gate across all three markets. + """ + import tempfile + from backtest.engines.crypto import CryptoEngine + from backtest.engines.global_equity import GlobalEquityEngine + + base_dir_ctx = ( + tempfile.TemporaryDirectory(prefix="vibe_bt_proof_") + if work_dir is None + else None + ) + try: + root_dir = Path(base_dir_ctx.name) if base_dir_ctx is not None else Path(work_dir) # type: ignore[arg-type] + root_dir.mkdir(parents=True, exist_ok=True) + signal_engine = _TrendCrossoverSignalEngine(fast_window=5, slow_window=15) + market_reports: Dict[str, Dict[str, Any]] = {} + + for mkt_key, default_codes in TINY_UNIVERSE_BY_MARKET.items(): + frames = (bar_data_by_market or {}).get(mkt_key) + if frames: + codes = sorted(frames.keys()) + loader: Any = _InMemoryFrameLoader(frames) + else: + from backtest.loaders.registry import resolve_loader + + codes = list(default_codes) + loader_market = { + "US": "us_equity", + "HK": "hk_equity", + "CRYPTO": "crypto", + }[mkt_key] + loader = resolve_loader(loader_market) + + bars_per_year = 365 if mkt_key == "CRYPTO" else 252 + config: Dict[str, Any] = { + "codes": codes, + "initial_cash": 100_000.0, + "leverage": 1.0, + "interval": "1D", + "validation": { + "bootstrap": { + "n_bootstrap": n_bootstrap, + "confidence": 0.95, + "seed": seed, + }, + "purged_walk_forward": { + "n_windows": n_windows, + "purge_bars": purge_bars, + "embargo_bars": embargo_bars, + }, + }, + } + if mkt_key == "US": + engine: Any = GlobalEquityEngine(config, market="us") + elif mkt_key == "HK": + config["hk_lot_sizes"] = {code: 100 for code in codes} + engine = GlobalEquityEngine(config, market="hk") + else: + engine = CryptoEngine(config) + + mkt_run_dir = root_dir / mkt_key.lower() + mkt_run_dir.mkdir(parents=True, exist_ok=True) + metrics = engine.run_backtest( + config, + loader, + signal_engine, + mkt_run_dir, + bars_per_year=bars_per_year, + ) + market_reports[mkt_key] = metrics + + gate = evaluate_promotion_gate( + market_reports, + min_bootstrap_ci_lower=min_bootstrap_ci_lower, + min_prob_positive=min_prob_positive, + min_wf_consistency=min_wf_consistency, + ) + gate["market_metrics"] = { + mkt: { + "codes": list((bar_data_by_market or {}).get(mkt, {}).keys()) + or list(TINY_UNIVERSE_BY_MARKET[mkt]), + "total_return": rep.get("total_return"), + "sharpe": rep.get("sharpe"), + "max_drawdown": rep.get("max_drawdown"), + "total_trades": rep.get("total_trades"), + "validation": rep.get("validation"), + } + for mkt, rep in market_reports.items() + } + return gate + finally: + if base_dir_ctx is not None: + base_dir_ctx.cleanup() + + if __name__ == "__main__": import sys diff --git a/agent/src/channels/runtime.py b/agent/src/channels/runtime.py index 037a00da..5243d6f0 100644 --- a/agent/src/channels/runtime.py +++ b/agent/src/channels/runtime.py @@ -45,6 +45,7 @@ class ChannelRuntime: poll_interval_s: float = 0.25, operators: Iterable[str] | None = None, channel_operators: Mapping[str, Iterable[str]] | None = None, + trade_approval_coordinator: Any = None, ) -> None: self.bus = bus self.session_service = session_service @@ -62,6 +63,7 @@ class ChannelRuntime: str(ch): {str(o) for o in ops} for ch, ops in (channel_operators or {}).items() } + self._trade_approval_coordinator = trade_approval_coordinator self.session_map_path = session_map_path or (get_data_dir() / "channels" / "sessions.json") self._session_map: dict[str, str] = {} self._consumer_task: asyncio.Task[None] | None = None @@ -111,6 +113,15 @@ class ChannelRuntime: "channels": self.manager.get_status() if self.manager is not None else {}, } + @property + def trade_approval_coordinator(self) -> Any: + """Return the configured TradeApprovalCoordinator, if any.""" + return self._trade_approval_coordinator + + async def publish_outbound(self, msg: OutboundMessage) -> None: + """Publish an outbound message (e.g., paper trade approval prompt) to the channel bus.""" + await self.bus.publish_outbound(msg) + async def _consume_loop(self) -> None: while True: msg = await self.bus.consume_inbound() @@ -183,6 +194,9 @@ class ChannelRuntime: ) return + if await self._handle_trade_approval(msg): + return + session_id = self._session_for(msg) if await self._handle_scheduled_confirmation(msg, session_id): return @@ -287,6 +301,44 @@ class ChannelRuntime: ) ) + def _is_trade_approver_authorized(self, msg: InboundMessage) -> bool: + """Return True if the sender is authorized to approve/reject trades or trigger HALT.""" + is_operator, _ = self._resolve_operator(msg.channel, msg.sender_id) + if is_operator: + return True + if self._operators or self._channel_operators.get(msg.channel): + return False + if self.manager is not None: + channels = getattr(self.manager, "channels", None) or {} + adapter = channels.get(msg.channel) + if adapter is not None and hasattr(adapter, "is_allowed"): + return bool(adapter.is_allowed(msg.sender_id)) + if msg.metadata.get("authorized_operator") is True: + return True + return False + + async def _handle_trade_approval(self, msg: InboundMessage) -> bool: + """Intercept WhatsApp / IM trade approval commands outside the LLM.""" + from src.channels.trade_approval import ( + TradeApprovalCoordinator, + parse_trade_approval_command, + ) + + if parse_trade_approval_command(msg.content) is None: + return False + coordinator = self._trade_approval_coordinator + if coordinator is None: + coordinator = TradeApprovalCoordinator() + self._trade_approval_coordinator = coordinator + outbound = coordinator.handle_inbound_command( + msg, + is_authorized=self._is_trade_approver_authorized(msg), + ) + if outbound is None: + return False + await self.bus.publish_outbound(outbound) + return True + async def _handle_scheduled_confirmation( self, msg: InboundMessage, session_id: str ) -> bool: diff --git a/agent/src/channels/trade_approval.py b/agent/src/channels/trade_approval.py new file mode 100644 index 00000000..71aaffde --- /dev/null +++ b/agent/src/channels/trade_approval.py @@ -0,0 +1,743 @@ +"""WhatsApp / IM human-in-the-loop trade approval coordinator for paper fills. + +Enforces AGENTS.md §2–§4: +- Every paper trade proposal must be grounded before requesting approval. +- Human approval on WhatsApp (or configured IM channel) is required before any paper fill. +- Deterministic command parsing (`APPROVE `, `REJECT [reason]`, `HALT [reason]`, + `STATUS `) runs outside the LLM so the model can never self-approve a trade. +- Every request, approval, rejection, and fill writes hash-chained audit records linking: + ``tool_trace_ids → proposal_id → approval_id → fill_id``. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from src.channels.bus.events import InboundMessage, OutboundMessage +from src.live.audit import LiveActionEvent, write_live_action +from src.live.halt import trip_halt +from src.live.mandate.model import Mandate +from src.live.mandate.paper_stub import ensure_paper_mandate +from src.memory.reflector import DecisionMemoryReflector +from src.shadow_account.paper_executor import ( + PaperFillReceipt, + PaperOrderExecutionError, + execute_paper_fill, + paper_root, +) +from src.swarm.proposal import TradeProposal + +logger = logging.getLogger(__name__) + +TRADE_APPROVAL_META_KEY = "_trade_approval" + +_APPROVE_RE = re.compile( + r"^/?(?:approve|yes)\s+(?P[A-Za-z0-9_.-]+)\s*$", + re.IGNORECASE, +) +_REJECT_RE = re.compile( + r"^/?(?:reject|no)\s+(?P[A-Za-z0-9_.-]+)(?:\s+(?P.+))?\s*$", + re.IGNORECASE | re.DOTALL, +) +_STATUS_RE = re.compile( + r"^/?status\s+(?P[A-Za-z0-9_.-]+)\s*$", + re.IGNORECASE, +) +_HALT_RE = re.compile( + r"^/?halt(?:\s+(?P.+))?\s*$", + re.IGNORECASE | re.DOTALL, +) + + +@dataclass(frozen=True) +class TradeApprovalCommand: + """Parsed human trade approval or emergency halt command.""" + + action: Literal["approve", "reject", "halt", "status"] + proposal_id: str | None = None + reason: str = "" + + +def parse_trade_approval_command(text: str) -> TradeApprovalCommand | None: + """Parse an inbound WhatsApp/IM text into a :class:`TradeApprovalCommand`, or ``None``.""" + stripped = (text or "").strip() + if not stripped: + return None + + m_approve = _APPROVE_RE.match(stripped) + if m_approve: + return TradeApprovalCommand( + action="approve", + proposal_id=m_approve.group("proposal_id").strip(), + ) + + m_reject = _REJECT_RE.match(stripped) + if m_reject: + return TradeApprovalCommand( + action="reject", + proposal_id=m_reject.group("proposal_id").strip(), + reason=(m_reject.group("reason") or "").strip(), + ) + + m_status = _STATUS_RE.match(stripped) + if m_status: + return TradeApprovalCommand( + action="status", + proposal_id=m_status.group("proposal_id").strip(), + ) + + m_halt = _HALT_RE.match(stripped) + if m_halt: + return TradeApprovalCommand( + action="halt", + reason=(m_halt.group("reason") or "").strip(), + ) + + return None + + +def pending_proposals_path(runtime_root: Path | None = None) -> Path: + """Return ``/paper/pending_proposals.json`` path.""" + root = (runtime_root / "paper") if runtime_root is not None else paper_root() + return root / "pending_proposals.json" + + +def format_channel_safety_alert( + kind: Literal[ + "halt_tripped", + "daily_loss_cap", + "mandate_breach", + "live_freeze_violation", + "outbound_send_failed", + ], + reason: str, + *, + chat_id: str, + channel: str = "whatsapp", + proposal_id: str | None = None, + audit_id: str | None = None, + metadata: dict[str, Any] | None = None, +) -> OutboundMessage: + """Format a structured channel safety alert for kill switch, daily loss cap, or mandate breach.""" + headers = { + "halt_tripped": "🛑 *[SAFETY ALERT: Kill Switch HALT Active]*", + "daily_loss_cap": "🚨 *[SAFETY ALERT: Daily Loss Cap Breached]*", + "mandate_breach": "⚠️ *[SAFETY ALERT: Mandate Gate Breach]*", + "live_freeze_violation": "🔒 *[SAFETY ALERT: Paper-Only Freeze Violation Blocked]*", + "outbound_send_failed": "📵 *[SAFETY ALERT: WhatsApp Approval Prompt Send Failed]*", + } + header = headers.get(kind, "⚠️ *[SAFETY ALERT]*") + lines = [ + header, + f"• *Alert Type:* `{kind}`", + f"• *Reason:* {reason}", + ] + if proposal_id: + lines.append(f"• *Proposal ID:* `{proposal_id}`") + if audit_id: + lines.append(f"• *Audit ID:* `{audit_id}`") + lines.append("• *Policy:* `mode=paper` (`live_unlocked=False` enforced)") + + merged_meta: dict[str, Any] = { + TRADE_APPROVAL_META_KEY: True, + "safety_alert": True, + "alert_kind": kind, + "proposal_id": proposal_id, + "audit_id": audit_id, + } + if metadata: + merged_meta.update(metadata) + + return OutboundMessage( + channel=channel, + chat_id=chat_id, + content="\n".join(lines), + metadata=merged_meta, + ) + + +def format_whatsapp_approval_request( + proposal: TradeProposal, + *, + chat_id: str, + channel: str = "whatsapp", +) -> OutboundMessage: + """Format a grounded :class:`TradeProposal` into an outbound WhatsApp approval prompt.""" + if not proposal.grounded: + raise ValueError( + f"Proposal {proposal.proposal_id} is not grounded against tool outputs; " + "refusing to emit approval prompt." + ) + if proposal.mode != "paper": + raise ValueError( + f"Proposal {proposal.proposal_id} has mode={proposal.mode!r}; only 'paper' is allowed." + ) + + sl_str = f"${proposal.stop_loss:,.4f}" if proposal.stop_loss is not None else "—" + tp_str = f"${proposal.take_profit:,.4f}" if proposal.take_profit is not None else "—" + traces_str = ", ".join(proposal.tool_trace_ids) if proposal.tool_trace_ids else "none" + + content = ( + f"📋 *[Paper Trade Approval Request]*\n" + f"• *Proposal ID:* `{proposal.proposal_id}`\n" + f"• *Symbol:* `{proposal.symbol}` ({proposal.market})\n" + f"• *Action:* *{proposal.side.upper()}* `{proposal.quantity:g}` @ `${proposal.reference_price:,.4f}`\n" + f"• *Notional (USD):* `${proposal.notional_usd:,.2f}`\n" + f"• *Stop Loss / Take Profit:* {sl_str} / {tp_str}\n" + f"• *Confidence:* `{proposal.confidence:.0%}` | *Mode:* `{proposal.mode}`\n" + f"• *Tool Traces:* `{traces_str}`\n" + ) + if proposal.rationale: + content += f"• *Rationale:* {proposal.rationale.strip()}\n" + + content += ( + "\nReply with one of:\n" + f"• `APPROVE {proposal.proposal_id}` (or `YES {proposal.proposal_id}`) — execute paper fill\n" + f"• `REJECT {proposal.proposal_id} ` (or `NO {proposal.proposal_id} `) — discard proposal\n" + f"• `STATUS {proposal.proposal_id}` — check status\n" + "• `HALT ` — trip emergency kill switch" + ) + + return OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content, + metadata={ + TRADE_APPROVAL_META_KEY: True, + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "side": proposal.side, + "mode": proposal.mode, + "tool_trace_ids": list(proposal.tool_trace_ids), + }, + ) + + + +class TradeApprovalCoordinator: + """Persist pending paper trade proposals and process inbound WhatsApp approval commands.""" + + def __init__( + self, + *, + runtime_root: Path | None = None, + broker: str = "paper", + reflector: DecisionMemoryReflector | None = None, + ) -> None: + self.runtime_root = runtime_root + self.broker = broker + self.reflector = reflector or DecisionMemoryReflector(runtime_root=runtime_root) + + @property + def store_path(self) -> Path: + """Path to the JSON file storing pending and resolved trade proposals.""" + return pending_proposals_path(self.runtime_root) + + def _load_store(self) -> dict[str, dict[str, Any]]: + path = self.store_path + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + return {str(k): dict(v) for k, v in data.items() if isinstance(v, dict)} + + def _save_store(self, store: dict[str, dict[str, Any]]) -> None: + path = self.store_path + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text( + json.dumps(store, indent=2, sort_keys=True, ensure_ascii=False), + encoding="utf-8", + ) + os.chmod(tmp, 0o600) + tmp.replace(path) + + def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: + """Return stored proposal record by ``proposal_id``, or ``None``.""" + return self._load_store().get(proposal_id) + + def submit_proposal( + self, + proposal: TradeProposal, + *, + chat_id: str, + channel: str = "whatsapp", + session_id: str = "paper-session", + force_resend: bool = False, + ) -> OutboundMessage: + """Register a grounded :class:`TradeProposal` for human approval and return the outbound prompt.""" + if not proposal.grounded: + raise ValueError( + f"Cannot submit ungrounded proposal {proposal.proposal_id} for approval." + ) + if proposal.mode != "paper": + raise ValueError( + f"Cannot submit non-paper proposal {proposal.proposal_id} (mode={proposal.mode!r})." + ) + if proposal.side not in ("buy", "sell"): + raise ValueError( + f"Only actionable ('buy'/'sell') proposals require fill approval; got {proposal.side!r}." + ) + + outbound = format_whatsapp_approval_request( + proposal, chat_id=chat_id, channel=channel + ) + store = self._load_store() + existing = store.get(proposal.proposal_id) + if ( + isinstance(existing, dict) + and existing.get("status") == "pending" + and existing.get("sent_at") + and not force_resend + ): + return outbound + + mandate = ensure_paper_mandate(broker=self.broker) + audit_event = LiveActionEvent( + kind="order_placed", + session_id=session_id, + outcome="accepted", + server=self.broker, + remote_tool="submit_proposal", + intent_normalized=( + f"{proposal.side} {proposal.quantity} {proposal.symbol} " + f"@ {proposal.reference_price} (paper)" + ), + mandate_snapshot_ref=f"mandate:v{mandate.schema_version}:{self.broker}", + consent_record_ref=mandate.consent.consent_token_sha256, + broker_request={ + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "market": proposal.market, + "side": proposal.side, + "quantity": proposal.quantity, + "reference_price": proposal.reference_price, + "notional_usd": proposal.notional_usd, + "tool_trace_ids": list(proposal.tool_trace_ids), + "channel": channel, + "chat_id": chat_id, + }, + gate_decision={"allowed": True, "stage": "awaiting_human_approval"}, + ) + audit_rec = write_live_action(audit_event, chain=True) + + store[proposal.proposal_id] = { + "proposal": proposal.model_dump(), + "status": "pending", + "channel": channel, + "chat_id": chat_id, + "session_id": session_id, + "broker": self.broker, + "request_audit_id": audit_rec["audit_id"], + "submitted_at_utc": datetime.now(timezone.utc).isoformat(), + "sent_at": None, + "send_error": None, + "outbound_message_id": None, + "approval_id": None, + "approved_by": None, + "fill_id": None, + "decision_audit_id": None, + } + self._save_store(store) + return outbound + + def mark_outbound_sent( + self, + proposal_id: str, + *, + sent_at: str | None = None, + outbound_message_id: str | None = None, + ) -> dict[str, Any]: + """Record successful outbound delivery of a pending trade approval prompt.""" + store = self._load_store() + record = store.get(proposal_id) + if record is None: + raise KeyError(f"Unknown proposal_id: {proposal_id}") + record["status"] = "pending" + record["sent_at"] = sent_at or datetime.now(timezone.utc).isoformat() + record["send_error"] = None + if outbound_message_id is not None: + record["outbound_message_id"] = outbound_message_id + store[proposal_id] = record + self._save_store(store) + return dict(record) + + def mark_outbound_failed( + self, + proposal_id: str, + error: str, + ) -> dict[str, Any]: + """Record failed outbound delivery so the proposal is never silently left pending.""" + store = self._load_store() + record = store.get(proposal_id) + if record is None: + raise KeyError(f"Unknown proposal_id: {proposal_id}") + record["status"] = "send_failed" + record["send_error"] = str(error) + record["sent_at"] = None + store[proposal_id] = record + self._save_store(store) + return dict(record) + + def handle_inbound_command( + self, + msg: InboundMessage, + *, + is_authorized: bool = True, + mandate: Mandate | None = None, + ) -> OutboundMessage | None: + """Process an inbound IM message if it matches a trade approval command. + + Returns an :class:`OutboundMessage` when ``msg`` is a trade command, or ``None`` + when ``msg`` is ordinary conversation traffic. + """ + cmd = parse_trade_approval_command(msg.content) + if cmd is None: + return None + + if not is_authorized: + active_mandate = mandate or ensure_paper_mandate(broker=self.broker) + audit_event = LiveActionEvent( + kind="order_rejected", + session_id=msg.session_key, + outcome="blocked", + server=self.broker, + remote_tool=f"whatsapp_{cmd.action}", + intent_normalized=f"{cmd.action} {cmd.proposal_id or ''}".strip(), + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{self.broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={ + "command": cmd.action, + "proposal_id": cmd.proposal_id, + "sender_id": msg.sender_id, + "channel": msg.channel, + }, + gate_decision={"allowed": False, "reason": "unauthorized_sender"}, + error=f"Sender {msg.sender_id} is not an authorized trade operator", + ) + write_live_action(audit_event, chain=True) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="⛔ Not authorized: trade approval and HALT commands require an authorized operator.", + metadata={ + TRADE_APPROVAL_META_KEY: True, + "unauthorized": True, + "command": cmd.action, + "proposal_id": cmd.proposal_id, + "message_id": msg.metadata.get("message_id"), + }, + ) + + if cmd.action == "halt": + reason = cmd.reason or f"HALT triggered via {msg.channel} by {msg.sender_id}" + halt_path = trip_halt( + by=f"{msg.channel}:{msg.sender_id}", + reason=reason, + ) + active_mandate = mandate or ensure_paper_mandate(broker=self.broker) + audit_event = LiveActionEvent( + kind="halt_tripped", + session_id=msg.session_key, + outcome="blocked", + server=self.broker, + remote_tool="whatsapp_halt", + intent_normalized=f"HALT ({reason})", + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{self.broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={ + "sender_id": msg.sender_id, + "channel": msg.channel, + "reason": reason, + "halt_path": str(halt_path), + }, + gate_decision={"allowed": False, "halt_tripped": True}, + ) + rec = write_live_action(audit_event, chain=True) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"🛑 *HALT Kill Switch Activated*\n" + f"• *Triggered by:* `{msg.sender_id}`\n" + f"• *Reason:* {reason}\n" + f"• *Audit ID:* `{rec['audit_id']}`\n" + "All paper and live order execution is now blocked." + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": "halt", + "halt_tripped": True, + "audit_id": rec["audit_id"], + "message_id": msg.metadata.get("message_id"), + }, + ) + + assert cmd.proposal_id is not None + store = self._load_store() + entry = store.get(cmd.proposal_id) + if entry is None: + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"⚠️ Unknown trade proposal ID: `{cmd.proposal_id}`.", + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": cmd.action, + "proposal_id": cmd.proposal_id, + "not_found": True, + "message_id": msg.metadata.get("message_id"), + }, + ) + + prop_data = entry.get("proposal") or {} + status = str(entry.get("status") or "unknown") + + if cmd.action == "status": + fill_id = entry.get("fill_id") or "—" + approval_id = entry.get("approval_id") or "—" + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"ℹ️ *Proposal Status:* `{cmd.proposal_id}`\n" + f"• *Status:* `{status}`\n" + f"• *Symbol:* `{prop_data.get('symbol', '?')}` ({prop_data.get('side', '?')})\n" + f"• *Approval ID:* `{approval_id}`\n" + f"• *Fill ID:* `{fill_id}`" + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": "status", + "proposal_id": cmd.proposal_id, + "status": status, + "message_id": msg.metadata.get("message_id"), + }, + ) + + if status != "pending": + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"ℹ️ Proposal `{cmd.proposal_id}` is already `{status}` " + f"(approval_id=`{entry.get('approval_id') or '—'}`)." + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": cmd.action, + "proposal_id": cmd.proposal_id, + "status": status, + "already_resolved": True, + "message_id": msg.metadata.get("message_id"), + }, + ) + + approval_id = f"appr_{uuid.uuid4().hex[:16]}" + symbol = str(prop_data.get("symbol") or "").strip().upper() + side = str(prop_data.get("side") or "").strip().lower() + quantity = float(prop_data.get("quantity") or 0.0) + reference_price = float(prop_data.get("reference_price") or 0.0) + market = str(prop_data.get("market") or "US") + tool_trace_ids = tuple(str(t) for t in (prop_data.get("tool_trace_ids") or ())) + session_id = str(entry.get("session_id") or msg.session_key) + + if cmd.action == "reject": + reason = cmd.reason or "Rejected by human operator" + active_mandate = mandate or ensure_paper_mandate(broker=self.broker) + audit_event = LiveActionEvent( + kind="order_rejected", + session_id=session_id, + outcome="rejected", + server=self.broker, + remote_tool="whatsapp_reject", + intent_normalized=f"{side} {quantity} {symbol} @ {reference_price} (paper)", + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{self.broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={ + "proposal_id": cmd.proposal_id, + "approval_id": approval_id, + "rejected_by": msg.sender_id, + "approval_channel": msg.channel, + "tool_trace_ids": list(tool_trace_ids), + "reason": reason, + }, + gate_decision={"allowed": False, "reason": "human_rejected"}, + error=reason, + ) + audit_rec = write_live_action(audit_event, chain=True) + entry.update( + { + "status": "rejected", + "approval_id": approval_id, + "rejected_by": msg.sender_id, + "rejection_reason": reason, + "decision_audit_id": audit_rec["audit_id"], + "resolved_at_utc": datetime.now(timezone.utc).isoformat(), + } + ) + store[cmd.proposal_id] = entry + self._save_store(store) + + self.reflector.record_reflection( + proposal_id=cmd.proposal_id, + symbol=symbol, + side=side, + lesson=f"Human operator rejected proposal ({reason}).", + tool_trace_ids=tool_trace_ids, + approval_id=approval_id, + fill_id=None, + outcome="rejected_by_human", + ) + + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"🚫 *Paper Trade Rejected*\n" + f"• *Proposal ID:* `{cmd.proposal_id}`\n" + f"• *Approval ID:* `{approval_id}`\n" + f"• *Symbol:* `{symbol}` ({side.upper()} {quantity:g})\n" + f"• *Reason:* {reason}\n" + f"• *Audit ID:* `{audit_rec['audit_id']}`" + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": "reject", + "proposal_id": cmd.proposal_id, + "approval_id": approval_id, + "audit_id": audit_rec["audit_id"], + "status": "rejected", + "message_id": msg.metadata.get("message_id"), + }, + ) + + # cmd.action == "approve" + try: + receipt: PaperFillReceipt = execute_paper_fill( + proposal_id=cmd.proposal_id, + symbol=symbol, + side=side, + quantity=quantity, + fill_price=reference_price, + approval_id=approval_id, + approved_by=msg.sender_id, + approval_channel=msg.channel, + tool_trace_ids=tool_trace_ids, + session_id=session_id, + broker=self.broker, + market=market, + mandate=mandate, + ) + except PaperOrderExecutionError as exc: + err_lower = str(exc).lower() + if exc.breach is not None and exc.breach.limit == "max_daily_loss_usd": + alert_kind = "daily_loss_cap" + elif "halt" in err_lower or "kill switch" in err_lower: + alert_kind = "halt_tripped" + elif "paper-only" in err_lower or "live_unlocked" in err_lower: + alert_kind = "live_freeze_violation" + else: + alert_kind = "mandate_breach" + + entry.update( + { + "status": "blocked", + "approval_id": approval_id, + "approved_by": msg.sender_id, + "error": str(exc), + "alert_kind": alert_kind, + "decision_audit_id": exc.audit_id, + "resolved_at_utc": datetime.now(timezone.utc).isoformat(), + } + ) + store[cmd.proposal_id] = entry + self._save_store(store) + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"❌ *Paper Fill Blocked by Mandate / Gate*\n" + f"• *Alert Type:* `{alert_kind}`\n" + f"• *Proposal ID:* `{cmd.proposal_id}`\n" + f"• *Approval ID:* `{approval_id}`\n" + f"• *Reason:* {exc}\n" + f"• *Audit ID:* `{exc.audit_id or '—'}`" + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": "approve", + "proposal_id": cmd.proposal_id, + "approval_id": approval_id, + "audit_id": exc.audit_id, + "status": "blocked", + "safety_alert": True, + "alert_kind": alert_kind, + "message_id": msg.metadata.get("message_id"), + }, + ) + + + entry.update( + { + "status": "approved", + "approval_id": receipt.approval_id, + "approved_by": receipt.approved_by, + "fill_id": receipt.fill_id, + "decision_audit_id": receipt.audit_id, + "resolved_at_utc": receipt.filled_at, + } + ) + store[cmd.proposal_id] = entry + self._save_store(store) + + self.reflector.record_reflection( + proposal_id=receipt.proposal_id, + symbol=receipt.symbol, + side=receipt.side, + lesson=( + f"Approved paper fill executed: {receipt.side} {receipt.quantity:g} " + f"{receipt.symbol} @ {receipt.fill_price} (notional=${receipt.notional_usd:.2f})." + ), + tool_trace_ids=receipt.tool_trace_ids, + approval_id=receipt.approval_id, + fill_id=receipt.fill_id, + outcome="paper_filled", + ) + + return OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=( + f"✅ *Paper Fill Executed*\n" + f"• *Fill ID:* `{receipt.fill_id}`\n" + f"• *Proposal ID:* `{receipt.proposal_id}`\n" + f"• *Approval ID:* `{receipt.approval_id}`\n" + f"• *Audit ID:* `{receipt.audit_id}`\n" + f"• *Order:* *{receipt.side.upper()}* `{receipt.quantity:g}` `{receipt.symbol}` " + f"@ `${receipt.fill_price:,.4f}` (`${receipt.notional_usd:,.2f}`)\n" + f"• *Mode:* `{receipt.mode}` | *Approved by:* `{receipt.approved_by}`" + ), + metadata={ + TRADE_APPROVAL_META_KEY: True, + "command": "approve", + "proposal_id": receipt.proposal_id, + "approval_id": receipt.approval_id, + "fill_id": receipt.fill_id, + "audit_id": receipt.audit_id, + "status": "approved", + "message_id": msg.metadata.get("message_id"), + }, + ) diff --git a/agent/src/config/env_schema.py b/agent/src/config/env_schema.py index 27a70dba..d8ea30ab 100644 --- a/agent/src/config/env_schema.py +++ b/agent/src/config/env_schema.py @@ -200,6 +200,12 @@ class LLMConfig(_EnvBase): vibe_trading_anthropic_prompt_cache: EnvBool = Field( alias="VIBE_TRADING_ANTHROPIC_PROMPT_CACHE", default=True, ) + vibe_quick_think_model: str = Field( + alias="VIBE_QUICK_THINK_MODEL", default="", + ) + vibe_deep_think_model: str = Field( + alias="VIBE_DEEP_THINK_MODEL", default="", + ) # --------------------------------------------------------------------------- diff --git a/agent/src/live/enforcement.py b/agent/src/live/enforcement.py index 5f1031c8..62d84575 100644 --- a/agent/src/live/enforcement.py +++ b/agent/src/live/enforcement.py @@ -482,13 +482,14 @@ def check_mandate( broker: str, remote_tool: str, daily_count: int, + daily_loss_usd: float = 0.0, ) -> BreachEvent | None: """Evaluate one order intent against the mandate (fail-closed). Checks run in a fixed order and the first failure produces the verdict: exclude-list, instrument allowance, asset-class allowance (universe market- cap/liquidity), single-order notional, post-trade total exposure, post-trade - gross leverage, daily order count, and funding (defense-in-depth). Any + gross leverage, daily order count, daily loss cap, and funding (defense-in-depth). Any unparseable input (bad intent, unreadable positions, missing market data) denies rather than allows. @@ -502,6 +503,8 @@ def check_mandate( remote_tool: Broker remote tool name the agent invoked. daily_count: Orders already placed today (from the persisted counter, after UTC rollover). + daily_loss_usd: Realized + unrealized loss accumulated today in USD + (positive number represents a loss). Returns: ``None`` when the order is fully in-mandate (ALLOW), otherwise a @@ -510,6 +513,14 @@ def check_mandate( caps = mandate.hard_caps universe = mandate.universe + if mandate.mode == "live" and not mandate.live_unlocked: + return _breach( + broker=broker, remote_tool=remote_tool, intent=intent, + kind=BREACH_KIND_INSTRUMENT, limit="live_unlocked", + limit_value=0.0, attempted_value=1.0, + detail="mandate mode is live but live_unlocked is False", + ) + symbol = (intent.symbol or "").strip().upper() if not symbol or intent.side not in ("buy", "sell"): return _breach( @@ -520,7 +531,8 @@ def check_mandate( ) # 1. Exclude-list — takes precedence over every other universe rule. - if symbol in {s.strip().upper() for s in universe.exclude_symbols}: + excluded = {s.strip().upper() for s in universe.exclude_symbols if s.strip()} + if symbol in excluded or (symbol.endswith(".US") and symbol[:-3] in excluded) or f"{symbol}.US" in excluded: return _breach( broker=broker, remote_tool=remote_tool, intent=intent, kind=BREACH_KIND_UNIVERSE, limit="exclude_symbols", @@ -528,6 +540,26 @@ def check_mandate( detail=f"{symbol} is on the mandate exclude list", ) + # 1b. Allow-list — when non-empty, only explicitly allowlisted symbols pass. + if universe.allow_symbols: + allowed = {s.strip().upper() for s in universe.allow_symbols if s.strip()} + symbol_allowed = ( + symbol in allowed + or (symbol.endswith(".US") and symbol[:-3] in allowed) + or ( + "." not in symbol + and "-" not in symbol + and f"{symbol}.US" in allowed + ) + ) + if not symbol_allowed: + return _breach( + broker=broker, remote_tool=remote_tool, intent=intent, + kind=BREACH_KIND_UNIVERSE, limit="allow_symbols", + limit_value=0.0, attempted_value=0.0, + detail=f"{symbol} is not in the mandate allow_symbols list", + ) + # 2. Instrument-type allowance (empty == deny all, fail-closed). if intent.instrument_type not in caps.allowed_instruments: return _breach( @@ -624,6 +656,19 @@ def check_mandate( attempted_value=float(attempted_count), ) + # 7b. Daily loss cap (realized + unrealized loss over UTC calendar day). + if caps.max_daily_loss_usd is not None and float(daily_loss_usd) > float(caps.max_daily_loss_usd): + return _breach( + broker=broker, remote_tool=remote_tool, intent=intent, + kind=BREACH_KIND_QUANTITATIVE, limit="max_daily_loss_usd", + limit_value=float(caps.max_daily_loss_usd), + attempted_value=float(daily_loss_usd), + detail=( + f"daily loss ${float(daily_loss_usd):,.2f} exceeds " + f"max_daily_loss_usd ${float(caps.max_daily_loss_usd):,.2f}" + ), + ) + # 8. Funding (defense-in-depth; broker is the real ceiling). Only a buy can # push us past funding — never block a sell on this. if intent.side == "buy" and post_exposure > caps.account_funding_usd: @@ -634,6 +679,7 @@ def check_mandate( detail="post-trade exposure exceeds mirrored funding ceiling", ) + # 9. Expensive universe rules (market-cap / liquidity floors). Fail-closed # on missing data per SPEC §5. Skipped for instruments with no equity # universe bucket (e.g. options) where these floors do not apply. diff --git a/agent/src/live/mandate/model.py b/agent/src/live/mandate/model.py index dc915c44..55329155 100644 --- a/agent/src/live/mandate/model.py +++ b/agent/src/live/mandate/model.py @@ -64,6 +64,8 @@ class HardCaps: types. Empty == deny all (fail-closed). max_trades_per_day: Vibe-enforced count of order placements allowed per UTC calendar day. Counter persisted alongside the mandate. + max_daily_loss_usd: Optional Vibe-enforced daily realized/unrealized + loss ceiling in USD. ``None`` == no daily loss cap. """ account_funding_usd: float @@ -72,6 +74,8 @@ class HardCaps: max_leverage: float allowed_instruments: tuple[InstrumentType, ...] max_trades_per_day: int + max_daily_loss_usd: float | None = None + @dataclass(frozen=True) @@ -79,7 +83,8 @@ class UniverseConstraint: """Layer (b): user-set universe the agent picks symbols WITHIN. Not a ticker whitelist — that would kill agent discovery. The agent selects - individual symbols freely so long as each clears these structural filters. + individual symbols freely so long as each clears these structural filters, + unless ``allow_symbols`` is non-empty (Phase 0 explicit symbol allowlist). Attributes: asset_classes: Permitted asset-class buckets. Empty == deny all. @@ -89,12 +94,17 @@ class UniverseConstraint: exclude_symbols: Hard per-symbol denylist (normalized upper-case, e.g. ``BTC-USDT`` style for crypto). Takes precedence over every other universe rule. + allow_symbols: Optional explicit symbol allowlist (normalized upper-case, + e.g. ``NVDA.US``, ``0700.HK``, ``BTC-USDT``). When non-empty, only + symbols in this allowlist are permitted. Empty tuple ``()`` means + structural asset_classes rules govern discovery. """ asset_classes: tuple[AssetClass, ...] min_market_cap_usd: float | None min_avg_daily_volume_usd: float | None exclude_symbols: tuple[str, ...] + allow_symbols: tuple[str, ...] = () @dataclass(frozen=True) @@ -122,7 +132,7 @@ class ConsentMeta: @dataclass(frozen=True) class Mandate: - """Immutable bounded-autonomy mandate for one live broker channel. + """Immutable bounded-autonomy mandate for one live or paper broker channel. Loaded read-only at session boot from the user-side protected store. The agent loop has no constructor or write path to this object (see §2). @@ -140,6 +150,9 @@ class Mandate: ``True`` is an explicit per-mandate opt-in the user makes at commit. Read by ``src.live.runtime.flatten`` on a halt trip (SPEC §7.5 #6 "optionally, per mandate"). + mode: Execution mode — ``"paper"`` (default) or ``"live"``. + live_unlocked: Explicit unlock flag required before ``mode="live"`` can + be loaded or executed. Defaults to ``False``. """ schema_version: int @@ -147,3 +160,5 @@ class Mandate: universe: UniverseConstraint consent: ConsentMeta flatten_on_halt: bool = False + mode: str = "paper" + live_unlocked: bool = False diff --git a/agent/src/live/mandate/paper_stub.py b/agent/src/live/mandate/paper_stub.py new file mode 100644 index 00000000..0a7e6f0b --- /dev/null +++ b/agent/src/live/mandate/paper_stub.py @@ -0,0 +1,252 @@ +"""Phase 0 paper-first mandate stub and YAML/JSON seed loader. + +Extends Vibe-Trading's immutable :class:`~src.live.mandate.model.Mandate` with: +- Paper-only default contract (``mode="paper"``, ``live_unlocked=False``). +- Explicit US / HK / Crypto ``allow_symbols`` seed list. +- Safe YAML / JSON mandate loader (:func:`load_mandate_yaml_or_json`). +- Protected store bootstrapper (:func:`ensure_paper_mandate`) writing ``0600`` + ``mandate.json`` under ``/live//mandate.json``. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import yaml + +from src.live.mandate.model import ( + MANDATE_SCHEMA_VERSION, + AssetClass, + ConsentMeta, + HardCaps, + InstrumentType, + Mandate, + UniverseConstraint, +) +from src.live.mandate.store import _parse_mandate, load_mandate +from src.live.paths import broker_dir + +logger = logging.getLogger(__name__) + +#: Default Phase 0 allowlisted symbols across US equities/ETFs, HK equities, and Crypto. +DEFAULT_ALLOW_SYMBOLS: tuple[str, ...] = ( + "NVDA.US", + "AAPL.US", + "MSFT.US", + "SPY.US", + "0700.HK", + "9988.HK", + "BTC-USDT", + "ETH-USDT", + "BTC-USD", + "ETH-USD", +) + +#: Known ETF tickers for automatic instrument classification. +_KNOWN_US_ETFS: frozenset[str] = frozenset( + {"SPY", "SPY.US", "QQQ", "QQQ.US", "IWM", "IWM.US", "DIA", "DIA.US", "VTI", "VTI.US"} +) + + +def infer_asset_class_and_instrument( + symbol: str, + market: str | None = None, +) -> tuple[AssetClass, InstrumentType]: + """Infer ``(AssetClass, InstrumentType)`` from a normalized symbol and optional market hint. + + Args: + symbol: Ticker or pair (e.g. ``"NVDA.US"``, ``"0700.HK"``, ``"BTC-USDT"``). + market: Optional market hint (``"us"``, ``"hk"``, ``"crypto"``). + + Returns: + Tuple of ``(AssetClass, InstrumentType)``. + """ + norm = symbol.strip().upper() + mkt = (market or "").strip().lower() + + if "-" in norm or norm.endswith("USDT") or norm.endswith("USD") and mkt == "crypto" or mkt == "crypto": + return AssetClass.CRYPTO, InstrumentType.CRYPTO + if norm.endswith(".HK") or mkt in {"hk", "hk_equity", "hong kong", "hkex"}: + return AssetClass.HK_EQUITY, InstrumentType.EQUITY + if norm in _KNOWN_US_ETFS: + return AssetClass.US_ETF, InstrumentType.ETF + return AssetClass.US_EQUITY, InstrumentType.EQUITY + + +def default_paper_mandate( + *, + broker: str = "paper", + account_ref: str = "paper-shadow-default", + account_funding_usd: float = 100_000.0, + max_order_notional_usd: float = 10_000.0, + max_total_exposure_usd: float = 50_000.0, + max_leverage: float = 1.0, + max_trades_per_day: int = 10, + max_daily_loss_usd: float | None = 2_000.0, + allow_symbols: tuple[str, ...] = DEFAULT_ALLOW_SYMBOLS, + exclude_symbols: tuple[str, ...] = (), +) -> Mandate: + """Construct the default Phase 0/1 paper-only mandate. + + Always sets ``mode="paper"`` and ``live_unlocked=False``. + """ + now = datetime.now(timezone.utc) + created_at = now.isoformat() + expires_at = (now + timedelta(days=30)).isoformat() + consent_token = hashlib.sha256( + f"paper-consent:{broker}:{account_ref}:{created_at}".encode("utf-8") + ).hexdigest() + + return Mandate( + schema_version=MANDATE_SCHEMA_VERSION, + hard_caps=HardCaps( + account_funding_usd=account_funding_usd, + max_order_notional_usd=max_order_notional_usd, + max_total_exposure_usd=max_total_exposure_usd, + max_leverage=max_leverage, + allowed_instruments=( + InstrumentType.EQUITY, + InstrumentType.ETF, + InstrumentType.CRYPTO, + ), + max_trades_per_day=max_trades_per_day, + max_daily_loss_usd=max_daily_loss_usd, + ), + universe=UniverseConstraint( + asset_classes=( + AssetClass.US_EQUITY, + AssetClass.US_ETF, + AssetClass.HK_EQUITY, + AssetClass.CRYPTO, + ), + min_market_cap_usd=None, + min_avg_daily_volume_usd=None, + exclude_symbols=tuple(s.strip().upper() for s in exclude_symbols if s.strip()), + allow_symbols=tuple(s.strip().upper() for s in allow_symbols if s.strip()), + ), + consent=ConsentMeta( + created_at=created_at, + consent_token_sha256=consent_token, + broker=broker, + account_ref=account_ref, + expires_at=expires_at, + ), + flatten_on_halt=False, + mode="paper", + live_unlocked=False, + ) + + +def mandate_to_dict(mandate: Mandate) -> dict[str, Any]: + """Serialize a :class:`Mandate` to a JSON/YAML-compatible dictionary.""" + return { + "schema_version": mandate.schema_version, + "mode": mandate.mode, + "live_unlocked": mandate.live_unlocked, + "flatten_on_halt": mandate.flatten_on_halt, + "hard_caps": { + "account_funding_usd": mandate.hard_caps.account_funding_usd, + "max_order_notional_usd": mandate.hard_caps.max_order_notional_usd, + "max_total_exposure_usd": mandate.hard_caps.max_total_exposure_usd, + "max_leverage": mandate.hard_caps.max_leverage, + "allowed_instruments": [i.value for i in mandate.hard_caps.allowed_instruments], + "max_trades_per_day": mandate.hard_caps.max_trades_per_day, + "max_daily_loss_usd": mandate.hard_caps.max_daily_loss_usd, + }, + + "universe": { + "asset_classes": [a.value for a in mandate.universe.asset_classes], + "min_market_cap_usd": mandate.universe.min_market_cap_usd, + "min_avg_daily_volume_usd": mandate.universe.min_avg_daily_volume_usd, + "exclude_symbols": list(mandate.universe.exclude_symbols), + "allow_symbols": list(mandate.universe.allow_symbols), + }, + "consent": { + "created_at": mandate.consent.created_at, + "consent_token_sha256": mandate.consent.consent_token_sha256, + "broker": mandate.consent.broker, + "account_ref": mandate.consent.account_ref, + "expires_at": mandate.consent.expires_at, + }, + } + + +def load_mandate_yaml_or_json(path: Path) -> Mandate | None: + """Load and validate a :class:`Mandate` from a YAML or JSON file (fail-closed). + + Uses ``yaml.safe_load`` for ``.yaml``/``.yml`` files and ``json.loads`` for + ``.json`` files. Any missing file, parse error, or invalid schema returns + ``None`` (fail-closed). + """ + if not path.is_file(): + return None + try: + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + raw = yaml.safe_load(text) + else: + raw = json.loads(text) + except (OSError, ValueError, yaml.YAMLError) as exc: + logger.warning("mandate file %s is unreadable/invalid: %s", path, exc) + return None + if not isinstance(raw, dict): + logger.warning("mandate file %s root must be a mapping", path) + return None + + # Allow YAML/JSON convenience defaults for optional floor fields if omitted + universe = raw.get("universe") + if isinstance(universe, dict): + universe = dict(universe) + universe.setdefault("min_market_cap_usd", None) + universe.setdefault("min_avg_daily_volume_usd", None) + universe.setdefault("exclude_symbols", []) + raw = {**raw, "universe": universe} + + try: + return _parse_mandate(raw) + except (KeyError, TypeError, ValueError) as exc: + logger.warning("mandate file %s failed validation: %s", path, exc) + return None + + +def ensure_paper_mandate( + broker: str = "paper", + *, + seed_path: Path | None = None, +) -> Mandate: + """Load existing mandate for ``broker`` or commit the default paper mandate (0600). + + Args: + broker: Broker key (defaults to ``"paper"``). + seed_path: Optional YAML/JSON seed file to initialize from when no + committed ``mandate.json`` exists yet. + + Returns: + The active :class:`Mandate` (guaranteed ``mode == "paper"`` when seeded + by default). + """ + existing = load_mandate(broker) + if existing is not None: + return existing + + mandate: Mandate | None = None + if seed_path is not None: + mandate = load_mandate_yaml_or_json(seed_path) + if mandate is None: + mandate = default_paper_mandate(broker=broker) + + target_dir = broker_dir(broker) + target_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + target_path = target_dir / "mandate.json" + tmp_path = target_dir / ".mandate.json.tmp" + payload = json.dumps(mandate_to_dict(mandate), indent=2, ensure_ascii=False) + tmp_path.write_text(payload, encoding="utf-8") + os.chmod(tmp_path, 0o600) + tmp_path.replace(target_path) + return mandate diff --git a/agent/src/live/mandate/store.py b/agent/src/live/mandate/store.py index cc0fb05c..f0f5fa95 100644 --- a/agent/src/live/mandate/store.py +++ b/agent/src/live/mandate/store.py @@ -94,12 +94,20 @@ def _parse_mandate(raw: object) -> Mandate: InstrumentType(value) for value in caps["allowed_instruments"] ), max_trades_per_day=int(caps["max_trades_per_day"]), + max_daily_loss_usd=_opt_float(caps.get("max_daily_loss_usd")), ) + + raw_allow_symbols = universe.get("allow_symbols", ()) + if raw_allow_symbols is None: + raw_allow_symbols = () + if not isinstance(raw_allow_symbols, (list, tuple)): + raise TypeError("mandate field 'universe.allow_symbols' must be a list") universe_constraint = UniverseConstraint( asset_classes=tuple(AssetClass(value) for value in universe["asset_classes"]), min_market_cap_usd=_opt_float(universe["min_market_cap_usd"]), min_avg_daily_volume_usd=_opt_float(universe["min_avg_daily_volume_usd"]), exclude_symbols=tuple(str(value) for value in universe["exclude_symbols"]), + allow_symbols=tuple(str(value) for value in raw_allow_symbols), ) consent_meta = ConsentMeta( created_at=str(consent["created_at"]), @@ -108,6 +116,12 @@ def _parse_mandate(raw: object) -> Mandate: account_ref=str(consent["account_ref"]), expires_at=str(consent["expires_at"]), ) + mode = str(raw.get("mode", "paper")).strip().lower() or "paper" + if mode not in ("paper", "live"): + raise ValueError(f"unsupported mandate mode: {mode!r}") + live_unlocked = bool(raw.get("live_unlocked", False)) + if mode == "live" and not live_unlocked: + raise ValueError("mandate mode='live' requires live_unlocked=True") return Mandate( schema_version=int(raw["schema_version"]), hard_caps=hard_caps, @@ -117,6 +131,8 @@ def _parse_mandate(raw: object) -> Mandate: # old mandate.json → False (cancel-only, the safe default), keeping the # read backward-compatible. Read on a halt trip by src.live.runtime.flatten. flatten_on_halt=bool(raw.get("flatten_on_halt", False)), + mode=mode, + live_unlocked=live_unlocked, ) diff --git a/agent/src/mandate/__init__.py b/agent/src/mandate/__init__.py new file mode 100644 index 00000000..e8a56dc0 --- /dev/null +++ b/agent/src/mandate/__init__.py @@ -0,0 +1,71 @@ +"""Compatibility re-export shim for Vibe-Trading's mandate subsystem. + +Canonical mandate models, protected store, kill switch, and pre-trade +enforcement live under :mod:`src.live.mandate`, :mod:`src.live.halt`, and +:mod:`src.live.enforcement`. This module re-exports the public surface at +``src.mandate`` so callers referencing ``agent/src/mandate/`` work seamlessly. +""" + +from __future__ import annotations + +from src.live.enforcement import ( + BREACH_KIND_INSTRUMENT, + BREACH_KIND_QUANTITATIVE, + BREACH_KIND_UNIVERSE, + BreachEvent, + OrderIntent, + check_mandate, +) +from src.live.halt import ( + clear_halt, + halt_flag_set, + halt_path, + read_halt, + trip_halt, +) +from src.live.mandate.model import ( + MANDATE_SCHEMA_VERSION, + AssetClass, + ConsentMeta, + HardCaps, + InstrumentType, + Mandate, + UniverseConstraint, +) +from src.live.mandate.paper_stub import ( + DEFAULT_ALLOW_SYMBOLS, + default_paper_mandate, + ensure_paper_mandate, + infer_asset_class_and_instrument, + load_mandate_yaml_or_json, + mandate_to_dict, +) +from src.live.mandate.store import load_mandate + +__all__ = [ + "AssetClass", + "BREACH_KIND_INSTRUMENT", + "BREACH_KIND_QUANTITATIVE", + "BREACH_KIND_UNIVERSE", + "BreachEvent", + "ConsentMeta", + "DEFAULT_ALLOW_SYMBOLS", + "HardCaps", + "InstrumentType", + "MANDATE_SCHEMA_VERSION", + "Mandate", + "OrderIntent", + "UniverseConstraint", + "check_mandate", + "clear_halt", + "default_paper_mandate", + "ensure_paper_mandate", + "halt_flag_set", + "halt_path", + "infer_asset_class_and_instrument", + "load_mandate", + "load_mandate_yaml_or_json", + "mandate_to_dict", + "read_halt", + "trip_halt", +] diff --git a/agent/src/memory/__init__.py b/agent/src/memory/__init__.py index 7a816221..0039fe25 100644 --- a/agent/src/memory/__init__.py +++ b/agent/src/memory/__init__.py @@ -1 +1,8 @@ """Persistent cross-session memory system.""" + +from src.memory.reflector import DecisionMemoryReflector, DecisionReflectionRecord + +__all__ = [ + "DecisionMemoryReflector", + "DecisionReflectionRecord", +] diff --git a/agent/src/memory/reflector.py b/agent/src/memory/reflector.py new file mode 100644 index 00000000..96381ad6 --- /dev/null +++ b/agent/src/memory/reflector.py @@ -0,0 +1,349 @@ +"""TradingAgents-style Reflector interface for post-trade decision memory.""" + +from __future__ import annotations + +import json +import os +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from src.config.paths import get_runtime_root + + +def _default_reflections_path(runtime_root: Path | None = None) -> Path: + root = runtime_root if runtime_root is not None else get_runtime_root() + return root / "memory" / "decision_reflections.jsonl" + + +@dataclass(frozen=True) +class DecisionReflectionRecord: + """Immutable reflection record linking tool traces -> proposal -> approval -> fill.""" + + reflection_id: str + proposal_id: str + symbol: str + side: str + lesson: str + tool_trace_ids: tuple[str, ...] = () + approval_id: str | None = None + fill_id: str | None = None + realized_pnl_usd: float | None = None + holding_period_days: int | None = None + entry_price: float | None = None + exit_price: float | None = None + realized_return_pct: float | None = None + benchmark_return_pct: float | None = None + alpha_pct: float | None = None + outcome: str = "pending" + created_at_utc: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Serialize reflection record to a JSON-compatible dictionary.""" + return { + "reflection_id": self.reflection_id, + "proposal_id": self.proposal_id, + "symbol": self.symbol, + "side": self.side, + "lesson": self.lesson, + "tool_trace_ids": list(self.tool_trace_ids), + "approval_id": self.approval_id, + "fill_id": self.fill_id, + "realized_pnl_usd": self.realized_pnl_usd, + "holding_period_days": self.holding_period_days, + "entry_price": self.entry_price, + "exit_price": self.exit_price, + "realized_return_pct": self.realized_return_pct, + "benchmark_return_pct": self.benchmark_return_pct, + "alpha_pct": self.alpha_pct, + "outcome": self.outcome, + "created_at_utc": self.created_at_utc, + "metadata": dict(self.metadata), + } + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> DecisionReflectionRecord: + """Deserialize a reflection record from a dictionary.""" + raw_traces = payload.get("tool_trace_ids") or () + traces = tuple(str(t) for t in raw_traces if str(t).strip()) + + def _opt_float(key: str) -> float | None: + val = payload.get(key) + return float(val) if val is not None else None + + raw_days = payload.get("holding_period_days") + days = int(raw_days) if raw_days is not None else None + + return cls( + reflection_id=str( + payload.get("reflection_id") or f"refl_{uuid.uuid4().hex[:12]}" + ), + proposal_id=str(payload["proposal_id"]), + symbol=str(payload["symbol"]).strip().upper(), + side=str(payload.get("side") or "hold").strip().lower(), + lesson=str(payload.get("lesson") or "").strip(), + tool_trace_ids=traces, + approval_id=( + str(payload["approval_id"]) + if payload.get("approval_id") is not None + else None + ), + fill_id=( + str(payload["fill_id"]) + if payload.get("fill_id") is not None + else None + ), + realized_pnl_usd=_opt_float("realized_pnl_usd"), + holding_period_days=days, + entry_price=_opt_float("entry_price"), + exit_price=_opt_float("exit_price"), + realized_return_pct=_opt_float("realized_return_pct"), + benchmark_return_pct=_opt_float("benchmark_return_pct"), + alpha_pct=_opt_float("alpha_pct"), + outcome=str(payload.get("outcome") or "pending"), + created_at_utc=str( + payload.get("created_at_utc") + or datetime.now(timezone.utc).isoformat() + ), + metadata=dict(payload.get("metadata") or {}), + ) + + +class DecisionMemoryReflector: + """Append-only JSONL store and query interface for TradingAgents decision reflections.""" + + def __init__(self, *, runtime_root: Path | None = None) -> None: + self.runtime_root = runtime_root + self.path = _default_reflections_path(runtime_root) + + def _append_record(self, record: DecisionReflectionRecord) -> DecisionReflectionRecord: + self.path.parent.mkdir(parents=True, exist_ok=True) + line = json.dumps(record.to_dict(), sort_keys=True) + "\n" + fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + try: + os.write(fd, line.encode("utf-8")) + finally: + os.close(fd) + return record + + def record_reflection( + self, + *, + proposal_id: str, + symbol: str, + side: str, + lesson: str, + tool_trace_ids: tuple[str, ...] | list[str] = (), + approval_id: str | None = None, + fill_id: str | None = None, + realized_pnl_usd: float | None = None, + holding_period_days: int | None = None, + entry_price: float | None = None, + exit_price: float | None = None, + realized_return_pct: float | None = None, + benchmark_return_pct: float | None = None, + alpha_pct: float | None = None, + outcome: str = "recorded", + metadata: dict[str, Any] | None = None, + ) -> DecisionReflectionRecord: + """Append a new reflection linking tool traces, proposal, approval, and fill.""" + if not proposal_id or not str(proposal_id).strip(): + raise ValueError("proposal_id must be non-empty") + if not symbol or not str(symbol).strip(): + raise ValueError("symbol must be non-empty") + if not lesson or not str(lesson).strip(): + raise ValueError("lesson must be non-empty") + + record = DecisionReflectionRecord( + reflection_id=f"refl_{uuid.uuid4().hex[:12]}", + proposal_id=str(proposal_id).strip(), + symbol=str(symbol).strip().upper(), + side=str(side).strip().lower(), + lesson=str(lesson).strip(), + tool_trace_ids=tuple(str(t).strip() for t in tool_trace_ids if str(t).strip()), + approval_id=str(approval_id).strip() if approval_id else None, + fill_id=str(fill_id).strip() if fill_id else None, + realized_pnl_usd=( + float(realized_pnl_usd) if realized_pnl_usd is not None else None + ), + holding_period_days=( + int(holding_period_days) if holding_period_days is not None else None + ), + entry_price=float(entry_price) if entry_price is not None else None, + exit_price=float(exit_price) if exit_price is not None else None, + realized_return_pct=( + float(realized_return_pct) if realized_return_pct is not None else None + ), + benchmark_return_pct=( + float(benchmark_return_pct) if benchmark_return_pct is not None else None + ), + alpha_pct=float(alpha_pct) if alpha_pct is not None else None, + outcome=str(outcome).strip() or "recorded", + metadata=dict(metadata or {}), + ) + return self._append_record(record) + + def settle_holding_period_alpha( + self, + *, + proposal_id: str, + symbol: str, + side: str, + entry_price: float, + exit_price: float, + quantity: float = 1.0, + benchmark_return_pct: float = 0.0, + holding_period_days: int = 5, + tool_trace_ids: tuple[str, ...] | list[str] = (), + approval_id: str | None = None, + fill_id: str | None = None, + lesson: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> DecisionReflectionRecord: + """Settle post-holding-period realized return and alpha into trading_memory. + + Computes directional trade return and benchmark-relative alpha after + ``holding_period_days``, writing an ``outcome="alpha_settled"`` reflection + record so future SwarmRuntime runs can incorporate empirical post-trade lessons. + """ + if entry_price <= 0 or exit_price <= 0: + raise ValueError("entry_price and exit_price must be positive") + if holding_period_days < 1: + raise ValueError("holding_period_days must be >= 1") + if quantity <= 0: + raise ValueError("quantity must be positive") + + norm_side = str(side).strip().lower() + norm_symbol = str(symbol).strip().upper() + direction = -1.0 if norm_side == "sell" else 1.0 + raw_price_diff = float(exit_price) - float(entry_price) + realized_return_pct = direction * (raw_price_diff / float(entry_price)) + realized_pnl_usd = direction * raw_price_diff * float(quantity) + bench_ret = float(benchmark_return_pct) + alpha_pct = realized_return_pct - bench_ret + + verdict = "outperformed" if alpha_pct >= 0 else "underperformed" + default_lesson = ( + f"Post-{int(holding_period_days)}d alpha settled for {norm_symbol} ({norm_side.upper()}): " + f"entry={float(entry_price):.4f}, exit={float(exit_price):.4f}, " + f"return={realized_return_pct:+.2%}, benchmark={bench_ret:+.2%}, " + f"alpha={alpha_pct:+.2%} ({verdict}, PnL=${realized_pnl_usd:+.2f})." + ) + final_lesson = (lesson.strip() if lesson and lesson.strip() else default_lesson) + + merged_meta = dict(metadata or {}) + merged_meta.update( + { + "holding_period_days": int(holding_period_days), + "entry_price": float(entry_price), + "exit_price": float(exit_price), + "quantity": float(quantity), + "realized_return_pct": round(realized_return_pct, 6), + "benchmark_return_pct": round(bench_ret, 6), + "alpha_pct": round(alpha_pct, 6), + } + ) + + return self.record_reflection( + proposal_id=proposal_id, + symbol=norm_symbol, + side=norm_side, + lesson=final_lesson, + tool_trace_ids=tool_trace_ids, + approval_id=approval_id, + fill_id=fill_id, + realized_pnl_usd=round(realized_pnl_usd, 4), + holding_period_days=int(holding_period_days), + entry_price=float(entry_price), + exit_price=float(exit_price), + realized_return_pct=round(realized_return_pct, 6), + benchmark_return_pct=round(bench_ret, 6), + alpha_pct=round(alpha_pct, 6), + outcome="alpha_settled", + metadata=merged_meta, + ) + + def query_reflections( + self, + *, + symbol: str | None = None, + proposal_id: str | None = None, + outcome: str | None = None, + limit: int = 20, + ) -> list[DecisionReflectionRecord]: + """Return matching reflection records in reverse chronological order.""" + if not self.path.is_file(): + return [] + + norm_symbol = symbol.strip().upper() if symbol else None + norm_proposal = proposal_id.strip() if proposal_id else None + norm_outcome = outcome.strip() if outcome else None + records: list[DecisionReflectionRecord] = [] + + for raw_line in self.path.read_text(encoding="utf-8").splitlines(): + stripped = raw_line.strip() + if not stripped: + continue + try: + payload = json.loads(stripped) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + rec = DecisionReflectionRecord.from_dict(payload) + if norm_symbol and rec.symbol != norm_symbol: + continue + if norm_proposal and rec.proposal_id != norm_proposal: + continue + if norm_outcome and rec.outcome != norm_outcome: + continue + records.append(rec) + + records.reverse() + return records[: max(0, int(limit))] + + def format_lessons_for_next_run( + self, + *, + symbol: str | None = None, + limit: int = 5, + ) -> str: + """Format settled alpha and operator reflections into a prompt block for the next swarm run.""" + records = self.query_reflections(symbol=symbol, limit=max(limit * 2, 10)) + if not records and symbol is not None: + # Fall back to global trading_memory lessons if symbol has no history yet + records = self.query_reflections(symbol=None, limit=max(limit * 2, 10)) + if not records: + return "No prior trading memory lessons recorded." + + # Prioritize alpha_settled first, then rejected_by_human, then paper_filled / others + priority = {"alpha_settled": 0, "rejected_by_human": 1, "paper_filled": 2} + ordered = sorted( + enumerate(records), + key=lambda item: (priority.get(item[1].outcome, 3), item[0]), + ) + selected = [rec for _, rec in ordered[: max(1, int(limit))]] + + lines = ["### Prior Trading Memory & Post-Holding-Period Alpha Lessons"] + for rec in selected: + alpha_tag = ( + f" | alpha={rec.alpha_pct:+.2%} ({rec.holding_period_days or '?'}d)" + if rec.alpha_pct is not None + else "" + ) + pnl_tag = ( + f" | PnL=${rec.realized_pnl_usd:+.2f}" + if rec.realized_pnl_usd is not None + else "" + ) + lines.append( + f"- [{rec.outcome}] `{rec.symbol}` ({rec.side.upper()}, proposal `{rec.proposal_id}`" + f"{alpha_tag}{pnl_tag}): {rec.lesson}" + ) + return "\n".join(lines) + diff --git a/agent/src/providers/chat.py b/agent/src/providers/chat.py index 1884fa5f..352499d1 100644 --- a/agent/src/providers/chat.py +++ b/agent/src/providers/chat.py @@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional from src.config.accessor import get_env_config from src.providers.content_filter import is_content_filter_triggered -from src.providers.llm import build_llm +from src.providers.llm import build_llm, resolve_thinking_tier logger = logging.getLogger(__name__) @@ -342,9 +342,7 @@ class ChatLLM: """ self._llm = build_llm(model_name=model_name) runtime_cfg = get_env_config().llm - configured_model = ( - model_name or runtime_cfg.langchain_model_name - ).strip() + configured_model = resolve_thinking_tier(model_name) self.model_name = configured_model self.runtime_snapshot = LLMRuntimeSnapshot( provider=runtime_cfg.langchain_provider.strip().lower() or "openai", diff --git a/agent/src/providers/llm.py b/agent/src/providers/llm.py index 65f0df99..37c696ae 100644 --- a/agent/src/providers/llm.py +++ b/agent/src/providers/llm.py @@ -1631,11 +1631,31 @@ def provider_diagnostics() -> dict[str, Any]: } +def resolve_thinking_tier(model_name: Optional[str] = None) -> str: + """Resolve ``quick_think`` / ``deep_think`` tier aliases to concrete model names. + + Falls back to ``LANGCHAIN_MODEL_NAME`` when ``VIBE_QUICK_THINK_MODEL`` or + ``VIBE_DEEP_THINK_MODEL`` is not set. + """ + llm_cfg = get_env_config().llm + default_model = llm_cfg.langchain_model_name.strip() + raw = (model_name or "").strip() + if not raw: + return default_model + lowered = raw.lower() + if lowered == "quick_think": + return llm_cfg.vibe_quick_think_model.strip() or default_model + if lowered == "deep_think": + return llm_cfg.vibe_deep_think_model.strip() or default_model + return raw + + def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any: """Construct the configured LangChain chat model. Args: - model_name: Model name; defaults to LANGCHAIN_MODEL_NAME. + model_name: Model name or thinking tier (``quick_think`` / ``deep_think``); + defaults to LANGCHAIN_MODEL_NAME. callbacks: Optional LangChain callbacks. Returns: @@ -1645,7 +1665,7 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset. """ _sync_provider_env() - name = model_name or get_env_config().llm.langchain_model_name.strip() + name = resolve_thinking_tier(model_name) if not name: raise RuntimeError("LANGCHAIN_MODEL_NAME is not set") temperature = get_env_config().llm.langchain_temperature diff --git a/agent/src/shadow_account/paper_automation.py b/agent/src/shadow_account/paper_automation.py new file mode 100644 index 00000000..f28a7444 --- /dev/null +++ b/agent/src/shadow_account/paper_automation.py @@ -0,0 +1,947 @@ +"""Phase 1 Daily Paper Automation — Asia/Hong_Kong scheduler, WhatsApp gate, and HTML/PDF digest. + +Enforces Phase 1 invariants: +1. Daily schedule runs strictly in ``Asia/Hong_Kong`` (HKT) timezone. +2. Schedule promotion is blocked until the multi-market (US + HK + CRYPTO) + purged walk-forward + bootstrap promotion gate passes (``passed == True``). +3. ``mode`` is frozen to ``"paper"`` and ``live_unlocked`` is frozen to ``False``. +4. Pre-run safety checks verify kill switch (``HALT``) and daily loss cap + (``max_daily_loss_usd``); any breach emits a channel safety alert and + blocks proposal dispatch. +5. Proposals are registered via ``TradeApprovalCoordinator`` and published onto + the channel ``MessageBus`` / ``WhatsAppChannel.send`` (with idempotency on + ``proposal_id``); zero paper fills are written until human ``APPROVE `` + or ``YES `` is received. + Prerequisite: WhatsApp session must be logged in and started + (``WhatsAppChannel.login`` / ``WhatsAppChannel.start``) before paper schedule runs. +6. Settled post-holding-period alpha lessons from ``DecisionMemoryReflector`` + are injected into each daily run and rendered in the HTML + PDF digest. +""" + +from __future__ import annotations + +import asyncio +import html +import inspect +import json +import os +import uuid +from collections.abc import Callable, Iterable +from dataclasses import asdict, dataclass, replace +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from src.channels.bus.events import InboundMessage, OutboundMessage +from src.channels.bus.queue import MessageBus +from src.channels.trade_approval import ( + TradeApprovalCoordinator, + format_channel_safety_alert, +) +from src.config.paths import get_runtime_root +from src.live.halt import halt_flag_set, read_halt, trip_halt +from src.live.mandate.model import Mandate +from src.live.mandate.paper_stub import ensure_paper_mandate +from src.memory.reflector import DecisionMemoryReflector +from src.shadow_account.paper_executor import ( + load_paper_positions, + paper_fills_path, + read_daily_paper_loss_usd, + read_daily_paper_pnl_usd, +) +from src.shadow_account.pdf_fallback import _ensure_font +from src.swarm.proposal import TradeProposal + +HKT_TIMEZONE = "Asia/Hong_Kong" +HKT_ZONE = ZoneInfo(HKT_TIMEZONE) +SCHEDULE_CONFIG_FILENAME = "schedule_config.json" + + +class PromotionGateBlockedError(RuntimeError): + """Raised when attempting to promote or execute a schedule before backtest proof passes.""" + + +class LiveModeFrozenError(RuntimeError): + """Raised when configuration attempts to enable live trading during Phase 1.""" + + +def now_hkt_iso(now: datetime | None = None) -> str: + """Return an ISO-8601 timestamp in ``Asia/Hong_Kong`` timezone.""" + if now is None: + return datetime.now(HKT_ZONE).isoformat() + if now.tzinfo is None: + return now.replace(tzinfo=HKT_ZONE).isoformat() + return now.astimezone(HKT_ZONE).isoformat() + + +def hkt_date_key(now: datetime | None = None) -> str: + """Return the ``YYYY-MM-DD`` date string in ``Asia/Hong_Kong`` timezone.""" + if now is None: + dt = datetime.now(HKT_ZONE) + elif now.tzinfo is None: + dt = now.replace(tzinfo=HKT_ZONE) + else: + dt = now.astimezone(HKT_ZONE) + return dt.strftime("%Y-%m-%d") + + +@dataclass(frozen=True) +class PaperAutomationScheduleConfig: + """Frozen daily schedule configuration for Phase 1 paper automation.""" + + job_id: str = "daily-paper-automation-hkt" + timezone: str = HKT_TIMEZONE + cron_expr: str = "30 9 * * 1-5" + universe: tuple[str, ...] = ("NVDA.US", "0700.HK", "BTC-USD") + chat_id: str = "operator@s.whatsapp.net" + channel: str = "whatsapp" + mode: str = "paper" + live_unlocked: bool = False + promoted: bool = False + promoted_at_hkt: str | None = None + promotion_summary: dict[str, Any] | None = None + + def __post_init__(self) -> None: + if self.timezone != HKT_TIMEZONE: + raise ValueError( + f"PaperAutomationScheduleConfig requires timezone={HKT_TIMEZONE!r}, got {self.timezone!r}" + ) + if str(self.mode).lower() != "paper" or bool(self.live_unlocked): + raise LiveModeFrozenError( + "Phase 1 paper automation freezes mode='paper' and live_unlocked=False" + ) + if not self.universe: + raise ValueError("universe must contain at least one symbol") + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["universe"] = list(self.universe) + return payload + + +def paper_schedule_config_path(runtime_root: Path | None = None) -> Path: + root = Path(runtime_root) if runtime_root is not None else get_runtime_root() + return root / "paper" / SCHEDULE_CONFIG_FILENAME + + +def promote_paper_schedule( + config: PaperAutomationScheduleConfig, + promotion_gate_result: dict[str, Any], + *, + runtime_root: Path | None = None, + now: datetime | None = None, +) -> PaperAutomationScheduleConfig: + """Promote ``config`` for daily execution only if ``promotion_gate_result['passed']`` is True.""" + if not isinstance(promotion_gate_result, dict) or not bool(promotion_gate_result.get("passed")): + failed = promotion_gate_result.get("failed_reasons") if isinstance(promotion_gate_result, dict) else None + reason_str = "; ".join(str(r) for r in (failed or ["promotion gate did not pass"])) + raise PromotionGateBlockedError( + f"Schedule promotion blocked until US+HK+CRYPTO purged walk-forward + bootstrap gates pass: {reason_str}" + ) + + markets = promotion_gate_result.get("per_market") or promotion_gate_result.get("markets") + if isinstance(markets, dict): + for required_mkt in ("US", "HK", "CRYPTO"): + mkt_entry = markets.get(required_mkt) + if not isinstance(mkt_entry, dict) or not bool(mkt_entry.get("passed")): + raise PromotionGateBlockedError( + f"Schedule promotion blocked: market {required_mkt} did not pass promotion gate" + ) + + promoted_cfg = replace( + config, + promoted=True, + promoted_at_hkt=now_hkt_iso(now), + promotion_summary={ + "passed": True, + "markets": sorted(markets.keys()) if isinstance(markets, dict) else ["US", "HK", "CRYPTO"], + }, + ) + path = paper_schedule_config_path(runtime_root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(promoted_cfg.to_dict(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + try: + os.chmod(path, 0o600) + except OSError: + pass + return promoted_cfg + + +def load_paper_schedule_config( + *, runtime_root: Path | None = None +) -> PaperAutomationScheduleConfig | None: + """Load persisted ``PaperAutomationScheduleConfig`` from ``/paper/schedule_config.json``.""" + path = paper_schedule_config_path(runtime_root) + if not path.is_file(): + return None + raw = json.loads(path.read_text(encoding="utf-8")) + return PaperAutomationScheduleConfig( + job_id=str(raw.get("job_id", "daily-paper-automation-hkt")), + timezone=str(raw.get("timezone", HKT_TIMEZONE)), + cron_expr=str(raw.get("cron_expr", "30 9 * * 1-5")), + universe=tuple(str(s) for s in raw.get("universe", ("NVDA.US", "0700.HK", "BTC-USD"))), + chat_id=str(raw.get("chat_id", "operator@s.whatsapp.net")), + channel=str(raw.get("channel", "whatsapp")), + mode=str(raw.get("mode", "paper")), + live_unlocked=bool(raw.get("live_unlocked", False)), + promoted=bool(raw.get("promoted", False)), + promoted_at_hkt=raw.get("promoted_at_hkt"), + promotion_summary=raw.get("promotion_summary"), + ) + + +def _read_recent_fills(limit: int = 50) -> list[dict[str, Any]]: + path = paper_fills_path() + if not path.is_file(): + return [] + rows: list[dict[str, Any]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(item, dict): + rows.append(item) + return rows[-limit:] + + +def render_daily_paper_digest( + *, + output_dir: Path, + run_id: str, + timestamp_hkt: str, + pending_proposals: list[dict[str, Any]], + approved_fills: list[dict[str, Any]], + open_positions: list[dict[str, Any]], + daily_pnl_usd: float, + daily_loss_usd: float, + max_daily_loss_usd: float | None, + memory_lessons: str, + halt_active: bool = False, +) -> dict[str, Any]: + """Render the daily paper digest as both ``paper_digest.html`` and ``paper_digest.pdf``.""" + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + html_path = out / "paper_digest.html" + pdf_path = out / "paper_digest.pdf" + + cap_str = f"${max_daily_loss_usd:,.2f}" if max_daily_loss_usd is not None else "Unbounded" + status_badge = "HALTED" if halt_active else "PAPER ACTIVE (LIVE FROZEN)" + + pending_rows_html = "".join( + f"{html.escape(str(p.get('proposal_id', '')))}" + f"{html.escape(str(p.get('symbol', '')))}" + f"{html.escape(str(p.get('side', '')))}" + f"${float(p.get('notional_usd', 0.0)):,.2f}" + f"{html.escape(str(p.get('status', 'pending')))}" + for p in pending_proposals + ) or "No pending proposals" + + fills_rows_html = "".join( + f"{html.escape(str(f.get('fill_id', '')))}" + f"{html.escape(str(f.get('proposal_id', '')))}" + f"{html.escape(str(f.get('symbol', '')))}" + f"{html.escape(str(f.get('side', '')))}" + f"{float(f.get('quantity', 0.0)):.4f}" + f"${float(f.get('fill_price', 0.0)):,.2f}" + f"${float(f.get('notional_usd', 0.0)):,.2f}" + for f in approved_fills + ) or "No WhatsApp-approved fills recorded" + + pos_rows_html = "".join( + f"{html.escape(str(pos.get('symbol', '')))}" + f"{float(pos.get('quantity', 0.0)):.4f}" + f"${float(pos.get('avg_entry_price', pos.get('avg_price', 0.0))):,.2f}" + f"${float(pos.get('market_value_usd', pos.get('notional_usd', 0.0))):,.2f}" + for pos in open_positions + ) or "No open shadow positions" + + lessons_escaped = html.escape(memory_lessons or "No settled post-holding-period alpha lessons yet.") + + html_doc = f""" + + + + Vibe-Trading Daily Paper Digest ({html.escape(run_id)}) + + + +

Vibe-Trading Daily Paper Digest

+
+

Run ID: {html.escape(run_id)}

+

Timestamp (Asia/Hong_Kong): {html.escape(timestamp_hkt)}

+

Execution Mode: {html.escape(status_badge)}

+

Daily Realized PnL (USD): ${daily_pnl_usd:+,.2f} | + Daily Loss Usage: ${daily_loss_usd:,.2f} / {html.escape(cap_str)}

+
+ +

Pending WhatsApp Proposals (Awaiting YES / APPROVE)

+ + + {pending_rows_html} +
Proposal IDSymbolSideNotional (USD)Status
+ +

WhatsApp-Approved Paper Fills

+ + + {fills_rows_html} +
Fill IDProposal IDSymbolSideQtyFill PriceNotional (USD)
+ +

Open Shadow Account Positions

+ + + {pos_rows_html} +
SymbolQtyAvg PriceNotional (USD)
+ +

Memory / Reflector Lessons (Post-Holding-Period Alpha)

+
{lessons_escaped}
+ + +""" + html_path.write_text(html_doc, encoding="utf-8") + + _render_digest_pdf_reportlab( + pdf_path=pdf_path, + run_id=run_id, + timestamp_hkt=timestamp_hkt, + status_badge=status_badge, + daily_pnl_usd=daily_pnl_usd, + daily_loss_usd=daily_loss_usd, + cap_str=cap_str, + pending_proposals=pending_proposals, + approved_fills=approved_fills, + open_positions=open_positions, + memory_lessons=memory_lessons, + ) + + return { + "html_path": str(html_path), + "pdf_path": str(pdf_path), + "engine": "reportlab", + "run_id": run_id, + "timestamp_hkt": timestamp_hkt, + } + + +def _render_digest_pdf_reportlab( + *, + pdf_path: Path, + run_id: str, + timestamp_hkt: str, + status_badge: str, + daily_pnl_usd: float, + daily_loss_usd: float, + cap_str: str, + pending_proposals: list[dict[str, Any]], + approved_fills: list[dict[str, Any]], + open_positions: list[dict[str, Any]], + memory_lessons: str, +) -> None: + """Render the daily paper digest PDF using reportlab platypus.""" + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import mm + from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + + font_name = _ensure_font() + base_styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "DigestTitle", + parent=base_styles["Heading1"], + fontName=font_name, + fontSize=16, + leading=20, + ) + h2_style = ParagraphStyle( + "DigestH2", + parent=base_styles["Heading2"], + fontName=font_name, + fontSize=12, + leading=15, + ) + body_style = ParagraphStyle( + "DigestBody", + parent=base_styles["Normal"], + fontName=font_name, + fontSize=9, + leading=12, + ) + + doc = SimpleDocTemplate( + str(pdf_path), + pagesize=A4, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=15 * mm, + bottomMargin=15 * mm, + title=f"Vibe-Trading Daily Paper Digest ({run_id})", + ) + story: list[Any] = [ + Paragraph("Vibe-Trading Daily Paper Digest", title_style), + Spacer(1, 4 * mm), + Paragraph(f"Run ID: {html.escape(run_id)}", body_style), + Paragraph(f"Timestamp (Asia/Hong_Kong): {html.escape(timestamp_hkt)}", body_style), + Paragraph(f"Execution Status: {html.escape(status_badge)}", body_style), + Paragraph( + f"Daily Realized PnL: ${daily_pnl_usd:+,.2f} | Daily Loss Usage: ${daily_loss_usd:,.2f} / {html.escape(cap_str)}", + body_style, + ), + Spacer(1, 5 * mm), + Paragraph("Pending WhatsApp Proposals (Awaiting YES / APPROVE)", h2_style), + Spacer(1, 2 * mm), + ] + + prop_table_data = [["Proposal ID", "Symbol", "Side", "Notional (USD)", "Status"]] + if pending_proposals: + for p in pending_proposals: + prop_table_data.append( + [ + str(p.get("proposal_id", "")), + str(p.get("symbol", "")), + str(p.get("side", "")), + f"${float(p.get('notional_usd', 0.0)):,.2f}", + str(p.get("status", "pending")), + ] + ) + else: + prop_table_data.append(["-", "-", "-", "$0.00", "None"]) + + t_style = TableStyle( + [ + ("FONTNAME", (0, 0), (-1, -1), font_name), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")), + ("PADDING", (0, 0), (-1, -1), 4), + ] + ) + prop_table = Table(prop_table_data, repeatRows=1) + prop_table.setStyle(t_style) + story.append(prop_table) + story.append(Spacer(1, 4 * mm)) + + story.append(Paragraph("WhatsApp-Approved Paper Fills", h2_style)) + story.append(Spacer(1, 2 * mm)) + fill_table_data = [["Fill ID", "Proposal ID", "Symbol", "Side", "Qty", "Price", "Notional"]] + if approved_fills: + for f in approved_fills: + fill_table_data.append( + [ + str(f.get("fill_id", "")), + str(f.get("proposal_id", "")), + str(f.get("symbol", "")), + str(f.get("side", "")), + f"{float(f.get('quantity', 0.0)):.4f}", + f"${float(f.get('fill_price', 0.0)):,.2f}", + f"${float(f.get('notional_usd', 0.0)):,.2f}", + ] + ) + else: + fill_table_data.append(["-", "-", "-", "-", "0", "$0.00", "$0.00"]) + fill_table = Table(fill_table_data, repeatRows=1) + fill_table.setStyle(t_style) + story.append(fill_table) + story.append(Spacer(1, 4 * mm)) + + story.append(Paragraph("Open Shadow Account Positions", h2_style)) + story.append(Spacer(1, 2 * mm)) + pos_table_data = [["Symbol", "Qty", "Avg Price", "Notional (USD)"]] + if open_positions: + for pos in open_positions: + pos_table_data.append( + [ + str(pos.get("symbol", "")), + f"{float(pos.get('quantity', 0.0)):.4f}", + f"${float(pos.get('avg_entry_price', pos.get('avg_price', 0.0))):,.2f}", + f"${float(pos.get('market_value_usd', pos.get('notional_usd', 0.0))):,.2f}", + ] + ) + else: + pos_table_data.append(["-", "0", "$0.00", "$0.00"]) + pos_table = Table(pos_table_data, repeatRows=1) + pos_table.setStyle(t_style) + story.append(pos_table) + story.append(Spacer(1, 4 * mm)) + + story.append(Paragraph("Memory / Reflector Lessons (Post-Holding-Period Alpha)", h2_style)) + story.append(Spacer(1, 2 * mm)) + lessons_text = html.escape( + memory_lessons or "No settled post-holding-period alpha lessons yet." + ).replace("\n", "
") + story.append(Paragraph(lessons_text, body_style)) + + doc.build(story) + + +async def dispatch_paper_approvals( + bus: MessageBus, + messages: Iterable[OutboundMessage], + *, + coordinator: TradeApprovalCoordinator | None = None, +) -> list[dict[str, Any]]: + """Publish outbound paper trade approval messages onto ``bus`` and update send status.""" + outcomes: list[dict[str, Any]] = [] + for msg in messages: + proposal_id = str(msg.metadata.get("proposal_id") or "") + try: + await bus.publish_outbound(msg) + if coordinator is not None and proposal_id: + rec = coordinator.mark_outbound_sent(proposal_id) + outcomes.append( + { + "proposal_id": proposal_id, + "status": "pending", + "sent_at": rec.get("sent_at"), + } + ) + else: + outcomes.append({"proposal_id": proposal_id, "status": "pending"}) + except Exception as exc: + if coordinator is not None and proposal_id: + coordinator.mark_outbound_failed(proposal_id, str(exc)) + outcomes.append( + { + "proposal_id": proposal_id, + "status": "send_failed", + "send_error": str(exc), + } + ) + return outcomes + + +class PaperAutomationRunner: + """Orchestrates daily Asia/Hong_Kong paper runs with WhatsApp approval gate and HTML/PDF digest.""" + + def __init__( + self, + *, + schedule_config: PaperAutomationScheduleConfig, + runtime_root: Path | None = None, + coordinator: TradeApprovalCoordinator | None = None, + reflector: DecisionMemoryReflector | None = None, + mandate: Mandate | None = None, + bus: MessageBus | None = None, + outbound_publisher: Callable[[OutboundMessage], Any] | None = None, + dry_run: bool = False, + ) -> None: + self._schedule_config = schedule_config + self._runtime_root = Path(runtime_root) if runtime_root is not None else get_runtime_root() + self._mandate = mandate + self._reflector = reflector or DecisionMemoryReflector(runtime_root=self._runtime_root) + self._coordinator = coordinator or TradeApprovalCoordinator( + runtime_root=self._runtime_root, + broker="paper", + reflector=self._reflector, + ) + self._bus = bus + self._outbound_publisher = outbound_publisher + self._dry_run = bool(dry_run) + self.safety_alerts: list[OutboundMessage] = [] + + @property + def coordinator(self) -> TradeApprovalCoordinator: + return self._coordinator + + @property + def reflector(self) -> DecisionMemoryReflector: + return self._reflector + + @property + def schedule_config(self) -> PaperAutomationScheduleConfig: + return self._schedule_config + + @property + def bus(self) -> MessageBus | None: + return self._bus + + def _resolve_mandate(self) -> Mandate: + if self._mandate is not None: + return self._mandate + return ensure_paper_mandate(broker="paper") + + def _publish_outbound(self, msg: OutboundMessage, *, dry_run: bool = False) -> str | None: + """Publish an :class:`OutboundMessage` via injected publisher or :class:`MessageBus`. + + Returns an optional outbound message identifier if provided by the publisher. + Raises :class:`RuntimeError` when ``dry_run=False`` and no publisher/bus is configured. + """ + if self._outbound_publisher is not None: + res = self._outbound_publisher(msg) + if inspect.isawaitable(res): + try: + asyncio.get_running_loop() + except RuntimeError: + res = asyncio.run(res) + else: + raise RuntimeError( + "Async outbound_publisher returned a coroutine inside a running event loop; " + "use dispatch_paper_approvals() or a synchronous/queue publisher." + ) + if isinstance(res, dict) and res.get("message_id"): + return str(res["message_id"]) + if isinstance(res, str) and res: + return res + return None + + if self._bus is not None: + self._bus.outbound.put_nowait(msg) + return None + + if dry_run: + return None + + raise RuntimeError( + "No outbound publisher or MessageBus configured for paper approval delivery; " + "pass bus=... / outbound_publisher=... or set dry_run=True." + ) + + def _publish_safety_alert_best_effort(self, alert: OutboundMessage) -> None: + """Best-effort publish of a safety alert when a bus or publisher is configured.""" + if self._outbound_publisher is None and self._bus is None: + return + try: + self._publish_outbound(alert, dry_run=True) + except Exception: + pass + + def run_daily_cycle( + self, + proposals: list[TradeProposal], + *, + output_dir: Path | None = None, + now: datetime | None = None, + dry_run: bool | None = None, + force_resend: bool = False, + ) -> dict[str, Any]: + """Run the daily Asia/Hong_Kong paper automation cycle. + + 1. Verifies schedule is promoted (raises ``PromotionGateBlockedError`` if not). + 2. Verifies paper-only freeze (``mode == 'paper'`` and ``not live_unlocked``). + 3. Verifies kill switch (``HALT``) and daily loss cap (``max_daily_loss_usd``); + if tripped, formats a channel safety alert and blocks proposal submission. + 4. Queries ``DecisionMemoryReflector`` for settled post-holding-period alpha lessons. + 5. Submits each grounded ``TradeProposal`` to ``TradeApprovalCoordinator`` and publishes + the outbound WhatsApp approval prompt onto ``MessageBus`` / ``outbound_publisher`` + (with idempotency on ``proposal_id``) — **no fills** are written until human + ``APPROVE `` or ``YES ``. + 6. Renders ``paper_digest.html`` and ``paper_digest.pdf``. + """ + if not self._schedule_config.promoted: + raise PromotionGateBlockedError( + "Daily paper schedule cannot run before promote_paper_schedule() passes the backtest promotion gate." + ) + + effective_dry_run = self._dry_run if dry_run is None else bool(dry_run) + ts_hkt = now_hkt_iso(now) + date_key = hkt_date_key(now) + run_id = f"paper-run-{date_key}-{uuid.uuid4().hex[:8]}" + digest_dir = ( + Path(output_dir) + if output_dir is not None + else (self._runtime_root / "paper" / "digests" / date_key) + ) + + mandate = self._resolve_mandate() + if str(mandate.mode).lower() != "paper" or bool(mandate.live_unlocked): + alert = format_channel_safety_alert( + "live_freeze_violation", + "Phase 1 paper automation requires mandate.mode='paper' and live_unlocked=False", + chat_id=self._schedule_config.chat_id, + channel=self._schedule_config.channel, + ) + self.safety_alerts.append(alert) + self._publish_safety_alert_best_effort(alert) + raise LiveModeFrozenError(alert.content) + + daily_pnl = read_daily_paper_pnl_usd(today_utc=date_key) + daily_loss = read_daily_paper_loss_usd(today_utc=date_key) + max_loss = mandate.hard_caps.max_daily_loss_usd + memory_lessons = self._reflector.format_lessons_for_next_run(limit=5) + + if halt_flag_set(): + rec = read_halt() or {} + alert = format_channel_safety_alert( + "halt_tripped", + str(rec.get("reason", "Kill switch HALT is active")), + chat_id=self._schedule_config.chat_id, + channel=self._schedule_config.channel, + ) + self.safety_alerts.append(alert) + self._publish_safety_alert_best_effort(alert) + digest = render_daily_paper_digest( + output_dir=digest_dir, + run_id=run_id, + timestamp_hkt=ts_hkt, + pending_proposals=[], + approved_fills=_read_recent_fills(), + open_positions=load_paper_positions(), + daily_pnl_usd=daily_pnl, + daily_loss_usd=daily_loss, + max_daily_loss_usd=max_loss, + memory_lessons=memory_lessons, + halt_active=True, + ) + return { + "run_id": run_id, + "timezone": HKT_TIMEZONE, + "timestamp_hkt": ts_hkt, + "status": "halted", + "safety_alert": alert, + "submitted_proposals": [], + "memory_lessons": memory_lessons, + "digest": digest, + } + + if max_loss is not None and max_loss > 0 and daily_loss >= max_loss: + reason = f"Daily paper loss ${daily_loss:,.2f} reached cap ${max_loss:,.2f}" + trip_halt(by="paper_automation:daily_loss_cap", reason=reason) + alert = format_channel_safety_alert( + "daily_loss_cap", + reason, + chat_id=self._schedule_config.chat_id, + channel=self._schedule_config.channel, + metadata={"daily_loss_usd": daily_loss, "max_daily_loss_usd": max_loss}, + ) + self.safety_alerts.append(alert) + self._publish_safety_alert_best_effort(alert) + digest = render_daily_paper_digest( + output_dir=digest_dir, + run_id=run_id, + timestamp_hkt=ts_hkt, + pending_proposals=[], + approved_fills=_read_recent_fills(), + open_positions=load_paper_positions(), + daily_pnl_usd=daily_pnl, + daily_loss_usd=daily_loss, + max_daily_loss_usd=max_loss, + memory_lessons=memory_lessons, + halt_active=True, + ) + return { + "run_id": run_id, + "timezone": HKT_TIMEZONE, + "timestamp_hkt": ts_hkt, + "status": "halted_daily_loss_cap", + "safety_alert": alert, + "submitted_proposals": [], + "memory_lessons": memory_lessons, + "digest": digest, + } + + submitted: list[dict[str, Any]] = [] + send_failures = 0 + send_successes = 0 + last_send_alert: OutboundMessage | None = None + + for proposal in proposals: + existing = self._coordinator.get_proposal(proposal.proposal_id) + already_sent = ( + isinstance(existing, dict) + and existing.get("status") == "pending" + and bool(existing.get("sent_at")) + and not force_resend + ) + outbound = self._coordinator.submit_proposal( + proposal, + chat_id=self._schedule_config.chat_id, + channel=self._schedule_config.channel, + force_resend=force_resend, + ) + if already_sent: + send_successes += 1 + submitted.append( + { + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "side": proposal.side, + "notional_usd": proposal.notional_usd, + "status": "pending", + "sent_at": existing.get("sent_at") if isinstance(existing, dict) else None, + "already_sent": True, + "prompt_text": outbound.content, + } + ) + continue + + try: + msg_id = self._publish_outbound(outbound, dry_run=effective_dry_run) + if effective_dry_run and self._outbound_publisher is None and self._bus is None: + submitted.append( + { + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "side": proposal.side, + "notional_usd": proposal.notional_usd, + "status": "pending_dry_run", + "sent_at": None, + "dry_run": True, + "prompt_text": outbound.content, + } + ) + send_successes += 1 + else: + rec = self._coordinator.mark_outbound_sent( + proposal.proposal_id, + outbound_message_id=msg_id, + ) + submitted.append( + { + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "side": proposal.side, + "notional_usd": proposal.notional_usd, + "status": "pending", + "sent_at": rec.get("sent_at"), + "outbound_message_id": rec.get("outbound_message_id"), + "prompt_text": outbound.content, + } + ) + send_successes += 1 + except Exception as exc: + send_failures += 1 + err_str = str(exc) + self._coordinator.mark_outbound_failed(proposal.proposal_id, err_str) + alert = format_channel_safety_alert( + "outbound_send_failed", + f"Failed to send WhatsApp approval prompt for {proposal.proposal_id}: {err_str}", + chat_id=self._schedule_config.chat_id, + channel=self._schedule_config.channel, + proposal_id=proposal.proposal_id, + metadata={"send_error": err_str}, + ) + self.safety_alerts.append(alert) + last_send_alert = alert + submitted.append( + { + "proposal_id": proposal.proposal_id, + "symbol": proposal.symbol, + "side": proposal.side, + "notional_usd": proposal.notional_usd, + "status": f"send_failed: {err_str}", + "send_error": err_str, + "sent_at": None, + "prompt_text": outbound.content, + } + ) + + digest = render_daily_paper_digest( + output_dir=digest_dir, + run_id=run_id, + timestamp_hkt=ts_hkt, + pending_proposals=submitted, + approved_fills=_read_recent_fills(), + open_positions=load_paper_positions(), + daily_pnl_usd=daily_pnl, + daily_loss_usd=daily_loss, + max_daily_loss_usd=max_loss, + memory_lessons=memory_lessons, + halt_active=False, + ) + + if send_failures > 0 and send_successes == 0: + cycle_status = "send_failed" + elif send_failures > 0: + cycle_status = "partial_send_failed" + else: + cycle_status = "awaiting_whatsapp_approval" + + return { + "run_id": run_id, + "timezone": HKT_TIMEZONE, + "timestamp_hkt": ts_hkt, + "status": cycle_status, + "safety_alert": last_send_alert, + "submitted_proposals": submitted, + "memory_lessons": memory_lessons, + "digest": digest, + } + + def handle_whatsapp_reply( + self, + text: str, + *, + sender_id: str, + chat_id: str | None = None, + is_authorized: bool = True, + output_dir: Path | None = None, + now: datetime | None = None, + ) -> dict[str, Any]: + """Process an operator WhatsApp reply and refresh the HTML/PDF digest when a fill or halt occurs.""" + mandate = self._resolve_mandate() + inbound = InboundMessage( + channel=self._schedule_config.channel, + sender_id=sender_id, + chat_id=chat_id or self._schedule_config.chat_id, + content=text, + ) + outbound = self._coordinator.handle_inbound_command( + inbound, + is_authorized=is_authorized, + mandate=mandate, + ) + if outbound is not None: + if outbound.metadata.get("safety_alert"): + self.safety_alerts.append(outbound) + if self._outbound_publisher is not None or self._bus is not None: + try: + self._publish_outbound(outbound, dry_run=True) + except Exception: + pass + + ts_hkt = now_hkt_iso(now) + date_key = hkt_date_key(now) + digest_dir = ( + Path(output_dir) + if output_dir is not None + else (self._runtime_root / "paper" / "digests" / date_key) + ) + digest = render_daily_paper_digest( + output_dir=digest_dir, + run_id=f"paper-reply-{date_key}-{uuid.uuid4().hex[:6]}", + timestamp_hkt=ts_hkt, + pending_proposals=[], + approved_fills=_read_recent_fills(), + open_positions=load_paper_positions(), + daily_pnl_usd=read_daily_paper_pnl_usd(today_utc=date_key), + daily_loss_usd=read_daily_paper_loss_usd(today_utc=date_key), + max_daily_loss_usd=mandate.hard_caps.max_daily_loss_usd, + memory_lessons=self._reflector.format_lessons_for_next_run(limit=5), + halt_active=halt_flag_set(), + ) + return { + "outbound": outbound, + "digest": digest, + } + + +__all__ = [ + "HKT_TIMEZONE", + "HKT_ZONE", + "LiveModeFrozenError", + "PaperAutomationRunner", + "PaperAutomationScheduleConfig", + "PromotionGateBlockedError", + "dispatch_paper_approvals", + "hkt_date_key", + "load_paper_schedule_config", + "now_hkt_iso", + "paper_schedule_config_path", + "promote_paper_schedule", + "render_daily_paper_digest", +] diff --git a/agent/src/shadow_account/paper_executor.py b/agent/src/shadow_account/paper_executor.py new file mode 100644 index 00000000..cfe60dd8 --- /dev/null +++ b/agent/src/shadow_account/paper_executor.py @@ -0,0 +1,530 @@ +"""Mandate-gated paper order executor with human approval & hash-chained audit logging. + +Every paper fill in Phase 0 must pass four sequential gates before execution: +1. Kill-switch check (:func:`src.live.halt.halt_flag_set`) — pure filesystem sentinel. +2. Paper-only policy check (``mandate.mode == "paper"`` and ``not mandate.live_unlocked``). +3. Explicit human approval receipt (``approval_id`` + ``approved_by`` from WhatsApp / IM operator). +4. Pre-trade mandate check (:func:`src.live.enforcement.check_mandate`) against hard caps and + the US / HK / Crypto ``allow_symbols`` universe. + +Every fill or rejection writes a tamper-evident audit entry via +:func:`src.live.audit.write_live_action` (with ``chain=True``) linking: +``tool_trace_ids → proposal_id → approval_id → fill_id``. +""" + +from __future__ import annotations + +import json +import logging +import os +import uuid +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from src.config.paths import get_runtime_root +from src.live.audit import EventCallback, LiveActionEvent, write_live_action +from src.live.enforcement import BreachEvent, OrderIntent, check_mandate +from src.live.halt import halt_flag_set, trip_halt +from src.live.mandate.model import AssetClass, InstrumentType, Mandate +from src.live.mandate.paper_stub import ( + ensure_paper_mandate, + infer_asset_class_and_instrument, +) + +logger = logging.getLogger(__name__) + + +class PaperOrderExecutionError(RuntimeError): + """Raised when a paper fill is blocked by HALT, mandate breach, or missing approval.""" + + def __init__( + self, + message: str, + *, + breach: BreachEvent | None = None, + audit_id: str | None = None, + ) -> None: + super().__init__(message) + self.breach = breach + self.audit_id = audit_id + + +@dataclass(frozen=True) +class PaperFillReceipt: + """Immutable receipt for an approved, mandate-checked paper fill.""" + + fill_id: str + proposal_id: str + approval_id: str + approved_by: str + approval_channel: str + tool_trace_ids: tuple[str, ...] + audit_id: str + symbol: str + side: str + quantity: float + fill_price: float + notional_usd: float + asset_class: str + instrument_type: str + mode: str + filled_at: str + + def to_dict(self) -> dict[str, Any]: + """Convert receipt to a JSON-serializable dict.""" + data = asdict(self) + data["tool_trace_ids"] = list(self.tool_trace_ids) + return data + + +def paper_root() -> Path: + """Return ``/paper`` directory path.""" + return get_runtime_root() / "paper" + + +def paper_fills_path() -> Path: + """Return ``/paper/fills.jsonl`` path.""" + return paper_root() / "fills.jsonl" + + +def paper_positions_path() -> Path: + """Return ``/paper/positions.json`` path.""" + return paper_root() / "positions.json" + + +def paper_daily_counter_path() -> Path: + """Return ``/paper/daily_counter.json`` path.""" + return paper_root() / "daily_counter.json" + + +def paper_daily_pnl_path() -> Path: + """Return ``/paper/daily_pnl.json`` path.""" + return paper_root() / "daily_pnl.json" + + +def read_daily_paper_pnl_usd(today_utc: str | None = None) -> float: + """Return cumulative realized/unrealized PnL in USD for ``today_utc`` (negative = loss).""" + day = today_utc or datetime.now(timezone.utc).date().isoformat() + path = paper_daily_pnl_path() + if not path.is_file(): + return 0.0 + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return 0.0 + if not isinstance(data, dict) or data.get("date") != day: + return 0.0 + try: + return float(data.get("pnl_usd", 0.0)) + except (TypeError, ValueError): + return 0.0 + + +def read_daily_paper_loss_usd(today_utc: str | None = None) -> float: + """Return today's cumulative paper loss in USD as a non-negative float.""" + pnl = read_daily_paper_pnl_usd(today_utc) + return max(0.0, -pnl) + + +def record_daily_paper_pnl( + pnl_delta_usd: float, + *, + today_utc: str | None = None, +) -> float: + """Add ``pnl_delta_usd`` to today's paper PnL ledger and return the updated total.""" + day = today_utc or datetime.now(timezone.utc).date().isoformat() + current = read_daily_paper_pnl_usd(day) + updated = round(current + float(pnl_delta_usd), 4) + path = paper_daily_pnl_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text( + json.dumps( + { + "date": day, + "pnl_usd": updated, + "loss_usd": max(0.0, -updated), + "updated_at_utc": datetime.now(timezone.utc).isoformat(), + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + os.chmod(tmp, 0o600) + tmp.replace(path) + return updated + + +def load_paper_positions() -> list[dict[str, Any]]: + """Read current open paper positions from ``/paper/positions.json``.""" + path = paper_positions_path() + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + if isinstance(data, list) and all(isinstance(row, dict) for row in data): + return data + return [] + + +def _save_paper_positions(positions: list[dict[str, Any]]) -> None: + path = paper_positions_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(json.dumps(positions, indent=2, ensure_ascii=False), encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(path) + + +def _read_daily_count(today_utc: str) -> int: + path = paper_daily_counter_path() + if not path.is_file(): + return 0 + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return 0 + if not isinstance(data, dict) or data.get("date") != today_utc: + return 0 + try: + return max(int(data.get("count", 0)), 0) + except (TypeError, ValueError): + return 0 + + +def _increment_daily_count(today_utc: str) -> int: + count = _read_daily_count(today_utc) + 1 + path = paper_daily_counter_path() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text( + json.dumps({"date": today_utc, "count": count}, ensure_ascii=False), + encoding="utf-8", + ) + os.chmod(tmp, 0o600) + tmp.replace(path) + return count + + +def _apply_fill_to_positions( + positions: list[dict[str, Any]], + *, + symbol: str, + side: str, + quantity: float, + fill_price: float, +) -> tuple[list[dict[str, Any]], float]: + by_symbol: dict[str, dict[str, Any]] = {} + for row in positions: + sym = str(row.get("symbol", "")).strip().upper() + if sym: + by_symbol[sym] = dict(row) + + signed_delta = quantity if side == "buy" else -quantity + existing = by_symbol.get(symbol) + prev_qty = float(existing.get("quantity", 0.0)) if existing else 0.0 + prev_price = float(existing.get("price", fill_price)) if existing else fill_price + realized_pnl_delta = 0.0 + if prev_qty > 0 and signed_delta < 0: + closed_qty = min(prev_qty, abs(signed_delta)) + realized_pnl_delta = closed_qty * (fill_price - prev_price) + elif prev_qty < 0 and signed_delta > 0: + closed_qty = min(abs(prev_qty), signed_delta) + realized_pnl_delta = closed_qty * (prev_price - fill_price) + + new_qty = prev_qty + signed_delta + if abs(new_qty) < 1e-9: + by_symbol.pop(symbol, None) + else: + by_symbol[symbol] = { + "symbol": symbol, + "quantity": new_qty, + "price": fill_price, + "market_value": abs(new_qty) * fill_price, + "side": "long" if new_qty > 0 else "short", + } + return list(by_symbol.values()), realized_pnl_delta + + +def execute_paper_fill( + *, + proposal_id: str, + symbol: str, + side: str, + quantity: float, + fill_price: float, + approval_id: str, + approved_by: str, + approval_channel: str = "whatsapp", + tool_trace_ids: tuple[str, ...] | list[str] = (), + session_id: str = "paper-session", + broker: str = "paper", + market: str | None = None, + asset_class: AssetClass | None = None, + instrument_type: InstrumentType | None = None, + mandate: Mandate | None = None, + positions: list[dict[str, Any]] | None = None, + daily_count: int | None = None, + daily_loss_usd: float | None = None, + auto_halt_on_daily_loss_cap: bool = True, + event_callback: EventCallback | None = None, +) -> PaperFillReceipt: + """Execute a mandate-checked, human-approved paper trade and append audit records. + + Raises: + PaperOrderExecutionError: If HALT is set, paper-only policy is violated, + human approval metadata is missing, or the order breaches any mandate rule. + """ + norm_symbol = (symbol or "").strip().upper() + norm_side = (side or "").strip().lower() + trace_ids = tuple(str(t) for t in tool_trace_ids if str(t).strip()) + active_mandate = mandate or ensure_paper_mandate(broker=broker) + + inferred_ac, inferred_inst = infer_asset_class_and_instrument(norm_symbol, market) + resolved_ac = asset_class or inferred_ac + resolved_inst = instrument_type or inferred_inst + notional_usd = float(quantity) * float(fill_price) + intent_str = f"{norm_side} {quantity} {norm_symbol} @ {fill_price} (paper)" + + trace_links = { + "proposal_id": proposal_id, + "approval_id": approval_id, + "approved_by": approved_by, + "approval_channel": approval_channel, + "tool_trace_ids": list(trace_ids), + } + + # 1. Kill-switch check (filesystem sentinel) + if halt_flag_set() or halt_flag_set(broker): + event = LiveActionEvent( + kind="order_rejected", + session_id=session_id, + outcome="blocked", + server=broker, + remote_tool="execute_paper_fill", + intent_normalized=intent_str, + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={**trace_links, "symbol": norm_symbol, "side": norm_side}, + gate_decision={"allowed": False, "reason": "halt_flag_set"}, + error="Kill switch (HALT) is active", + ) + rec = write_live_action(event, event_callback=event_callback, chain=True) + raise PaperOrderExecutionError( + "Paper fill blocked: kill switch (HALT) is active.", + audit_id=rec["audit_id"], + ) + + # 2. Paper-only policy enforcement + if active_mandate.mode != "paper" or active_mandate.live_unlocked: + event = LiveActionEvent( + kind="order_rejected", + session_id=session_id, + outcome="blocked", + server=broker, + remote_tool="execute_paper_fill", + intent_normalized=intent_str, + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={**trace_links, "mode": active_mandate.mode}, + gate_decision={"allowed": False, "reason": "paper_only_policy"}, + error="Paper executor refuses non-paper or live_unlocked mandates", + ) + rec = write_live_action(event, event_callback=event_callback, chain=True) + raise PaperOrderExecutionError( + "Paper fill blocked: paper-only policy violated (mode must be 'paper' and live_unlocked=False).", + audit_id=rec["audit_id"], + ) + + # 3. Explicit human approval gate (WhatsApp / configured channel) + if not (approval_id and str(approval_id).strip() and approved_by and str(approved_by).strip()): + event = LiveActionEvent( + kind="order_rejected", + session_id=session_id, + outcome="blocked", + server=broker, + remote_tool="execute_paper_fill", + intent_normalized=intent_str, + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={**trace_links, "symbol": norm_symbol, "side": norm_side}, + gate_decision={"allowed": False, "reason": "missing_human_approval"}, + error="Human approval (WhatsApp/operator) is required before paper fill", + ) + rec = write_live_action(event, event_callback=event_callback, chain=True) + raise PaperOrderExecutionError( + "Paper fill blocked: explicit human approval receipt is required before fill.", + audit_id=rec["audit_id"], + ) + + if quantity <= 0 or fill_price <= 0: + raise PaperOrderExecutionError( + f"Paper fill blocked: quantity ({quantity}) and fill_price ({fill_price}) must be positive." + ) + + # 4. Pre-trade mandate check + intent = OrderIntent( + symbol=norm_symbol, + side=norm_side, + notional_usd=notional_usd, + quantity=float(quantity), + instrument_type=resolved_inst, + asset_class=resolved_ac, + limit_price=float(fill_price), + ) + current_positions = load_paper_positions() if positions is None else positions + now_utc = datetime.now(timezone.utc) + today_str = now_utc.date().isoformat() + effective_daily_count = ( + _read_daily_count(today_str) if daily_count is None else daily_count + ) + effective_daily_loss_usd = ( + read_daily_paper_loss_usd(today_str) + if daily_loss_usd is None + else float(daily_loss_usd) + ) + + breach = check_mandate( + active_mandate, + intent, + current_positions, + {"equity": active_mandate.hard_caps.account_funding_usd}, + broker=broker, + remote_tool="execute_paper_fill", + daily_count=effective_daily_count, + daily_loss_usd=effective_daily_loss_usd, + ) + if breach is not None: + halt_tripped = False + if breach.limit == "max_daily_loss_usd" and auto_halt_on_daily_loss_cap: + trip_halt( + by=f"paper_executor:{broker}", + reason=breach.detail or "Daily loss cap breached", + ) + halt_tripped = True + + event = LiveActionEvent( + kind="breach", + session_id=session_id, + outcome="rejected", + server=broker, + remote_tool="execute_paper_fill", + intent_normalized=intent_str, + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={ + **trace_links, + "symbol": norm_symbol, + "side": norm_side, + "quantity": quantity, + "fill_price": fill_price, + "notional_usd": notional_usd, + "daily_loss_usd": effective_daily_loss_usd, + }, + gate_decision={ + "allowed": False, + "kind": breach.kind, + "limit": breach.limit, + "limit_value": breach.limit_value, + "attempted_value": breach.attempted_value, + "overage": breach.overage, + "detail": breach.detail, + "halt_tripped": halt_tripped, + }, + error=breach.detail or f"Mandate breach on {breach.limit}", + ) + rec = write_live_action(event, event_callback=event_callback, chain=True) + raise PaperOrderExecutionError( + f"Paper fill rejected by mandate gate ({breach.limit}): {breach.detail or breach.overage}", + breach=breach, + audit_id=rec["audit_id"], + ) + + # 5. Execute paper fill, persist positions + fills.jsonl + audit chain + fill_id = f"pf_{uuid.uuid4().hex[:16]}" + filled_at = now_utc.isoformat() + + updated_positions, realized_pnl_delta = _apply_fill_to_positions( + current_positions, + symbol=norm_symbol, + side=norm_side, + quantity=float(quantity), + fill_price=float(fill_price), + ) + if positions is None: + _save_paper_positions(updated_positions) + if abs(realized_pnl_delta) > 1e-9: + record_daily_paper_pnl(realized_pnl_delta, today_utc=today_str) + if daily_count is None: + _increment_daily_count(today_str) + + + event = LiveActionEvent( + kind="order_placed", + session_id=session_id, + outcome="filled", + server=broker, + remote_tool="execute_paper_fill", + intent_normalized=intent_str, + mandate_snapshot_ref=f"mandate:v{active_mandate.schema_version}:{broker}", + consent_record_ref=active_mandate.consent.consent_token_sha256, + broker_request={ + **trace_links, + "symbol": norm_symbol, + "side": norm_side, + "quantity": float(quantity), + "fill_price": float(fill_price), + "notional_usd": notional_usd, + }, + broker_response={ + "fill_id": fill_id, + "status": "filled", + "mode": "paper", + "filled_at": filled_at, + }, + gate_decision={ + "allowed": True, + "mode": "paper", + "proposal_id": proposal_id, + "approval_id": approval_id, + "fill_id": fill_id, + "tool_trace_ids": list(trace_ids), + }, + ) + audit_record = write_live_action(event, event_callback=event_callback, chain=True) + + receipt = PaperFillReceipt( + fill_id=fill_id, + proposal_id=proposal_id, + approval_id=approval_id, + approved_by=approved_by, + approval_channel=approval_channel, + tool_trace_ids=trace_ids, + audit_id=audit_record["audit_id"], + symbol=norm_symbol, + side=norm_side, + quantity=float(quantity), + fill_price=float(fill_price), + notional_usd=notional_usd, + asset_class=resolved_ac.value, + instrument_type=resolved_inst.value, + mode="paper", + filled_at=filled_at, + ) + + fills_file = paper_fills_path() + fills_file.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with fills_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(receipt.to_dict(), ensure_ascii=False) + "\n") + handle.flush() + try: + os.fsync(handle.fileno()) + except OSError: + pass + + return receipt diff --git a/agent/src/swarm/presets.py b/agent/src/swarm/presets.py index 6ec21da3..5b99b961 100644 --- a/agent/src/swarm/presets.py +++ b/agent/src/swarm/presets.py @@ -336,6 +336,17 @@ def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmR status=status, )) + effective_vars = dict(user_vars) + for var_def in data.get("variables", []): + if isinstance(var_def, dict): + vname = var_def.get("name") + if ( + isinstance(vname, str) + and vname not in effective_vars + and "default" in var_def + ): + effective_vars[vname] = str(var_def["default"]) + # Generate run ID now = datetime.now(timezone.utc) ts = now.strftime("%Y%m%d-%H%M%S") @@ -346,7 +357,7 @@ def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmR id=run_id, preset_name=preset_name, status=RunStatus.pending, - user_vars=user_vars, + user_vars=effective_vars, agents=agents, tasks=tasks, created_at=now.isoformat(), diff --git a/agent/src/swarm/presets/trading_agents.yaml b/agent/src/swarm/presets/trading_agents.yaml new file mode 100644 index 00000000..c37ecec4 --- /dev/null +++ b/agent/src/swarm/presets/trading_agents.yaml @@ -0,0 +1,350 @@ +name: trading_agents +title: "TradingAgents Decision Pipeline" +description: "TradingAgents v0.6.0 multi-agent workflow grafted onto Vibe SwarmRuntime: 4 Analysts → Bull/Bear Debate → Research Manager → Trader → 3-Way Risk Debate → Portfolio Manager (grounded paper TradeProposal)." + +agents: + - id: market_analyst + role: Market & Technical Analyst + model_name: quick_think + system_prompt: | + You are the Market & Technical Analyst in the TradingAgents decision pipeline. + Your mission is to analyze price action, regime, momentum, and volatility for {target} (market: {market}). + + ## Rules + - Route ALL price/OHLCV queries through `get_market_data`. Never fabricate or guess prices. + - Cite exact latest close, high, low, volume, and date range returned by `get_market_data`. + - Compute technical indicators (MA stack, RSI, MACD, ATR/realized vol) from the retrieved bars. + tools: [bash, read_file, write_file, load_skill, get_market_data, factor_analysis] + skills: [technical-basic, volatility, yfinance] + max_iterations: 30 + timeout_seconds: 900 + max_retries: 1 + + - id: fundamentals_analyst + role: Fundamentals & Valuation Analyst + model_name: quick_think + system_prompt: | + You are the Fundamentals & Valuation Analyst in the TradingAgents decision pipeline. + Your mission is to evaluate financial health, earnings quality, valuation multiples, and intrinsic drivers for {target} (market: {market}). + + ## Rules + - Use `get_market_data` and `get_financial_statements` / `get_research_reports` for all figures. + - For crypto assets where traditional financial statements do not apply, focus on market structure, liquidity, volume profile, and on-exchange metrics from `get_market_data`. + - Never invent accounting or valuation numbers; report unavailable fields explicitly. + tools: [bash, read_file, write_file, load_skill, get_market_data, get_financial_statements, get_research_reports] + skills: [fundamental-filter, earnings-revision, yfinance] + max_iterations: 30 + timeout_seconds: 900 + max_retries: 1 + + - id: news_analyst + role: News & Macro Catalyst Analyst + model_name: quick_think + system_prompt: | + You are the News & Macro Catalyst Analyst in the TradingAgents decision pipeline. + Your mission is to gather and synthesize recent company/asset news, macro catalysts, and event risks for {target} (market: {market}). + + ## Rules + - Use `get_stock_news`, `get_research_reports`, and `get_market_data` to anchor event dates against actual price reaction. + - Separate verified facts from speculation and list upcoming catalyst dates. + tools: [bash, read_file, write_file, load_skill, get_market_data, get_stock_news, get_research_reports] + skills: [sentiment-analysis, yfinance] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: sentiment_analyst + role: Sentiment & Flow Analyst + model_name: quick_think + system_prompt: | + You are the Sentiment & Flow Analyst in the TradingAgents decision pipeline. + Your mission is to evaluate capital flows, positioning, margin trends, and crowd sentiment for {target} (market: {market}). + + ## Rules + - Use `get_fund_flow`, `get_margin_trading`, `get_stock_news`, and `get_market_data`. + - Quantify volume anomalies and flow direction strictly from tool outputs. + tools: [bash, read_file, write_file, load_skill, get_market_data, get_fund_flow, get_margin_trading, get_stock_news] + skills: [sentiment-analysis] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: bull_researcher + role: Bull-Side Debate Researcher + model_name: quick_think + system_prompt: | + You are the Bull-Side Researcher in the TradingAgents investment debate. + Using the four upstream analyst reports on {target} (market: {market}), construct the strongest data-backed bullish case and directly rebut potential bearish objections. + + {upstream_context} + + ## Rules + - Every price, multiple, or return figure must trace to the upstream analyst tool outputs or your own `get_market_data` check. + - State 3-5 high-conviction bull arguments, upside target range, and key catalysts. + tools: [bash, read_file, write_file, load_skill, get_market_data, factor_analysis] + skills: [technical-basic, fundamental-filter] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: bear_researcher + role: Bear-Side Debate Researcher + model_name: quick_think + system_prompt: | + You are the Bear-Side Researcher in the TradingAgents investment debate. + Using the four upstream analyst reports on {target} (market: {market}), construct a rigorous bearish/downside case, surface hidden risks, and challenge bullish assumptions. + + {upstream_context} + + ## Rules + - Every price, drawdown, or valuation figure must trace to upstream analyst tool outputs or `get_market_data`. + - State 3-5 concrete downside risks, invalidation levels, and downside price targets. + tools: [bash, read_file, write_file, load_skill, get_market_data, factor_analysis] + skills: [technical-basic, risk-analysis, volatility] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: research_manager + role: Research Manager (Debate Judge) + model_name: deep_think + system_prompt: | + You are the Research Manager chairing the Bull vs. Bear investment debate on {target} (market: {market}). + Evaluate the evidence quality of both researchers, score their arguments, and issue a synthesized investment thesis with a directional recommendation (BUY / SELL / HOLD). + + {upstream_context} + + ## Prior Trading Memory & Settled Alpha Lessons + {memory_lessons} + + ## Required Outputs + 1. **Debate Verdict** — Directional stance (BUY / SELL / HOLD) and conviction score (0.0 to 1.0). + 2. **Evidence Ruling** — Which bull and bear points are empirically grounded vs. speculative. + 3. **Grounded Reference Price** — Exact latest close price from `get_market_data`. + tools: [bash, read_file, write_file, load_skill, get_market_data] + skills: [strategy-generate] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: trader_agent + role: Senior Execution Trader + model_name: deep_think + system_prompt: | + You are the Trader Agent in the TradingAgents pipeline for {target} (market: {market}). + Translate the Research Manager's thesis into a concrete, mandate-aware paper trade proposal with exact entry reference price, position size (quantity and USD notional), stop-loss, and take-profit levels. + + {upstream_context} + + ## Prior Trading Memory & Settled Alpha Lessons + {memory_lessons} + + ## Rules + - Verify current price via `get_market_data` before sizing. + - Default paper mandate caps single-order notional at $10,000 USD and 1.0x leverage. + - State exact `symbol`, `side` (`buy` / `sell` / `hold`), `quantity`, `reference_price`, `notional_usd`, `stop_loss`, and `take_profit`. + tools: [bash, read_file, write_file, load_skill, get_market_data, backtest] + skills: [strategy-generate, risk-analysis] + max_iterations: 25 + timeout_seconds: 900 + max_retries: 1 + + - id: aggressive_risk_debater + role: Aggressive Risk Debater + model_name: quick_think + system_prompt: | + You are the Aggressive Risk Analyst in the TradingAgents 3-way risk debate on {target} (market: {market}). + Evaluate the Trader Agent's proposal from an upside-capture / opportunity-cost perspective. Argue where sizing or targets are overly timid if risk-reward is asymmetric. + + {upstream_context} + tools: [bash, read_file, write_file, load_skill, get_market_data] + skills: [risk-analysis, volatility] + max_iterations: 20 + timeout_seconds: 600 + max_retries: 1 + + - id: conservative_risk_debater + role: Conservative Risk Debater + model_name: quick_think + system_prompt: | + You are the Conservative Risk Analyst in the TradingAgents 3-way risk debate on {target} (market: {market}). + Stress-test the Trader Agent's proposal for drawdown, tail risk (VaR/CVaR), liquidity, and mandate cap compliance. Recommend size reductions or tighter stops where needed. + + {upstream_context} + tools: [bash, read_file, write_file, load_skill, get_market_data] + skills: [risk-analysis, volatility] + max_iterations: 20 + timeout_seconds: 600 + max_retries: 1 + + - id: neutral_risk_debater + role: Neutral Risk Debater + model_name: quick_think + system_prompt: | + You are the Neutral / Balanced Risk Analyst in the TradingAgents 3-way risk debate on {target} (market: {market}). + Balance the Aggressive and Conservative risk perspectives using expected value, Sharpe/Kelly logic, and correlation considerations. + + {upstream_context} + tools: [bash, read_file, write_file, load_skill, get_market_data] + skills: [risk-analysis, asset-allocation] + max_iterations: 20 + timeout_seconds: 600 + max_retries: 1 + + - id: portfolio_manager + role: Portfolio Manager & Final Risk Judge + model_name: deep_think + system_prompt: | + You are the Portfolio Manager and Final Risk Judge in the TradingAgents pipeline for {target} (market: {market}). + Review the Trader's proposal and the 3-way risk debate (Aggressive, Conservative, Neutral), verify all numbers against `get_market_data`, and issue the final binding paper trade decision. + + {upstream_context} + + ## Prior Trading Memory & Settled Alpha Lessons + {memory_lessons} + + ## Mandatory Structured TradeProposal Block + At the end of your report, you MUST include a fenced `json` code block containing a valid `trade_proposal` object with exact grounded numbers from `get_market_data`: + + ```json + { + "trade_proposal": { + "symbol": "{target}", + "market": "{market}", + "side": "buy", + "quantity": 10.0, + "reference_price": 125.50, + "notional_usd": 1255.00, + "stop_loss": 118.00, + "take_profit": 142.00, + "confidence": 0.78, + "mode": "paper", + "rationale": "Concise synthesis of thesis and risk ruling." + } + } + ``` + + Never fabricate `reference_price`; it must match the latest bar from `get_market_data`, and `notional_usd` must equal `quantity * reference_price` within mandate caps ($10,000 max per order). + tools: [bash, read_file, write_file, load_skill, get_market_data, backtest] + skills: [strategy-generate, asset-allocation, risk-analysis] + max_iterations: 30 + timeout_seconds: 900 + max_retries: 1 + +tasks: + - id: task-market-analyst + agent_id: market_analyst + prompt_template: "Run technical and price-regime analysis on {target} (market: {market}) using get_market_data." + depends_on: [] + + - id: task-fundamentals-analyst + agent_id: fundamentals_analyst + prompt_template: "Run fundamental and valuation analysis on {target} (market: {market})." + depends_on: [] + + - id: task-news-analyst + agent_id: news_analyst + prompt_template: "Analyze recent news, catalysts, and macro events for {target} (market: {market})." + depends_on: [] + + - id: task-sentiment-analyst + agent_id: sentiment_analyst + prompt_template: "Analyze capital flows, positioning, and sentiment for {target} (market: {market})." + depends_on: [] + + - id: task-bull-researcher + agent_id: bull_researcher + prompt_template: "Synthesize the analyst reports on {target} ({market}) into the strongest data-backed Bull case." + depends_on: + - task-market-analyst + - task-fundamentals-analyst + - task-news-analyst + - task-sentiment-analyst + input_from: + market_report: task-market-analyst + fundamentals_report: task-fundamentals-analyst + news_report: task-news-analyst + sentiment_report: task-sentiment-analyst + + - id: task-bear-researcher + agent_id: bear_researcher + prompt_template: "Synthesize the analyst reports on {target} ({market}) into the strongest data-backed Bear case and risk critique." + depends_on: + - task-market-analyst + - task-fundamentals-analyst + - task-news-analyst + - task-sentiment-analyst + input_from: + market_report: task-market-analyst + fundamentals_report: task-fundamentals-analyst + news_report: task-news-analyst + sentiment_report: task-sentiment-analyst + + - id: task-research-manager + agent_id: research_manager + prompt_template: "Judge the Bull vs. Bear debate on {target} ({market}) and issue the Research Manager investment thesis.\n\nPrior Trading Memory & Settled Alpha Lessons:\n{memory_lessons}" + depends_on: + - task-bull-researcher + - task-bear-researcher + input_from: + bull_case: task-bull-researcher + bear_case: task-bear-researcher + + - id: task-trader-proposal + agent_id: trader_agent + prompt_template: "Formulate a concrete, sized paper trade proposal for {target} ({market}) from the Research Manager thesis.\n\nPrior Trading Memory & Settled Alpha Lessons:\n{memory_lessons}" + depends_on: + - task-research-manager + input_from: + research_thesis: task-research-manager + + - id: task-risk-aggressive + agent_id: aggressive_risk_debater + prompt_template: "Critique the Trader's proposal on {target} ({market}) from the Aggressive / Upside-Capture risk lens." + depends_on: + - task-trader-proposal + input_from: + trader_proposal: task-trader-proposal + + - id: task-risk-conservative + agent_id: conservative_risk_debater + prompt_template: "Critique the Trader's proposal on {target} ({market}) from the Conservative / Tail-Protection risk lens." + depends_on: + - task-trader-proposal + input_from: + trader_proposal: task-trader-proposal + + - id: task-risk-neutral + agent_id: neutral_risk_debater + prompt_template: "Critique the Trader's proposal on {target} ({market}) from the Neutral / Risk-Adjusted Expected-Value lens." + depends_on: + - task-trader-proposal + input_from: + trader_proposal: task-trader-proposal + + - id: task-portfolio-decision + agent_id: portfolio_manager + prompt_template: "Review the Trader proposal and 3-way risk debate on {target} ({market}), verify grounding, and emit the final paper TradeProposal JSON block.\n\nPrior Trading Memory & Settled Alpha Lessons:\n{memory_lessons}" + depends_on: + - task-trader-proposal + - task-risk-aggressive + - task-risk-conservative + - task-risk-neutral + input_from: + trader_proposal: task-trader-proposal + aggressive_risk: task-risk-aggressive + conservative_risk: task-risk-conservative + neutral_risk: task-risk-neutral + +variables: + - name: target + description: "Security or crypto pair (e.g., NVDA.US, 0700.HK, BTC-USDT)" + required: true + - name: market + description: "Market (e.g., US, HK, crypto)" + required: true + - name: memory_lessons + description: "Prior trading memory and settled post-holding-period alpha lessons" + required: false + default: "No prior trading memory lessons recorded." + diff --git a/agent/src/swarm/proposal.py b/agent/src/swarm/proposal.py new file mode 100644 index 00000000..c45e5470 --- /dev/null +++ b/agent/src/swarm/proposal.py @@ -0,0 +1,297 @@ +"""Typed TradeProposal schema and numeric grounding verification for the TradingAgents swarm. + +Enforces AGENTS.md §3 & §4: +- Every price, metric, and size in a trade proposal must be grounded against + ``get_market_data`` / ``SwarmRun.grounding_data`` tool outputs before release. +- Stable trace IDs link ``tool_trace_ids → proposal_id → approval_id → fill_id``. +- Paper-only default (``mode="paper"``). +""" + +from __future__ import annotations + +import json +import math +import re +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator + + +def _new_proposal_id() -> str: + return f"prop_{uuid.uuid4().hex[:16]}" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class TradeProposal(BaseModel): + """Typed, grounded paper trade proposal emitted by the TradingAgents swarm.""" + + proposal_id: str = Field(default_factory=_new_proposal_id) + run_id: str | None = None + symbol: str + market: str = "US" + side: Literal["buy", "sell", "hold"] + quantity: float = Field(ge=0.0) + reference_price: float = Field(gt=0.0) + notional_usd: float = Field(ge=0.0) + stop_loss: float | None = None + take_profit: float | None = None + confidence: float = Field(default=0.5, ge=0.0, le=1.0) + mode: Literal["paper"] = "paper" + rationale: str = "" + tool_trace_ids: list[str] = Field(default_factory=list) + grounded: bool = False + created_at: str = Field(default_factory=_utc_now_iso) + + @field_validator("symbol") + @classmethod + def _normalize_symbol(cls, value: str) -> str: + norm = (value or "").strip().upper() + if not norm: + raise ValueError("symbol must not be empty") + return norm + + +@dataclass(frozen=True) +class GroundingVerificationResult: + """Result of verifying a :class:`TradeProposal` against tool/OHLCV outputs.""" + + grounded: bool + observed_prices: tuple[float, ...] + matched_price: float | None + notional_consistent: bool + reason: str + + +def _extract_prices_from_bars(bars: list[dict[str, Any]]) -> list[float]: + prices: list[float] = [] + for row in bars: + if not isinstance(row, dict): + continue + for key in ("close", "Close", "price", "last_price", "open", "high", "low"): + raw = row.get(key) + if raw is None or isinstance(raw, bool): + continue + try: + val = float(raw) + except (TypeError, ValueError): + continue + if math.isfinite(val) and val > 0: + prices.append(val) + return prices + + +def _extract_prices_and_ids_from_traces( + tool_traces: list[dict[str, Any]] | None, +) -> tuple[list[float], list[str]]: + prices: list[float] = [] + trace_ids: list[str] = [] + if not tool_traces: + return prices, trace_ids + + for idx, entry in enumerate(tool_traces): + if not isinstance(entry, dict): + continue + tool_name = str(entry.get("tool") or entry.get("name") or "") + if tool_name and tool_name not in {"get_market_data", "market_data"}: + continue + tid = str( + entry.get("trace_id") + or entry.get("tool_call_id") + or entry.get("id") + or f"trace_{idx + 1}" + ) + if tid not in trace_ids: + trace_ids.append(tid) + + for field_name in ("bars", "data", "rows", "output", "result"): + payload = entry.get(field_name) + if isinstance(payload, list): + prices.extend(_extract_prices_from_bars(payload)) + elif isinstance(payload, dict): + if isinstance(payload.get("bars"), list): + prices.extend(_extract_prices_from_bars(payload["bars"])) + else: + prices.extend(_extract_prices_from_bars([payload])) + prices.extend(_extract_prices_from_bars([entry])) + + return prices, trace_ids + + +def verify_proposal_grounding( + proposal: TradeProposal, + grounding_data: dict[str, list[dict[str, Any]]] | None = None, + tool_traces: list[dict[str, Any]] | None = None, + *, + price_tolerance_pct: float = 0.02, + notional_tolerance_usd: float = 1.0, +) -> GroundingVerificationResult: + """Verify that ``proposal`` price, quantity, and notional match real tool outputs. + + Fail-closed: returns ``grounded=False`` when no tool/OHLCV data is provided, + when ``reference_price`` deviates beyond ``price_tolerance_pct`` from all + observed market prices, or when ``quantity * reference_price`` disagrees + with ``notional_usd`` beyond ``notional_tolerance_usd``. + """ + expected_notional = proposal.quantity * proposal.reference_price + notional_consistent = ( + math.isfinite(expected_notional) + and abs(expected_notional - proposal.notional_usd) <= notional_tolerance_usd + ) + if not notional_consistent: + return GroundingVerificationResult( + grounded=False, + observed_prices=(), + matched_price=None, + notional_consistent=False, + reason=( + f"notional_usd ({proposal.notional_usd:.2f}) does not match " + f"quantity * reference_price ({expected_notional:.2f})" + ), + ) + + if proposal.side in ("buy", "sell") and (proposal.quantity <= 0 or proposal.notional_usd <= 0): + return GroundingVerificationResult( + grounded=False, + observed_prices=(), + matched_price=None, + notional_consistent=notional_consistent, + reason=f"actionable side {proposal.side!r} requires positive quantity and notional_usd", + ) + + observed: list[float] = [] + sym = proposal.symbol.strip().upper() + candidates = {sym} + if sym.endswith(".US"): + candidates.add(sym[:-3]) + elif "." not in sym and "-" not in sym: + candidates.add(f"{sym}.US") + + if isinstance(grounding_data, dict): + for key, bars in grounding_data.items(): + if key.strip().upper() in candidates and isinstance(bars, list): + observed.extend(_extract_prices_from_bars(bars)) + + trace_prices, _ = _extract_prices_and_ids_from_traces(tool_traces) + observed.extend(trace_prices) + + if not observed: + return GroundingVerificationResult( + grounded=False, + observed_prices=(), + matched_price=None, + notional_consistent=True, + reason="no grounded market data prices found in grounding_data or tool_traces", + ) + + matched_price: float | None = None + best_rel_diff = float("inf") + for price in observed: + rel_diff = abs(proposal.reference_price - price) / price + if rel_diff < best_rel_diff: + best_rel_diff = rel_diff + matched_price = price + + # Also allow reference_price if it lies inside the observed [min, max] OHLCV bar range + min_obs = min(observed) + max_obs = max(observed) + in_bar_range = min_obs * (1.0 - price_tolerance_pct) <= proposal.reference_price <= max_obs * (1.0 + price_tolerance_pct) + + if best_rel_diff > price_tolerance_pct and not in_bar_range: + return GroundingVerificationResult( + grounded=False, + observed_prices=tuple(observed), + matched_price=matched_price, + notional_consistent=True, + reason=( + f"reference_price {proposal.reference_price} deviates by " + f"{best_rel_diff * 100:.2f}% from nearest grounded tool price {matched_price}" + ), + ) + + return GroundingVerificationResult( + grounded=True, + observed_prices=tuple(observed), + matched_price=matched_price, + notional_consistent=True, + reason="verified against grounded market data", + ) + + +_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE) + + +def extract_trade_proposal( + report_text: str, + *, + run_id: str | None = None, + grounding_data: dict[str, list[dict[str, Any]]] | None = None, + tool_traces: list[dict[str, Any]] | None = None, + require_grounded: bool = True, +) -> TradeProposal: + """Extract and validate a :class:`TradeProposal` from a Portfolio Manager report. + + Args: + report_text: Markdown report containing a fenced JSON ``trade_proposal`` block + (or a raw JSON string). + run_id: Optional originating ``SwarmRun.id``. + grounding_data: Pre-fetched ``SwarmRun.grounding_data`` OHLCV bars. + tool_traces: Optional list of ``get_market_data`` tool trace dicts. + require_grounded: When ``True`` (default), raises ``ValueError`` if + numeric grounding verification fails. + + Returns: + Validated :class:`TradeProposal` with ``grounded`` and ``tool_trace_ids`` populated. + """ + raw_obj: dict[str, Any] | None = None + for match in reversed(list(_JSON_FENCE_RE.finditer(report_text or ""))): + try: + candidate = json.loads(match.group(1)) + except json.JSONDecodeError: + continue + if isinstance(candidate, dict) and ("trade_proposal" in candidate or "symbol" in candidate): + raw_obj = candidate + break + + if raw_obj is None: + try: + candidate = json.loads((report_text or "").strip()) + if isinstance(candidate, dict): + raw_obj = candidate + except json.JSONDecodeError: + pass + + if raw_obj is None: + raise ValueError("No valid trade_proposal JSON block found in report text") + + payload = raw_obj.get("trade_proposal") if isinstance(raw_obj.get("trade_proposal"), dict) else raw_obj + if not isinstance(payload, dict): + raise ValueError("trade_proposal payload must be a JSON object") + + data = dict(payload) + if run_id and not data.get("run_id"): + data["run_id"] = run_id + + _, inferred_trace_ids = _extract_prices_and_ids_from_traces(tool_traces) + existing_traces = [str(t) for t in (data.get("tool_trace_ids") or []) if str(t).strip()] + if not existing_traces and inferred_trace_ids: + existing_traces = inferred_trace_ids + if not existing_traces and grounding_data: + existing_traces = [f"grounding:{sym}" for sym in grounding_data] + data["tool_trace_ids"] = existing_traces + + proposal = TradeProposal.model_validate(data) + verification = verify_proposal_grounding( + proposal, + grounding_data=grounding_data, + tool_traces=tool_traces, + ) + if require_grounded and not verification.grounded: + raise ValueError(f"TradeProposal failed grounding check: {verification.reason}") + + return proposal.model_copy(update={"grounded": verification.grounded}) diff --git a/agent/tests/test_phase0_swarm_mandate_whatsapp.py b/agent/tests/test_phase0_swarm_mandate_whatsapp.py new file mode 100644 index 00000000..ac259dfd --- /dev/null +++ b/agent/tests/test_phase0_swarm_mandate_whatsapp.py @@ -0,0 +1,633 @@ +"""Phase 0 unit tests: TradingAgents Swarm Graft, Mandate Stub, Paper Gate, and WhatsApp Approval.""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from src.channels.bus.events import InboundMessage +from src.channels.bus.queue import MessageBus +from src.channels.runtime import ChannelRuntime +from src.channels.trade_approval import ( + TradeApprovalCoordinator, + format_whatsapp_approval_request, + parse_trade_approval_command, +) +from src.config.accessor import reset_env_config +from src.governance.ledger import verify_chain +from src.live.audit import audit_chain_ledger_path, audit_ledger_path +from src.live.enforcement import OrderIntent, check_mandate +from src.live.halt import clear_halt, halt_flag_set, trip_halt +from src.live.mandate.model import AssetClass, InstrumentType +from src.live.mandate.paper_stub import ( + DEFAULT_ALLOW_SYMBOLS, + default_paper_mandate, + ensure_paper_mandate, + load_mandate_yaml_or_json, + mandate_to_dict, +) +from src.live.mandate.store import _parse_mandate, load_mandate +from src.mandate import Mandate as CompatibilityMandate +from src.memory.reflector import DecisionMemoryReflector +from src.providers.llm import resolve_thinking_tier +from src.shadow_account.paper_executor import ( + PaperOrderExecutionError, + execute_paper_fill, + load_paper_positions, + paper_fills_path, +) +from src.swarm.presets import build_run_from_preset, inspect_preset, list_presets +from src.swarm.proposal import ( + TradeProposal, + extract_trade_proposal, + verify_proposal_grounding, +) + + +class Phase0SwarmGraftTests(unittest.TestCase): + """Tests for Ticket P0-2: TradingAgents swarm preset, thinking tiers, proposal grounding, and reflector.""" + + def tearDown(self) -> None: + reset_env_config() + + def test_trading_agents_preset_loads_full_dag(self) -> None: + preset_names = {p["name"] for p in list_presets()} + self.assertIn("trading_agents", preset_names) + + inspection = inspect_preset("trading_agents") + self.assertTrue(inspection["valid"], msg=f"Preset invalid: {inspection['errors']}") + self.assertEqual(len(inspection["agents"]), 12) + self.assertEqual(len(inspection["tasks"]), 12) + + run = build_run_from_preset( + "trading_agents", + {"target": "NVDA.US", "market": "US"}, + ) + self.assertEqual(len(run.agents), 12) + self.assertEqual(len(run.tasks), 12) + + agent_by_id = {a.id: a for a in run.agents} + expected_quick = { + "market_analyst", + "sentiment_analyst", + "news_analyst", + "fundamentals_analyst", + "bull_researcher", + "bear_researcher", + "aggressive_risk_debater", + "conservative_risk_debater", + "neutral_risk_debater", + } + expected_deep = {"research_manager", "trader_agent", "portfolio_manager"} + for aid in expected_quick: + self.assertEqual(agent_by_id[aid].model_name, "quick_think") + for aid in expected_deep: + self.assertEqual(agent_by_id[aid].model_name, "deep_think") + + tasks_by_id = {t.id: t for t in run.tasks} + self.assertEqual( + set(tasks_by_id["task-bull-researcher"].depends_on), + { + "task-market-analyst", + "task-sentiment-analyst", + "task-news-analyst", + "task-fundamentals-analyst", + }, + ) + self.assertEqual( + set(tasks_by_id["task-research-manager"].depends_on), + {"task-bull-researcher", "task-bear-researcher"}, + ) + self.assertEqual(tasks_by_id["task-trader-proposal"].depends_on, ["task-research-manager"]) + self.assertEqual( + set(tasks_by_id["task-portfolio-decision"].depends_on), + { + "task-trader-proposal", + "task-risk-aggressive", + "task-risk-conservative", + "task-risk-neutral", + }, + ) + + def test_resolve_thinking_tier_aliases(self) -> None: + with patch.dict( + os.environ, + { + "VIBE_QUICK_THINK_MODEL": "gpt-4o-mini-fast", + "VIBE_DEEP_THINK_MODEL": "claude-3-7-sonnet-deep", + }, + clear=False, + ): + reset_env_config() + self.assertEqual(resolve_thinking_tier("quick_think"), "gpt-4o-mini-fast") + self.assertEqual(resolve_thinking_tier("deep_think"), "claude-3-7-sonnet-deep") + self.assertEqual(resolve_thinking_tier("custom-model"), "custom-model") + self.assertEqual(resolve_thinking_tier(None), "") + + def test_proposal_grounding_passes_and_blocks_fabricated_numbers(self) -> None: + grounding_data = { + "NVDA": [ + {"date": "2026-10-15", "open": 138.0, "high": 141.0, "low": 137.5, "close": 140.0}, + ] + } + tool_traces = [ + { + "trace_id": "trace_mkt_nvda_01", + "tool": "get_market_data", + "bars": [{"close": 140.0}], + } + ] + report = """ + ### Final Portfolio Manager Decision + We approve a paper buy on NVDA based on grounded market data. + ```json + { + "trade_proposal": { + "symbol": "NVDA", + "market": "US", + "side": "buy", + "quantity": 10, + "reference_price": 140.0, + "notional_usd": 1400.0, + "stop_loss": 133.0, + "take_profit": 155.0, + "confidence": 0.82, + "mode": "paper", + "rationale": "Strong datacenter demand and technical breakout.", + "tool_trace_ids": ["trace_mkt_nvda_01"] + } + } + ``` + """ + proposal = extract_trade_proposal( + report, + run_id="run_test_01", + grounding_data=grounding_data, + tool_traces=tool_traces, + require_grounded=True, + ) + self.assertTrue(proposal.grounded) + self.assertEqual(proposal.symbol, "NVDA") + self.assertEqual(proposal.notional_usd, 1400.0) + self.assertIn("trace_mkt_nvda_01", proposal.tool_trace_ids) + + # Fabricated price (e.g. 250.0 when tool returned 140.0) must fail grounding + fabricated_report = report.replace('"reference_price": 140.0', '"reference_price": 250.0').replace( + '"notional_usd": 1400.0', '"notional_usd": 2500.0' + ) + with self.assertRaises(ValueError) as ctx: + extract_trade_proposal( + fabricated_report, + grounding_data=grounding_data, + tool_traces=tool_traces, + require_grounded=True, + ) + self.assertIn("failed grounding check", str(ctx.exception)) + + # Inconsistent notional (quantity * price != notional_usd) must fail grounding + bad_notional = TradeProposal( + symbol="NVDA", + side="buy", + quantity=10, + reference_price=140.0, + notional_usd=9999.0, + ) + res = verify_proposal_grounding(bad_notional, grounding_data=grounding_data) + self.assertFalse(res.grounded) + self.assertFalse(res.notional_consistent) + + def test_decision_memory_reflector_persists_and_queries(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + reflector = DecisionMemoryReflector(runtime_root=root) + rec = reflector.record_reflection( + proposal_id="prop_abc123", + symbol="0700.HK", + side="buy", + lesson="Wait for volume confirmation on HK tech breakouts.", + tool_trace_ids=("trace_hk_1", "trace_hk_2"), + approval_id="appr_xyz789", + fill_id="fill_001", + realized_pnl_usd=185.5, + outcome="closed_profit", + ) + self.assertEqual(rec.symbol, "0700.HK") + self.assertEqual(rec.tool_trace_ids, ("trace_hk_1", "trace_hk_2")) + + results = reflector.query_reflections(symbol="0700.hk") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].proposal_id, "prop_abc123") + self.assertEqual(results[0].approval_id, "appr_xyz789") + self.assertEqual(results[0].fill_id, "fill_001") + self.assertEqual(results[0].realized_pnl_usd, 185.5) + + +class Phase0MandateAndPaperGateTests(unittest.TestCase): + """Tests for Ticket P0-3: Mandate stub, US/HK/Crypto allowlist, live_unlocked gate, and paper fills.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.runtime_root = Path(self._tmp.name) + self._env_patch = patch.dict( + os.environ, + {"VIBE_TRADING_HOME": str(self.runtime_root)}, + clear=False, + ) + self._env_patch.start() + reset_env_config() + + def tearDown(self) -> None: + self._env_patch.stop() + reset_env_config() + self._tmp.cleanup() + + def test_mandate_shim_and_default_paper_mandate_roundtrip(self) -> None: + mandate = ensure_paper_mandate(broker="paper") + self.assertIsInstance(mandate, CompatibilityMandate) + self.assertEqual(mandate.mode, "paper") + self.assertFalse(mandate.live_unlocked) + self.assertEqual(mandate.universe.allow_symbols, DEFAULT_ALLOW_SYMBOLS) + + loaded = load_mandate("paper") + self.assertEqual(loaded, mandate) + + # YAML file loading works cleanly + yaml_path = self.runtime_root / "mandate_seed.yaml" + import yaml + + yaml_path.write_text(yaml.safe_dump(mandate_to_dict(mandate)), encoding="utf-8") + from_yaml = load_mandate_yaml_or_json(yaml_path) + assert from_yaml is not None + self.assertEqual(from_yaml.mode, "paper") + self.assertFalse(from_yaml.live_unlocked) + self.assertEqual(from_yaml.universe.allow_symbols, DEFAULT_ALLOW_SYMBOLS) + + def test_mandate_blocks_live_mode_when_not_unlocked(self) -> None: + bad_payload = mandate_to_dict(default_paper_mandate()) + bad_payload["mode"] = "live" + bad_payload["live_unlocked"] = False + with self.assertRaises(ValueError) as ctx: + _parse_mandate(bad_payload) + self.assertIn("live_unlocked", str(ctx.exception)) + + bad_path = self.runtime_root / "bad_live.json" + bad_path.write_text(json.dumps(bad_payload), encoding="utf-8") + self.assertIsNone(load_mandate_yaml_or_json(bad_path)) + + def test_check_mandate_enforces_us_hk_crypto_allowlist_and_caps(self) -> None: + mandate = default_paper_mandate(max_order_notional_usd=5_000.0) + balance = {"equity": 100_000.0} + + # Allowed US equity (both AAPL and AAPL.US) + for sym in ("AAPL", "AAPL.US"): + intent = OrderIntent( + symbol=sym, + side="buy", + quantity=10.0, + notional_usd=2_000.0, + asset_class=AssetClass.US_EQUITY, + instrument_type=InstrumentType.EQUITY, + ) + self.assertIsNone( + check_mandate( + mandate, + intent, + [], + balance, + broker="paper", + remote_tool="test", + daily_count=0, + ) + ) + + # Allowed HK equity (0700.HK) and Crypto (BTC-USD) + hk_intent = OrderIntent( + symbol="0700.HK", + side="buy", + quantity=10.0, + notional_usd=4_000.0, + asset_class=AssetClass.HK_EQUITY, + instrument_type=InstrumentType.EQUITY, + ) + self.assertIsNone( + check_mandate( + mandate, + hk_intent, + [], + balance, + broker="paper", + remote_tool="test", + daily_count=0, + ) + ) + + crypto_intent = OrderIntent( + symbol="BTC-USD", + side="buy", + quantity=0.05, + notional_usd=3_500.0, + asset_class=AssetClass.CRYPTO, + instrument_type=InstrumentType.CRYPTO, + ) + self.assertIsNone( + check_mandate( + mandate, + crypto_intent, + [], + balance, + broker="paper", + remote_tool="test", + daily_count=0, + ) + ) + + # Disallowed symbol (not in allow_symbols) + unlisted = OrderIntent( + symbol="DOGE-USD", + side="buy", + quantity=1000.0, + notional_usd=200.0, + asset_class=AssetClass.CRYPTO, + instrument_type=InstrumentType.CRYPTO, + ) + breach = check_mandate( + mandate, + unlisted, + [], + balance, + broker="paper", + remote_tool="test", + daily_count=0, + ) + self.assertIsNotNone(breach) + assert breach is not None + self.assertEqual(breach.limit, "allow_symbols") + + # Notional cap breach + oversize = OrderIntent( + symbol="NVDA", + side="buy", + quantity=100.0, + notional_usd=14_000.0, + asset_class=AssetClass.US_EQUITY, + instrument_type=InstrumentType.EQUITY, + ) + breach_cap = check_mandate( + mandate, + oversize, + [], + balance, + broker="paper", + remote_tool="test", + daily_count=0, + ) + self.assertIsNotNone(breach_cap) + assert breach_cap is not None + self.assertEqual(breach_cap.limit, "max_order_notional_usd") + + def test_execute_paper_fill_and_audit_hash_chain(self) -> None: + ensure_paper_mandate() + receipt = execute_paper_fill( + proposal_id="prop_nvda_001", + symbol="NVDA", + side="buy", + quantity=10, + fill_price=140.0, + approval_id="appr_001", + approved_by="85290001111", + approval_channel="whatsapp", + tool_trace_ids=("trace_01", "trace_02"), + ) + self.assertEqual(receipt.symbol, "NVDA") + self.assertEqual(receipt.notional_usd, 1400.0) + self.assertEqual(receipt.mode, "paper") + self.assertTrue(paper_fills_path().is_file()) + self.assertEqual(len(load_paper_positions()), 1) + + # Missing human approval must be blocked + with self.assertRaises(PaperOrderExecutionError): + execute_paper_fill( + proposal_id="prop_nvda_002", + symbol="NVDA", + side="buy", + quantity=5, + fill_price=140.0, + approval_id="", + approved_by="", + ) + + # HALT flag blocks execution + trip_halt(by="test", reason="unit test halt") + with self.assertRaises(PaperOrderExecutionError): + execute_paper_fill( + proposal_id="prop_nvda_003", + symbol="NVDA", + side="buy", + quantity=5, + fill_price=140.0, + approval_id="appr_003", + approved_by="85290001111", + ) + clear_halt() + + # Verify hash-chained audit ledger integrity + verification = verify_chain(audit_chain_ledger_path()) + self.assertTrue(verification.ok, msg=f"Audit chain failed: {verification.first_break}") + self.assertEqual(verification.record_count, 3) + + +class Phase0WhatsAppApprovalHookTests(unittest.IsolatedAsyncioTestCase): + """Tests for Ticket P0-4: WhatsApp human-in-the-loop approval coordinator and ChannelRuntime hook.""" + + async def asyncSetUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.runtime_root = Path(self._tmp.name) + self._env_patch = patch.dict( + os.environ, + {"VIBE_TRADING_HOME": str(self.runtime_root)}, + clear=False, + ) + self._env_patch.start() + reset_env_config() + ensure_paper_mandate() + + async def asyncTearDown(self) -> None: + clear_halt() + self._env_patch.stop() + reset_env_config() + self._tmp.cleanup() + + def test_command_parser(self) -> None: + cmd1 = parse_trade_approval_command("APPROVE prop_1234abcd") + self.assertIsNotNone(cmd1) + assert cmd1 is not None + self.assertEqual(cmd1.action, "approve") + self.assertEqual(cmd1.proposal_id, "prop_1234abcd") + + cmd2 = parse_trade_approval_command("reject prop_1234abcd spread too wide") + self.assertIsNotNone(cmd2) + assert cmd2 is not None + self.assertEqual(cmd2.action, "reject") + self.assertEqual(cmd2.proposal_id, "prop_1234abcd") + self.assertEqual(cmd2.reason, "spread too wide") + + cmd3 = parse_trade_approval_command("HALT macro shock") + self.assertIsNotNone(cmd3) + assert cmd3 is not None + self.assertEqual(cmd3.action, "halt") + self.assertEqual(cmd3.reason, "macro shock") + + cmd4 = parse_trade_approval_command("STATUS prop_1234abcd") + self.assertIsNotNone(cmd4) + assert cmd4 is not None + self.assertEqual(cmd4.action, "status") + + self.assertIsNone(parse_trade_approval_command("What is the price of NVDA?")) + + def test_ungrounded_proposal_rejected_by_formatter_and_coordinator(self) -> None: + ungrounded = TradeProposal( + proposal_id="prop_ungrounded", + symbol="AAPL", + side="buy", + quantity=10, + reference_price=200.0, + notional_usd=2000.0, + grounded=False, + ) + with self.assertRaises(ValueError): + format_whatsapp_approval_request(ungrounded, chat_id="85290001111@s.whatsapp.net") + + coord = TradeApprovalCoordinator(runtime_root=self.runtime_root) + with self.assertRaises(ValueError): + coord.submit_proposal(ungrounded, chat_id="85290001111@s.whatsapp.net") + + async def test_channel_runtime_whatsapp_approve_reject_halt_flow(self) -> None: + bus = MessageBus() + session_service = MagicMock() + session_service.send_message = AsyncMock() + coordinator = TradeApprovalCoordinator(runtime_root=self.runtime_root) + + runtime = ChannelRuntime( + bus=bus, + session_service=session_service, + manager=None, + session_map_path=self.runtime_root / "sessions.json", + channel_operators={"whatsapp": {"85290001111"}}, + trade_approval_coordinator=coordinator, + ) + + # 1. Submit grounded proposal for BTC-USD + proposal = TradeProposal( + proposal_id="prop_btc_01", + symbol="BTC-USD", + market="CRYPTO", + side="buy", + quantity=0.05, + reference_price=68000.0, + notional_usd=3400.0, + stop_loss=65000.0, + take_profit=74000.0, + confidence=0.78, + rationale="Bullish funding reset and spot inflow.", + tool_trace_ids=["trace_btc_ohlcv_1"], + grounded=True, + ) + prompt_msg = coordinator.submit_proposal( + proposal, + chat_id="85290001111@s.whatsapp.net", + channel="whatsapp", + ) + self.assertIn("APPROVE prop_btc_01", prompt_msg.content) + + # 2. Unauthorized sender attempts APPROVE -> rejected without touching LLM session + unauth_inbound = InboundMessage( + channel="whatsapp", + sender_id="85299990000", + chat_id="85299990000@s.whatsapp.net", + content="APPROVE prop_btc_01", + ) + await runtime._handle_inbound(unauth_inbound) + unauth_reply = await bus.consume_outbound() + self.assertTrue(unauth_reply.metadata.get("unauthorized")) + self.assertIn("Not authorized", unauth_reply.content) + session_service.send_message.assert_not_called() + + # Proposal is still pending + stored = coordinator.get_proposal("prop_btc_01") + assert stored is not None + self.assertEqual(stored["status"], "pending") + + # 3. Authorized operator approves -> executes paper fill and records full trace chain + auth_inbound = InboundMessage( + channel="whatsapp", + sender_id="85290001111", + chat_id="85290001111@s.whatsapp.net", + content="APPROVE prop_btc_01", + ) + await runtime._handle_inbound(auth_inbound) + approve_reply = await bus.consume_outbound() + self.assertEqual(approve_reply.metadata.get("status"), "approved") + self.assertIn("Paper Fill Executed", approve_reply.content) + fill_id = approve_reply.metadata.get("fill_id") + approval_id = approve_reply.metadata.get("approval_id") + self.assertTrue(str(fill_id).startswith("pf_")) + self.assertTrue(str(approval_id).startswith("appr_")) + session_service.send_message.assert_not_called() + + # Verify reflection record links tool_trace_ids -> proposal_id -> approval_id -> fill_id + reflections = coordinator.reflector.query_reflections(proposal_id="prop_btc_01") + self.assertEqual(len(reflections), 1) + self.assertEqual(reflections[0].tool_trace_ids, ("trace_btc_ohlcv_1",)) + self.assertEqual(reflections[0].approval_id, approval_id) + self.assertEqual(reflections[0].fill_id, fill_id) + + # 4. Submit second proposal and REJECT it + proposal2 = TradeProposal( + proposal_id="prop_hk_02", + symbol="0700.HK", + market="HK", + side="buy", + quantity=10, + reference_price=410.0, + notional_usd=4100.0, + tool_trace_ids=["trace_0700_1"], + grounded=True, + ) + coordinator.submit_proposal(proposal2, chat_id="85290001111@s.whatsapp.net") + reject_inbound = InboundMessage( + channel="whatsapp", + sender_id="85290001111", + chat_id="85290001111@s.whatsapp.net", + content="REJECT prop_hk_02 waiting for earnings", + ) + await runtime._handle_inbound(reject_inbound) + reject_reply = await bus.consume_outbound() + self.assertEqual(reject_reply.metadata.get("status"), "rejected") + self.assertIn("waiting for earnings", reject_reply.content) + + # 5. Authorized operator sends HALT -> trips kill switch + halt_inbound = InboundMessage( + channel="whatsapp", + sender_id="85290001111", + chat_id="85290001111@s.whatsapp.net", + content="HALT risk limit test", + ) + await runtime._handle_inbound(halt_inbound) + halt_reply = await bus.consume_outbound() + self.assertTrue(halt_reply.metadata.get("halt_tripped")) + self.assertTrue(halt_flag_set()) + + # Verify entire audit log is hash-chain valid and contains linked trace IDs + chain_res = verify_chain(audit_chain_ledger_path()) + self.assertTrue(chain_res.ok, msg=f"Audit chain invalid: {chain_res.first_break}") + self.assertGreaterEqual(chain_res.record_count, 6) + audit_lines = audit_ledger_path().read_text(encoding="utf-8").strip().splitlines() + self.assertGreaterEqual(len(audit_lines), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/tests/test_phase1_paper_automation.py b/agent/tests/test_phase1_paper_automation.py new file mode 100644 index 00000000..c7911dac --- /dev/null +++ b/agent/tests/test_phase1_paper_automation.py @@ -0,0 +1,769 @@ +"""Phase 1 Paper Automation verification suite (Tasks 1–5). + +Covers: +1. Phase 0 Acceptance Gate re-verification (grounded proposal, mandate symbol + allowlist including US/HK/Crypto, WhatsApp YES/APPROVE gate, hash-chained + audit linking tool -> proposal -> approve -> fill). +2. Shadow Account + Asia/Hong_Kong Scheduler + HTML & PDF Daily Digest. +3. Multi-market (US + HK + CRYPTO) Tiny-Universe Backtest Proof with + Purged Walk-Forward + Bootstrap + Schedule Promotion Gate. +4. DecisionMemoryReflector post-holding-period alpha settlement and injection + of lessons into the next run + trading_agents swarm preset. +5. Hardening: Kill switch (HALT), daily loss cap (max_daily_loss_usd), + channel safety alerts, and strict freeze of mode='paper' / live_unlocked=False. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pandas as pd + +from backtest.validation import ( + TINY_UNIVERSE_BY_MARKET, + evaluate_promotion_gate, + purged_walk_forward_analysis, + run_tiny_universe_backtest_proof, + run_validation, +) +from src.channels.bus.events import InboundMessage, OutboundMessage +from src.channels.bus.queue import MessageBus +from src.channels.runtime import ChannelRuntime +from src.channels.trade_approval import ( + TradeApprovalCoordinator, + format_channel_safety_alert, +) +from src.config.accessor import reset_env_config +from src.governance.ledger import verify_chain +from src.live.audit import audit_chain_ledger_path +from src.live.enforcement import OrderIntent, check_mandate +from src.live.halt import clear_halt, halt_flag_set +from src.live.mandate.model import AssetClass, InstrumentType +from src.live.mandate.paper_stub import ( + default_paper_mandate, + ensure_paper_mandate, +) +from src.memory.reflector import DecisionMemoryReflector +from src.shadow_account.paper_automation import ( + HKT_TIMEZONE, + HKT_ZONE, + LiveModeFrozenError, + PaperAutomationRunner, + PaperAutomationScheduleConfig, + PromotionGateBlockedError, + dispatch_paper_approvals, + load_paper_schedule_config, + promote_paper_schedule, +) +from src.shadow_account.paper_executor import ( + paper_fills_path, + read_daily_paper_loss_usd, + read_daily_paper_pnl_usd, + record_daily_paper_pnl, +) +from src.swarm.presets import build_run_from_preset, inspect_preset +from src.swarm.proposal import extract_trade_proposal + + +def _make_grounded_proposal( + *, + proposal_id: str = "prop-p1-001", + symbol: str = "NVDA.US", + market: str = "US", + side: str = "buy", + quantity: float = 10.0, + reference_price: float = 125.0, + tool_trace_ids: list[str] | None = None, +): + traces = tool_trace_ids or ["trace-mkt-101", "trace-risk-102"] + notional_usd = quantity * reference_price + payload = { + "proposal_id": proposal_id, + "symbol": symbol, + "market": market, + "side": side, + "quantity": quantity, + "reference_price": reference_price, + "notional_usd": notional_usd, + "stop_loss": round(reference_price * 0.95, 4), + "take_profit": round(reference_price * 1.12, 4), + "confidence": 0.80, + "mode": "paper", + "rationale": f"Grounded {side} thesis on {symbol} at {reference_price}.", + "tool_trace_ids": traces, + } + raw_text = f"```json\n{json.dumps(payload, indent=2)}\n```" + grounding_data = {symbol: [{"close": reference_price}]} + tool_traces = [ + { + "tool": "get_market_data", + "trace_id": tid, + "bars": [{"close": reference_price}], + } + for tid in traces + ] + proposal = extract_trade_proposal( + raw_text, + grounding_data=grounding_data, + tool_traces=tool_traces, + ) + assert proposal is not None + return proposal + + +def _make_synthetic_market_bars( + symbols: tuple[str, ...], + *, + n_bars: int = 120, + base_price: float = 100.0, +) -> dict[str, pd.DataFrame]: + """Generate deterministic upward-trending OHLCV bars for tiny-universe backtest proof.""" + dates = pd.date_range("2025-01-01", periods=n_bars, freq="B") + rng = np.random.default_rng(42) + data: dict[str, pd.DataFrame] = {} + for idx, sym in enumerate(symbols): + # First 15 bars dip slightly so fast_window(5) crosses above slow_window(15) cleanly, + # then steady positive drift with low noise so every purged walk-forward window profits. + daily_ret = np.full(n_bars, 0.003) + rng.normal(0.0, 0.002, size=n_bars) + daily_ret[:12] = -0.003 + close = base_price * (1.0 + idx * 0.1) * np.cumprod(1.0 + daily_ret) + df = pd.DataFrame( + { + "open": close * 0.998, + "high": close * 1.005, + "low": close * 0.995, + "close": close, + "volume": np.full(n_bars, 1_000_000.0), + }, + index=dates, + ) + data[sym] = df + return data + + +class TestPhase1PaperAutomation(unittest.TestCase): + """End-to-end verification of Phase 1 tasks 1 through 5.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self._env_patch = patch.dict( + os.environ, {"VIBE_TRADING_HOME": str(self.root)}, clear=False + ) + self._env_patch.start() + reset_env_config() + + def tearDown(self) -> None: + self._env_patch.stop() + reset_env_config() + self._tmp.cleanup() + + # ------------------------------------------------------------------ + # Task 1: Phase 0 Acceptance Gate Checklist + # ------------------------------------------------------------------ + def test_task1_acceptance_gate_checklist(self) -> None: + mandate = ensure_paper_mandate(broker="paper") + + # 1) Grounded proposal parses & validates for US, HK, and Crypto allowlisted symbols + for sym, mkt, pid in [ + ("NVDA.US", "US", "prop-us-1"), + ("0700.HK", "HK", "prop-hk-1"), + ("BTC-USD", "CRYPTO", "prop-crypto-1"), + ]: + proposal = _make_grounded_proposal( + proposal_id=pid, symbol=sym, market=mkt + ) + self.assertEqual(proposal.symbol, sym) + self.assertTrue(proposal.grounded) + + # 2) Mandate stub blocks non-allowlisted symbol (e.g. TSLA.US) + bad_intent = OrderIntent( + symbol="TSLA.US", + asset_class=AssetClass.US_EQUITY, + instrument_type=InstrumentType.EQUITY, + side="buy", + quantity=10.0, + notional_usd=1_000.0, + ) + breach = check_mandate( + mandate, + bad_intent, + [], + {"equity": 100_000.0}, + broker="paper", + remote_tool="test", + daily_count=0, + ) + self.assertIsNotNone(breach) + assert breach is not None + self.assertEqual(breach.limit, "allow_symbols") + + # 3) WhatsApp approve required before fill & audit IDs link tool -> proposal -> approve -> fill + proposal = _make_grounded_proposal( + proposal_id="prop-gate-link", + symbol="NVDA.US", + market="US", + tool_trace_ids=["trace-alpha-1", "trace-risk-2"], + ) + coord = TradeApprovalCoordinator(runtime_root=self.root, broker="paper") + outbound = coord.submit_proposal(proposal, chat_id="operator@s.whatsapp.net") + self.assertIn("prop-gate-link", outbound.content) + self.assertFalse(paper_fills_path().exists()) + + # Approve using YES alias + inbound = InboundMessage( + channel="whatsapp", + sender_id="kenneth@s.whatsapp.net", + chat_id="operator@s.whatsapp.net", + content="YES prop-gate-link", + ) + reply = coord.handle_inbound_command( + inbound, is_authorized=True, mandate=mandate + ) + self.assertIsNotNone(reply) + assert reply is not None + self.assertEqual(reply.metadata.get("status"), "approved") + fill_id = reply.metadata.get("fill_id") + self.assertTrue(fill_id) + + stored = coord.get_proposal("prop-gate-link") + self.assertIsNotNone(stored) + assert stored is not None + self.assertEqual(stored["status"], "approved") + self.assertEqual(stored["fill_id"], fill_id) + self.assertTrue(stored["approval_id"]) + self.assertEqual( + stored["proposal"]["tool_trace_ids"], ["trace-alpha-1", "trace-risk-2"] + ) + + # Verify hash-chained audit ledger integrity + chain_res = verify_chain(audit_chain_ledger_path()) + self.assertTrue( + chain_res.ok, msg=f"Audit chain failed: {chain_res.first_break}" + ) + self.assertGreaterEqual(chain_res.record_count, 2) + + # ------------------------------------------------------------------ + # Task 2: Shadow Account + Asia/Hong_Kong Scheduler + HTML/PDF Digest + # ------------------------------------------------------------------ + def test_task2_shadow_account_hkt_scheduler_and_digest(self) -> None: + ensure_paper_mandate(broker="paper") + + # Timezone must be Asia/Hong_Kong + with self.assertRaises(ValueError): + PaperAutomationScheduleConfig(timezone="UTC") + + # Unpromoted schedule cannot run daily cycle + unpromoted_cfg = PaperAutomationScheduleConfig() + runner_unpromoted = PaperAutomationRunner( + schedule_config=unpromoted_cfg, runtime_root=self.root + ) + proposal = _make_grounded_proposal(proposal_id="prop-hkt-01") + + with self.assertRaises(PromotionGateBlockedError): + runner_unpromoted.run_daily_cycle([proposal]) + + # Promote schedule after passing gate result + passing_gate = { + "passed": True, + "per_market": { + "US": {"passed": True}, + "HK": {"passed": True}, + "CRYPTO": {"passed": True}, + }, + } + promoted_cfg = promote_paper_schedule( + unpromoted_cfg, passing_gate, runtime_root=self.root + ) + self.assertTrue(promoted_cfg.promoted) + loaded_cfg = load_paper_schedule_config(runtime_root=self.root) + self.assertIsNotNone(loaded_cfg) + assert loaded_cfg is not None + self.assertTrue(loaded_cfg.promoted) + self.assertEqual(loaded_cfg.timezone, HKT_TIMEZONE) + + # Run daily cycle in Asia/Hong_Kong with MessageBus wired + bus = MessageBus() + runner = PaperAutomationRunner( + schedule_config=promoted_cfg, runtime_root=self.root, bus=bus + ) + fixed_now = datetime(2026, 10, 16, 9, 30, 0, tzinfo=HKT_ZONE) + cycle_res = runner.run_daily_cycle([proposal], now=fixed_now) + self.assertEqual(cycle_res["status"], "awaiting_whatsapp_approval") + self.assertEqual(cycle_res["timezone"], "Asia/Hong_Kong") + self.assertIn("+08:00", cycle_res["timestamp_hkt"]) + self.assertEqual(bus.outbound_size, 1) + queued_prompt = bus.outbound.get_nowait() + self.assertIn("APPROVE prop-hkt-01", queued_prompt.content) + self.assertIn("YES prop-hkt-01", queued_prompt.content) + + # Crucial invariant: zero fills written before WhatsApp YES/APPROVE + self.assertFalse(paper_fills_path().exists()) + + # Verify both HTML and PDF digests were generated + html_path = Path(cycle_res["digest"]["html_path"]) + pdf_path = Path(cycle_res["digest"]["pdf_path"]) + self.assertTrue(html_path.is_file()) + self.assertTrue(pdf_path.is_file()) + self.assertIn("prop-hkt-01", html_path.read_text(encoding="utf-8")) + pdf_bytes = pdf_path.read_bytes() + self.assertTrue(pdf_bytes.startswith(b"%PDF-")) + self.assertGreater(len(pdf_bytes), 500) + + # Now operator replies YES on WhatsApp -> fill is written and digest refreshed + reply_res = runner.handle_whatsapp_reply( + "YES prop-hkt-01", + sender_id="kenneth@s.whatsapp.net", + now=fixed_now, + ) + outbound = reply_res["outbound"] + self.assertIsNotNone(outbound) + assert outbound is not None + self.assertEqual(outbound.metadata.get("status"), "approved") + self.assertTrue(paper_fills_path().is_file()) + fills_lines = paper_fills_path().read_text(encoding="utf-8").strip().splitlines() + self.assertEqual(len(fills_lines), 1) + refreshed_html = Path(reply_res["digest"]["html_path"]).read_text(encoding="utf-8") + self.assertIn(str(outbound.metadata.get("fill_id")), refreshed_html) + + # ------------------------------------------------------------------ + # Task 3: Backtest Proof (US + HK + CRYPTO + Purged Walk-Forward + Bootstrap) + # ------------------------------------------------------------------ + def test_task3_multi_market_backtest_proof_and_promotion_gate(self) -> None: + # 1) Verify purged walk-forward enforces purge_bars and embargo_bars + n_bars = 100 + dates = pd.date_range("2025-01-01", periods=n_bars, freq="B") + eq_series = pd.Series( + 100_000.0 * np.cumprod(1.0 + np.full(n_bars, 0.002)), + index=dates, + name="equity", + ) + pwf = purged_walk_forward_analysis( + eq_series, [], n_windows=4, purge_bars=2, embargo_bars=1 + ) + self.assertEqual(pwf["n_windows"], 4) + self.assertEqual(pwf["purge_bars"], 2) + self.assertEqual(pwf["embargo_bars"], 1) + self.assertGreaterEqual(pwf["consistency_rate"], 0.75) + + val = run_validation( + { + "validation": { + "purged_walk_forward": { + "n_windows": 4, + "purge_bars": 2, + "embargo_bars": 1, + }, + "bootstrap": {"n_bootstrap": 200, "seed": 42}, + } + }, + eq_series, + [], + initial_capital=100_000.0, + ) + self.assertIn("purged_walk_forward", val) + self.assertIn("bootstrap", val) + + # 2) Run US + HK + CRYPTO engines on tiny universe with synthetic data + bar_data_by_market = { + mkt: _make_synthetic_market_bars(syms, n_bars=120) + for mkt, syms in TINY_UNIVERSE_BY_MARKET.items() + } + proof = run_tiny_universe_backtest_proof( + bar_data_by_market=bar_data_by_market, + n_windows=4, + purge_bars=2, + embargo_bars=1, + n_bootstrap=200, + ) + self.assertEqual(set(proof["per_market"].keys()), {"US", "HK", "CRYPTO"}) + self.assertTrue(proof["passed"], msg=f"Failed reasons: {proof['failed_reasons']}") + + # 3) Verify promotion gate blocks schedule promotion when any market fails + failing_gate = evaluate_promotion_gate( + { + "US": proof["market_metrics"]["US"], + "HK": proof["market_metrics"]["HK"], + "CRYPTO": { + "validation": { + "purged_walk_forward": { + "consistency_rate": 0.10, + "purge_bars": 2, + }, + "bootstrap": { + "ci_lower": -2.5, + "prob_positive": 0.15, + }, + }, + }, + } + ) + self.assertFalse(failing_gate["passed"]) + with self.assertRaises(PromotionGateBlockedError): + promote_paper_schedule( + PaperAutomationScheduleConfig(), + failing_gate, + runtime_root=self.root, + ) + + # ------------------------------------------------------------------ + # Task 4: Memory / Reflector Post-Holding-Period Alpha -> Next Run + # ------------------------------------------------------------------ + def test_task4_memory_reflector_alpha_settlement_and_next_run_injection(self) -> None: + reflector = DecisionMemoryReflector(runtime_root=self.root) + + # Settle a positive-alpha trade on NVDA.US and a negative-alpha trade on 0700.HK + rec_win = reflector.settle_holding_period_alpha( + proposal_id="prop-mem-01", + symbol="NVDA.US", + side="buy", + entry_price=100.0, + exit_price=108.0, + holding_period_days=5, + benchmark_return_pct=0.02, + ) + self.assertAlmostEqual(rec_win.realized_return_pct or 0.0, 0.08) + self.assertAlmostEqual(rec_win.alpha_pct or 0.0, 0.06) + self.assertIn("outperformed", rec_win.lesson.lower()) + + rec_loss = reflector.settle_holding_period_alpha( + proposal_id="prop-mem-02", + symbol="0700.HK", + side="buy", + entry_price=400.0, + exit_price=388.0, + holding_period_days=5, + benchmark_return_pct=0.01, + ) + self.assertAlmostEqual(rec_loss.realized_return_pct or 0.0, -0.03) + self.assertAlmostEqual(rec_loss.alpha_pct or 0.0, -0.04) + self.assertIn("underperformed", rec_loss.lesson.lower()) + + lessons_block = reflector.format_lessons_for_next_run(limit=5) + self.assertIn("NVDA.US", lessons_block) + self.assertIn("0700.HK", lessons_block) + self.assertIn("alpha=", lessons_block) + + # Verify trading_agents preset declares memory_lessons with zero warnings + preset_info = inspect_preset("trading_agents") + self.assertIn("memory_lessons", preset_info["variables"]) + self.assertIn("memory_lessons", preset_info["used_variables"]) + self.assertEqual(preset_info["warnings"], []) + + run_spec = build_run_from_preset( + "trading_agents", + { + "target": "NVDA.US", + "market": "US", + "memory_lessons": lessons_block, + }, + ) + task_map = {t.id: t for t in run_spec.tasks} + for task_id in ( + "task-research-manager", + "task-trader-proposal", + "task-portfolio-decision", + ): + rendered_prompt = task_map[task_id].prompt_template.format_map( + run_spec.user_vars + ) + self.assertIn("NVDA.US", rendered_prompt) + self.assertIn("0700.HK", rendered_prompt) + self.assertIn("alpha=", rendered_prompt) + + # ------------------------------------------------------------------ + # Task 5: Hardening — Kill Switch, Daily Loss Cap, Channel Alerts, Freeze Live Off + # ------------------------------------------------------------------ + def test_task5_hardening_kill_switch_daily_loss_cap_and_live_freeze(self) -> None: + mandate = default_paper_mandate(max_daily_loss_usd=2_000.0) + ensure_paper_mandate(broker="paper") + self.assertEqual(mandate.mode, "paper") + self.assertFalse(mandate.live_unlocked) + self.assertEqual(mandate.hard_caps.max_daily_loss_usd, 2_000.0) + + # 1) Freeze live / live_unlocked still off + with self.assertRaises(LiveModeFrozenError): + PaperAutomationScheduleConfig(mode="live") + with self.assertRaises(LiveModeFrozenError): + PaperAutomationScheduleConfig(live_unlocked=True) + + # 2) Daily loss cap breach trips HALT and emits channel safety alert + record_daily_paper_pnl(-2_500.0, today_utc="2026-10-16") + self.assertEqual(read_daily_paper_loss_usd(today_utc="2026-10-16"), 2_500.0) + self.assertEqual(read_daily_paper_pnl_usd(today_utc="2026-10-16"), -2_500.0) + + passing_gate = { + "passed": True, + "per_market": { + "US": {"passed": True}, + "HK": {"passed": True}, + "CRYPTO": {"passed": True}, + }, + } + promoted_cfg = promote_paper_schedule( + PaperAutomationScheduleConfig(), passing_gate, runtime_root=self.root + ) + runner = PaperAutomationRunner( + schedule_config=promoted_cfg, runtime_root=self.root, mandate=mandate + ) + proposal = _make_grounded_proposal(proposal_id="prop-cap-01") + + fixed_now = datetime(2026, 10, 16, 10, 0, 0, tzinfo=HKT_ZONE) + res = runner.run_daily_cycle([proposal], now=fixed_now) + self.assertEqual(res["status"], "halted_daily_loss_cap") + self.assertTrue(halt_flag_set()) + self.assertIsNotNone(res["safety_alert"]) + self.assertEqual(res["safety_alert"].metadata.get("alert_kind"), "daily_loss_cap") + self.assertEqual(res["submitted_proposals"], []) + + # 3) Clear halt, reset loss, submit proposal, then trip HALT via WhatsApp + clear_halt() + record_daily_paper_pnl(3_000.0, today_utc="2026-10-16") + coord = TradeApprovalCoordinator(runtime_root=self.root, broker="paper") + coord.submit_proposal(proposal, chat_id="operator@s.whatsapp.net") + + halt_msg = InboundMessage( + channel="whatsapp", + sender_id="kenneth@s.whatsapp.net", + chat_id="operator@s.whatsapp.net", + content="HALT manual operator emergency stop", + ) + halt_reply = coord.handle_inbound_command( + halt_msg, is_authorized=True, mandate=mandate + ) + self.assertIsNotNone(halt_reply) + assert halt_reply is not None + self.assertTrue(halt_reply.metadata.get("halt_tripped")) + self.assertTrue(halt_flag_set()) + + # Attempting YES after HALT is blocked and returns a channel safety alert + approve_msg = InboundMessage( + channel="whatsapp", + sender_id="kenneth@s.whatsapp.net", + chat_id="operator@s.whatsapp.net", + content="YES prop-cap-01", + ) + blocked_reply = coord.handle_inbound_command( + approve_msg, is_authorized=True, mandate=mandate + ) + self.assertIsNotNone(blocked_reply) + assert blocked_reply is not None + self.assertEqual(blocked_reply.metadata.get("status"), "blocked") + self.assertTrue(blocked_reply.metadata.get("safety_alert")) + self.assertEqual(blocked_reply.metadata.get("alert_kind"), "halt_tripped") + self.assertFalse(paper_fills_path().exists()) + + # Verify format_channel_safety_alert helper directly + alert_msg = format_channel_safety_alert( + "live_freeze_violation", + "live_unlocked is frozen to False", + chat_id="operator@s.whatsapp.net", + ) + self.assertTrue(alert_msg.metadata.get("safety_alert")) + self.assertEqual(alert_msg.metadata.get("alert_kind"), "live_freeze_violation") + + # ------------------------------------------------------------------ + # P0: WhatsApp Outbound Send, Idempotency, and Fail-Closed Delivery + # ------------------------------------------------------------------ + def test_p0_whatsapp_outbound_send_idempotency_and_failure_handling(self) -> None: + ensure_paper_mandate(broker="paper") + passing_gate = { + "passed": True, + "per_market": { + "US": {"passed": True}, + "HK": {"passed": True}, + "CRYPTO": {"passed": True}, + }, + } + promoted_cfg = promote_paper_schedule( + PaperAutomationScheduleConfig(), passing_gate, runtime_root=self.root + ) + fixed_now = datetime(2026, 10, 16, 9, 30, 0, tzinfo=HKT_ZONE) + + p1 = _make_grounded_proposal( + proposal_id="prop-p0-send-1", symbol="NVDA.US", market="US" + ) + p2 = _make_grounded_proposal( + proposal_id="prop-p0-send-2", + symbol="0700.HK", + market="HK", + quantity=20.0, + reference_price=400.0, + ) + + # 1) Mock publisher receives N=2 OutboundMessages for N=2 proposals + published_messages: list[OutboundMessage] = [] + + def _mock_publisher(msg: OutboundMessage) -> str: + published_messages.append(msg) + return f"wamid-{msg.metadata.get('proposal_id')}" + + runner = PaperAutomationRunner( + schedule_config=promoted_cfg, + runtime_root=self.root, + outbound_publisher=_mock_publisher, + ) + res1 = runner.run_daily_cycle([p1, p2], now=fixed_now) + self.assertEqual(res1["status"], "awaiting_whatsapp_approval") + self.assertEqual(len(published_messages), 2) + self.assertIn("APPROVE prop-p0-send-1", published_messages[0].content) + self.assertIn("YES prop-p0-send-1", published_messages[0].content) + self.assertIn("APPROVE prop-p0-send-2", published_messages[1].content) + self.assertIn("YES prop-p0-send-2", published_messages[1].content) + + # Pending store records sent_at and outbound_message_id + rec1 = runner.coordinator.get_proposal("prop-p0-send-1") + rec2 = runner.coordinator.get_proposal("prop-p0-send-2") + assert rec1 is not None and rec2 is not None + self.assertEqual(rec1["status"], "pending") + self.assertTrue(rec1["sent_at"]) + self.assertEqual(rec1["outbound_message_id"], "wamid-prop-p0-send-1") + self.assertIsNone(rec1["send_error"]) + self.assertEqual(rec2["outbound_message_id"], "wamid-prop-p0-send-2") + + # 2) Idempotency: second run_daily_cycle with same proposal_ids does NOT double-send + res_retry = runner.run_daily_cycle([p1, p2], now=fixed_now) + self.assertEqual(res_retry["status"], "awaiting_whatsapp_approval") + self.assertEqual(len(published_messages), 2) + self.assertTrue(res_retry["submitted_proposals"][0].get("already_sent")) + self.assertTrue(res_retry["submitted_proposals"][1].get("already_sent")) + + # force_resend=True explicitly re-publishes + res_forced = runner.run_daily_cycle([p1], now=fixed_now, force_resend=True) + self.assertEqual(res_forced["status"], "awaiting_whatsapp_approval") + self.assertEqual(len(published_messages), 3) + + # 3) Publisher raises -> no fill written, pending marked send_failed, cycle status == send_failed + p_fail = _make_grounded_proposal( + proposal_id="prop-p0-fail-1", symbol="AAPL.US", market="US" + ) + + def _failing_publisher(_msg: OutboundMessage) -> None: + raise RuntimeError("WhatsApp neonize socket disconnected") + + fail_runner = PaperAutomationRunner( + schedule_config=promoted_cfg, + runtime_root=self.root, + outbound_publisher=_failing_publisher, + ) + res_fail = fail_runner.run_daily_cycle([p_fail], now=fixed_now) + self.assertEqual(res_fail["status"], "send_failed") + self.assertIsNotNone(res_fail["safety_alert"]) + self.assertEqual( + res_fail["safety_alert"].metadata.get("alert_kind"), + "outbound_send_failed", + ) + self.assertFalse(paper_fills_path().exists()) + + failed_rec = fail_runner.coordinator.get_proposal("prop-p0-fail-1") + assert failed_rec is not None + self.assertEqual(failed_rec["status"], "send_failed") + self.assertIn("WhatsApp neonize socket disconnected", failed_rec["send_error"]) + self.assertIsNone(failed_rec["sent_at"]) + + # Attempting YES on a send_failed proposal is rejected (cannot fill unsent prompt) + approve_failed = fail_runner.handle_whatsapp_reply( + "YES prop-p0-fail-1", + sender_id="kenneth@s.whatsapp.net", + now=fixed_now, + ) + assert approve_failed["outbound"] is not None + self.assertTrue(approve_failed["outbound"].metadata.get("already_resolved")) + self.assertFalse(paper_fills_path().exists()) + + # 4) Missing bus/publisher with dry_run=False fails closed; dry_run=True succeeds for offline tests + p_nobus = _make_grounded_proposal( + proposal_id="prop-p0-nobus", symbol="MSFT.US", market="US" + ) + nobus_runner = PaperAutomationRunner( + schedule_config=promoted_cfg, runtime_root=self.root + ) + res_nobus = nobus_runner.run_daily_cycle([p_nobus], now=fixed_now) + self.assertEqual(res_nobus["status"], "send_failed") + self.assertFalse(paper_fills_path().exists()) + + p_dry = _make_grounded_proposal( + proposal_id="prop-p0-dry", symbol="MSFT.US", market="US" + ) + res_dry = nobus_runner.run_daily_cycle([p_dry], now=fixed_now, dry_run=True) + self.assertEqual(res_dry["status"], "awaiting_whatsapp_approval") + self.assertTrue(res_dry["submitted_proposals"][0].get("dry_run")) + + def test_p0_channel_runtime_roundtrip_and_dispatch_helper(self) -> None: + import asyncio + + mandate = ensure_paper_mandate(broker="paper") + passing_gate = { + "passed": True, + "per_market": { + "US": {"passed": True}, + "HK": {"passed": True}, + "CRYPTO": {"passed": True}, + }, + } + promoted_cfg = promote_paper_schedule( + PaperAutomationScheduleConfig(), passing_gate, runtime_root=self.root + ) + bus = MessageBus() + coord = TradeApprovalCoordinator(runtime_root=self.root, broker="paper") + runtime = ChannelRuntime( + bus=bus, + session_service=None, + manager=None, + operators=["kenneth@s.whatsapp.net"], + trade_approval_coordinator=coord, + ) + self.assertIs(runtime.trade_approval_coordinator, coord) + + runner = PaperAutomationRunner( + schedule_config=promoted_cfg, + runtime_root=self.root, + coordinator=runtime.trade_approval_coordinator, + bus=runtime.bus, + mandate=mandate, + ) + proposal = _make_grounded_proposal( + proposal_id="prop-p0-rt-1", symbol="NVDA.US", market="US" + ) + cycle_res = runner.run_daily_cycle([proposal]) + self.assertEqual(cycle_res["status"], "awaiting_whatsapp_approval") + self.assertEqual(bus.outbound_size, 1) + outbound_prompt = bus.outbound.get_nowait() + self.assertIn("APPROVE prop-p0-rt-1", outbound_prompt.content) + + # Route inbound operator YES through ChannelRuntime._handle_inbound -> bus.outbound + inbound_yes = InboundMessage( + channel="whatsapp", + sender_id="kenneth@s.whatsapp.net", + chat_id="operator@s.whatsapp.net", + content="YES prop-p0-rt-1", + ) + asyncio.run(runtime._handle_inbound(inbound_yes)) + self.assertEqual(bus.outbound_size, 1) + fill_reply = bus.outbound.get_nowait() + self.assertEqual(fill_reply.metadata.get("status"), "approved") + self.assertTrue(paper_fills_path().is_file()) + + # Also verify async dispatch_paper_approvals helper + p_async = _make_grounded_proposal( + proposal_id="prop-p0-async-1", symbol="0700.HK", market="HK" + ) + out_msg = coord.submit_proposal(p_async, chat_id="operator@s.whatsapp.net") + outcomes = asyncio.run( + dispatch_paper_approvals(bus, [out_msg], coordinator=coord) + ) + self.assertEqual(len(outcomes), 1) + self.assertEqual(outcomes[0]["status"], "pending") + self.assertTrue(outcomes[0]["sent_at"]) + self.assertEqual(bus.outbound_size, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/components/chat/SwarmStatusCard.tsx b/frontend/src/components/chat/SwarmStatusCard.tsx index 3b4291d4..76a3cd23 100644 --- a/frontend/src/components/chat/SwarmStatusCard.tsx +++ b/frontend/src/components/chat/SwarmStatusCard.tsx @@ -2,6 +2,10 @@ import { useTranslation } from "react-i18next"; import { memo } from "react"; import { Clock, Users } from "lucide-react"; import { SwarmDashboard } from "@/components/chat/SwarmDashboard"; +import { + TradingAgentsPipelineCard, + isTradingAgentsSwarm, +} from "@/components/chat/TradingAgentsPipelineCard"; import { localizePreset, localizeStatus, @@ -132,6 +136,7 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props) const total = status.agents.length; const hasAgentTotal = total > 0; const isActive = status.status === "pending" || status.status === "running"; + const showTradingAgentsPipeline = isTradingAgentsSwarm(status); return (
@@ -177,6 +182,10 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
)} + {showTradingAgentsPipeline && ( + + )} + ([ + ...TRADING_AGENTS_ROLES.map((r) => r.agentId), + "trader", + "risky_analyst", + "safe_analyst", + "neutral_analyst", + "risk_aggressive", + "risk_conservative", + "risk_neutral", +]); + +export interface ParsedTradeProposal { + proposal_id?: string | null; + symbol: string; + market: "US" | "HK" | "CRYPTO" | string; + side: "BUY" | "SELL" | "HOLD" | string; + quantity: number; + reference_price: number; + notional_usd: number; + stop_loss?: number | null; + take_profit?: number | null; + confidence?: number | null; + mode: "paper" | string; + rationale: string; + tool_trace_ids?: string[]; +} + +const STAGE_DEFINITIONS = [ + { + stage: 1 as const, + name: "Stage 1 · Analyst Team", + tier: "quick_think" as const, + slots: [ + ["market_analyst"], + ["fundamentals_analyst"], + ["news_analyst"], + ["sentiment_analyst"], + ] as const, + }, + { + stage: 2 as const, + name: "Stage 2 · Bull vs. Bear Debate", + tier: "deep_think" as const, + slots: [ + ["bull_researcher"], + ["bear_researcher"], + ["research_manager"], + ] as const, + }, + { + stage: 3 as const, + name: "Stage 3 · Execution Trader", + tier: "deep_think" as const, + slots: [["trader_agent", "trader"]] as const, + }, + { + stage: 4 as const, + name: "Stage 4 · 3-Way Risk Committee", + tier: "quick_think" as const, + slots: [ + ["aggressive_risk_debater", "risky_analyst", "risk_aggressive"], + ["conservative_risk_debater", "safe_analyst", "risk_conservative"], + ["neutral_risk_debater", "neutral_analyst", "risk_neutral"], + ] as const, + }, + { + stage: 5 as const, + name: "Stage 5 · Portfolio Manager", + tier: "deep_think" as const, + slots: [["portfolio_manager"]] as const, + }, +] as const; + +function getFirstMatchingAgent( + agentMap: Map, + aliases: readonly string[], +): SwarmAgentRow | undefined { + for (const id of aliases) { + const found = agentMap.get(id); + if (found) return found; + } + return undefined; +} + +export function isTradingAgentsSwarm(status: SwarmRunStatus): boolean { + if (status.preset === "trading_agents") return true; + return status.agents.some((agent) => TRADING_AGENTS_ID_SET.has(agent.agentId)); +} + +export function extractTradeProposalFromText( + text: string | undefined, +): ParsedTradeProposal | null { + if (!text || !text.includes("trade_proposal")) return null; + + const candidates: string[] = []; + const fencedRegex = /```(?:json)?\s*([\s\S]*?)```/gi; + let match: RegExpExecArray | null = null; + while ((match = fencedRegex.exec(text)) !== null) { + if (match[1]) candidates.push(match[1].trim()); + } + candidates.push(text.trim()); + + for (const candidate of candidates) { + const startIdx = candidate.indexOf("{"); + const endIdx = candidate.lastIndexOf("}"); + if (startIdx < 0 || endIdx <= startIdx) continue; + const slice = candidate.slice(startIdx, endIdx + 1); + try { + const parsed = JSON.parse(slice) as Record; + const raw = + parsed.trade_proposal && typeof parsed.trade_proposal === "object" + ? (parsed.trade_proposal as Record) + : parsed; + if (typeof raw.symbol === "string" && raw.symbol.trim()) { + const traceIds = Array.isArray(raw.tool_trace_ids) + ? raw.tool_trace_ids + .map((id) => String(id).trim()) + .filter((id) => id.length > 0) + : []; + return { + proposal_id: + typeof raw.proposal_id === "string" && raw.proposal_id.trim() + ? raw.proposal_id.trim() + : null, + symbol: raw.symbol.trim().toUpperCase(), + market: + typeof raw.market === "string" + ? raw.market.trim().toUpperCase() + : inferMarketFromSymbol(raw.symbol.trim()), + side: + typeof raw.side === "string" + ? raw.side.trim().toUpperCase() + : "HOLD", + quantity: Number(raw.quantity ?? 0), + reference_price: Number(raw.reference_price ?? 0), + notional_usd: Number(raw.notional_usd ?? 0), + stop_loss: + raw.stop_loss != null && Number.isFinite(Number(raw.stop_loss)) + ? Number(raw.stop_loss) + : null, + take_profit: + raw.take_profit != null && Number.isFinite(Number(raw.take_profit)) + ? Number(raw.take_profit) + : null, + confidence: + raw.confidence != null && Number.isFinite(Number(raw.confidence)) + ? Number(raw.confidence) + : null, + mode: typeof raw.mode === "string" ? raw.mode : "paper", + rationale: + typeof raw.rationale === "string" + ? raw.rationale + : "Grounded multi-agent synthesis complete.", + tool_trace_ids: traceIds, + }; + } + } catch { + // Continue trying remaining candidates + } + } + return null; +} + +function inferMarketFromSymbol(symbol: string): "US" | "HK" | "CRYPTO" { + const upper = symbol.toUpperCase(); + if (upper.endsWith(".HK")) return "HK"; + if (upper.includes("-USD") || upper.includes("-USDT")) return "CRYPTO"; + return "US"; +} + +export function extractTradeProposalFromSwarm( + status: SwarmRunStatus, +): ParsedTradeProposal | null { + const priorityOrder = [ + "portfolio_manager", + "trader_agent", + "trader", + "research_manager", + ]; + for (const agentId of priorityOrder) { + const row = status.agents.find((a) => a.agentId === agentId); + const extracted = extractTradeProposalFromText(row?.lastText); + if (extracted) return extracted; + } + for (const row of status.agents) { + const extracted = extractTradeProposalFromText(row.lastText); + if (extracted) return extracted; + } + return null; +} + +function TierBadge({ tier }: { tier: ModelTier }) { + const isDeep = tier === "deep_think"; + return ( + + + {tier} + + ); +} + +function deriveStageState( + slots: readonly (readonly string[])[], + agentMap: Map, +): "waiting" | "running" | "done" | "failed" { + const rows = slots + .map((aliases) => getFirstMatchingAgent(agentMap, aliases)) + .filter((r): r is SwarmAgentRow => Boolean(r)); + if (rows.length === 0) return "waiting"; + if (rows.some((r) => r.status === "failed" || r.status === "blocked")) { + return "failed"; + } + if (rows.some((r) => r.status === "running" || r.status === "retry")) { + return "running"; + } + if (rows.every((r) => r.status === "done")) { + return "done"; + } + if (rows.some((r) => r.status === "done")) { + return "running"; + } + return "waiting"; +} + +function formatPrice(val: number | null | undefined): string { + if (val == null || !Number.isFinite(val)) return "—"; + return `$${val.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +export const TradingAgentsPipelineCard = memo( + function TradingAgentsPipelineCard({ status }: { status: SwarmRunStatus }) { + const agentMap = new Map( + status.agents.map((a) => [a.agentId, a]), + ); + + const bullRow = getFirstMatchingAgent(agentMap, ["bull_researcher"]); + const bearRow = getFirstMatchingAgent(agentMap, ["bear_researcher"]); + const managerRow = getFirstMatchingAgent(agentMap, ["research_manager"]); + const traderRow = getFirstMatchingAgent(agentMap, ["trader_agent", "trader"]); + const aggressiveRow = getFirstMatchingAgent(agentMap, [ + "aggressive_risk_debater", + "risky_analyst", + "risk_aggressive", + ]); + const conservativeRow = getFirstMatchingAgent(agentMap, [ + "conservative_risk_debater", + "safe_analyst", + "risk_conservative", + ]); + const neutralRow = getFirstMatchingAgent(agentMap, [ + "neutral_risk_debater", + "neutral_analyst", + "risk_neutral", + ]); + const pmRow = getFirstMatchingAgent(agentMap, ["portfolio_manager"]); + + const proposal = extractTradeProposalFromSwarm(status); + + return ( +
+ {/* Top Pipeline Header */} +
+
+ + + TradingAgents v0.6.0 Decision DAG + + + + PAPER ONLY · SHADOW LEDGER + +
+
+ + Grounding + Mandate Gate Active +
+
+ + {/* 5-Stage DAG Stepper */} +
+ {STAGE_DEFINITIONS.map((stageDef) => { + const state = deriveStageState(stageDef.slots, agentMap); + const doneCount = stageDef.slots.filter( + (aliases) => + getFirstMatchingAgent(agentMap, aliases)?.status === "done", + ).length; + return ( +
+
+ {stageDef.name} + {state === "done" ? ( + + ) : state === "running" ? ( + + ) : state === "failed" ? ( + + ) : ( + + )} +
+
+ + + {doneCount}/{stageDef.slots.length} + +
+
+ ); + })} +
+ + {/* Stage 2: Bull vs. Bear Debate Arena */} + {(bullRow || bearRow || managerRow) && ( +
+
+
+ + Bull vs. Bear Researcher Debate Arena +
+ +
+
+ {/* Bull Researcher Card */} +
+
+ + + Bull Researcher + + + {bullRow?.status ?? "waiting"} + +
+

+ {bullRow?.lastText || + bullRow?.error || + "Gathering grounded upside catalysts and technical momentum signals…"} +

+
+ + {/* Bear Researcher Card */} +
+
+ + + Bear Researcher + + + {bearRow?.status ?? "waiting"} + +
+

+ {bearRow?.lastText || + bearRow?.error || + "Stress-testing valuation multiples, macro headwinds, and downside levels…"} +

+
+
+ + {/* Research Manager + Trader Synthesis */} + {(managerRow?.lastText || traderRow?.lastText) && ( +
+ {managerRow?.lastText && ( +
+
+ Research Manager Verdict +
+

+ {managerRow.lastText} +

+
+ )} + {traderRow?.lastText && ( +
+
+ Senior Execution Trader Plan +
+

+ {traderRow.lastText} +

+
+ )} +
+ )} +
+ )} + + {/* Stage 4: 3-Way Risk Committee Matrix */} + {(aggressiveRow || conservativeRow || neutralRow) && ( +
+
+
+ + 3-Way Risk Committee Deliberation +
+ + Max Single Order Cap: $10,000 · Leverage: 1.0× + +
+
+
+
+ + Aggressive Risk + + + {aggressiveRow?.status ?? "waiting"} + +
+

+ {aggressiveRow?.lastText || + "Evaluating upside asymmetry and conviction sizing…"} +

+
+
+
+ + Conservative Risk + + + {conservativeRow?.status ?? "waiting"} + +
+

+ {conservativeRow?.lastText || + "Enforcing drawdown stop-loss and tail-risk limits…"} +

+
+
+
+ + Neutral Risk + + + {neutralRow?.status ?? "waiting"} + +
+

+ {neutralRow?.lastText || + "Balancing risk-adjusted expected value…"} +

+
+
+
+ )} + + {/* Stage 5: Grounded Paper TradeProposal Ticket */} + {proposal ? ( +
+
+
+ + {proposal.symbol} + + + {proposal.market} + + + {proposal.side} + + + MODE: {proposal.mode.toUpperCase()} + + {proposal.proposal_id && ( + + id: {proposal.proposal_id} + + )} +
+
+ + Grounded Proposal Verified +
+
+ +
+
+
+ Quantity +
+
+ {proposal.quantity} +
+
+
+
+ Ref Price +
+
+ {formatPrice(proposal.reference_price)} +
+
+
+
+ Notional USD +
+
+ {formatPrice(proposal.notional_usd)} +
+
+
+
+ Stop Loss +
+
+ {formatPrice(proposal.stop_loss)} +
+
+
+
+ Take Profit +
+
+ {formatPrice(proposal.take_profit)} +
+
+
+
+ Confidence +
+
+ {proposal.confidence != null + ? `${Math.round(proposal.confidence * 100)}%` + : "—"} +
+
+
+ +

+ {proposal.rationale} +

+ + {proposal.tool_trace_ids && proposal.tool_trace_ids.length > 0 && ( +
+ Tool Traces: + {proposal.tool_trace_ids.map((traceId) => ( + + {traceId} + + ))} +
+ )} + + {/* Governance & WhatsApp Approval Footer */} +
+
+
+ + + Grounding Gate: ±2% Price / ±$1.00 Notional OK + + + + WhatsApp Human Approval Required Before Paper Fill + +
+ + run_id: {status.runId} + +
+
+ + WhatsApp Commands: + + + APPROVE {proposal.proposal_id || ""} + + + REJECT {proposal.proposal_id || ""} <reason> + + + STATUS {proposal.proposal_id || ""} + + + HALT <reason> + +
+
+
+ ) : pmRow ? ( +
+ + Portfolio Manager ({pmRow.status}) — awaiting grounded{" "} + trade_proposal JSON emission… + + +
+ ) : null} +
+ ); + }, +); diff --git a/frontend/src/components/chat/__tests__/TradingAgentsPipelineCard.test.tsx b/frontend/src/components/chat/__tests__/TradingAgentsPipelineCard.test.tsx new file mode 100644 index 00000000..97b8472b --- /dev/null +++ b/frontend/src/components/chat/__tests__/TradingAgentsPipelineCard.test.tsx @@ -0,0 +1,223 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SwarmStatusCard } from "../SwarmStatusCard"; +import { + extractTradeProposalFromSwarm, + extractTradeProposalFromText, + isTradingAgentsSwarm, +} from "../TradingAgentsPipelineCard"; +import type { SwarmRunStatus } from "@/types/agent"; + +function makeTradingAgentsSwarm( + overrides: Partial = {}, +): SwarmRunStatus { + return { + runId: "swarm-ta-1", + preset: "trading_agents", + status: "done", + currentLayer: 4, + totalLayers: 5, + startedAt: 1000, + agents: [ + { + agentId: "fundamentals_analyst", + status: "done", + elapsed_s: 1.2, + iterations: 2, + lastText: + "Revenue +122% YoY, gross margin 75.1% verified via get_market_data.", + }, + { + agentId: "market_analyst", + status: "done", + elapsed_s: 1.2, + iterations: 1, + lastText: "Breakout above 50D SMA at $138.50 with constructive RSI 61.", + }, + { + agentId: "bull_researcher", + status: "done", + elapsed_s: 1.7, + iterations: 1, + lastText: + "Blackwell rack shipments accelerate hyperscaler capex visibility.", + }, + { + agentId: "bear_researcher", + status: "done", + elapsed_s: 1.8, + iterations: 1, + lastText: + "Valuation multiple leaves limited margin for custom ASIC competition.", + }, + { + agentId: "research_manager", + status: "done", + elapsed_s: 1.4, + iterations: 1, + lastText: + "Bull thesis prevails on grounded earnings revisions; proceed with disciplined stop.", + }, + { + agentId: "trader_agent", + status: "done", + elapsed_s: 0.9, + iterations: 1, + lastText: + "Sizing 25 shares NVDA.US @ $138.50 ($3,462.50 notional) with stop $131.00.", + }, + { + agentId: "aggressive_risk_debater", + status: "done", + elapsed_s: 0.9, + iterations: 1, + lastText: "Momentum supports full 25-share allocation.", + }, + { + agentId: "conservative_risk_debater", + status: "done", + elapsed_s: 1.0, + iterations: 1, + lastText: + "Enforce hard stop at $131.00 to cap downside at <2% portfolio NAV.", + }, + { + agentId: "neutral_risk_debater", + status: "done", + elapsed_s: 1.05, + iterations: 1, + lastText: "Risk-reward ratio of 2.06x is balanced.", + }, + { + agentId: "portfolio_manager", + status: "done", + elapsed_s: 1.5, + iterations: 1, + lastText: `Decision reached after 5 stages.\n\`\`\`json\n{"trade_proposal":{"proposal_id":"prop-nvda-001","symbol":"NVDA.US","market":"US","side":"BUY","quantity":25,"reference_price":138.5,"notional_usd":3462.5,"stop_loss":131.0,"take_profit":154.0,"confidence":0.84,"rationale":"Strong AI data center demand and clean risk profile.","mode":"paper","tool_trace_ids":["trace-mkt-1","trace-fund-2"]}}\n\`\`\``, + }, + ], + ...overrides, + }; +} + +describe("TradingAgentsPipelineCard", () => { + it("detects TradingAgents preset and role topology accurately", () => { + const taStatus = makeTradingAgentsSwarm(); + expect(isTradingAgentsSwarm(taStatus)).toBe(true); + + const legacyStatus: SwarmRunStatus = { + ...taStatus, + preset: "demo_team", + agents: [ + { + agentId: "factor_researcher", + status: "done", + iterations: 1, + lastText: "ok", + }, + ], + }; + expect(isTradingAgentsSwarm(legacyStatus)).toBe(false); + }); + + it("extracts grounded TradeProposal JSON blocks from text and swarm state", () => { + const proposal = extractTradeProposalFromText( + `Here is the proposal:\n\`\`\`json\n{"trade_proposal":{"proposal_id":"prop_hk_01","symbol":"0700.HK","market":"HK","side":"BUY","quantity":100,"reference_price":412.0,"notional_usd":5280,"confidence":0.79,"mode":"paper","tool_trace_ids":["trace-hk-1"]}}\n\`\`\``, + ); + expect(proposal).not.toBeNull(); + expect(proposal?.proposal_id).toBe("prop_hk_01"); + expect(proposal?.symbol).toBe("0700.HK"); + expect(proposal?.market).toBe("HK"); + expect(proposal?.side).toBe("BUY"); + expect(proposal?.quantity).toBe(100); + expect(proposal?.mode).toBe("paper"); + expect(proposal?.tool_trace_ids).toEqual(["trace-hk-1"]); + + const swarmProposal = extractTradeProposalFromSwarm(makeTradingAgentsSwarm()); + expect(swarmProposal?.symbol).toBe("NVDA.US"); + expect(swarmProposal?.notional_usd).toBe(3462.5); + expect(swarmProposal?.proposal_id).toBe("prop-nvda-001"); + expect(swarmProposal?.tool_trace_ids).toEqual([ + "trace-mkt-1", + "trace-fund-2", + ]); + }); + + it("renders the 5-stage pipeline, Bull vs Bear Arena, Risk Committee, and Paper TradeProposal ticket", () => { + const status = makeTradingAgentsSwarm(); + render(); + + const pipeline = screen.getByTestId("trading-agents-pipeline"); + expect(pipeline).toBeInTheDocument(); + + // Check 5 stages + expect(pipeline).toHaveTextContent("Stage 1 · Analyst Team"); + expect(pipeline).toHaveTextContent("Stage 2 · Bull vs. Bear Debate"); + expect(pipeline).toHaveTextContent("Stage 3 · Execution Trader"); + expect(pipeline).toHaveTextContent("Stage 4 · 3-Way Risk Committee"); + expect(pipeline).toHaveTextContent("Stage 5 · Portfolio Manager"); + + // Check Bull vs Bear Arena & Risk Committee + expect(pipeline).toHaveTextContent( + "Blackwell rack shipments accelerate hyperscaler capex visibility.", + ); + expect(pipeline).toHaveTextContent( + "Enforce hard stop at $131.00 to cap downside at <2% portfolio NAV.", + ); + + // Check Grounded TradeProposal Ticket & WhatsApp commands + const proposalCard = screen.getByTestId("grounded-trade-proposal-ticket"); + expect(proposalCard).toHaveTextContent("NVDA.US"); + expect(proposalCard).toHaveTextContent("BUY"); + expect(proposalCard).toHaveTextContent("MODE: PAPER"); + expect(proposalCard).toHaveTextContent("id: prop-nvda-001"); + expect(proposalCard).toHaveTextContent("trace-mkt-1"); + expect(proposalCard).toHaveTextContent("trace-fund-2"); + expect(proposalCard).toHaveTextContent( + "WhatsApp Human Approval Required Before Paper Fill", + ); + + const waCommands = screen.getByTestId("whatsapp-approval-commands"); + expect(waCommands).toHaveTextContent("APPROVE prop-nvda-001"); + expect(waCommands).toHaveTextContent("REJECT prop-nvda-001"); + expect(waCommands).toHaveTextContent("STATUS prop-nvda-001"); + expect(waCommands).toHaveTextContent("HALT"); + }); + + it("supports backend role aliases (trader, risky_analyst, safe_analyst, neutral_analyst)", () => { + const aliasStatus = makeTradingAgentsSwarm({ + agents: [ + { + agentId: "trader", + status: "done", + iterations: 1, + lastText: "Alias trader execution plan.", + }, + { + agentId: "risky_analyst", + status: "done", + iterations: 1, + lastText: "Alias risky analyst view.", + }, + { + agentId: "safe_analyst", + status: "done", + iterations: 1, + lastText: "Alias safe analyst view.", + }, + { + agentId: "neutral_analyst", + status: "done", + iterations: 1, + lastText: "Alias neutral analyst view.", + }, + ], + }); + + render(); + const pipeline = screen.getByTestId("trading-agents-pipeline"); + expect(pipeline).toHaveTextContent("Alias risky analyst view."); + expect(pipeline).toHaveTextContent("Alias safe analyst view."); + expect(pipeline).toHaveTextContent("Alias neutral analyst view."); + }); +}); diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index f7ffc4de..ee37a87f 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -14,6 +14,7 @@ import i18n from "@/i18n"; import { toast } from "sonner"; import { SidebarNavigation } from "./SidebarNavigation"; import { PageHelp } from "./PageHelp"; +import { SafetyTelemetryBar } from "./SafetyTelemetryBar"; // APP_VERSION is sourced from i18n locale files (app.version key) to keep a // single source of truth across the footer and every localised README. @@ -368,6 +369,7 @@ export function Layout() { {t("layout.settings")} ) : } +
diff --git a/frontend/src/components/layout/SafetyTelemetryBar.tsx b/frontend/src/components/layout/SafetyTelemetryBar.tsx new file mode 100644 index 00000000..8c490db6 --- /dev/null +++ b/frontend/src/components/layout/SafetyTelemetryBar.tsx @@ -0,0 +1,97 @@ +import { memo } from "react"; +import { Lock, MessageSquare, ShieldCheck, Swords } from "lucide-react"; + +export interface MarketSessionBadge { + market: "US" | "HK" | "CRYPTO"; + label: string; + state: "OPEN" | "CLOSED" | "24/7"; +} + +export function getMultiMarketSessions(now: Date = new Date()): MarketSessionBadge[] { + const utcDay = now.getUTCDay(); + const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes(); + const isWeekday = utcDay >= 1 && utcDay <= 5; + + // US Regular Session: ~13:30 - 20:00 UTC (EDT) / 14:30 - 21:00 UTC (EST) + const usOpen = isWeekday && utcMinutes >= 13 * 60 + 30 && utcMinutes < 21 * 60; + // HKEX Regular Session: 01:30 - 08:00 UTC (09:30 - 16:00 HKT) + const hkOpen = isWeekday && utcMinutes >= 90 && utcMinutes < 8 * 60; + + return [ + { + market: "US", + label: "US (NYSE/NASDAQ)", + state: usOpen ? "OPEN" : "CLOSED", + }, + { + market: "HK", + label: "HK (HKEX)", + state: hkOpen ? "OPEN" : "CLOSED", + }, + { + market: "CRYPTO", + label: "CRYPTO", + state: "24/7", + }, + ]; +} + +export const SafetyTelemetryBar = memo(function SafetyTelemetryBar() { + const markets = getMultiMarketSessions(); + + return ( +
+ {/* Left: Paper-Only Mandate Lock + Grounding Gate */} +
+ + + PAPER ONLY · LIVE LOCKED + + + + Mandate Cap: $10,000/order · 1.0× + + + + + TradingAgents DAG: quick_think + deep_think + +
+ + {/* Right: Multi-Market Clocks (US / HK / Crypto) + WhatsApp Gate */} +
+
+ {markets.map((m) => { + const isActive = m.state === "OPEN" || m.state === "24/7"; + return ( + + + ); + })} +
+ + + WhatsApp Gate: Approve-Before-Fill + +
+
+ ); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css index 74d71048..675ea9ac 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -187,6 +187,27 @@ 50% { opacity: 1; transform: translateY(0); } } +@keyframes pixel-bob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } +} + +.pixel-character--active .pixel-character__body { + animation: pixel-bob 1.1s steps(2, end) infinite; +} + +.pixel-speech-bubble { + position: relative; + border: 1px solid hsl(var(--border)); + background: hsl(var(--popover)); + box-shadow: 2px 2px 0 hsl(var(--foreground) / 0.12); +} +@media (prefers-reduced-motion: reduce) { + .pixel-character--active .pixel-character__body { + animation: none; + } +} + /* Message/hero entrance — one subtle rise per mount; removed under reduced motion */ @keyframes msg-enter { from { opacity: 0; transform: translateY(10px); } diff --git a/frontend/src/lib/swarmI18n.ts b/frontend/src/lib/swarmI18n.ts index 61568a16..35a55f40 100644 --- a/frontend/src/lib/swarmI18n.ts +++ b/frontend/src/lib/swarmI18n.ts @@ -22,6 +22,10 @@ const PRESET_WELCOME_KEYS: Record = { value_investing_committee: "welcome.examples.valueCommittee", }; +const PRESET_DEFAULT_LABELS: Record = { + trading_agents: "TradingAgents Decision Pipeline", +}; + export function localizeStatus( status: keyof typeof STATUS_TRANSLATION_KEYS, t: ManifestTranslation, @@ -33,9 +37,11 @@ export function localizePreset( preset: string, t: ManifestTranslation, ): string { - const humanized = preset - .replace(/_/g, " ") - .replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase()); + const humanized = + PRESET_DEFAULT_LABELS[preset] ?? + preset + .replace(/_/g, " ") + .replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase()); const welcomeKey = PRESET_WELCOME_KEYS[preset]; const fallback = welcomeKey ? t(welcomeKey, { defaultValue: humanized }) diff --git a/frontend/src/pages/PixelOffice.tsx b/frontend/src/pages/PixelOffice.tsx index 40e9907b..00ad5727 100644 --- a/frontend/src/pages/PixelOffice.tsx +++ b/frontend/src/pages/PixelOffice.tsx @@ -1,8 +1,314 @@ -import { useCallback, useEffect, useState, type CSSProperties, type FormEvent } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, + type FormEvent, +} from "react"; import { useTranslation } from "react-i18next"; +import { + Activity, + AlertTriangle, + Brain, + Coffee, + Lock, + MessageSquare, + Scale, + ShieldCheck, + Swords, +} from "lucide-react"; import { api, type OfficeSnapshot } from "@/lib/api"; import { cn } from "@/lib/utils"; +export type SpatialZoneId = + | "lounge" + | "analyst_pods" + | "debate_arena" + | "risk_war_room" + | "whatsapp_desk" + | "quarantine_corner"; + +export type SimulationScenario = + | "live_pipeline" + | "analyst_sweep" + | "bull_bear_clash" + | "risk_deliberation" + | "whatsapp_approval" + | "quarantine_drill"; + +interface TradingFloorSprite { + id: string; + label: string; + tier: "quick_think" | "deep_think"; + color: string; + defaultZone: SpatialZoneId; + tools: string[]; + speechByScenario: Record; +} + +const TRADING_FLOOR_SPRITES: readonly TradingFloorSprite[] = [ + { + id: "market_analyst", + label: "Market Analyst", + tier: "quick_think", + color: "#38bdf8", + defaultZone: "analyst_pods", + tools: ["get_market_data", "pattern"], + speechByScenario: { + live_pipeline: { zone: "analyst_pods", bubble: "NVDA.US OHLCV + RSI 61.8", active: true }, + analyst_sweep: { zone: "analyst_pods", bubble: "Scanning US/HK/Crypto bars", active: true }, + bull_bear_clash: { zone: "lounge", bubble: "Bars grounded ✓", active: false }, + risk_deliberation: { zone: "lounge", bubble: "ATR stop levels ready", active: false }, + whatsapp_approval: { zone: "lounge", bubble: " Standing by", active: false }, + quarantine_drill: { zone: "analyst_pods", bubble: "Re-verifying 0700.HK quote", active: true }, + }, + }, + { + id: "fundamentals_analyst", + label: "Fundamentals Analyst", + tier: "quick_think", + color: "#60a5fa", + defaultZone: "analyst_pods", + tools: ["get_fundamentals", "get_financial_statements"], + speechByScenario: { + live_pipeline: { zone: "analyst_pods", bubble: "FCF margin 48.2% grounded", active: true }, + analyst_sweep: { zone: "analyst_pods", bubble: "Auditing 10-Q & HKEX filings", active: true }, + bull_bear_clash: { zone: "lounge", bubble: "Multiples verified ✓", active: false }, + risk_deliberation: { zone: "lounge", bubble: "Balance sheet clean", active: false }, + whatsapp_approval: { zone: "lounge", bubble: "Filing trace linked", active: false }, + quarantine_drill: { zone: "lounge", bubble: "P/E ratio cross-checked", active: false }, + }, + }, + { + id: "news_analyst", + label: "News & Macro Analyst", + tier: "quick_think", + color: "#818cf8", + defaultZone: "analyst_pods", + tools: ["get_stock_news", "get_macro_series"], + speechByScenario: { + live_pipeline: { zone: "analyst_pods", bubble: "Macro CPI & earnings calendar", active: true }, + analyst_sweep: { zone: "analyst_pods", bubble: "Pulling US/HK/Crypto headlines", active: true }, + bull_bear_clash: { zone: "lounge", bubble: "Catalysts logged ✓", active: false }, + risk_deliberation: { zone: "lounge", bubble: "No FOMC blackout", active: false }, + whatsapp_approval: { zone: "lounge", bubble: "Idle at coffee bar", active: false }, + quarantine_drill: { zone: "lounge", bubble: "Checking news timestamp", active: false }, + }, + }, + { + id: "sentiment_analyst", + label: "Sentiment & Flow", + tier: "quick_think", + color: "#a78bfa", + defaultZone: "analyst_pods", + tools: ["get_fund_flow", "get_northbound_flow"], + speechByScenario: { + live_pipeline: { zone: "lounge", bubble: "BTC-USDT funding +0.01%", active: false }, + analyst_sweep: { zone: "analyst_pods", bubble: "Northbound & options skew", active: true }, + bull_bear_clash: { zone: "lounge", bubble: "Flow regime neutral-bull", active: false }, + risk_deliberation: { zone: "lounge", bubble: "Crowd positioning normal", active: false }, + whatsapp_approval: { zone: "lounge", bubble: "Resting in lounge", active: false }, + quarantine_drill: { zone: "lounge", bubble: "Flow feed nominal", active: false }, + }, + }, + { + id: "bull_researcher", + label: "Bull Researcher", + tier: "quick_think", + color: "#10b981", + defaultZone: "debate_arena", + tools: ["session_search", "remember"], + speechByScenario: { + live_pipeline: { zone: "debate_arena", bubble: "Bull: Datacenter rev +112% YoY", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Awaiting analyst briefs", active: false }, + bull_bear_clash: { zone: "debate_arena", bubble: "Breakout above $138 w/ 1.8x vol!", active: true }, + risk_deliberation: { zone: "debate_arena", bubble: "Asymmetric R:R 2.6x", active: false }, + whatsapp_approval: { zone: "lounge", bubble: "Bull thesis submitted", active: false }, + quarantine_drill: { zone: "quarantine_corner", bubble: "Blocked: ungrounded EPS claim!", active: true }, + }, + }, + { + id: "bear_researcher", + label: "Bear Researcher", + tier: "quick_think", + color: "#f43f5e", + defaultZone: "debate_arena", + tools: ["session_search", "remember"], + speechByScenario: { + live_pipeline: { zone: "debate_arena", bubble: "Bear: 38x fwd P/E + supply risk", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Prepping downside stress test", active: false }, + bull_bear_clash: { zone: "debate_arena", bubble: "Resistance at $142, trim size!", active: true }, + risk_deliberation: { zone: "debate_arena", bubble: "Enforce hard stop at $131.50", active: false }, + whatsapp_approval: { zone: "lounge", bubble: "Downside bounds set", active: false }, + quarantine_drill: { zone: "debate_arena", bubble: "Flagged unverified figure", active: true }, + }, + }, + { + id: "research_manager", + label: "Research Manager", + tier: "deep_think", + color: "#f59e0b", + defaultZone: "debate_arena", + tools: ["financial_rigor"], + speechByScenario: { + live_pipeline: { zone: "debate_arena", bubble: "Judge: Bull wins w/ 0.78 conf", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Reviewing past reflections", active: false }, + bull_bear_clash: { zone: "debate_arena", bubble: "Weighing grounded evidence…", active: true }, + risk_deliberation: { zone: "risk_war_room", bubble: "Handing synthesis to Trader", active: true }, + whatsapp_approval: { zone: "lounge", bubble: "Debate synthesis archived", active: false }, + quarantine_drill: { zone: "quarantine_corner", bubble: "Halted ungrounded draft", active: true }, + }, + }, + { + id: "trader_agent", + label: "Senior Trader", + tier: "deep_think", + color: "#06b6d4", + defaultZone: "risk_war_room", + tools: ["get_market_data"], + speechByScenario: { + live_pipeline: { zone: "risk_war_room", bubble: "BUY 20 NVDA.US @ $138.50 ($2,770)", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Checking US/HK/Crypto lots", active: false }, + bull_bear_clash: { zone: "risk_war_room", bubble: "Drafting entry & stop-loss", active: true }, + risk_deliberation: { zone: "risk_war_room", bubble: "Notional $2,770 <= $10k cap", active: true }, + whatsapp_approval: { zone: "whatsapp_desk", bubble: "Paper ticket staged", active: true }, + quarantine_drill: { zone: "lounge", bubble: "Order held by gate", active: false }, + }, + }, + { + id: "aggressive_risk_debater", + label: "Aggressive Risk", + tier: "quick_think", + color: "#fb923c", + defaultZone: "risk_war_room", + tools: ["portfolio_risk_xray"], + speechByScenario: { + live_pipeline: { zone: "risk_war_room", bubble: "Momentum supports 28% allocation", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Idle", active: false }, + bull_bear_clash: { zone: "lounge", bubble: "Watching debate", active: false }, + risk_deliberation: { zone: "risk_war_room", bubble: "Upside skew justifies full lot", active: true }, + whatsapp_approval: { zone: "lounge", bubble: "Risk sign-off logged", active: false }, + quarantine_drill: { zone: "risk_war_room", bubble: "Checking notional cap", active: false }, + }, + }, + { + id: "conservative_risk_debater", + label: "Conservative Risk", + tier: "quick_think", + color: "#38bdf8", + defaultZone: "risk_war_room", + tools: ["portfolio_risk_xray"], + speechByScenario: { + live_pipeline: { zone: "risk_war_room", bubble: "Cap at $2,770 · 1.0x no leverage", active: true }, + analyst_sweep: { zone: "lounge", bubble: "Monitoring drawdown limits", active: false }, + bull_bear_clash: { zone: "lounge", bubble: "Awaiting trader sizing", active: false }, + risk_deliberation: { zone: "risk_war_room", bubble: "Stop-loss $131.50 mandatory!", active: true }, + whatsapp_approval: { zone: "lounge", bubble: "Mandate bounds respected", active: false }, + quarantine_drill: { zone: "quarantine_corner", bubble: "Enforcing ±0.5% price tolerance", active: true }, + }, + }, + { + id: "neutral_risk_debater", + label: "Neutral Risk", + tier: "quick_think", + color: "#c084fc", + defaultZone: "risk_war_room", + tools: ["portfolio_risk_xray"], + speechByScenario: { + live_pipeline: { zone: "risk_war_room", bubble: "EV positive · Sharpe +1.42", active: false }, + analyst_sweep: { zone: "lounge", bubble: "Idle in lounge", active: false }, + bull_bear_clash: { zone: "lounge", bubble: "Reviewing correlation matrix", active: false }, + risk_deliberation: { zone: "risk_war_room", bubble: "Balanced 20-share paper fill", active: true }, + whatsapp_approval: { zone: "lounge", bubble: "Consensus reached", active: false }, + quarantine_drill: { zone: "risk_war_room", bubble: "Holding for grounding pass", active: false }, + }, + }, + { + id: "portfolio_manager", + label: "Portfolio Manager", + tier: "deep_think", + color: "#22c55e", + defaultZone: "whatsapp_desk", + tools: ["verify_proposal_grounding", "whatsapp_approval_gate"], + speechByScenario: { + live_pipeline: { zone: "whatsapp_desk", bubble: "WhatsApp: Awaiting Human APPROVE", active: true }, + analyst_sweep: { zone: "risk_war_room", bubble: "Mandate: PAPER ONLY ($10k cap)", active: false }, + bull_bear_clash: { zone: "risk_war_room", bubble: "Monitoring debate arena", active: false }, + risk_deliberation: { zone: "risk_war_room", bubble: "Finalizing grounded trade_proposal", active: true }, + whatsapp_approval: { zone: "whatsapp_desk", bubble: "Sent to WhatsApp -> APPROVE?", active: true }, + quarantine_drill: { zone: "quarantine_corner", bubble: "REJECTED: Grounding mismatch", active: true }, + }, + }, +] as const; + +const SPATIAL_ZONES: readonly { + id: SpatialZoneId; + title: string; + subtitle: string; + badge: string; + accentClass: string; + icon: typeof Coffee; +}[] = [ + { + id: "analyst_pods", + title: "Analyst Data Pods", + subtitle: "US / HK / Crypto get_market_data & Fundamentals (Stage 1)", + badge: "STAGE 1 · QUICK_THINK", + accentClass: "border-sky-500/30 bg-sky-500/5", + icon: Activity, + }, + { + id: "debate_arena", + title: "Bull vs. Bear Debate Arena", + subtitle: "Adversarial Thesis Clash & Research Manager Verdict (Stage 2)", + badge: "STAGE 2 · DEEP_THINK", + accentClass: "border-amber-500/30 bg-amber-500/5", + icon: Swords, + }, + { + id: "risk_war_room", + title: "Risk Committee & PM War Room", + subtitle: "Trader Sizing + 3-Way Risk Trio + Mandate Check (Stages 3–4)", + badge: "STAGES 3–4 · MANDATE GATE", + accentClass: "border-purple-500/30 bg-purple-500/5", + icon: Scale, + }, + { + id: "whatsapp_desk", + title: "WhatsApp Paper Approval Dispatch Desk", + subtitle: "Human-in-the-Loop Sign-Off Before execute_paper_fill() (Stage 5)", + badge: "STAGE 5 · HUMAN APPROVE", + accentClass: "border-emerald-500/30 bg-emerald-500/5", + icon: MessageSquare, + }, + { + id: "lounge", + title: "Break Lounge & Memory Archive", + subtitle: "Idle Agents & Post-Trade DecisionMemoryReflector", + badge: "IDLE · REFLECTOR", + accentClass: "border-border/70 bg-muted/20", + icon: Coffee, + }, + { + id: "quarantine_corner", + title: "Grounding Quarantine Corner", + subtitle: "Isolates Ungrounded Claims (±0.5% Price / ±1% Notional Guard)", + badge: "GROUNDING GUARD", + accentClass: "border-rose-500/30 bg-rose-500/5", + icon: AlertTriangle, + }, +] as const; + +const SCENARIO_BUTTONS: readonly { id: SimulationScenario; label: string }[] = [ + { id: "live_pipeline", label: "Full 5-Stage Pipeline" }, + { id: "analyst_sweep", label: "Stage 1: Analyst Sweep" }, + { id: "bull_bear_clash", label: "Stage 2: Bull vs. Bear" }, + { id: "risk_deliberation", label: "Stages 3–4: Risk War Room" }, + { id: "whatsapp_approval", label: "Stage 5: WhatsApp Gate" }, + { id: "quarantine_drill", label: "Grounding Quarantine Drill" }, +] as const; + export function PixelOffice() { const { t } = useTranslation(); const [snapshot, setSnapshot] = useState(null); @@ -13,6 +319,9 @@ export function PixelOffice() { const [description, setDescription] = useState(""); const [priority, setPriority] = useState("normal"); const [results, setResults] = useState>({}); + const [scenario, setScenario] = useState("live_pipeline"); + const [selectedSpriteId, setSelectedSpriteId] = useState("portfolio_manager"); + const refresh = useCallback(async () => { setPending(true); try { @@ -20,103 +329,528 @@ export function PixelOffice() { setSnapshot(value); setAgentId((current) => current || value.agents[0]?.id || ""); setError(""); - } catch (err) { setError(err instanceof Error ? err.message : String(err)); } - finally { setPending(false); } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } }, []); - useEffect(() => { void refresh(); }, [refresh]); - async function mutate(action: "create" | "assign" | "complete" | "rank", arguments_: Record) { + useEffect(() => { + void refresh(); + }, [refresh]); + + async function mutate( + action: "create" | "assign" | "complete" | "rank", + arguments_: Record, + ) { setPending(true); try { setSnapshot(await api.updatePixelOffice(action, arguments_)); setError(""); return true; - } catch (err) { setError(err instanceof Error ? err.message : String(err)); return false; } - finally { setPending(false); } - } - async function create(event: FormEvent) { - event.preventDefault(); - if (await mutate("create", { task_id: crypto.randomUUID(), title, description, priority, assigned_to: agentId || null })) { - setTitle(""); setDescription(""); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return false; + } finally { + setPending(false); } } - const input = "mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"; + + async function create(event: FormEvent) { + event.preventDefault(); + if ( + await mutate("create", { + task_id: crypto.randomUUID(), + title, + description, + priority, + assigned_to: agentId || null, + }) + ) { + setTitle(""); + setDescription(""); + } + } + + const input = + "mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"; const agents = snapshot?.agents ?? []; - const agentName = (id: string | null) => agents.find((agent) => agent.id === id)?.name ?? t("pixelOffice.unassigned"); + const agentName = (id: string | null) => + agents.find((agent) => agent.id === id)?.name ?? t("pixelOffice.unassigned"); + + const selectedSprite = useMemo( + () => + TRADING_FLOOR_SPRITES.find((s) => s.id === selectedSpriteId) ?? + TRADING_FLOOR_SPRITES[0], + [selectedSpriteId], + ); + return (
-
-

{t("pixelOffice.title")}

-

{t("pixelOffice.description")}

- -
- {error &&

{error}

} - {!snapshot &&

{t(pending ? "pixelOffice.loading" : "pixelOffice.unavailable")}

} - {snapshot && <> -
-

{t("pixelOffice.floor")}

-
{agents.map((agent) => ( - - ))}
-
-
-
-

{t("pixelOffice.board")}

- {!snapshot.tasks.length &&

{t("pixelOffice.empty")}

} -
{snapshot.tasks.map((task) => ( -
-

{task.title}

{t(`pixelOffice.priorities.${task.priority}`)} · {t(`pixelOffice.statuses.${task.status}`)}
-

{task.description}

-

{agentName(task.assigned_to)}

- {task.status === "completed" ?

{task.result}

: ( -
- -
{ event.preventDefault(); void mutate("complete", { task_id: task.id, result: results[task.id] ?? "" }); }}> -