Spaces:
Sleeping
Sleeping
| 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) | |
| 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()) | |