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
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:
1 parent
4da7579065
commit
aab57cdeb3
29 files changed
+7522
-98
No files matched your search
@@ -319,6 +319,132 @@ def walk_forward_analysis(
|
||||
}
|
||||
|
||||
|
||||
def purged_walk_forward_analysis(
|
||||
equity_curve: pd.Series,
|
||||
trades: List[TradeRecord],
|
||||
n_windows: int = 5,
|
||||
purge_bars: int = 2,
|
||||
embargo_bars: int = 1,
|
||||
bars_per_year: int = 252,
|
||||
) -> Dict[str, Any]:
|
||||
"""Purged walk-forward analysis with purge and embargo gaps between windows.
|
||||
|
||||
Prevents holding-period and serial-correlation leakage across sequential
|
||||
walk-forward folds by:
|
||||
1. Dropping ``purge_bars`` from the tail of each fold before a boundary.
|
||||
2. Dropping ``embargo_bars`` from the head of each subsequent fold after a boundary.
|
||||
3. Excluding any trade whose ``[entry_time, exit_time]`` straddles outside
|
||||
the purged fold's ``[win_start, win_end]`` interval.
|
||||
|
||||
Args:
|
||||
equity_curve: Equity time series.
|
||||
trades: Completed trades.
|
||||
n_windows: Number of sequential evaluation folds.
|
||||
purge_bars: Bars removed before each inter-fold boundary (>= 0).
|
||||
embargo_bars: Bars removed after each inter-fold boundary (>= 0).
|
||||
bars_per_year: Annualisation factor.
|
||||
|
||||
Returns:
|
||||
Dict with per-window stats, purged bar counts, and consistency metrics.
|
||||
"""
|
||||
if isinstance(n_windows, bool) or not isinstance(n_windows, Integral) or n_windows < 1:
|
||||
return {"error": f"n_windows must be >= 1, got {n_windows}"}
|
||||
if isinstance(purge_bars, bool) or not isinstance(purge_bars, Integral) or purge_bars < 0:
|
||||
return {"error": f"purge_bars must be >= 0, got {purge_bars}"}
|
||||
if isinstance(embargo_bars, bool) or not isinstance(embargo_bars, Integral) or embargo_bars < 0:
|
||||
return {"error": f"embargo_bars must be >= 0, got {embargo_bars}"}
|
||||
|
||||
min_bars_per_win = int(purge_bars) + int(embargo_bars) + 3
|
||||
if len(equity_curve) < n_windows * min_bars_per_win:
|
||||
return {
|
||||
"error": (
|
||||
f"need at least {n_windows * min_bars_per_win} bars for "
|
||||
f"{n_windows} purged windows (purge={purge_bars}, embargo={embargo_bars})"
|
||||
)
|
||||
}
|
||||
|
||||
indices = equity_curve.index
|
||||
raw_window_size = len(indices) // n_windows
|
||||
windows: List[Dict[str, Any]] = []
|
||||
purged_bars_total = 0
|
||||
|
||||
for i in range(n_windows):
|
||||
raw_start = i * raw_window_size
|
||||
raw_end = (i + 1) * raw_window_size if i < n_windows - 1 else len(indices)
|
||||
|
||||
eff_start = raw_start + (int(embargo_bars) if i > 0 else 0)
|
||||
eff_end = raw_end - (int(purge_bars) if i < n_windows - 1 else 0)
|
||||
purged_in_fold = (eff_start - raw_start) + (raw_end - eff_end)
|
||||
purged_bars_total += purged_in_fold
|
||||
|
||||
if eff_end - eff_start < 2:
|
||||
return {"error": f"window {i + 1} has fewer than 2 bars after purge/embargo"}
|
||||
|
||||
win_eq = equity_curve.iloc[eff_start:eff_end]
|
||||
win_start = indices[eff_start]
|
||||
win_end = indices[eff_end - 1]
|
||||
if not np.isfinite(win_eq.iloc[[0, -1]]).all():
|
||||
return {"error": f"window {i + 1} requires observed start and end equity"}
|
||||
|
||||
# Strict purge on trades: both entry_time and exit_time must fall inside [win_start, win_end]
|
||||
win_trades = [
|
||||
t
|
||||
for t in trades
|
||||
if win_start <= t.entry_time <= win_end and win_start <= t.exit_time <= win_end
|
||||
]
|
||||
|
||||
ret = float(win_eq.iloc[-1] / win_eq.iloc[0] - 1) if win_eq.iloc[0] > 0 else 0.0
|
||||
win_returns = (
|
||||
win_eq.pct_change(fill_method=None)
|
||||
.replace([np.inf, -np.inf], 0.0)
|
||||
.dropna()
|
||||
.values
|
||||
)
|
||||
if not len(win_returns):
|
||||
return {"error": f"window {i + 1} has no observed adjacent equity returns"}
|
||||
sharpe = _sharpe(win_returns, bars_per_year) if len(win_returns) > 1 else 0.0
|
||||
|
||||
peak = win_eq.cummax()
|
||||
dd = (win_eq - peak) / peak.replace(0, 1)
|
||||
max_dd = float(dd.min())
|
||||
|
||||
win_pnls = [t.pnl for t in win_trades]
|
||||
win_rate = len([p for p in win_pnls if p > 0]) / len(win_pnls) if win_pnls else 0.0
|
||||
|
||||
windows.append(
|
||||
{
|
||||
"window": i + 1,
|
||||
"start": str(win_start.date()) if hasattr(win_start, "date") else str(win_start),
|
||||
"end": str(win_end.date()) if hasattr(win_end, "date") else str(win_end),
|
||||
"bars": int(eff_end - eff_start),
|
||||
"purged_bars": int(purged_in_fold),
|
||||
"return": round(ret, 6),
|
||||
"sharpe": round(sharpe, 4),
|
||||
"max_dd": round(max_dd, 6),
|
||||
"trades": len(win_trades),
|
||||
"win_rate": round(win_rate, 4),
|
||||
}
|
||||
)
|
||||
|
||||
returns_list = [w["return"] for w in windows]
|
||||
sharpes_list = [w["sharpe"] for w in windows]
|
||||
profitable_windows = sum(1 for r in returns_list if r > 0)
|
||||
|
||||
return {
|
||||
"n_windows": int(n_windows),
|
||||
"purge_bars": int(purge_bars),
|
||||
"embargo_bars": int(embargo_bars),
|
||||
"purged_bars_total": int(purged_bars_total),
|
||||
"windows": windows,
|
||||
"profitable_windows": profitable_windows,
|
||||
"consistency_rate": round(profitable_windows / n_windows, 4),
|
||||
"return_mean": round(float(np.mean(returns_list)), 6),
|
||||
"return_std": round(float(np.std(returns_list)), 6),
|
||||
"sharpe_mean": round(float(np.mean(sharpes_list)), 4),
|
||||
"sharpe_std": round(float(np.std(sharpes_list)), 4),
|
||||
}
|
||||
|
||||
|
||||
# ─── Runner integration ───
|
||||
|
||||
|
||||
@@ -335,6 +461,7 @@ def run_validation(
|
||||
- monte_carlo: {n_simulations, seed}
|
||||
- bootstrap: {n_bootstrap, confidence, seed}
|
||||
- walk_forward: {n_windows}
|
||||
- purged_walk_forward: {n_windows, purge_bars, embargo_bars}
|
||||
|
||||
Args:
|
||||
config: Backtest config (must contain "validation" key).
|
||||
@@ -386,6 +513,21 @@ def run_validation(
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
|
||||
if "purged_walk_forward" in v_cfg:
|
||||
pwf_cfg = (
|
||||
v_cfg["purged_walk_forward"]
|
||||
if isinstance(v_cfg["purged_walk_forward"], dict)
|
||||
else {}
|
||||
)
|
||||
results["purged_walk_forward"] = purged_walk_forward_analysis(
|
||||
equity_curve,
|
||||
trades,
|
||||
n_windows=pwf_cfg.get("n_windows", 5),
|
||||
purge_bars=pwf_cfg.get("purge_bars", 2),
|
||||
embargo_bars=pwf_cfg.get("embargo_bars", 1),
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -558,6 +700,267 @@ def main(run_dir: Path) -> Dict[str, Any]:
|
||||
return safe_results
|
||||
|
||||
|
||||
# ─── Phase 1 Schedule Promotion Gate & Tiny-Universe Multi-Market Proof ───
|
||||
|
||||
TINY_UNIVERSE_BY_MARKET: Dict[str, tuple[str, ...]] = {
|
||||
"US": ("NVDA.US", "AAPL.US"),
|
||||
"HK": ("0700.HK", "9988.HK"),
|
||||
"CRYPTO": ("BTC-USD", "ETH-USD"),
|
||||
}
|
||||
|
||||
|
||||
class _InMemoryFrameLoader:
|
||||
"""Minimal DataLoader adapter wrapping pre-loaded OHLCV DataFrames."""
|
||||
|
||||
name = "in_memory_frame_loader"
|
||||
|
||||
def __init__(self, frames: Dict[str, pd.DataFrame]) -> None:
|
||||
self._frames = dict(frames)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
codes: List[str],
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
fields: List[str] | None = None,
|
||||
interval: str = "1D",
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
del start_date, end_date, fields, interval
|
||||
return {code: self._frames[code].copy() for code in codes if code in self._frames}
|
||||
|
||||
|
||||
class _TrendCrossoverSignalEngine:
|
||||
"""Deterministic fast/slow MA crossover signal engine for tiny-universe proof."""
|
||||
|
||||
def __init__(self, fast_window: int = 5, slow_window: int = 15) -> None:
|
||||
self.fast_window = fast_window
|
||||
self.slow_window = slow_window
|
||||
|
||||
def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]:
|
||||
signals: Dict[str, pd.Series] = {}
|
||||
for code, df in data_map.items():
|
||||
close = df["close"].astype(float)
|
||||
fast = close.rolling(self.fast_window, min_periods=1).mean()
|
||||
slow = close.rolling(self.slow_window, min_periods=1).mean()
|
||||
sig = pd.Series(np.where(fast > slow, 1.0, 0.0), index=close.index, dtype=float)
|
||||
signals[code] = sig
|
||||
return signals
|
||||
|
||||
|
||||
def evaluate_promotion_gate(
|
||||
market_reports: Dict[str, Dict[str, Any]],
|
||||
*,
|
||||
required_markets: tuple[str, ...] = ("US", "HK", "CRYPTO"),
|
||||
min_bootstrap_ci_lower: float = -0.25,
|
||||
min_prob_positive: float = 0.55,
|
||||
min_wf_consistency: float = 0.40,
|
||||
) -> Dict[str, Any]:
|
||||
"""Evaluate whether US + HK + Crypto backtest validation gates pass for schedule promotion.
|
||||
|
||||
No paper automation schedule may promote or dispatch proposals unless every
|
||||
required market passes both Purged Walk-Forward consistency and Bootstrap
|
||||
Sharpe CI thresholds (fail-closed).
|
||||
|
||||
Args:
|
||||
market_reports: Mapping of market key (``"US"``, ``"HK"``, ``"CRYPTO"``)
|
||||
to backtest metrics/validation dicts.
|
||||
required_markets: Markets that must all pass before promotion.
|
||||
min_bootstrap_ci_lower: Minimum lower bound for bootstrap Sharpe CI.
|
||||
min_prob_positive: Minimum bootstrap probability of positive Sharpe.
|
||||
min_wf_consistency: Minimum fraction of profitable purged walk-forward folds.
|
||||
|
||||
Returns:
|
||||
Dict with ``passed`` (bool), ``markets_checked``, ``per_market``, and
|
||||
``failed_reasons``.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
failed_reasons: List[str] = []
|
||||
per_market: Dict[str, Dict[str, Any]] = {}
|
||||
normalized_reports = {
|
||||
str(k).strip().upper(): v for k, v in (market_reports or {}).items() if isinstance(v, dict)
|
||||
}
|
||||
|
||||
for market in required_markets:
|
||||
mkt = market.strip().upper()
|
||||
report = normalized_reports.get(mkt)
|
||||
if report is None:
|
||||
failed_reasons.append(f"{mkt}: missing backtest validation report")
|
||||
per_market[mkt] = {"passed": False, "reason": "missing_report"}
|
||||
continue
|
||||
|
||||
val = report.get("validation") if isinstance(report.get("validation"), dict) else report
|
||||
bs = val.get("bootstrap") if isinstance(val.get("bootstrap"), dict) else None
|
||||
pwf = (
|
||||
val.get("purged_walk_forward")
|
||||
if isinstance(val.get("purged_walk_forward"), dict)
|
||||
else val.get("walk_forward")
|
||||
if isinstance(val.get("walk_forward"), dict)
|
||||
else None
|
||||
)
|
||||
|
||||
mkt_reasons: List[str] = []
|
||||
if not bs or "error" in bs:
|
||||
mkt_reasons.append(
|
||||
f"{mkt}: bootstrap validation missing or failed ({(bs or {}).get('error', 'missing')})"
|
||||
)
|
||||
else:
|
||||
ci_lower = float(bs.get("ci_lower", float("-inf")))
|
||||
prob_pos = float(bs.get("prob_positive", 0.0))
|
||||
if ci_lower < min_bootstrap_ci_lower:
|
||||
mkt_reasons.append(
|
||||
f"{mkt}: bootstrap ci_lower {ci_lower:.4f} < {min_bootstrap_ci_lower:.4f}"
|
||||
)
|
||||
if prob_pos < min_prob_positive:
|
||||
mkt_reasons.append(
|
||||
f"{mkt}: bootstrap prob_positive {prob_pos:.4f} < {min_prob_positive:.4f}"
|
||||
)
|
||||
|
||||
if not pwf or "error" in pwf:
|
||||
mkt_reasons.append(
|
||||
f"{mkt}: purged_walk_forward missing or failed ({(pwf or {}).get('error', 'missing')})"
|
||||
)
|
||||
else:
|
||||
consistency = float(pwf.get("consistency_rate", 0.0))
|
||||
if consistency < min_wf_consistency:
|
||||
mkt_reasons.append(
|
||||
f"{mkt}: purged_walk_forward consistency_rate {consistency:.4f} < {min_wf_consistency:.4f}"
|
||||
)
|
||||
if "purge_bars" in pwf and int(pwf.get("purge_bars", 0)) < 1:
|
||||
mkt_reasons.append(f"{mkt}: purged_walk_forward requires purge_bars >= 1")
|
||||
|
||||
mkt_passed = len(mkt_reasons) == 0
|
||||
failed_reasons.extend(mkt_reasons)
|
||||
per_market[mkt] = {
|
||||
"passed": mkt_passed,
|
||||
"bootstrap": bs,
|
||||
"purged_walk_forward": pwf,
|
||||
"reasons": mkt_reasons,
|
||||
}
|
||||
|
||||
return {
|
||||
"passed": len(failed_reasons) == 0 and len(required_markets) > 0,
|
||||
"markets_checked": [m.strip().upper() for m in required_markets],
|
||||
"per_market": per_market,
|
||||
"failed_reasons": failed_reasons,
|
||||
"evaluated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def run_tiny_universe_backtest_proof(
|
||||
*,
|
||||
bar_data_by_market: Dict[str, Dict[str, pd.DataFrame]] | None = None,
|
||||
work_dir: Path | None = None,
|
||||
n_bootstrap: int = 300,
|
||||
n_windows: int = 4,
|
||||
purge_bars: int = 2,
|
||||
embargo_bars: int = 1,
|
||||
seed: int = 42,
|
||||
min_bootstrap_ci_lower: float = -0.25,
|
||||
min_prob_positive: float = 0.55,
|
||||
min_wf_consistency: float = 0.40,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run US + HK + Crypto backtest engines on a tiny universe with purged walk-forward + bootstrap.
|
||||
|
||||
Executes:
|
||||
- ``GlobalEquityEngine(market="us")`` on ``("NVDA.US", "AAPL.US")``
|
||||
- ``GlobalEquityEngine(market="hk")`` on ``("0700.HK", "9988.HK")``
|
||||
- ``CryptoEngine`` on ``("BTC-USD", "ETH-USD")``
|
||||
and evaluates the schedule promotion gate across all three markets.
|
||||
"""
|
||||
import tempfile
|
||||
from backtest.engines.crypto import CryptoEngine
|
||||
from backtest.engines.global_equity import GlobalEquityEngine
|
||||
|
||||
base_dir_ctx = (
|
||||
tempfile.TemporaryDirectory(prefix="vibe_bt_proof_")
|
||||
if work_dir is None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
root_dir = Path(base_dir_ctx.name) if base_dir_ctx is not None else Path(work_dir) # type: ignore[arg-type]
|
||||
root_dir.mkdir(parents=True, exist_ok=True)
|
||||
signal_engine = _TrendCrossoverSignalEngine(fast_window=5, slow_window=15)
|
||||
market_reports: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for mkt_key, default_codes in TINY_UNIVERSE_BY_MARKET.items():
|
||||
frames = (bar_data_by_market or {}).get(mkt_key)
|
||||
if frames:
|
||||
codes = sorted(frames.keys())
|
||||
loader: Any = _InMemoryFrameLoader(frames)
|
||||
else:
|
||||
from backtest.loaders.registry import resolve_loader
|
||||
|
||||
codes = list(default_codes)
|
||||
loader_market = {
|
||||
"US": "us_equity",
|
||||
"HK": "hk_equity",
|
||||
"CRYPTO": "crypto",
|
||||
}[mkt_key]
|
||||
loader = resolve_loader(loader_market)
|
||||
|
||||
bars_per_year = 365 if mkt_key == "CRYPTO" else 252
|
||||
config: Dict[str, Any] = {
|
||||
"codes": codes,
|
||||
"initial_cash": 100_000.0,
|
||||
"leverage": 1.0,
|
||||
"interval": "1D",
|
||||
"validation": {
|
||||
"bootstrap": {
|
||||
"n_bootstrap": n_bootstrap,
|
||||
"confidence": 0.95,
|
||||
"seed": seed,
|
||||
},
|
||||
"purged_walk_forward": {
|
||||
"n_windows": n_windows,
|
||||
"purge_bars": purge_bars,
|
||||
"embargo_bars": embargo_bars,
|
||||
},
|
||||
},
|
||||
}
|
||||
if mkt_key == "US":
|
||||
engine: Any = GlobalEquityEngine(config, market="us")
|
||||
elif mkt_key == "HK":
|
||||
config["hk_lot_sizes"] = {code: 100 for code in codes}
|
||||
engine = GlobalEquityEngine(config, market="hk")
|
||||
else:
|
||||
engine = CryptoEngine(config)
|
||||
|
||||
mkt_run_dir = root_dir / mkt_key.lower()
|
||||
mkt_run_dir.mkdir(parents=True, exist_ok=True)
|
||||
metrics = engine.run_backtest(
|
||||
config,
|
||||
loader,
|
||||
signal_engine,
|
||||
mkt_run_dir,
|
||||
bars_per_year=bars_per_year,
|
||||
)
|
||||
market_reports[mkt_key] = metrics
|
||||
|
||||
gate = evaluate_promotion_gate(
|
||||
market_reports,
|
||||
min_bootstrap_ci_lower=min_bootstrap_ci_lower,
|
||||
min_prob_positive=min_prob_positive,
|
||||
min_wf_consistency=min_wf_consistency,
|
||||
)
|
||||
gate["market_metrics"] = {
|
||||
mkt: {
|
||||
"codes": list((bar_data_by_market or {}).get(mkt, {}).keys())
|
||||
or list(TINY_UNIVERSE_BY_MARKET[mkt]),
|
||||
"total_return": rep.get("total_return"),
|
||||
"sharpe": rep.get("sharpe"),
|
||||
"max_drawdown": rep.get("max_drawdown"),
|
||||
"total_trades": rep.get("total_trades"),
|
||||
"validation": rep.get("validation"),
|
||||
}
|
||||
for mkt, rep in market_reports.items()
|
||||
}
|
||||
return gate
|
||||
finally:
|
||||
if base_dir_ctx is not None:
|
||||
base_dir_ctx.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class ChannelRuntime:
|
||||
poll_interval_s: float = 0.25,
|
||||
operators: Iterable[str] | None = None,
|
||||
channel_operators: Mapping[str, Iterable[str]] | None = None,
|
||||
trade_approval_coordinator: Any = None,
|
||||
) -> None:
|
||||
self.bus = bus
|
||||
self.session_service = session_service
|
||||
@@ -62,6 +63,7 @@ class ChannelRuntime:
|
||||
str(ch): {str(o) for o in ops}
|
||||
for ch, ops in (channel_operators or {}).items()
|
||||
}
|
||||
self._trade_approval_coordinator = trade_approval_coordinator
|
||||
self.session_map_path = session_map_path or (get_data_dir() / "channels" / "sessions.json")
|
||||
self._session_map: dict[str, str] = {}
|
||||
self._consumer_task: asyncio.Task[None] | None = None
|
||||
@@ -111,6 +113,15 @@ class ChannelRuntime:
|
||||
"channels": self.manager.get_status() if self.manager is not None else {},
|
||||
}
|
||||
|
||||
@property
|
||||
def trade_approval_coordinator(self) -> Any:
|
||||
"""Return the configured TradeApprovalCoordinator, if any."""
|
||||
return self._trade_approval_coordinator
|
||||
|
||||
async def publish_outbound(self, msg: OutboundMessage) -> None:
|
||||
"""Publish an outbound message (e.g., paper trade approval prompt) to the channel bus."""
|
||||
await self.bus.publish_outbound(msg)
|
||||
|
||||
async def _consume_loop(self) -> None:
|
||||
while True:
|
||||
msg = await self.bus.consume_inbound()
|
||||
@@ -183,6 +194,9 @@ class ChannelRuntime:
|
||||
)
|
||||
return
|
||||
|
||||
if await self._handle_trade_approval(msg):
|
||||
return
|
||||
|
||||
session_id = self._session_for(msg)
|
||||
if await self._handle_scheduled_confirmation(msg, session_id):
|
||||
return
|
||||
@@ -287,6 +301,44 @@ class ChannelRuntime:
|
||||
)
|
||||
)
|
||||
|
||||
def _is_trade_approver_authorized(self, msg: InboundMessage) -> bool:
|
||||
"""Return True if the sender is authorized to approve/reject trades or trigger HALT."""
|
||||
is_operator, _ = self._resolve_operator(msg.channel, msg.sender_id)
|
||||
if is_operator:
|
||||
return True
|
||||
if self._operators or self._channel_operators.get(msg.channel):
|
||||
return False
|
||||
if self.manager is not None:
|
||||
channels = getattr(self.manager, "channels", None) or {}
|
||||
adapter = channels.get(msg.channel)
|
||||
if adapter is not None and hasattr(adapter, "is_allowed"):
|
||||
return bool(adapter.is_allowed(msg.sender_id))
|
||||
if msg.metadata.get("authorized_operator") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _handle_trade_approval(self, msg: InboundMessage) -> bool:
|
||||
"""Intercept WhatsApp / IM trade approval commands outside the LLM."""
|
||||
from src.channels.trade_approval import (
|
||||
TradeApprovalCoordinator,
|
||||
parse_trade_approval_command,
|
||||
)
|
||||
|
||||
if parse_trade_approval_command(msg.content) is None:
|
||||
return False
|
||||
coordinator = self._trade_approval_coordinator
|
||||
if coordinator is None:
|
||||
coordinator = TradeApprovalCoordinator()
|
||||
self._trade_approval_coordinator = coordinator
|
||||
outbound = coordinator.handle_inbound_command(
|
||||
msg,
|
||||
is_authorized=self._is_trade_approver_authorized(msg),
|
||||
)
|
||||
if outbound is None:
|
||||
return False
|
||||
await self.bus.publish_outbound(outbound)
|
||||
return True
|
||||
|
||||
async def _handle_scheduled_confirmation(
|
||||
self, msg: InboundMessage, session_id: str
|
||||
) -> bool:
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
)
|
||||
@@ -200,6 +200,12 @@ class LLMConfig(_EnvBase):
|
||||
vibe_trading_anthropic_prompt_cache: EnvBool = Field(
|
||||
alias="VIBE_TRADING_ANTHROPIC_PROMPT_CACHE", default=True,
|
||||
)
|
||||
vibe_quick_think_model: str = Field(
|
||||
alias="VIBE_QUICK_THINK_MODEL", default="",
|
||||
)
|
||||
vibe_deep_think_model: str = Field(
|
||||
alias="VIBE_DEEP_THINK_MODEL", default="",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -482,13 +482,14 @@ def check_mandate(
|
||||
broker: str,
|
||||
remote_tool: str,
|
||||
daily_count: int,
|
||||
daily_loss_usd: float = 0.0,
|
||||
) -> BreachEvent | None:
|
||||
"""Evaluate one order intent against the mandate (fail-closed).
|
||||
|
||||
Checks run in a fixed order and the first failure produces the verdict:
|
||||
exclude-list, instrument allowance, asset-class allowance (universe market-
|
||||
cap/liquidity), single-order notional, post-trade total exposure, post-trade
|
||||
gross leverage, daily order count, and funding (defense-in-depth). Any
|
||||
gross leverage, daily order count, daily loss cap, and funding (defense-in-depth). Any
|
||||
unparseable input (bad intent, unreadable positions, missing market data)
|
||||
denies rather than allows.
|
||||
|
||||
@@ -502,6 +503,8 @@ def check_mandate(
|
||||
remote_tool: Broker remote tool name the agent invoked.
|
||||
daily_count: Orders already placed today (from the persisted counter,
|
||||
after UTC rollover).
|
||||
daily_loss_usd: Realized + unrealized loss accumulated today in USD
|
||||
(positive number represents a loss).
|
||||
|
||||
Returns:
|
||||
``None`` when the order is fully in-mandate (ALLOW), otherwise a
|
||||
@@ -510,6 +513,14 @@ def check_mandate(
|
||||
caps = mandate.hard_caps
|
||||
universe = mandate.universe
|
||||
|
||||
if mandate.mode == "live" and not mandate.live_unlocked:
|
||||
return _breach(
|
||||
broker=broker, remote_tool=remote_tool, intent=intent,
|
||||
kind=BREACH_KIND_INSTRUMENT, limit="live_unlocked",
|
||||
limit_value=0.0, attempted_value=1.0,
|
||||
detail="mandate mode is live but live_unlocked is False",
|
||||
)
|
||||
|
||||
symbol = (intent.symbol or "").strip().upper()
|
||||
if not symbol or intent.side not in ("buy", "sell"):
|
||||
return _breach(
|
||||
@@ -520,7 +531,8 @@ def check_mandate(
|
||||
)
|
||||
|
||||
# 1. Exclude-list — takes precedence over every other universe rule.
|
||||
if symbol in {s.strip().upper() for s in universe.exclude_symbols}:
|
||||
excluded = {s.strip().upper() for s in universe.exclude_symbols if s.strip()}
|
||||
if symbol in excluded or (symbol.endswith(".US") and symbol[:-3] in excluded) or f"{symbol}.US" in excluded:
|
||||
return _breach(
|
||||
broker=broker, remote_tool=remote_tool, intent=intent,
|
||||
kind=BREACH_KIND_UNIVERSE, limit="exclude_symbols",
|
||||
@@ -528,6 +540,26 @@ def check_mandate(
|
||||
detail=f"{symbol} is on the mandate exclude list",
|
||||
)
|
||||
|
||||
# 1b. Allow-list — when non-empty, only explicitly allowlisted symbols pass.
|
||||
if universe.allow_symbols:
|
||||
allowed = {s.strip().upper() for s in universe.allow_symbols if s.strip()}
|
||||
symbol_allowed = (
|
||||
symbol in allowed
|
||||
or (symbol.endswith(".US") and symbol[:-3] in allowed)
|
||||
or (
|
||||
"." not in symbol
|
||||
and "-" not in symbol
|
||||
and f"{symbol}.US" in allowed
|
||||
)
|
||||
)
|
||||
if not symbol_allowed:
|
||||
return _breach(
|
||||
broker=broker, remote_tool=remote_tool, intent=intent,
|
||||
kind=BREACH_KIND_UNIVERSE, limit="allow_symbols",
|
||||
limit_value=0.0, attempted_value=0.0,
|
||||
detail=f"{symbol} is not in the mandate allow_symbols list",
|
||||
)
|
||||
|
||||
# 2. Instrument-type allowance (empty == deny all, fail-closed).
|
||||
if intent.instrument_type not in caps.allowed_instruments:
|
||||
return _breach(
|
||||
@@ -624,6 +656,19 @@ def check_mandate(
|
||||
attempted_value=float(attempted_count),
|
||||
)
|
||||
|
||||
# 7b. Daily loss cap (realized + unrealized loss over UTC calendar day).
|
||||
if caps.max_daily_loss_usd is not None and float(daily_loss_usd) > float(caps.max_daily_loss_usd):
|
||||
return _breach(
|
||||
broker=broker, remote_tool=remote_tool, intent=intent,
|
||||
kind=BREACH_KIND_QUANTITATIVE, limit="max_daily_loss_usd",
|
||||
limit_value=float(caps.max_daily_loss_usd),
|
||||
attempted_value=float(daily_loss_usd),
|
||||
detail=(
|
||||
f"daily loss ${float(daily_loss_usd):,.2f} exceeds "
|
||||
f"max_daily_loss_usd ${float(caps.max_daily_loss_usd):,.2f}"
|
||||
),
|
||||
)
|
||||
|
||||
# 8. Funding (defense-in-depth; broker is the real ceiling). Only a buy can
|
||||
# push us past funding — never block a sell on this.
|
||||
if intent.side == "buy" and post_exposure > caps.account_funding_usd:
|
||||
@@ -634,6 +679,7 @@ def check_mandate(
|
||||
detail="post-trade exposure exceeds mirrored funding ceiling",
|
||||
)
|
||||
|
||||
|
||||
# 9. Expensive universe rules (market-cap / liquidity floors). Fail-closed
|
||||
# on missing data per SPEC §5. Skipped for instruments with no equity
|
||||
# universe bucket (e.g. options) where these floors do not apply.
|
||||
|
||||
@@ -64,6 +64,8 @@ class HardCaps:
|
||||
types. Empty == deny all (fail-closed).
|
||||
max_trades_per_day: Vibe-enforced count of order placements allowed
|
||||
per UTC calendar day. Counter persisted alongside the mandate.
|
||||
max_daily_loss_usd: Optional Vibe-enforced daily realized/unrealized
|
||||
loss ceiling in USD. ``None`` == no daily loss cap.
|
||||
"""
|
||||
|
||||
account_funding_usd: float
|
||||
@@ -72,6 +74,8 @@ class HardCaps:
|
||||
max_leverage: float
|
||||
allowed_instruments: tuple[InstrumentType, ...]
|
||||
max_trades_per_day: int
|
||||
max_daily_loss_usd: float | None = None
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -79,7 +83,8 @@ class UniverseConstraint:
|
||||
"""Layer (b): user-set universe the agent picks symbols WITHIN.
|
||||
|
||||
Not a ticker whitelist — that would kill agent discovery. The agent selects
|
||||
individual symbols freely so long as each clears these structural filters.
|
||||
individual symbols freely so long as each clears these structural filters,
|
||||
unless ``allow_symbols`` is non-empty (Phase 0 explicit symbol allowlist).
|
||||
|
||||
Attributes:
|
||||
asset_classes: Permitted asset-class buckets. Empty == deny all.
|
||||
@@ -89,12 +94,17 @@ class UniverseConstraint:
|
||||
exclude_symbols: Hard per-symbol denylist (normalized upper-case,
|
||||
e.g. ``BTC-USDT`` style for crypto). Takes precedence over every
|
||||
other universe rule.
|
||||
allow_symbols: Optional explicit symbol allowlist (normalized upper-case,
|
||||
e.g. ``NVDA.US``, ``0700.HK``, ``BTC-USDT``). When non-empty, only
|
||||
symbols in this allowlist are permitted. Empty tuple ``()`` means
|
||||
structural asset_classes rules govern discovery.
|
||||
"""
|
||||
|
||||
asset_classes: tuple[AssetClass, ...]
|
||||
min_market_cap_usd: float | None
|
||||
min_avg_daily_volume_usd: float | None
|
||||
exclude_symbols: tuple[str, ...]
|
||||
allow_symbols: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -122,7 +132,7 @@ class ConsentMeta:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mandate:
|
||||
"""Immutable bounded-autonomy mandate for one live broker channel.
|
||||
"""Immutable bounded-autonomy mandate for one live or paper broker channel.
|
||||
|
||||
Loaded read-only at session boot from the user-side protected store. The
|
||||
agent loop has no constructor or write path to this object (see §2).
|
||||
@@ -140,6 +150,9 @@ class Mandate:
|
||||
``True`` is an explicit per-mandate opt-in the user makes at commit.
|
||||
Read by ``src.live.runtime.flatten`` on a halt trip (SPEC §7.5 #6
|
||||
"optionally, per mandate").
|
||||
mode: Execution mode — ``"paper"`` (default) or ``"live"``.
|
||||
live_unlocked: Explicit unlock flag required before ``mode="live"`` can
|
||||
be loaded or executed. Defaults to ``False``.
|
||||
"""
|
||||
|
||||
schema_version: int
|
||||
@@ -147,3 +160,5 @@ class Mandate:
|
||||
universe: UniverseConstraint
|
||||
consent: ConsentMeta
|
||||
flatten_on_halt: bool = False
|
||||
mode: str = "paper"
|
||||
live_unlocked: bool = False
|
||||
@@ -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
|
||||
@@ -94,12 +94,20 @@ def _parse_mandate(raw: object) -> Mandate:
|
||||
InstrumentType(value) for value in caps["allowed_instruments"]
|
||||
),
|
||||
max_trades_per_day=int(caps["max_trades_per_day"]),
|
||||
max_daily_loss_usd=_opt_float(caps.get("max_daily_loss_usd")),
|
||||
)
|
||||
|
||||
raw_allow_symbols = universe.get("allow_symbols", ())
|
||||
if raw_allow_symbols is None:
|
||||
raw_allow_symbols = ()
|
||||
if not isinstance(raw_allow_symbols, (list, tuple)):
|
||||
raise TypeError("mandate field 'universe.allow_symbols' must be a list")
|
||||
universe_constraint = UniverseConstraint(
|
||||
asset_classes=tuple(AssetClass(value) for value in universe["asset_classes"]),
|
||||
min_market_cap_usd=_opt_float(universe["min_market_cap_usd"]),
|
||||
min_avg_daily_volume_usd=_opt_float(universe["min_avg_daily_volume_usd"]),
|
||||
exclude_symbols=tuple(str(value) for value in universe["exclude_symbols"]),
|
||||
allow_symbols=tuple(str(value) for value in raw_allow_symbols),
|
||||
)
|
||||
consent_meta = ConsentMeta(
|
||||
created_at=str(consent["created_at"]),
|
||||
@@ -108,6 +116,12 @@ def _parse_mandate(raw: object) -> Mandate:
|
||||
account_ref=str(consent["account_ref"]),
|
||||
expires_at=str(consent["expires_at"]),
|
||||
)
|
||||
mode = str(raw.get("mode", "paper")).strip().lower() or "paper"
|
||||
if mode not in ("paper", "live"):
|
||||
raise ValueError(f"unsupported mandate mode: {mode!r}")
|
||||
live_unlocked = bool(raw.get("live_unlocked", False))
|
||||
if mode == "live" and not live_unlocked:
|
||||
raise ValueError("mandate mode='live' requires live_unlocked=True")
|
||||
return Mandate(
|
||||
schema_version=int(raw["schema_version"]),
|
||||
hard_caps=hard_caps,
|
||||
@@ -117,6 +131,8 @@ def _parse_mandate(raw: object) -> Mandate:
|
||||
# old mandate.json → False (cancel-only, the safe default), keeping the
|
||||
# read backward-compatible. Read on a halt trip by src.live.runtime.flatten.
|
||||
flatten_on_halt=bool(raw.get("flatten_on_halt", False)),
|
||||
mode=mode,
|
||||
live_unlocked=live_unlocked,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -1 +1,8 @@
|
||||
"""Persistent cross-session memory system."""
|
||||
|
||||
from src.memory.reflector import DecisionMemoryReflector, DecisionReflectionRecord
|
||||
|
||||
__all__ = [
|
||||
"DecisionMemoryReflector",
|
||||
"DecisionReflectionRecord",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.config.accessor import get_env_config
|
||||
from src.providers.content_filter import is_content_filter_triggered
|
||||
from src.providers.llm import build_llm
|
||||
from src.providers.llm import build_llm, resolve_thinking_tier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -342,9 +342,7 @@ class ChatLLM:
|
||||
"""
|
||||
self._llm = build_llm(model_name=model_name)
|
||||
runtime_cfg = get_env_config().llm
|
||||
configured_model = (
|
||||
model_name or runtime_cfg.langchain_model_name
|
||||
).strip()
|
||||
configured_model = resolve_thinking_tier(model_name)
|
||||
self.model_name = configured_model
|
||||
self.runtime_snapshot = LLMRuntimeSnapshot(
|
||||
provider=runtime_cfg.langchain_provider.strip().lower() or "openai",
|
||||
|
||||
@@ -1631,11 +1631,31 @@ def provider_diagnostics() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def resolve_thinking_tier(model_name: Optional[str] = None) -> str:
|
||||
"""Resolve ``quick_think`` / ``deep_think`` tier aliases to concrete model names.
|
||||
|
||||
Falls back to ``LANGCHAIN_MODEL_NAME`` when ``VIBE_QUICK_THINK_MODEL`` or
|
||||
``VIBE_DEEP_THINK_MODEL`` is not set.
|
||||
"""
|
||||
llm_cfg = get_env_config().llm
|
||||
default_model = llm_cfg.langchain_model_name.strip()
|
||||
raw = (model_name or "").strip()
|
||||
if not raw:
|
||||
return default_model
|
||||
lowered = raw.lower()
|
||||
if lowered == "quick_think":
|
||||
return llm_cfg.vibe_quick_think_model.strip() or default_model
|
||||
if lowered == "deep_think":
|
||||
return llm_cfg.vibe_deep_think_model.strip() or default_model
|
||||
return raw
|
||||
|
||||
|
||||
def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any:
|
||||
"""Construct the configured LangChain chat model.
|
||||
|
||||
Args:
|
||||
model_name: Model name; defaults to LANGCHAIN_MODEL_NAME.
|
||||
model_name: Model name or thinking tier (``quick_think`` / ``deep_think``);
|
||||
defaults to LANGCHAIN_MODEL_NAME.
|
||||
callbacks: Optional LangChain callbacks.
|
||||
|
||||
Returns:
|
||||
@@ -1645,7 +1665,7 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
|
||||
RuntimeError: If langchain-openai is missing or LANGCHAIN_MODEL_NAME is unset.
|
||||
"""
|
||||
_sync_provider_env()
|
||||
name = model_name or get_env_config().llm.langchain_model_name.strip()
|
||||
name = resolve_thinking_tier(model_name)
|
||||
if not name:
|
||||
raise RuntimeError("LANGCHAIN_MODEL_NAME is not set")
|
||||
temperature = get_env_config().llm.langchain_temperature
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -336,6 +336,17 @@ def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmR
|
||||
status=status,
|
||||
))
|
||||
|
||||
effective_vars = dict(user_vars)
|
||||
for var_def in data.get("variables", []):
|
||||
if isinstance(var_def, dict):
|
||||
vname = var_def.get("name")
|
||||
if (
|
||||
isinstance(vname, str)
|
||||
and vname not in effective_vars
|
||||
and "default" in var_def
|
||||
):
|
||||
effective_vars[vname] = str(var_def["default"])
|
||||
|
||||
# Generate run ID
|
||||
now = datetime.now(timezone.utc)
|
||||
ts = now.strftime("%Y%m%d-%H%M%S")
|
||||
@@ -346,7 +357,7 @@ def build_run_from_preset(preset_name: str, user_vars: dict[str, str]) -> SwarmR
|
||||
id=run_id,
|
||||
preset_name=preset_name,
|
||||
status=RunStatus.pending,
|
||||
user_vars=user_vars,
|
||||
user_vars=effective_vars,
|
||||
agents=agents,
|
||||
tasks=tasks,
|
||||
created_at=now.isoformat(),
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
@@ -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 { Clock, Users } from "lucide-react";
|
||||
import { SwarmDashboard } from "@/components/chat/SwarmDashboard";
|
||||
import {
|
||||
TradingAgentsPipelineCard,
|
||||
isTradingAgentsSwarm,
|
||||
} from "@/components/chat/TradingAgentsPipelineCard";
|
||||
import {
|
||||
localizePreset,
|
||||
localizeStatus,
|
||||
@@ -132,6 +136,7 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
|
||||
const total = status.agents.length;
|
||||
const hasAgentTotal = total > 0;
|
||||
const isActive = status.status === "pending" || status.status === "running";
|
||||
const showTradingAgentsPipeline = isTradingAgentsSwarm(status);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
@@ -177,6 +182,10 @@ export const SwarmStatusCard = memo(function SwarmStatusCard({ status }: Props)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTradingAgentsPipeline && (
|
||||
<TradingAgentsPipelineCard status={status} />
|
||||
)}
|
||||
|
||||
<SwarmDashboard
|
||||
agents={status.agents}
|
||||
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>"} <reason>
|
||||
</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 <reason>
|
||||
</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 { SidebarNavigation } from "./SidebarNavigation";
|
||||
import { PageHelp } from "./PageHelp";
|
||||
import { SafetyTelemetryBar } from "./SafetyTelemetryBar";
|
||||
|
||||
// APP_VERSION is sourced from i18n locale files (app.version key) to keep a
|
||||
// single source of truth across the footer and every localised README.
|
||||
@@ -368,6 +369,7 @@ export function Layout() {
|
||||
<Link to="/settings" className="text-primary underline">{t("layout.settings")}</Link>
|
||||
</div>
|
||||
) : <ConnectionBanner status={sseStatus} retryAttempt={sseRetryAttempt} />}
|
||||
<SafetyTelemetryBar />
|
||||
<main id="main" className="flex-1 min-h-0 overflow-auto">
|
||||
<PageHelp />
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -187,6 +187,27 @@
|
||||
50% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pixel-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
.pixel-character--active .pixel-character__body {
|
||||
animation: pixel-bob 1.1s steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.pixel-speech-bubble {
|
||||
position: relative;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--popover));
|
||||
box-shadow: 2px 2px 0 hsl(var(--foreground) / 0.12);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pixel-character--active .pixel-character__body {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Message/hero entrance — one subtle rise per mount; removed under reduced motion */
|
||||
@keyframes msg-enter {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
|
||||
@@ -22,6 +22,10 @@ const PRESET_WELCOME_KEYS: Record<string, string> = {
|
||||
value_investing_committee: "welcome.examples.valueCommittee",
|
||||
};
|
||||
|
||||
const PRESET_DEFAULT_LABELS: Record<string, string> = {
|
||||
trading_agents: "TradingAgents Decision Pipeline",
|
||||
};
|
||||
|
||||
export function localizeStatus(
|
||||
status: keyof typeof STATUS_TRANSLATION_KEYS,
|
||||
t: ManifestTranslation,
|
||||
@@ -33,9 +37,11 @@ export function localizePreset(
|
||||
preset: string,
|
||||
t: ManifestTranslation,
|
||||
): string {
|
||||
const humanized = preset
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase());
|
||||
const humanized =
|
||||
PRESET_DEFAULT_LABELS[preset] ??
|
||||
preset
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\p{L}/u, (letter) => letter.toLocaleUpperCase());
|
||||
const welcomeKey = PRESET_WELCOME_KEYS[preset];
|
||||
const fallback = welcomeKey
|
||||
? t(welcomeKey, { defaultValue: humanized })
|
||||
|
||||
@@ -1,8 +1,314 @@
|
||||
import { useCallback, useEffect, useState, type CSSProperties, type FormEvent } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
Coffee,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Swords,
|
||||
} from "lucide-react";
|
||||
import { api, type OfficeSnapshot } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SpatialZoneId =
|
||||
| "lounge"
|
||||
| "analyst_pods"
|
||||
| "debate_arena"
|
||||
| "risk_war_room"
|
||||
| "whatsapp_desk"
|
||||
| "quarantine_corner";
|
||||
|
||||
export type SimulationScenario =
|
||||
| "live_pipeline"
|
||||
| "analyst_sweep"
|
||||
| "bull_bear_clash"
|
||||
| "risk_deliberation"
|
||||
| "whatsapp_approval"
|
||||
| "quarantine_drill";
|
||||
|
||||
interface TradingFloorSprite {
|
||||
id: string;
|
||||
label: string;
|
||||
tier: "quick_think" | "deep_think";
|
||||
color: string;
|
||||
defaultZone: SpatialZoneId;
|
||||
tools: string[];
|
||||
speechByScenario: Record<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() {
|
||||
const { t } = useTranslation();
|
||||
const [snapshot, setSnapshot] = useState<OfficeSnapshot | null>(null);
|
||||
@@ -13,6 +319,9 @@ export function PixelOffice() {
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState("normal");
|
||||
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 () => {
|
||||
setPending(true);
|
||||
try {
|
||||
@@ -20,103 +329,528 @@ export function PixelOffice() {
|
||||
setSnapshot(value);
|
||||
setAgentId((current) => current || value.agents[0]?.id || "");
|
||||
setError("");
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setPending(false); }
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
async function mutate(action: "create" | "assign" | "complete" | "rank", arguments_: Record<string, unknown>) {
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function mutate(
|
||||
action: "create" | "assign" | "complete" | "rank",
|
||||
arguments_: Record<string, unknown>,
|
||||
) {
|
||||
setPending(true);
|
||||
try {
|
||||
setSnapshot(await api.updatePixelOffice(action, arguments_));
|
||||
setError("");
|
||||
return true;
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); return false; }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function create(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (await mutate("create", { task_id: crypto.randomUUID(), title, description, priority, assigned_to: agentId || null })) {
|
||||
setTitle(""); setDescription("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
const input = "mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm";
|
||||
|
||||
async function create(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
await mutate("create", {
|
||||
task_id: crypto.randomUUID(),
|
||||
title,
|
||||
description,
|
||||
priority,
|
||||
assigned_to: agentId || null,
|
||||
})
|
||||
) {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
}
|
||||
}
|
||||
|
||||
const input =
|
||||
"mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm";
|
||||
const agents = snapshot?.agents ?? [];
|
||||
const agentName = (id: string | null) => agents.find((agent) => agent.id === id)?.name ?? t("pixelOffice.unassigned");
|
||||
const agentName = (id: string | null) =>
|
||||
agents.find((agent) => agent.id === id)?.name ?? t("pixelOffice.unassigned");
|
||||
|
||||
const selectedSprite = useMemo(
|
||||
() =>
|
||||
TRADING_FLOOR_SPRITES.find((s) => s.id === selectedSpriteId) ??
|
||||
TRADING_FLOOR_SPRITES[0],
|
||||
[selectedSpriteId],
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div><h1 className="text-2xl font-semibold">{t("pixelOffice.title")}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{t("pixelOffice.description")}</p></div>
|
||||
<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>
|
||||
</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 && <>
|
||||
<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>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<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">
|
||||
<Lock className="h-3 w-3" />
|
||||
PAPER ONLY · SHADOW LEDGER
|
||||
</span>
|
||||
<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">
|
||||
<ShieldCheck className="h-3 w-3 text-primary" />
|
||||
Star-Office-UI × TradingAgents v0.6.0
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{t("pixelOffice.title")}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
|
||||
{t("pixelOffice.description")}
|
||||
</p>
|
||||
</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>
|
||||
</>}
|
||||
<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>
|
||||
</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>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -112,6 +112,45 @@ export function Runtime() {
|
||||
</button>
|
||||
</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 ? (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
|
||||
Reference in new issue
Block a user