feat: Phase 0/1 TradingAgents swarm graft, mandate paper automation, and P0 WhatsApp outbound send
Desktop Windows / package (push) Waiting to run
Build and Push Docker Image / validate-build (push) Waiting to run
Build and Push Docker Image / build-and-push (push) Skipped
CI / Hash lock / macOS ARM64 (push) Waiting to run
CI / Hash lock / Linux x64 (push) Waiting to run
CI / Hash lock / Windows x64 (push) Waiting to run
CI / test / Python 3.11 (push) Waiting to run
CI / test / Python 3.14 (push) Waiting to run
CI / frontend (push) Waiting to run
CI / Windows 11 ARM64 / Python 3.14 background lifecycle (push) Waiting to run
CI / Windows Server 2025 x64 / Python 3.14 background lifecycle (push) Waiting to run
CI / Windows desktop source lifecycle (push) Waiting to run
Deploy Wiki / Deploy to Cloudflare Pages (push) Waiting to run
Wiki / validate (push) Waiting to run

This commit is contained in:
Kenneth Hau committed 2026-10-16 14:58:47 +08:00
1 parent 4da7579065
commit aab57cdeb3
29 files changed
+7522 -98

No files matched your search

+403
View File
@@ -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 ─── # ─── Runner integration ───
@@ -335,6 +461,7 @@ def run_validation(
- monte_carlo: {n_simulations, seed} - monte_carlo: {n_simulations, seed}
- bootstrap: {n_bootstrap, confidence, seed} - bootstrap: {n_bootstrap, confidence, seed}
- walk_forward: {n_windows} - walk_forward: {n_windows}
- purged_walk_forward: {n_windows, purge_bars, embargo_bars}
Args: Args:
config: Backtest config (must contain "validation" key). config: Backtest config (must contain "validation" key).
@@ -386,6 +513,21 @@ def run_validation(
bars_per_year=bars_per_year, 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 return results
@@ -558,6 +700,267 @@ def main(run_dir: Path) -> Dict[str, Any]:
return safe_results 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__": if __name__ == "__main__":
import sys import sys
+52
View File
@@ -45,6 +45,7 @@ class ChannelRuntime:
poll_interval_s: float = 0.25, poll_interval_s: float = 0.25,
operators: Iterable[str] | None = None, operators: Iterable[str] | None = None,
channel_operators: Mapping[str, Iterable[str]] | None = None, channel_operators: Mapping[str, Iterable[str]] | None = None,
trade_approval_coordinator: Any = None,
) -> None: ) -> None:
self.bus = bus self.bus = bus
self.session_service = session_service self.session_service = session_service
@@ -62,6 +63,7 @@ class ChannelRuntime:
str(ch): {str(o) for o in ops} str(ch): {str(o) for o in ops}
for ch, ops in (channel_operators or {}).items() 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_path = session_map_path or (get_data_dir() / "channels" / "sessions.json")
self._session_map: dict[str, str] = {} self._session_map: dict[str, str] = {}
self._consumer_task: asyncio.Task[None] | None = None 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 {}, "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: async def _consume_loop(self) -> None:
while True: while True:
msg = await self.bus.consume_inbound() msg = await self.bus.consume_inbound()
@@ -183,6 +194,9 @@ class ChannelRuntime:
) )
return return
if await self._handle_trade_approval(msg):
return
session_id = self._session_for(msg) session_id = self._session_for(msg)
if await self._handle_scheduled_confirmation(msg, session_id): if await self._handle_scheduled_confirmation(msg, session_id):
return 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( async def _handle_scheduled_confirmation(
self, msg: InboundMessage, session_id: str self, msg: InboundMessage, session_id: str
) -> bool: ) -> bool:
+743
View File
@@ -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 <id>`, `REJECT <id> [reason]`, `HALT [reason]`,
`STATUS <id>`) 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<proposal_id>[A-Za-z0-9_.-]+)\s*$",
re.IGNORECASE,
)
_REJECT_RE = re.compile(
r"^/?(?:reject|no)\s+(?P<proposal_id>[A-Za-z0-9_.-]+)(?:\s+(?P<reason>.+))?\s*$",
re.IGNORECASE | re.DOTALL,
)
_STATUS_RE = re.compile(
r"^/?status\s+(?P<proposal_id>[A-Za-z0-9_.-]+)\s*$",
re.IGNORECASE,
)
_HALT_RE = re.compile(
r"^/?halt(?:\s+(?P<reason>.+))?\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 ``<runtime_root>/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} <reason>` (or `NO {proposal.proposal_id} <reason>`) — discard proposal\n"
f"• `STATUS {proposal.proposal_id}` — check status\n"
"• `HALT <reason>` — 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"),
},
)
+6
View File
@@ -200,6 +200,12 @@ class LLMConfig(_EnvBase):
vibe_trading_anthropic_prompt_cache: EnvBool = Field( vibe_trading_anthropic_prompt_cache: EnvBool = Field(
alias="VIBE_TRADING_ANTHROPIC_PROMPT_CACHE", default=True, 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="",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+48 -2
View File
@@ -482,13 +482,14 @@ def check_mandate(
broker: str, broker: str,
remote_tool: str, remote_tool: str,
daily_count: int, daily_count: int,
daily_loss_usd: float = 0.0,
) -> BreachEvent | None: ) -> BreachEvent | None:
"""Evaluate one order intent against the mandate (fail-closed). """Evaluate one order intent against the mandate (fail-closed).
Checks run in a fixed order and the first failure produces the verdict: Checks run in a fixed order and the first failure produces the verdict:
exclude-list, instrument allowance, asset-class allowance (universe market- exclude-list, instrument allowance, asset-class allowance (universe market-
cap/liquidity), single-order notional, post-trade total exposure, post-trade 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) unparseable input (bad intent, unreadable positions, missing market data)
denies rather than allows. denies rather than allows.
@@ -502,6 +503,8 @@ def check_mandate(
remote_tool: Broker remote tool name the agent invoked. remote_tool: Broker remote tool name the agent invoked.
daily_count: Orders already placed today (from the persisted counter, daily_count: Orders already placed today (from the persisted counter,
after UTC rollover). after UTC rollover).
daily_loss_usd: Realized + unrealized loss accumulated today in USD
(positive number represents a loss).
Returns: Returns:
``None`` when the order is fully in-mandate (ALLOW), otherwise a ``None`` when the order is fully in-mandate (ALLOW), otherwise a
@@ -510,6 +513,14 @@ def check_mandate(
caps = mandate.hard_caps caps = mandate.hard_caps
universe = mandate.universe 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() symbol = (intent.symbol or "").strip().upper()
if not symbol or intent.side not in ("buy", "sell"): if not symbol or intent.side not in ("buy", "sell"):
return _breach( return _breach(
@@ -520,7 +531,8 @@ def check_mandate(
) )
# 1. Exclude-list — takes precedence over every other universe rule. # 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( return _breach(
broker=broker, remote_tool=remote_tool, intent=intent, broker=broker, remote_tool=remote_tool, intent=intent,
kind=BREACH_KIND_UNIVERSE, limit="exclude_symbols", kind=BREACH_KIND_UNIVERSE, limit="exclude_symbols",
@@ -528,6 +540,26 @@ def check_mandate(
detail=f"{symbol} is on the mandate exclude list", 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). # 2. Instrument-type allowance (empty == deny all, fail-closed).
if intent.instrument_type not in caps.allowed_instruments: if intent.instrument_type not in caps.allowed_instruments:
return _breach( return _breach(
@@ -624,6 +656,19 @@ def check_mandate(
attempted_value=float(attempted_count), 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 # 8. Funding (defense-in-depth; broker is the real ceiling). Only a buy can
# push us past funding — never block a sell on this. # push us past funding — never block a sell on this.
if intent.side == "buy" and post_exposure > caps.account_funding_usd: 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", detail="post-trade exposure exceeds mirrored funding ceiling",
) )
# 9. Expensive universe rules (market-cap / liquidity floors). Fail-closed # 9. Expensive universe rules (market-cap / liquidity floors). Fail-closed
# on missing data per SPEC §5. Skipped for instruments with no equity # on missing data per SPEC §5. Skipped for instruments with no equity
# universe bucket (e.g. options) where these floors do not apply. # universe bucket (e.g. options) where these floors do not apply.
+17 -2
View File
@@ -64,6 +64,8 @@ class HardCaps:
types. Empty == deny all (fail-closed). types. Empty == deny all (fail-closed).
max_trades_per_day: Vibe-enforced count of order placements allowed max_trades_per_day: Vibe-enforced count of order placements allowed
per UTC calendar day. Counter persisted alongside the mandate. 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 account_funding_usd: float
@@ -72,6 +74,8 @@ class HardCaps:
max_leverage: float max_leverage: float
allowed_instruments: tuple[InstrumentType, ...] allowed_instruments: tuple[InstrumentType, ...]
max_trades_per_day: int max_trades_per_day: int
max_daily_loss_usd: float | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -79,7 +83,8 @@ class UniverseConstraint:
"""Layer (b): user-set universe the agent picks symbols WITHIN. """Layer (b): user-set universe the agent picks symbols WITHIN.
Not a ticker whitelist — that would kill agent discovery. The agent selects 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: Attributes:
asset_classes: Permitted asset-class buckets. Empty == deny all. asset_classes: Permitted asset-class buckets. Empty == deny all.
@@ -89,12 +94,17 @@ class UniverseConstraint:
exclude_symbols: Hard per-symbol denylist (normalized upper-case, exclude_symbols: Hard per-symbol denylist (normalized upper-case,
e.g. ``BTC-USDT`` style for crypto). Takes precedence over every e.g. ``BTC-USDT`` style for crypto). Takes precedence over every
other universe rule. 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, ...] asset_classes: tuple[AssetClass, ...]
min_market_cap_usd: float | None min_market_cap_usd: float | None
min_avg_daily_volume_usd: float | None min_avg_daily_volume_usd: float | None
exclude_symbols: tuple[str, ...] exclude_symbols: tuple[str, ...]
allow_symbols: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -122,7 +132,7 @@ class ConsentMeta:
@dataclass(frozen=True) @dataclass(frozen=True)
class Mandate: 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 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). 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. ``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 Read by ``src.live.runtime.flatten`` on a halt trip (SPEC §7.5 #6
"optionally, per mandate"). "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 schema_version: int
@@ -147,3 +160,5 @@ class Mandate:
universe: UniverseConstraint universe: UniverseConstraint
consent: ConsentMeta consent: ConsentMeta
flatten_on_halt: bool = False flatten_on_halt: bool = False
mode: str = "paper"
live_unlocked: bool = False
+252
View File
@@ -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 ``<runtime_root>/live/<broker>/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
+16
View File
@@ -94,12 +94,20 @@ def _parse_mandate(raw: object) -> Mandate:
InstrumentType(value) for value in caps["allowed_instruments"] InstrumentType(value) for value in caps["allowed_instruments"]
), ),
max_trades_per_day=int(caps["max_trades_per_day"]), 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( universe_constraint = UniverseConstraint(
asset_classes=tuple(AssetClass(value) for value in universe["asset_classes"]), asset_classes=tuple(AssetClass(value) for value in universe["asset_classes"]),
min_market_cap_usd=_opt_float(universe["min_market_cap_usd"]), min_market_cap_usd=_opt_float(universe["min_market_cap_usd"]),
min_avg_daily_volume_usd=_opt_float(universe["min_avg_daily_volume_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"]), 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( consent_meta = ConsentMeta(
created_at=str(consent["created_at"]), created_at=str(consent["created_at"]),
@@ -108,6 +116,12 @@ def _parse_mandate(raw: object) -> Mandate:
account_ref=str(consent["account_ref"]), account_ref=str(consent["account_ref"]),
expires_at=str(consent["expires_at"]), 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( return Mandate(
schema_version=int(raw["schema_version"]), schema_version=int(raw["schema_version"]),
hard_caps=hard_caps, 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 # old mandate.json → False (cancel-only, the safe default), keeping the
# read backward-compatible. Read on a halt trip by src.live.runtime.flatten. # read backward-compatible. Read on a halt trip by src.live.runtime.flatten.
flatten_on_halt=bool(raw.get("flatten_on_halt", False)), flatten_on_halt=bool(raw.get("flatten_on_halt", False)),
mode=mode,
live_unlocked=live_unlocked,
) )
+71
View File
@@ -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",
]
+7
View File
@@ -1 +1,8 @@
"""Persistent cross-session memory system.""" """Persistent cross-session memory system."""
from src.memory.reflector import DecisionMemoryReflector, DecisionReflectionRecord
__all__ = [
"DecisionMemoryReflector",
"DecisionReflectionRecord",
]
+349
View File
@@ -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)
+2 -4
View File
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional
from src.config.accessor import get_env_config from src.config.accessor import get_env_config
from src.providers.content_filter import is_content_filter_triggered 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__) logger = logging.getLogger(__name__)
@@ -342,9 +342,7 @@ class ChatLLM:
""" """
self._llm = build_llm(model_name=model_name) self._llm = build_llm(model_name=model_name)
runtime_cfg = get_env_config().llm runtime_cfg = get_env_config().llm
configured_model = ( configured_model = resolve_thinking_tier(model_name)
model_name or runtime_cfg.langchain_model_name
).strip()
self.model_name = configured_model self.model_name = configured_model
self.runtime_snapshot = LLMRuntimeSnapshot( self.runtime_snapshot = LLMRuntimeSnapshot(
provider=runtime_cfg.langchain_provider.strip().lower() or "openai", provider=runtime_cfg.langchain_provider.strip().lower() or "openai",
+22 -2
View File
@@ -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: def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any:
"""Construct the configured LangChain chat model. """Construct the configured LangChain chat model.
Args: 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. callbacks: Optional LangChain callbacks.
Returns: 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. RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset.
""" """
_sync_provider_env() _sync_provider_env()
name = model_name or get_env_config().llm.langchain_model_name.strip() name = resolve_thinking_tier(model_name)
if not name: if not name:
raise RuntimeError("LANGCHAIN_MODEL_NAME is not set") raise RuntimeError("LANGCHAIN_MODEL_NAME is not set")
temperature = get_env_config().llm.langchain_temperature temperature = get_env_config().llm.langchain_temperature
@@ -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 <id>``
or ``YES <id>`` 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 ``<runtime_root>/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"<tr><td>{html.escape(str(p.get('proposal_id', '')))}</td>"
f"<td>{html.escape(str(p.get('symbol', '')))}</td>"
f"<td>{html.escape(str(p.get('side', '')))}</td>"
f"<td>${float(p.get('notional_usd', 0.0)):,.2f}</td>"
f"<td>{html.escape(str(p.get('status', 'pending')))}</td></tr>"
for p in pending_proposals
) or "<tr><td colspan='5'>No pending proposals</td></tr>"
fills_rows_html = "".join(
f"<tr><td>{html.escape(str(f.get('fill_id', '')))}</td>"
f"<td>{html.escape(str(f.get('proposal_id', '')))}</td>"
f"<td>{html.escape(str(f.get('symbol', '')))}</td>"
f"<td>{html.escape(str(f.get('side', '')))}</td>"
f"<td>{float(f.get('quantity', 0.0)):.4f}</td>"
f"<td>${float(f.get('fill_price', 0.0)):,.2f}</td>"
f"<td>${float(f.get('notional_usd', 0.0)):,.2f}</td></tr>"
for f in approved_fills
) or "<tr><td colspan='7'>No WhatsApp-approved fills recorded</td></tr>"
pos_rows_html = "".join(
f"<tr><td>{html.escape(str(pos.get('symbol', '')))}</td>"
f"<td>{float(pos.get('quantity', 0.0)):.4f}</td>"
f"<td>${float(pos.get('avg_entry_price', pos.get('avg_price', 0.0))):,.2f}</td>"
f"<td>${float(pos.get('market_value_usd', pos.get('notional_usd', 0.0))):,.2f}</td></tr>"
for pos in open_positions
) or "<tr><td colspan='4'>No open shadow positions</td></tr>"
lessons_escaped = html.escape(memory_lessons or "No settled post-holding-period alpha lessons yet.")
html_doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Vibe-Trading Daily Paper Digest ({html.escape(run_id)})</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 24px; color: #1a1a1a; }}
h1, h2 {{ color: #0f172a; }}
.meta {{ background: #f8fafc; border: 1px solid #cbd5e1; padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; font-size: 13px; }}
th, td {{ border: 1px solid #cbd5e1; padding: 8px 10px; text-align: left; }}
th {{ background: #f1f5f9; }}
pre {{ background: #f8fafc; border: 1px solid #cbd5e1; padding: 12px; border-radius: 6px; white-space: pre-wrap; }}
</style>
</head>
<body>
<h1>Vibe-Trading Daily Paper Digest</h1>
<div class="meta">
<p><strong>Run ID:</strong> {html.escape(run_id)}</p>
<p><strong>Timestamp (Asia/Hong_Kong):</strong> {html.escape(timestamp_hkt)}</p>
<p><strong>Execution Mode:</strong> {html.escape(status_badge)}</p>
<p><strong>Daily Realized PnL (USD):</strong> ${daily_pnl_usd:+,.2f} |
<strong>Daily Loss Usage:</strong> ${daily_loss_usd:,.2f} / {html.escape(cap_str)}</p>
</div>
<h2>Pending WhatsApp Proposals (Awaiting YES / APPROVE)</h2>
<table>
<thead><tr><th>Proposal ID</th><th>Symbol</th><th>Side</th><th>Notional (USD)</th><th>Status</th></tr></thead>
<tbody>{pending_rows_html}</tbody>
</table>
<h2>WhatsApp-Approved Paper Fills</h2>
<table>
<thead><tr><th>Fill ID</th><th>Proposal ID</th><th>Symbol</th><th>Side</th><th>Qty</th><th>Fill Price</th><th>Notional (USD)</th></tr></thead>
<tbody>{fills_rows_html}</tbody>
</table>
<h2>Open Shadow Account Positions</h2>
<table>
<thead><tr><th>Symbol</th><th>Qty</th><th>Avg Price</th><th>Notional (USD)</th></tr></thead>
<tbody>{pos_rows_html}</tbody>
</table>
<h2>Memory / Reflector Lessons (Post-Holding-Period Alpha)</h2>
<pre>{lessons_escaped}</pre>
</body>
</html>
"""
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", "<br/>")
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 <id>`` or ``YES <id>``.
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",
]
+530
View File
@@ -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 ``<runtime_root>/paper`` directory path."""
return get_runtime_root() / "paper"
def paper_fills_path() -> Path:
"""Return ``<runtime_root>/paper/fills.jsonl`` path."""
return paper_root() / "fills.jsonl"
def paper_positions_path() -> Path:
"""Return ``<runtime_root>/paper/positions.json`` path."""
return paper_root() / "positions.json"
def paper_daily_counter_path() -> Path:
"""Return ``<runtime_root>/paper/daily_counter.json`` path."""
return paper_root() / "daily_counter.json"
def paper_daily_pnl_path() -> Path:
"""Return ``<runtime_root>/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 ``<runtime_root>/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
+12 -1
View File
@@ -336,6 +336,17 @@ def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmR
status=status, 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 # Generate run ID
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d-%H%M%S") 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, id=run_id,
preset_name=preset_name, preset_name=preset_name,
status=RunStatus.pending, status=RunStatus.pending,
user_vars=user_vars, user_vars=effective_vars,
agents=agents, agents=agents,
tasks=tasks, tasks=tasks,
created_at=now.isoformat(), created_at=now.isoformat(),
+350
View File
@@ -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."
+297
View File
@@ -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})
@@ -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()
+769
View File
@@ -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 <proposal_id> 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()
@@ -2,6 +2,10 @@ import { useTranslation } from "react-i18next";
import { memo } from "react"; import { memo } from "react";
import { Clock, Users } from "lucide-react"; import { Clock, Users } from "lucide-react";
import { SwarmDashboard } from "@/components/chat/SwarmDashboard"; import { SwarmDashboard } from "@/components/chat/SwarmDashboard";
import {
TradingAgentsPipelineCard,
isTradingAgentsSwarm,
} from "@/components/chat/TradingAgentsPipelineCard";
import { import {
localizePreset, localizePreset,
localizeStatus, localizeStatus,
@@ -132,6 +136,7 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
const total = status.agents.length; const total = status.agents.length;
const hasAgentTotal = total > 0; const hasAgentTotal = total > 0;
const isActive = status.status === "pending" || status.status === "running"; const isActive = status.status === "pending" || status.status === "running";
const showTradingAgentsPipeline = isTradingAgentsSwarm(status);
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
@@ -177,6 +182,10 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
</div> </div>
)} )}
{showTradingAgentsPipeline && (
<TradingAgentsPipelineCard status={status} />
)}
<SwarmDashboard <SwarmDashboard
agents={status.agents} agents={status.agents}
emptyStatus={localizeStatus(status.status, t)} emptyStatus={localizeStatus(status.status, t)}
@@ -0,0 +1,778 @@
import { memo } from "react";
import {
Activity,
AlertTriangle,
Brain,
CheckCircle2,
Clock,
Lock,
MessageSquare,
Scale,
ShieldCheck,
Swords,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { SwarmRunStatus } from "@/types/agent";
import { cn } from "@/lib/utils";
type SwarmAgentRow = SwarmRunStatus["agents"][number];
export type ModelTier = "quick_think" | "deep_think";
export interface TradingAgentRoleMeta {
agentId: string;
title: string;
shortLabel: string;
stage: 1 | 2 | 3 | 4 | 5;
tier: ModelTier;
domain: string;
}
export const TRADING_AGENTS_ROLES: readonly TradingAgentRoleMeta[] = [
// Stage 1: Analyst Team (quick_think)
{
agentId: "market_analyst",
title: "Market & Technical Analyst",
shortLabel: "Market / Tech",
stage: 1,
tier: "quick_think",
domain: "US / HK / Crypto OHLCV & Indicators",
},
{
agentId: "fundamentals_analyst",
title: "Fundamentals & Valuation Analyst",
shortLabel: "Fundamentals",
stage: 1,
tier: "quick_think",
domain: "Filings, Valuation & Tokenomics",
},
{
agentId: "news_analyst",
title: "News & Macro Catalyst Analyst",
shortLabel: "News & Macro",
stage: 1,
tier: "quick_think",
domain: "Catalysts & Regulatory Events",
},
{
agentId: "sentiment_analyst",
title: "Sentiment & Flow Analyst",
shortLabel: "Sentiment & Flow",
stage: 1,
tier: "quick_think",
domain: "Crowd Regime & Positioning",
},
// Stage 2: Bull vs. Bear Researchers + Research Manager
{
agentId: "bull_researcher",
title: "Bull-Side Debate Researcher",
shortLabel: "Bull Researcher",
stage: 2,
tier: "quick_think",
domain: "Constructs Grounded Bull Case",
},
{
agentId: "bear_researcher",
title: "Bear-Side Debate Researcher",
shortLabel: "Bear Researcher",
stage: 2,
tier: "quick_think",
domain: "Constructs Grounded Bear Case",
},
{
agentId: "research_manager",
title: "Research Manager (Debate Judge)",
shortLabel: "Research Manager",
stage: 2,
tier: "deep_think",
domain: "Adjudicates Bull vs. Bear Debate",
},
// Stage 3: Trader
{
agentId: "trader_agent",
title: "Senior Execution Trader",
shortLabel: "Trader Agent",
stage: 3,
tier: "deep_think",
domain: "Concrete Entry, Stop-Loss & Sizing",
},
// Stage 4: 3-Way Risk Debate
{
agentId: "aggressive_risk_debater",
title: "Aggressive Risk Debater",
shortLabel: "Aggressive Risk",
stage: 4,
tier: "quick_think",
domain: "Upside Asymmetry & Conviction",
},
{
agentId: "conservative_risk_debater",
title: "Conservative Risk Debater",
shortLabel: "Conservative Risk",
stage: 4,
tier: "quick_think",
domain: "Drawdown Cap & Tail-Risk Guard",
},
{
agentId: "neutral_risk_debater",
title: "Neutral Risk Debater",
shortLabel: "Neutral Risk",
stage: 4,
tier: "quick_think",
domain: "Risk-Adjusted Expected Value",
},
// Stage 5: Portfolio Manager
{
agentId: "portfolio_manager",
title: "Portfolio Manager & Final Risk Judge",
shortLabel: "Portfolio Manager",
stage: 5,
tier: "deep_think",
domain: "Mandate Check & Paper TradeProposal",
},
] as const;
const TRADING_AGENTS_ID_SET = new Set<string>([
...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<string, SwarmAgentRow>,
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<string, unknown>;
const raw =
parsed.trade_proposal && typeof parsed.trade_proposal === "object"
? (parsed.trade_proposal as Record<string, unknown>)
: 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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[10px] font-medium",
isDeep
? "border border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400"
: "border border-sky-500/30 bg-sky-500/10 text-sky-600 dark:text-sky-400",
)}
>
<Brain className="h-2.5 w-2.5" />
{tier}
</span>
);
}
function deriveStageState(
slots: readonly (readonly string[])[],
agentMap: Map<string, SwarmAgentRow>,
): "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<string, SwarmAgentRow>(
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 (
<div
className="mt-3 space-y-3 rounded-xl border border-border/80 bg-muted/20 p-3"
data-testid="trading-agents-pipeline"
>
{/* Top Pipeline Header */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 pb-2.5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
<Swords className="h-3.5 w-3.5" />
TradingAgents v0.6.0 Decision DAG
</span>
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 font-mono text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
<Lock className="h-2.5 w-2.5" />
PAPER ONLY · SHADOW LEDGER
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
<span>Grounding + Mandate Gate Active</span>
</div>
</div>
{/* 5-Stage DAG Stepper */}
<div
className="grid gap-2 sm:grid-cols-5"
aria-label="TradingAgents 5-stage pipeline"
>
{STAGE_DEFINITIONS.map((stageDef) => {
const state = deriveStageState(stageDef.slots, agentMap);
const doneCount = stageDef.slots.filter(
(aliases) =>
getFirstMatchingAgent(agentMap, aliases)?.status === "done",
).length;
return (
<div
key={stageDef.stage}
data-stage={stageDef.stage}
data-stage-state={state}
className={cn(
"rounded-lg border p-2 text-xs transition-colors",
state === "done" &&
"border-emerald-500/40 bg-emerald-500/5 text-foreground",
state === "running" &&
"border-primary/50 bg-primary/5 text-foreground",
state === "failed" &&
"border-destructive/50 bg-destructive/5 text-destructive",
state === "waiting" &&
"border-border/60 bg-background/60 text-muted-foreground",
)}
>
<div className="flex items-center justify-between gap-1">
<span className="font-semibold truncate">{stageDef.name}</span>
{state === "done" ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
) : state === "running" ? (
<Activity className="h-3.5 w-3.5 shrink-0 text-primary" />
) : state === "failed" ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
)}
</div>
<div className="mt-1.5 flex items-center justify-between gap-1">
<TierBadge tier={stageDef.tier} />
<span className="font-mono text-[10px] text-muted-foreground">
{doneCount}/{stageDef.slots.length}
</span>
</div>
</div>
);
})}
</div>
{/* Stage 2: Bull vs. Bear Debate Arena */}
{(bullRow || bearRow || managerRow) && (
<div className="rounded-lg border border-border/70 bg-background p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Swords className="h-3.5 w-3.5 text-primary" />
<span>Bull vs. Bear Researcher Debate Arena</span>
</div>
<TierBadge tier="deep_think" />
</div>
<div className="grid gap-2.5 md:grid-cols-2">
{/* Bull Researcher Card */}
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<TrendingUp className="h-3.5 w-3.5" />
Bull Researcher
</span>
<span className="font-mono text-[10px] uppercase text-muted-foreground">
{bullRow?.status ?? "waiting"}
</span>
</div>
<p className="mt-1.5 line-clamp-3 text-xs text-foreground/90">
{bullRow?.lastText ||
bullRow?.error ||
"Gathering grounded upside catalysts and technical momentum signals…"}
</p>
</div>
{/* Bear Researcher Card */}
<div className="rounded-lg border border-rose-500/30 bg-rose-500/5 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-xs font-semibold text-rose-600 dark:text-rose-400">
<TrendingDown className="h-3.5 w-3.5" />
Bear Researcher
</span>
<span className="font-mono text-[10px] uppercase text-muted-foreground">
{bearRow?.status ?? "waiting"}
</span>
</div>
<p className="mt-1.5 line-clamp-3 text-xs text-foreground/90">
{bearRow?.lastText ||
bearRow?.error ||
"Stress-testing valuation multiples, macro headwinds, and downside levels…"}
</p>
</div>
</div>
{/* Research Manager + Trader Synthesis */}
{(managerRow?.lastText || traderRow?.lastText) && (
<div className="mt-2.5 grid gap-2 border-t border-border/60 pt-2.5 md:grid-cols-2">
{managerRow?.lastText && (
<div className="rounded-md bg-muted/40 p-2 text-xs">
<div className="font-semibold text-foreground">
Research Manager Verdict
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">
{managerRow.lastText}
</p>
</div>
)}
{traderRow?.lastText && (
<div className="rounded-md bg-muted/40 p-2 text-xs">
<div className="font-semibold text-foreground">
Senior Execution Trader Plan
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">
{traderRow.lastText}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Stage 4: 3-Way Risk Committee Matrix */}
{(aggressiveRow || conservativeRow || neutralRow) && (
<div className="rounded-lg border border-border/70 bg-background p-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Scale className="h-3.5 w-3.5 text-primary" />
<span>3-Way Risk Committee Deliberation</span>
</div>
<span className="font-mono text-[10px] text-muted-foreground">
Max Single Order Cap: $10,000 · Leverage: 1.0×
</span>
</div>
<div className="grid gap-2 md:grid-cols-3">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-xs">
<div className="flex items-center justify-between">
<span className="font-semibold text-amber-600 dark:text-amber-400">
Aggressive Risk
</span>
<span className="font-mono text-[10px] uppercase text-muted-foreground">
{aggressiveRow?.status ?? "waiting"}
</span>
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">
{aggressiveRow?.lastText ||
"Evaluating upside asymmetry and conviction sizing…"}
</p>
</div>
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-2 text-xs">
<div className="flex items-center justify-between">
<span className="font-semibold text-sky-600 dark:text-sky-400">
Conservative Risk
</span>
<span className="font-mono text-[10px] uppercase text-muted-foreground">
{conservativeRow?.status ?? "waiting"}
</span>
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">
{conservativeRow?.lastText ||
"Enforcing drawdown stop-loss and tail-risk limits…"}
</p>
</div>
<div className="rounded-md border border-purple-500/30 bg-purple-500/5 p-2 text-xs">
<div className="flex items-center justify-between">
<span className="font-semibold text-purple-600 dark:text-purple-400">
Neutral Risk
</span>
<span className="font-mono text-[10px] uppercase text-muted-foreground">
{neutralRow?.status ?? "waiting"}
</span>
</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">
{neutralRow?.lastText ||
"Balancing risk-adjusted expected value…"}
</p>
</div>
</div>
</div>
)}
{/* Stage 5: Grounded Paper TradeProposal Ticket */}
{proposal ? (
<div
className="rounded-xl border-2 border-primary/40 bg-background p-3.5 shadow-sm"
data-testid="grounded-trade-proposal-ticket"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 pb-2.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-bold text-foreground">
{proposal.symbol}
</span>
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase text-muted-foreground">
{proposal.market}
</span>
<span
className={cn(
"rounded px-2 py-0.5 font-mono text-xs font-bold uppercase",
proposal.side === "BUY" &&
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
proposal.side === "SELL" &&
"bg-rose-500/15 text-rose-600 dark:text-rose-400",
proposal.side !== "BUY" &&
proposal.side !== "SELL" &&
"bg-amber-500/15 text-amber-600 dark:text-amber-400",
)}
>
{proposal.side}
</span>
<span className="rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 font-mono text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
MODE: {proposal.mode.toUpperCase()}
</span>
{proposal.proposal_id && (
<span className="rounded bg-muted/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
id: {proposal.proposal_id}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-4 w-4" />
<span>Grounded Proposal Verified</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2.5 sm:grid-cols-3 md:grid-cols-6">
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Quantity
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-foreground">
{proposal.quantity}
</div>
</div>
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Ref Price
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-foreground">
{formatPrice(proposal.reference_price)}
</div>
</div>
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Notional USD
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-foreground">
{formatPrice(proposal.notional_usd)}
</div>
</div>
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Stop Loss
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-rose-600 dark:text-rose-400">
{formatPrice(proposal.stop_loss)}
</div>
</div>
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Take Profit
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-emerald-600 dark:text-emerald-400">
{formatPrice(proposal.take_profit)}
</div>
</div>
<div className="rounded-lg bg-muted/40 p-2">
<div className="text-[10px] uppercase text-muted-foreground">
Confidence
</div>
<div className="mt-0.5 font-mono text-xs font-semibold text-foreground">
{proposal.confidence != null
? `${Math.round(proposal.confidence * 100)}%`
: "—"}
</div>
</div>
</div>
<p className="mt-2.5 text-xs leading-relaxed text-muted-foreground">
{proposal.rationale}
</p>
{proposal.tool_trace_ids && proposal.tool_trace_ids.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="font-semibold uppercase">Tool Traces:</span>
{proposal.tool_trace_ids.map((traceId) => (
<code
key={traceId}
className="rounded border border-border/60 bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground/90"
>
{traceId}
</code>
))}
</div>
)}
{/* Governance & WhatsApp Approval Footer */}
<div className="mt-3 space-y-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-[11px]">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
Grounding Gate: ±2% Price / ±$1.00 Notional OK
</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5 text-primary" />
WhatsApp Human Approval Required Before Paper Fill
</span>
</div>
<span className="font-mono text-[10px] text-muted-foreground">
run_id: {status.runId}
</span>
</div>
<div
className="flex flex-wrap items-center gap-1.5 border-t border-border/50 pt-1.5 font-mono text-[10px] text-muted-foreground"
data-testid="whatsapp-approval-commands"
>
<span className="font-sans font-semibold text-foreground/80">
WhatsApp Commands:
</span>
<code className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-600 dark:text-emerald-400">
APPROVE {proposal.proposal_id || "<id>"}
</code>
<code className="rounded bg-rose-500/10 px-1.5 py-0.5 text-rose-600 dark:text-rose-400">
REJECT {proposal.proposal_id || "<id>"} &lt;reason&gt;
</code>
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground/80">
STATUS {proposal.proposal_id || "<id>"}
</code>
<code className="rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-600 dark:text-amber-400">
HALT &lt;reason&gt;
</code>
</div>
</div>
</div>
) : pmRow ? (
<div className="flex items-center justify-between rounded-lg border border-dashed border-border/70 bg-background/60 px-3 py-2 text-xs text-muted-foreground">
<span>
Portfolio Manager ({pmRow.status}) — awaiting grounded{" "}
<code className="font-mono">trade_proposal</code> JSON emission…
</span>
<TierBadge tier="deep_think" />
</div>
) : null}
</div>
);
},
);
@@ -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> = {},
): 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(<SwarmStatusCard status={status} />);
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(<SwarmStatusCard status={aliasStatus} />);
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.");
});
});
@@ -14,6 +14,7 @@ import i18n from "@/i18n";
import { toast } from "sonner"; import { toast } from "sonner";
import { SidebarNavigation } from "./SidebarNavigation"; import { SidebarNavigation } from "./SidebarNavigation";
import { PageHelp } from "./PageHelp"; import { PageHelp } from "./PageHelp";
import { SafetyTelemetryBar } from "./SafetyTelemetryBar";
// APP_VERSION is sourced from i18n locale files (app.version key) to keep a // 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. // single source of truth across the footer and every localised README.
@@ -368,6 +369,7 @@ export function Layout() {
<Link to="/settings" className="text-primary underline">{t("layout.settings")}</Link> <Link to="/settings" className="text-primary underline">{t("layout.settings")}</Link>
</div> </div>
) : <ConnectionBanner status={sseStatus} retryAttempt={sseRetryAttempt} />} ) : <ConnectionBanner status={sseStatus} retryAttempt={sseRetryAttempt} />}
<SafetyTelemetryBar />
<main id="main" className="flex-1 min-h-0 overflow-auto"> <main id="main" className="flex-1 min-h-0 overflow-auto">
<PageHelp /> <PageHelp />
<Outlet /> <Outlet />
@@ -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 (
<div
data-testid="safety-telemetry-bar"
className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 bg-card/80 px-4 py-1.5 text-[11px] backdrop-blur-sm max-sm:hidden"
>
{/* Left: Paper-Only Mandate Lock + Grounding Gate */}
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 rounded border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 font-mono text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
<Lock className="h-2.5 w-2.5" />
PAPER ONLY · LIVE LOCKED
</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<ShieldCheck className="h-3 w-3 text-emerald-500" />
<span>Mandate Cap: $10,000/order · 1.0×</span>
</span>
<span className="text-border" aria-hidden="true">
|
</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Swords className="h-3 w-3 text-primary" />
<span>TradingAgents DAG: quick_think + deep_think</span>
</span>
</div>
{/* Right: Multi-Market Clocks (US / HK / Crypto) + WhatsApp Gate */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
{markets.map((m) => {
const isActive = m.state === "OPEN" || m.state === "24/7";
return (
<span
key={m.market}
className="inline-flex items-center gap-1 rounded border border-border/70 bg-background/80 px-1.5 py-0.5 font-mono text-[10px]"
>
<span
className={
isActive
? "h-1.5 w-1.5 rounded-full bg-emerald-500"
: "h-1.5 w-1.5 rounded-full bg-muted-foreground/50"
}
aria-hidden="true"
/>
<span className="font-semibold text-foreground">{m.market}</span>
<span className="text-muted-foreground">{m.state}</span>
</span>
);
})}
</div>
<span className="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-2 py-0.5 font-mono text-[10px] font-medium text-primary">
<MessageSquare className="h-2.5 w-2.5" />
WhatsApp Gate: Approve-Before-Fill
</span>
</div>
</div>
);
});
+21
View File
@@ -187,6 +187,27 @@
50% { opacity: 1; transform: translateY(0); } 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 */ /* Message/hero entrance — one subtle rise per mount; removed under reduced motion */
@keyframes msg-enter { @keyframes msg-enter {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
+9 -3
View File
@@ -22,6 +22,10 @@ const PRESET_WELCOME_KEYS: Record<string, string> = {
value_investing_committee: "welcome.examples.valueCommittee", value_investing_committee: "welcome.examples.valueCommittee",
}; };
const PRESET_DEFAULT_LABELS: Record<string, string> = {
trading_agents: "TradingAgents Decision Pipeline",
};
export function localizeStatus( export function localizeStatus(
status: keyof typeof STATUS_TRANSLATION_KEYS, status: keyof typeof STATUS_TRANSLATION_KEYS,
t: ManifestTranslation, t: ManifestTranslation,
@@ -33,9 +37,11 @@ export function localizePreset(
preset: string, preset: string,
t: ManifestTranslation, t: ManifestTranslation,
): string { ): string {
const humanized = preset const humanized =
.replace(/_/g, " ") PRESET_DEFAULT_LABELS[preset] ??
.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase()); preset
.replace(/_/g, " ")
.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase());
const welcomeKey = PRESET_WELCOME_KEYS[preset]; const welcomeKey = PRESET_WELCOME_KEYS[preset];
const fallback = welcomeKey const fallback = welcomeKey
? t(welcomeKey, { defaultValue: humanized }) ? t(welcomeKey, { defaultValue: humanized })
+818 -84
View File
@@ -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 { 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 { api, type OfficeSnapshot } from "@/lib/api";
import { cn } from "@/lib/utils"; 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<SimulationScenario, { zone: SpatialZoneId; bubble: string; active: boolean }>;
}
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() { export function PixelOffice() {
const { t } = useTranslation(); const { t } = useTranslation();
const [snapshot, setSnapshot] = useState<OfficeSnapshot | null>(null); const [snapshot, setSnapshot] = useState<OfficeSnapshot | null>(null);
@@ -13,6 +319,9 @@ export function PixelOffice() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [priority, setPriority] = useState("normal"); const [priority, setPriority] = useState("normal");
const [results, setResults] = useState<Record<string, string>>({}); const [results, setResults] = useState<Record<string, string>>({});
const [scenario, setScenario] = useState<SimulationScenario>("live_pipeline");
const [selectedSpriteId, setSelectedSpriteId] = useState<string>("portfolio_manager");
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setPending(true); setPending(true);
try { try {
@@ -20,103 +329,528 @@ export function PixelOffice() {
setSnapshot(value); setSnapshot(value);
setAgentId((current) => current || value.agents[0]?.id || ""); setAgentId((current) => current || value.agents[0]?.id || "");
setError(""); setError("");
} catch (err) { setError(err instanceof Error ? err.message : String(err)); } } catch (err) {
finally { setPending(false); } 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<string, unknown>) { useEffect(() => {
void refresh();
}, [refresh]);
async function mutate(
action: "create" | "assign" | "complete" | "rank",
arguments_: Record<string, unknown>,
) {
setPending(true); setPending(true);
try { try {
setSnapshot(await api.updatePixelOffice(action, arguments_)); setSnapshot(await api.updatePixelOffice(action, arguments_));
setError(""); setError("");
return true; return true;
} catch (err) { setError(err instanceof Error ? err.message : String(err)); return false; } } catch (err) {
finally { setPending(false); } setError(err instanceof Error ? err.message : String(err));
} return false;
async function create(event: FormEvent) { } finally {
event.preventDefault(); setPending(false);
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";
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 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 ( return (
<main className="h-full overflow-y-auto bg-background px-4 py-5 sm:px-6"> <main className="h-full overflow-y-auto bg-background px-4 py-5 sm:px-6">
<div className="mx-auto max-w-7xl space-y-5"> <div className="mx-auto max-w-7xl space-y-5">
<header className="flex items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div><h1 className="text-2xl font-semibold">{t("pixelOffice.title")}</h1> <div>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{t("pixelOffice.description")}</p></div> <div className="mb-1.5 flex flex-wrap items-center gap-2">
<button type="button" disabled={pending} onClick={() => void refresh()} className="rounded-md border px-3 py-2 text-sm disabled:opacity-50">{t("pixelOffice.refresh")}</button> <span className="inline-flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 font-mono text-[11px] font-semibold text-emerald-600 dark:text-emerald-400">
</header> <Lock className="h-3 w-3" />
{error && <p role="alert" className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive">{error}</p>} PAPER ONLY · SHADOW LEDGER
{!snapshot && <p role="status">{t(pending ? "pixelOffice.loading" : "pixelOffice.unavailable")}</p>} </span>
{snapshot && <> <span className="inline-flex items-center gap-1 rounded-md border border-border/70 bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground">
<section className="pixel-office-floor rounded-xl border border-border p-4 sm:p-6" aria-label={t("pixelOffice.floor")}> <ShieldCheck className="h-3 w-3 text-primary" />
<h2 className="mb-4 font-semibold">{t("pixelOffice.floor")}</h2> Star-Office-UI × TradingAgents v0.6.0
<div className="grid gap-4 md:grid-cols-3">{agents.map((agent) => ( </span>
<button type="button" key={agent.id} onClick={() => setAgentId(agent.id)} </div>
className={cn("pixel-desk rounded-lg border p-4 text-start", agentId === agent.id ? "border-primary ring-2 ring-primary/20" : "border-border/70")}> <h1 className="text-2xl font-semibold">{t("pixelOffice.title")}</h1>
<div className="flex items-start justify-between gap-3"> <p className="mt-2 max-w-3xl text-sm text-muted-foreground">
<div className="pixel-character" style={{ "--pixel-color": agent.color } as CSSProperties} aria-hidden="true"> {t("pixelOffice.description")}
<div className="pixel-character__shadow" /><div className="pixel-character__body"> </p>
<span className="pixel-character__head" /><span className="pixel-character__torso" />
<span className="pixel-character__arm pixel-character__arm--left" /><span className="pixel-character__arm pixel-character__arm--right" />
<span className="pixel-character__leg pixel-character__leg--left" /><span className="pixel-character__leg pixel-character__leg--right" />
</div>
</div>
<span className="rounded-full bg-background/70 px-2 py-1 text-xs">{t("pixelOffice.taskCount", { count: agent.load })}</span>
</div>
<p className="mt-3 font-semibold">{agent.name}</p><p className="text-xs text-muted-foreground">{t(`pixelOffice.ranks.${agent.rank}`)}</p>
</button>
))}</div>
</section>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="mb-4 font-semibold">{t("pixelOffice.board")}</h2>
{!snapshot.tasks.length && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t("pixelOffice.empty")}</p>}
<div className="space-y-3">{snapshot.tasks.map((task) => (
<article key={task.id} className="rounded-lg border border-border/70 p-3">
<div className="flex flex-wrap justify-between gap-2"><h3 className="break-words font-medium">{task.title}</h3><span className="text-xs text-muted-foreground">{t(`pixelOffice.priorities.${task.priority}`)} · {t(`pixelOffice.statuses.${task.status}`)}</span></div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-muted-foreground">{task.description}</p>
<p className="mt-2 text-xs text-primary">{agentName(task.assigned_to)}</p>
{task.status === "completed" ? <p className="mt-2 whitespace-pre-wrap break-words text-sm">{task.result}</p> : (
<fieldset disabled={pending} className="mt-3 space-y-2 disabled:opacity-50">
<label className="block text-xs">{t("pixelOffice.assignee")}
<select className={input} value={task.assigned_to ?? ""} onChange={(event) => void mutate("assign", { task_id: task.id, agent_id: event.target.value })}>
<option value="" disabled>{t("pixelOffice.unassigned")}</option>{agents.map((agent) => <option value={agent.id} key={agent.id}>{agent.name}</option>)}
</select>
</label>
<form onSubmit={(event) => { event.preventDefault(); void mutate("complete", { task_id: task.id, result: results[task.id] ?? "" }); }}>
<label className="block text-xs">{t("pixelOffice.result")}
<textarea className={input} rows={2} required maxLength={2000} value={results[task.id] ?? ""} onChange={(event) => setResults((current) => ({ ...current, [task.id]: event.target.value }))} />
</label>
<button type="submit" disabled={!results[task.id]?.trim()} className="mt-2 rounded-md border px-3 py-1.5 text-xs disabled:opacity-50">{t("pixelOffice.complete")}</button>
</form>
</fieldset>
)}
</article>
))}</div>
</section>
<section className="h-fit rounded-xl border border-border bg-card p-4">
<h2 className="mb-4 font-semibold">{t("pixelOffice.create")}</h2>
<form onSubmit={(event) => void create(event)}><fieldset disabled={pending} className="space-y-3 disabled:opacity-50">
<label className="block text-xs">{t("pixelOffice.assignee")}<select className={input} value={agentId} onChange={(event) => setAgentId(event.target.value)}><option value="">{t("pixelOffice.unassigned")}</option>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select></label>
<label className="block text-xs">{t("pixelOffice.taskTitle")}<input className={input} required maxLength={200} value={title} onChange={(event) => setTitle(event.target.value)} /></label>
<label className="block text-xs">{t("pixelOffice.instructions")}<textarea className={input} rows={3} maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} /></label>
<label className="block text-xs">{t("pixelOffice.priority")}<select className={input} value={priority} onChange={(event) => setPriority(event.target.value)}>{(["low", "normal", "high"] as const).map((value) => <option key={value} value={value}>{t(`pixelOffice.priorities.${value}`)}</option>)}</select></label>
<button type="submit" disabled={!title.trim()} className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-50">{t("pixelOffice.create")}</button>
</fieldset></form>
</section>
</div> </div>
<section className="rounded-xl border border-border bg-card p-4"><h2 className="mb-3 font-semibold">{t("pixelOffice.ranksTitle")}</h2> <button
<div className="grid gap-3 md:grid-cols-3">{agents.map((agent) => <label key={agent.id} className="block text-sm">{agent.name} type="button"
<select className={input} disabled={pending} value={agent.rank} onChange={(event) => void mutate("rank", { agent_id: agent.id, rank: event.target.value })}>{(["Operator", "Senior", "Lead"] as const).map((rank) => <option key={rank} value={rank}>{t(`pixelOffice.ranks.${rank}`)}</option>)}</select> disabled={pending}
</label>)}</div> onClick={() => void refresh()}
</section> className="rounded-md border px-3 py-2 text-sm disabled:opacity-50"
</>} >
{t("pixelOffice.refresh")}
</button>
</header>
{error && (
<p
role="alert"
className="rounded-lg border border-destructive/30 p-3 text-sm text-destructive"
>
{error}
</p>
)}
{!snapshot && (
<p role="status">
{t(pending ? "pixelOffice.loading" : "pixelOffice.unavailable")}
</p>
)}
{snapshot && (
<>
{/* Star-Office-UI × TradingAgents 6-Zone Spatial Trading Floor */}
<section
className="pixel-office-floor rounded-xl border border-border p-4 sm:p-6"
aria-label="TradingAgents spatial floor"
>
<div className="flex flex-col gap-3 border-b border-border/60 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold">
Star-Office-UI Spatial Trading Floor (12-Agent TradingAgents DAG)
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Agents physically relocate across zones as the swarm progresses from
market data grounding to Bull/Bear debate, risk deliberation, and
WhatsApp paper trade approval.
</p>
</div>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label="Swarm stage view"
>
{SCENARIO_BUTTONS.map((btn) => (
<button
key={btn.id}
type="button"
onClick={() => setScenario(btn.id)}
aria-pressed={scenario === btn.id}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
scenario === btn.id
? "border-primary bg-primary text-primary-foreground"
: "border-border/70 bg-background/80 text-muted-foreground hover:text-foreground",
)}
>
{btn.label}
</button>
))}
</div>
</div>
{/* 6 Functional Zones Grid */}
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{SPATIAL_ZONES.map((zone) => {
const ZoneIcon = zone.icon;
const zoneSprites = TRADING_FLOOR_SPRITES.filter(
(s) => s.speechByScenario[scenario].zone === zone.id,
);
return (
<div
key={zone.id}
data-zone-id={zone.id}
className={cn(
"flex flex-col justify-between rounded-xl border p-3.5 transition-colors",
zone.accentClass,
)}
>
<div>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<ZoneIcon className="h-4 w-4 shrink-0 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{zone.title}
</h3>
</div>
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-muted-foreground">
{zone.badge}
</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
{zone.subtitle}
</p>
<div className="mt-3 grid grid-cols-2 gap-2.5">
{zoneSprites.map((sprite) => {
const state = sprite.speechByScenario[scenario];
const isSelected = selectedSprite.id === sprite.id;
return (
<button
key={sprite.id}
type="button"
onClick={() => setSelectedSpriteId(sprite.id)}
className={cn(
"pixel-desk flex flex-col items-start rounded-lg border p-2.5 text-start transition-all",
isSelected
? "border-primary ring-2 ring-primary/20"
: "border-border/60 hover:border-primary/40",
)}
>
<div className="pixel-speech-bubble mb-2 w-full rounded px-2 py-1 font-mono text-[10px] leading-tight text-foreground">
{state.bubble}
</div>
<div className="flex w-full items-center justify-between gap-2">
<div
className={cn(
"pixel-character",
state.active && "pixel-character--active",
)}
style={
{
"--pixel-color": sprite.color,
} as CSSProperties
}
aria-hidden="true"
>
<div className="pixel-character__shadow" />
<div className="pixel-character__body">
<span className="pixel-character__head" />
<span className="pixel-character__torso" />
<span className="pixel-character__arm pixel-character__arm--left" />
<span className="pixel-character__arm pixel-character__arm--right" />
<span className="pixel-character__leg pixel-character__leg--left" />
<span className="pixel-character__leg pixel-character__leg--right" />
</div>
{state.active && (
<span className="pixel-character__typing">
...
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-foreground">
{sprite.label}
</div>
<span className="mt-0.5 inline-flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground">
<Brain className="h-2.5 w-2.5" />
{sprite.tier}
</span>
</div>
</div>
</button>
);
})}
{zoneSprites.length === 0 && (
<div className="col-span-2 rounded-lg border border-dashed border-border/60 bg-background/40 p-3 text-center text-xs text-muted-foreground">
Zone clear in this stage
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Selected Sprite Inspector Bar */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-background/90 px-3.5 py-2.5 text-xs">
<div className="flex flex-wrap items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: selectedSprite.color }}
/>
<span className="font-semibold text-foreground">
{selectedSprite.label}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
({selectedSprite.id})
</span>
<span className="rounded bg-primary/10 px-1.5 py-0.5 font-mono text-[10px] font-medium text-primary">
{selectedSprite.tier}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
<span>
Allowed Tools:{" "}
<code className="font-mono text-foreground">
{selectedSprite.tools.join(", ")}
</code>
</span>
<span>·</span>
<span className="font-mono text-foreground">
{selectedSprite.speechByScenario[scenario].bubble}
</span>
</div>
</div>
</section>
{/* Original Office Floor & Manual Planning Desks */}
<section
className="pixel-office-floor rounded-xl border border-border p-4 sm:p-6"
aria-label={t("pixelOffice.floor")}
>
<h2 className="mb-4 font-semibold">{t("pixelOffice.floor")}</h2>
<div className="grid gap-4 md:grid-cols-3">
{agents.map((agent) => (
<button
type="button"
key={agent.id}
onClick={() => setAgentId(agent.id)}
className={cn(
"pixel-desk rounded-lg border p-4 text-start",
agentId === agent.id
? "border-primary ring-2 ring-primary/20"
: "border-border/70",
)}
>
<div className="flex items-start justify-between gap-3">
<div
className="pixel-character"
style={{ "--pixel-color": agent.color } as CSSProperties}
aria-hidden="true"
>
<div className="pixel-character__shadow" />
<div className="pixel-character__body">
<span className="pixel-character__head" />
<span className="pixel-character__torso" />
<span className="pixel-character__arm pixel-character__arm--left" />
<span className="pixel-character__arm pixel-character__arm--right" />
<span className="pixel-character__leg pixel-character__leg--left" />
<span className="pixel-character__leg pixel-character__leg--right" />
</div>
</div>
<span className="rounded-full bg-background/70 px-2 py-1 text-xs">
{t("pixelOffice.taskCount", { count: agent.load })}
</span>
</div>
<p className="mt-3 font-semibold">{agent.name}</p>
<p className="text-xs text-muted-foreground">
{t(`pixelOffice.ranks.${agent.rank}`)}
</p>
</button>
))}
</div>
</section>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="mb-4 font-semibold">{t("pixelOffice.board")}</h2>
{!snapshot.tasks.length && (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t("pixelOffice.empty")}
</p>
)}
<div className="space-y-3">
{snapshot.tasks.map((task) => (
<article
key={task.id}
className="rounded-lg border border-border/70 p-3"
>
<div className="flex flex-wrap justify-between gap-2">
<h3 className="break-words font-medium">{task.title}</h3>
<span className="text-xs text-muted-foreground">
{t(`pixelOffice.priorities.${task.priority}`)} ·{" "}
{t(`pixelOffice.statuses.${task.status}`)}
</span>
</div>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-muted-foreground">
{task.description}
</p>
<p className="mt-2 text-xs text-primary">
{agentName(task.assigned_to)}
</p>
{task.status === "completed" ? (
<p className="mt-2 whitespace-pre-wrap break-words text-sm">
{task.result}
</p>
) : (
<fieldset
disabled={pending}
className="mt-3 space-y-2 disabled:opacity-50"
>
<label className="block text-xs">
{t("pixelOffice.assignee")}
<select
className={input}
value={task.assigned_to ?? ""}
onChange={(event) =>
void mutate("assign", {
task_id: task.id,
agent_id: event.target.value,
})
}
>
<option value="" disabled>
{t("pixelOffice.unassigned")}
</option>
{agents.map((agent) => (
<option value={agent.id} key={agent.id}>
{agent.name}
</option>
))}
</select>
</label>
<form
onSubmit={(event) => {
event.preventDefault();
void mutate("complete", {
task_id: task.id,
result: results[task.id] ?? "",
});
}}
>
<label className="block text-xs">
{t("pixelOffice.result")}
<textarea
className={input}
rows={2}
required
maxLength={2000}
value={results[task.id] ?? ""}
onChange={(event) =>
setResults((current) => ({
...current,
[task.id]: event.target.value,
}))
}
/>
</label>
<button
type="submit"
disabled={!results[task.id]?.trim()}
className="mt-2 rounded-md border px-3 py-1.5 text-xs disabled:opacity-50"
>
{t("pixelOffice.complete")}
</button>
</form>
</fieldset>
)}
</article>
))}
</div>
</section>
<section className="h-fit rounded-xl border border-border bg-card p-4">
<h2 className="mb-4 font-semibold">{t("pixelOffice.create")}</h2>
<form onSubmit={(event) => void create(event)}>
<fieldset
disabled={pending}
className="space-y-3 disabled:opacity-50"
>
<label className="block text-xs">
{t("pixelOffice.assignee")}
<select
className={input}
value={agentId}
onChange={(event) => setAgentId(event.target.value)}
>
<option value="">{t("pixelOffice.unassigned")}</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</label>
<label className="block text-xs">
{t("pixelOffice.taskTitle")}
<input
className={input}
required
maxLength={200}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="block text-xs">
{t("pixelOffice.instructions")}
<textarea
className={input}
rows={3}
maxLength={2000}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<label className="block text-xs">
{t("pixelOffice.priority")}
<select
className={input}
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{(["low", "normal", "high"] as const).map((value) => (
<option key={value} value={value}>
{t(`pixelOffice.priorities.${value}`)}
</option>
))}
</select>
</label>
<button
type="submit"
disabled={!title.trim()}
className="w-full rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-50"
>
{t("pixelOffice.create")}
</button>
</fieldset>
</form>
</section>
</div>
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="mb-3 font-semibold">{t("pixelOffice.ranksTitle")}</h2>
<div className="grid gap-3 md:grid-cols-3">
{agents.map((agent) => (
<label key={agent.id} className="block text-sm">
{agent.name}
<select
className={input}
disabled={pending}
value={agent.rank}
onChange={(event) =>
void mutate("rank", {
agent_id: agent.id,
rank: event.target.value,
})
}
>
{(["Operator", "Senior", "Lead"] as const).map((rank) => (
<option key={rank} value={rank}>
{t(`pixelOffice.ranks.${rank}`)}
</option>
))}
</select>
</label>
))}
</div>
</section>
</>
)}
</div> </div>
</main> </main>
); );
+39
View File
@@ -112,6 +112,45 @@ export function Runtime() {
</button> </button>
</section> </section>
<section
aria-label="TradingAgents Governance Pipeline"
className="grid gap-3 rounded-xl border border-emerald-500/25 bg-emerald-500/[0.04] p-4 shadow-sm md:grid-cols-3"
>
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
<div>
<div className="text-xs font-semibold text-foreground">
Mandate Gate · Shadow Account Default
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Live execution remains locked by default (`live_unlocked: false`). Multi-market US, HK, and Crypto proposals route to shadow fills.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-sky-500" />
<div>
<div className="text-xs font-semibold text-foreground">
Numeric Grounding Verification
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Prices, metrics, and order sizes are cross-checked against tool outputs and recorded in the hash-chained governance ledger.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wifi className="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
<div>
<div className="text-xs font-semibold text-foreground">
WhatsApp Human Approval Channel
</div>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Every approved TradingAgents proposal dispatches a signed ticket to WhatsApp (`neonize`) before executing a shadow fill.
</p>
</div>
</div>
</section>
{loading ? ( {loading ? (
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
{[1, 2, 3, 4].map((item) => ( {[1, 2, 3, 4].map((item) => (