100 lines
3.0 KiB
Python
100 lines
3.0 KiB
Python
"""Live executor risk gate tests — no py-clob-client required."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal as D
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from arbitrage.db import db_conn
|
|
from arbitrage.engine.live_executor import RiskDenied, RiskLimits, risk_gate
|
|
from arbitrage.models import Event, Opportunity, OpportunityLeg, Outcome
|
|
|
|
|
|
def _opportunity(cost_per_share: D, size: D) -> Opportunity:
|
|
ev = Event(
|
|
id="e",
|
|
slug="e",
|
|
title="t",
|
|
is_neg_risk=True,
|
|
end_date=None,
|
|
outcomes=(
|
|
Outcome(token_id="A", name="A", outcome_index=0),
|
|
Outcome(token_id="B", name="B", outcome_index=1),
|
|
),
|
|
)
|
|
return Opportunity.from_legs(
|
|
detected_at=datetime.now(UTC),
|
|
event=ev,
|
|
legs=(
|
|
OpportunityLeg(
|
|
token_id="A",
|
|
outcome_name="A",
|
|
outcome_index=0,
|
|
vwap_price=cost_per_share / D(2),
|
|
size=size,
|
|
levels_consumed=1,
|
|
),
|
|
OpportunityLeg(
|
|
token_id="B",
|
|
outcome_name="B",
|
|
outcome_index=1,
|
|
vwap_price=cost_per_share / D(2),
|
|
size=size,
|
|
levels_consumed=1,
|
|
),
|
|
),
|
|
fees_per_share=D("0"),
|
|
gas_per_basket_usd=D("0.10"),
|
|
max_baskets=size,
|
|
)
|
|
|
|
|
|
def _limits(**overrides) -> RiskLimits:
|
|
base = dict(
|
|
max_basket_usd=D("200"),
|
|
max_open_baskets=3,
|
|
max_open_baskets_per_event=1,
|
|
daily_loss_stop_usd=D("100"),
|
|
kill_switch_file=Path("/nope/does-not-exist"),
|
|
)
|
|
base.update(overrides)
|
|
return RiskLimits(**base)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_risk_gate_accepts_within_limits(db) -> None:
|
|
opp = _opportunity(D("0.90"), D("100")) # cost = $90
|
|
await risk_gate(opp, _limits())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_risk_gate_rejects_oversized_basket(db) -> None:
|
|
opp = _opportunity(D("0.90"), D("1000")) # cost = $900
|
|
with pytest.raises(RiskDenied, match="basket cost"):
|
|
await risk_gate(opp, _limits(max_basket_usd=D("500")))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_risk_gate_rejects_when_kill_switch_present(db, tmp_path) -> None:
|
|
kill = tmp_path / "KILL"
|
|
kill.touch()
|
|
opp = _opportunity(D("0.90"), D("100"))
|
|
with pytest.raises(RiskDenied, match="kill switch"):
|
|
await risk_gate(opp, _limits(kill_switch_file=kill))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_risk_gate_rejects_daily_loss_stop(db) -> None:
|
|
async with db_conn() as conn:
|
|
today = datetime.now(UTC).date().isoformat()
|
|
await conn.execute(
|
|
"INSERT INTO daily_pnl (date, live_pnl_usd) VALUES (?, ?)",
|
|
(today, "-150.00"),
|
|
)
|
|
await conn.commit()
|
|
opp = _opportunity(D("0.90"), D("100"))
|
|
with pytest.raises(RiskDenied, match="daily loss stop"):
|
|
await risk_gate(opp, _limits(daily_loss_stop_usd=D("100")))
|