from __future__ import annotations import asyncio import json import pytest import trading.domain.execution_service as execution_module from trading.adapters.paper_adapter import PaperExchangeAdapter from trading.domain.approvals import create_approval from trading.domain.execution_contracts import ExecuteApprovedPlanCommand, ExecutionErrorCode from trading.domain.execution_service import ExecutionService, ExecutionServiceError from trading.domain.identity_repo import ensure_paper_exchange_account from trading.domain.plan_revalidation import PlanRevalidationResult from trading.domain.plans_repo import create_plan from trading.domain.risk_limits import MarketRiskContext, RiskRejected from trading.domain.schema import apply_migrations, connect from trading.futures_execution import TradingError class SpyPaperAdapter(PaperExchangeAdapter): def __init__(self, *, default_price: float = 100.0) -> None: super().__init__(default_price=default_price) self.load_market_calls = 0 self.configure_calls = 0 self.create_order_calls = 0 async def load_markets(self): self.load_market_calls += 1 return await super().load_markets() async def configure_account(self, symbol, leverage, margin_mode, position_mode): self.configure_calls += 1 return await super().configure_account(symbol, leverage, margin_mode, position_mode) async def create_order(self, req): self.create_order_calls += 1 return await super().create_order(req) @pytest.fixture(autouse=True) def _db(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("TRADING_MODE", "paper") monkeypatch.setenv("HERMES_EXECUTION_ENABLED", "true") ok, messages = apply_migrations() assert ok, messages def _plan(**overrides): plan = { "symbol": "BTCUSDT", "decision": "LONG", "entry": 100.0, "stop_loss": 95.0, "take_profit": 110.0, "leverage": 3, "quantity": 1.0, "risk_profile": "moderate", "risk_percent": 0.01, "futuresVerified": True, "noTradeGuard": False, "trading_readiness": "ready", "executable": True, "expires_at": "2099-01-01T00:00:00Z", } plan.update(overrides) return plan def _accepted(plan_id: str) -> PlanRevalidationResult: return PlanRevalidationResult( revalidation_id=f"rv-{plan_id}", accepted=True, reasons=(), context_hash="context-hash", observed_price=100.0, observed_spread_bps=1.0, observed_slippage_percent=0.0, market_risk=MarketRiskContext( provider_fresh=True, reconciliation_clear=True, spread_bps=1.0, funding_rate=0.0, liquidity_ok=True, ), orderbook={"bids": [[99.9, 100]], "asks": [[100.0, 100]]}, ) def _setup(owner_id: str = "owner-validation"): account = ensure_paper_exchange_account(owner_id) plan = create_plan(_plan(), owner_id=owner_id, account_id=account.account_id) approval_id = create_approval( plan["plan_id"], plan["plan_hash"], owner_id, account_id=account.account_id, ) return owner_id, account, plan, approval_id def _command(base_owner_id, base_account, plan, approval_id, **overrides): values = { "plan_id": plan["plan_id"], "approval_id": approval_id, "owner_id": base_owner_id, "account_id": base_account.account_id, "session_id": None, "idempotency_key": f"validation:{plan['plan_id']}:{approval_id}", } values.update(overrides) return ExecuteApprovedPlanCommand(**values) def test_binding_mode_integrity_and_revalidation_fail_before_adapter_calls(monkeypatch): owner_id, account, plan, approval_id = _setup() adapter = SpyPaperAdapter() service = ExecutionService(adapter) async def rejected(plan_id, **_kwargs): result = _accepted(plan_id) return PlanRevalidationResult( **{**result.__dict__, "accepted": False, "reasons": ("plan expired",)} ) async def run(): with pytest.raises(ExecutionServiceError) as owner_error: await service.execute_approved_plan( _command(owner_id, account, plan, approval_id, owner_id="other-owner") ) assert owner_error.value.code is ExecutionErrorCode.BINDING_MISMATCH with pytest.raises(ExecutionServiceError) as account_error: await service.execute_approved_plan( _command(owner_id, account, plan, approval_id, account_id="other-account") ) assert account_error.value.code is ExecutionErrorCode.BINDING_MISMATCH conn = connect() try: conn.execute("UPDATE plans SET mode='testnet' WHERE plan_id=?", (plan["plan_id"],)) conn.commit() finally: conn.close() with pytest.raises(ExecutionServiceError) as mode_error: await service.execute_approved_plan(_command(owner_id, account, plan, approval_id)) assert mode_error.value.code is ExecutionErrorCode.MODE_NOT_ALLOWED conn = connect() try: conn.execute("UPDATE plans SET mode='paper' WHERE plan_id=?", (plan["plan_id"],)) row = conn.execute("SELECT payload FROM plans WHERE plan_id=?", (plan["plan_id"],)).fetchone() payload = json.loads(row[0]) payload["entry"] = 101.0 conn.execute("UPDATE plans SET payload=? WHERE plan_id=?", (json.dumps(payload), plan["plan_id"])) conn.commit() finally: conn.close() with pytest.raises(ExecutionServiceError) as hash_error: await service.execute_approved_plan(_command(owner_id, account, plan, approval_id)) assert hash_error.value.code is ExecutionErrorCode.PLAN_INTEGRITY_FAILED # Restore immutable payload and then prove an expiry/freshness rejection also # occurs before any adapter account mutation or submission. conn = connect() try: conn.execute("UPDATE plans SET payload=? WHERE plan_id=?", (json.dumps(plan), plan["plan_id"])) conn.commit() finally: conn.close() monkeypatch.setattr(execution_module, "revalidate_plan", rejected) with pytest.raises(ExecutionServiceError) as stale_error: await service.execute_approved_plan(_command(owner_id, account, plan, approval_id)) assert stale_error.value.code is ExecutionErrorCode.REVALIDATION_FAILED asyncio.run(run()) assert adapter.load_market_calls == 0 assert adapter.configure_calls == 0 assert adapter.create_order_calls == 0 def test_nonfinite_geometry_and_final_risk_fail_before_configuration_or_submission(monkeypatch): adapter = SpyPaperAdapter() service = ExecutionService(adapter) async def run(): with pytest.raises(TradingError, match="finite and positive"): await service._submit_entry( symbol="BTCUSDT", side="long", size=float("nan"), price=100.0, stop_loss=95.0, take_profit=110.0, ) assert adapter.load_market_calls == 0 def reject_risk(**_kwargs): raise RiskRejected("final risk rejected") monkeypatch.setattr(execution_module, "assert_entry_allowed", reject_risk) with pytest.raises(RiskRejected, match="final risk rejected"): await service._submit_entry( symbol="BTCUSDT", side="long", size=1.0, price=100.0, stop_loss=95.0, take_profit=110.0, ) asyncio.run(run()) assert adapter.configure_calls == 0 assert adapter.create_order_calls == 0 def test_unverified_market_fails_before_account_configuration_or_submission(): class MissingMarketAdapter(SpyPaperAdapter): async def load_markets(self): self.load_market_calls += 1 return {} adapter = MissingMarketAdapter() service = ExecutionService(adapter) async def run(): with pytest.raises(TradingError, match="market metadata unavailable"): await service._submit_entry( symbol="BTCUSDT", side="long", size=1.0, price=100.0, stop_loss=95.0, take_profit=110.0, ) asyncio.run(run()) assert adapter.load_market_calls == 1 assert adapter.configure_calls == 0 assert adapter.create_order_calls == 0 def test_existing_exposure_is_reconciled_before_second_configuration_or_submission(monkeypatch): owner_id, account, first_plan, first_approval = _setup("owner-exposure") adapter = SpyPaperAdapter() service = ExecutionService(adapter) async def accepted(plan_id, **_kwargs): return _accepted(plan_id) monkeypatch.setattr(execution_module, "revalidate_plan", accepted) async def run(): first = await service.execute_approved_plan( _command(owner_id, account, first_plan, first_approval) ) assert first["status"] == "protected" baseline = (adapter.load_market_calls, adapter.configure_calls, adapter.create_order_calls) second_plan = create_plan( _plan(entry=101.0, stop_loss=96.0, take_profit=111.0), owner_id=owner_id, account_id=account.account_id, ) second_approval = create_approval( second_plan["plan_id"], second_plan["plan_hash"], owner_id, account_id=account.account_id, ) with pytest.raises(ExecutionServiceError, match="existing exposure") as caught: await service.execute_approved_plan( _command(owner_id, account, second_plan, second_approval) ) assert caught.value.code is ExecutionErrorCode.EXECUTION_FAILED assert (adapter.load_market_calls, adapter.configure_calls, adapter.create_order_calls) == baseline asyncio.run(run())