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
770 lines
31 KiB
Python
770 lines
31 KiB
Python
"""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()
|