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
634 lines
23 KiB
Python
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()
|