Files
Auto-Trader/agent/tests/test_phase0_swarm_mandate_whatsapp.py
T
Kenneth Hau aab57cdeb3
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
feat: Phase 0/1 TradingAgents swarm graft, mandate paper automation, and P0 WhatsApp outbound send
2026-10-16 14:58:47 +08:00

634 lines
23 KiB
Python

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