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