Files
Auto-Trader/agent/tests/test_phase1_paper_automation.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

770 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()