first commit
deploy GitHub Pages / deploy (push) Has been cancelled
tests / test (push) Has been cancelled

This commit is contained in:
2026-07-22 18:53:44 +08:00
commit 9259325d8a
60 changed files with 11081 additions and 0 deletions
View File
+38
View File
@@ -0,0 +1,38 @@
"""Pytest fixtures — temp SQLite per test, fresh Settings singleton."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
import pytest
@pytest.fixture(autouse=True)
def _tmp_arb_env(monkeypatch, tmp_path: Path) -> Path:
"""Point every test at a throwaway DB + kill file."""
db_path = tmp_path / "arb.db"
kill_path = tmp_path / "KILL"
monkeypatch.setenv("ARB_DB_PATH", str(db_path))
monkeypatch.setenv("ARB_KILL_SWITCH_FILE", str(kill_path))
monkeypatch.setenv("ARB_MODE", "paper")
# Refresh the settings singleton and rebind any module-level imports.
import arbitrage.config as cfg
cfg.settings = cfg.Settings()
import arbitrage.db as dbmod
dbmod.settings = cfg.settings
import arbitrage.clients.polymarket_rest as rest
rest.settings = cfg.settings
import arbitrage.engine.paper_fills as pf
pf.settings = cfg.settings
import arbitrage.web.app as web
web.settings = cfg.settings
return tmp_path
@pytest.fixture
async def db(_tmp_arb_env):
from arbitrage.db import init_db
await init_db()
return _tmp_arb_env
+158
View File
@@ -0,0 +1,158 @@
"""CLI + scan-loop integration tests."""
from __future__ import annotations
import asyncio
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry
from arbitrage.clients.polymarket_rest import normalize_event, upsert_events
from arbitrage.engine.loop import hydrate_event_index, run_scan_loop
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.engine.paper_fills import PaperExecutor
from arbitrage.models import BasketStatus
def _raw_event(event_id: str = "0xmkt") -> dict:
return {
"id": event_id,
"slug": "slug",
"title": "Title",
"negRisk": True,
"negRiskMarketID": event_id,
"markets": [
{"clobTokenIds": ["A", "A2"], "groupItemTitle": "A"},
{"clobTokenIds": ["B", "B2"], "groupItemTitle": "B"},
],
}
@pytest.mark.asyncio
async def test_hydrate_event_index_loads_from_db(db) -> None:
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
n = await hydrate_event_index(index)
assert n == 1
assert index.event_for_token("A") is not None
@pytest.mark.asyncio
async def test_run_scan_loop_emits_basket_from_live_book(db) -> None:
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
await hydrate_event_index(index)
books = BookRegistry()
engine = OpportunityEngine(
books=books,
index=index,
config=EngineConfig(
min_net_edge_bps=50,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=D("50"),
),
)
executor = PaperExecutor(books=books, latency_ms=0)
# Kick the scan loop off and feed it two arbitrage-crossing books.
task = asyncio.create_task(
run_scan_loop(books=books, index=index, engine=engine, executor=executor)
)
# Give both the scan loop and engine.run() a chance to subscribe.
for _ in range(5):
await asyncio.sleep(0)
books.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
books.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
# Wait for a basket row to appear (bounded)
from arbitrage.db import db_conn
async def has_basket() -> bool:
async with db_conn() as conn:
cur = await conn.execute("SELECT COUNT(*) FROM baskets")
(n,) = await cur.fetchone()
return n > 0
for _ in range(40): # up to ~2s
if await has_basket():
break
await asyncio.sleep(0.05)
assert await has_basket(), "scan loop did not persist a basket"
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def test_cli_help_exits_cleanly() -> None:
from arbitrage.cli import main
with pytest.raises(SystemExit) as exc:
main(["--help"])
assert exc.value.code == 0
def test_cli_init_creates_db(tmp_path, monkeypatch) -> None:
db_path = tmp_path / "cli.db"
monkeypatch.setenv("ARB_DB_PATH", str(db_path))
import arbitrage.config as cfg
cfg.settings = cfg.Settings()
import arbitrage.db as dbmod
dbmod.settings = cfg.settings
import arbitrage.cli as cli
cli.settings = cfg.settings
cli.main(["init"])
assert db_path.exists()
def test_cli_resolve_updates_basket(_tmp_arb_env) -> None:
"""End-to-end: set up a paper basket then run `arb resolve` to redeem it."""
async def setup() -> str:
from arbitrage.db import init_db
await init_db()
ev = normalize_event(_raw_event())
assert ev is not None
await upsert_events([ev])
index = EventIndex()
await hydrate_event_index(index)
books = BookRegistry()
books.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
books.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
engine = OpportunityEngine(
books=books, index=index,
config=EngineConfig(
min_net_edge_bps=50, fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"), max_basket_usd=D("50"),
),
)
opp = engine.evaluate(index.by_event_id["0xmkt"])
assert opp is not None
basket = await PaperExecutor(books=books, latency_ms=0).execute_now(opp)
assert basket is not None
return basket.id
async def check(basket_id: str) -> str:
from arbitrage.db import db_conn
async with db_conn() as conn:
(status,) = await (
await conn.execute(
"SELECT status FROM baskets WHERE id=?", (basket_id,)
)
).fetchone()
return status
basket_id = asyncio.run(setup())
from arbitrage.cli import main
rc = main(["resolve", "0xmkt", "--winner", "A"])
assert rc == 0
assert asyncio.run(check(basket_id)) == BasketStatus.REDEEMED.value
+124
View File
@@ -0,0 +1,124 @@
"""L2 book delta + registry tests."""
from __future__ import annotations
import asyncio
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry, LevelChange, LiveBook, Side
class TestLiveBook:
def test_snapshot_sets_best_bid_ask(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[(D("0.10"), D("100")), (D("0.12"), D("50"))],
asks=[(D("0.20"), D("80")), (D("0.19"), D("40"))],
)
assert b.best_bid() == (D("0.12"), D("50"))
assert b.best_ask() == (D("0.19"), D("40"))
def test_snapshot_drops_zero_size_levels(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[(D("0.10"), D("0"))], asks=[(D("0.20"), D("10"))])
assert b.best_bid() is None
def test_delta_removes_level_at_zero(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10")), (D("0.60"), D("5"))])
b.apply_delta([LevelChange(price=D("0.50"), size=D("0"), side=Side.ASK)])
assert b.best_ask() == (D("0.60"), D("5"))
def test_delta_replaces_size_at_level(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10"))])
b.apply_delta([LevelChange(price=D("0.50"), size=D("3"), side=Side.ASK)])
assert b.best_ask() == (D("0.50"), D("3"))
def test_vwap_buy_walks_multiple_levels(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[],
asks=[(D("0.19"), D("40")), (D("0.20"), D("80")), (D("0.21"), D("120"))],
)
result = b.vwap_buy(D("100"))
assert result is not None
vwap, filled, levels = result
assert vwap == D("0.196") # (40*0.19 + 60*0.20) / 100
assert filled == D("100")
assert levels == 2
def test_vwap_buy_partial_if_insufficient_depth(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("10"))])
result = b.vwap_buy(D("100"))
assert result is not None
_, filled, _ = result
assert filled == D("10")
def test_vwap_buy_none_on_empty_book(self) -> None:
b = LiveBook(token_id="t")
assert b.vwap_buy(D("10")) is None
def test_to_snapshot_orders_bids_descending(self) -> None:
b = LiveBook(token_id="t")
b.apply_snapshot(
bids=[(D("0.10"), D("1")), (D("0.12"), D("2")), (D("0.11"), D("3"))],
asks=[],
)
snap = b.to_snapshot()
assert [lvl.price for lvl in snap.bids] == [D("0.12"), D("0.11"), D("0.10")]
def test_sequence_increments_on_updates(self) -> None:
b = LiveBook(token_id="t")
assert b.snapshots_applied == 0 and b.deltas_applied == 0
b.apply_snapshot(bids=[], asks=[(D("0.5"), D("1"))])
b.apply_delta([LevelChange(price=D("0.5"), size=D("2"), side=Side.ASK)])
b.apply_delta([LevelChange(price=D("0.5"), size=D("0"), side=Side.ASK)])
assert b.snapshots_applied == 1
assert b.deltas_applied == 2
class TestBookRegistry:
async def test_registry_publishes_updates_to_subscribers(self) -> None:
reg = BookRegistry()
events: list[str] = []
async def reader() -> None:
async for u in reg.updates():
events.append(u.reason)
if len(events) >= 2:
return
task = asyncio.create_task(reader())
await asyncio.sleep(0)
reg.apply_snapshot("x", bids=[], asks=[(D("0.5"), D("1"))])
reg.apply_delta("x", [LevelChange(price=D("0.5"), size=D("0"), side=Side.ASK)])
await asyncio.wait_for(task, timeout=1.0)
assert events == ["snapshot", "delta"]
def test_get_returns_none_for_unknown_token(self) -> None:
reg = BookRegistry()
assert reg.get("missing") is None
def test_book_creates_and_returns_same_instance(self) -> None:
reg = BookRegistry()
a = reg.book("t")
b = reg.book("t")
assert a is b
@pytest.mark.parametrize(
"raw,expected",
[
("BUY", Side.BID),
("buy", Side.BID),
("bid", Side.BID),
("SELL", Side.ASK),
("ASK", Side.ASK),
],
)
def test_level_change_from_raw(raw: str, expected: Side) -> None:
lc = LevelChange.from_raw({"price": "0.5", "size": "1", "side": raw})
assert lc.side is expected
+115
View File
@@ -0,0 +1,115 @@
"""Opportunity engine math tests."""
from __future__ import annotations
from decimal import Decimal as D
from arbitrage.book.l2 import BookRegistry
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.models import Event, Outcome
def _two_outcome_event() -> Event:
return Event(
id="e1",
slug="e1",
title="two-outcome",
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),
),
)
def _engine(
reg: BookRegistry,
index: EventIndex,
*,
min_bps: int = 50,
max_basket_usd: D = D("500"),
) -> OpportunityEngine:
return OpportunityEngine(
books=reg,
index=index,
config=EngineConfig(
min_net_edge_bps=min_bps,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=max_basket_usd,
),
)
class TestEvaluate:
def test_detects_arb_when_asks_sum_below_one(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
assert opp.event_id == "e1"
assert opp.net_edge_bps >= 50
assert opp.max_baskets > 0
def test_rejects_when_asks_sum_above_one(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.70"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
assert _engine(reg, idx).evaluate(ev) is None
def test_rejects_when_any_leg_has_no_asks(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
assert _engine(reg, idx).evaluate(ev) is None
def test_rejects_when_edge_below_threshold(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Sum = 0.995 -> 50bps gross, net will be below 50 after gas
reg.apply_snapshot("A", bids=[], asks=[(D("0.495"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.500"), D("200"))])
assert _engine(reg, idx, min_bps=50).evaluate(ev) is None
def test_depth_clips_basket_count(self) -> None:
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Leg A is very thin
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("12"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("10000"))])
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
assert opp.max_baskets <= D("12")
def test_vwap_degrades_with_size(self) -> None:
"""At large sizes we consume worse levels; edge per basket must shrink."""
ev = _two_outcome_event()
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
# Both legs: cheap top, expensive deep levels
reg.apply_snapshot(
"A", bids=[], asks=[(D("0.40"), D("10")), (D("0.48"), D("10000"))]
)
reg.apply_snapshot(
"B", bids=[], asks=[(D("0.50"), D("10")), (D("0.52"), D("10000"))]
)
opp = _engine(reg, idx).evaluate(ev)
assert opp is not None
# At size 10, sum_vwap = 0.90. At larger sizes it'll rise toward 1.00.
# The engine chose the size maximizing expected profit, so sum is <= 1.0.
assert opp.sum_vwap_asks <= D("1.0")
+144
View File
@@ -0,0 +1,144 @@
"""Paper executor + resolution tests."""
from __future__ import annotations
from decimal import Decimal as D
import pytest
from arbitrage.book.l2 import BookRegistry
from arbitrage.db import db_conn
from arbitrage.engine.opportunity import EngineConfig, EventIndex, OpportunityEngine
from arbitrage.engine.paper_fills import (
PaperExecutor,
mark_resolution,
simulate_leg_fill,
)
from arbitrage.models import BasketStatus, Event, Outcome
def _setup_event_and_books():
ev = Event(
id="evt",
slug="s",
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),
),
)
idx = EventIndex()
idx.upsert(ev)
reg = BookRegistry()
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("200"))])
reg.apply_snapshot("B", bids=[], asks=[(D("0.50"), D("200"))])
eng = OpportunityEngine(
books=reg,
index=idx,
config=EngineConfig(
min_net_edge_bps=50,
fees_per_share_usd=D("0"),
gas_per_basket_usd=D("0.10"),
max_basket_usd=D("100"),
),
)
return ev, reg, eng
def test_simulate_leg_fill_partial_depth() -> None:
from arbitrage.book.l2 import LiveBook
b = LiveBook(token_id="t")
b.apply_snapshot(bids=[], asks=[(D("0.50"), D("5"))])
res = simulate_leg_fill(b, D("100"))
assert res.filled == D("5")
assert res.vwap_price == D("0.50")
def test_simulate_leg_fill_empty_book() -> None:
from arbitrage.book.l2 import LiveBook
b = LiveBook(token_id="t")
res = simulate_leg_fill(b, D("10"))
assert res.filled == 0
assert res.levels_consumed == 0
@pytest.mark.asyncio
async def test_successful_basket_reaches_pending_resolution(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
assert basket.status is BasketStatus.PENDING_RESOLUTION
assert basket.basket_count == opp.max_baskets
assert basket.total_cost_usd > 0
@pytest.mark.asyncio
async def test_vanishing_depth_produces_failed_basket(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
reg.apply_snapshot("A", bids=[], asks=[(D("0.40"), D("1"))])
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
assert basket.status is BasketStatus.FAILED
@pytest.mark.asyncio
async def test_resolution_redeems_winning_basket(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
updated = await mark_resolution("evt", winning_token_id="A")
assert updated == 1
async with db_conn() as conn:
row = await (
await conn.execute(
"SELECT status, realized_pnl_usd FROM baskets WHERE id=?", (basket.id,)
)
).fetchone()
assert row[0] == BasketStatus.REDEEMED.value
assert D(row[1]) > 0
@pytest.mark.asyncio
async def test_invalid_resolution_marks_basket_loss(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
execr = PaperExecutor(books=reg, latency_ms=0)
basket = await execr.execute_now(opp)
assert basket is not None
updated = await mark_resolution("evt", winning_token_id=None)
assert updated == 1
async with db_conn() as conn:
row = await (
await conn.execute(
"SELECT status, realized_pnl_usd FROM baskets WHERE id=?", (basket.id,)
)
).fetchone()
assert row[0] == BasketStatus.INVALID.value
assert D(row[1]) < 0
@pytest.mark.asyncio
async def test_resolution_is_idempotent(db) -> None:
ev, reg, eng = _setup_event_and_books()
opp = eng.evaluate(ev)
assert opp is not None
await PaperExecutor(books=reg, latency_ms=0).execute_now(opp)
first = await mark_resolution("evt", winning_token_id="A")
second = await mark_resolution("evt", winning_token_id="A")
assert first == 1
assert second == 0 # nothing pending anymore
+121
View File
@@ -0,0 +1,121 @@
"""REST discovery normalization + persistence tests."""
from __future__ import annotations
from decimal import Decimal as D
import pytest
from arbitrage.clients.polymarket_rest import (
mark_inactive,
normalize_event,
upsert_events,
)
from arbitrage.db import db_conn
def _good_raw() -> dict:
return {
"id": 12345,
"slug": "world-cup",
"title": "World Cup",
"negRisk": True,
"negRiskMarketID": "0xmkt",
"endDate": "2026-07-20T00:00:00Z",
"markets": [
{"conditionId": "0xc1", "clobTokenIds": ["11", "22"], "groupItemTitle": "Brazil"},
{"conditionId": "0xc2", "clobTokenIds": '["33","44"]', "groupItemTitle": "France"},
{"conditionId": "0xc3", "clobTokenIds": ["55", "66"], "groupItemTitle": "Argentina"},
],
}
class TestNormalize:
def test_accepts_well_formed_event(self) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
assert ev.id == "0xmkt"
assert len(ev.outcomes) == 3
assert [o.token_id for o in ev.outcomes] == ["11", "33", "55"]
assert [o.name for o in ev.outcomes] == ["Brazil", "France", "Argentina"]
assert ev.end_date is not None and ev.end_date.year == 2026
def test_rejects_non_neg_risk(self) -> None:
raw = _good_raw() | {"negRisk": False}
assert normalize_event(raw) is None
def test_rejects_too_few_outcomes(self) -> None:
raw = _good_raw()
raw["markets"] = raw["markets"][:1]
assert normalize_event(raw) is None
def test_rejects_closed_child_market(self) -> None:
raw = _good_raw()
raw["markets"][0]["closed"] = True
assert normalize_event(raw) is None
def test_rejects_duplicate_token_ids(self) -> None:
raw = _good_raw()
raw["markets"][1]["clobTokenIds"] = ["11", "99"] # dup of market[0]
assert normalize_event(raw) is None
def test_parses_json_string_token_ids(self) -> None:
raw = _good_raw()
raw["markets"][0]["clobTokenIds"] = '["11","22"]'
ev = normalize_event(raw)
assert ev is not None
assert ev.outcomes[0].token_id == "11"
def test_rejects_malformed_token_ids_json(self) -> None:
raw = _good_raw()
raw["markets"][0]["clobTokenIds"] = "not-json{"
assert normalize_event(raw) is None
def test_falls_back_to_event_id_when_neg_risk_market_id_missing(self) -> None:
raw = _good_raw()
del raw["negRiskMarketID"]
ev = normalize_event(raw)
assert ev is not None
assert ev.id == "12345"
@pytest.mark.asyncio
async def test_upsert_is_idempotent(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
assert await upsert_events([ev]) == 1
assert await upsert_events([ev]) == 1
async with db_conn() as conn:
(count,) = await (await conn.execute("SELECT COUNT(*) FROM events")).fetchone()
(outcome_count,) = await (
await conn.execute("SELECT COUNT(*) FROM outcomes")
).fetchone()
assert count == 1
assert outcome_count == 3
@pytest.mark.asyncio
async def test_mark_inactive_flips_dropped_events(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
await upsert_events([ev])
dropped = await mark_inactive({"kept-other-event"})
assert dropped == 1
async with db_conn() as conn:
(active,) = await (
await conn.execute("SELECT active FROM events WHERE id=?", (ev.id,))
).fetchone()
assert active == 0
@pytest.mark.asyncio
async def test_mark_inactive_preserves_kept_events(db) -> None:
ev = normalize_event(_good_raw())
assert ev is not None
await upsert_events([ev])
dropped = await mark_inactive({ev.id})
assert dropped == 0
async with db_conn() as conn:
(active,) = await (
await conn.execute("SELECT active FROM events WHERE id=?", (ev.id,))
).fetchone()
assert active == 1
+99
View File
@@ -0,0 +1,99 @@
"""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")))
+53
View File
@@ -0,0 +1,53 @@
"""FastAPI dashboard endpoint smoke tests."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from arbitrage.web.app import create_app
@pytest.fixture
def client(_tmp_arb_env):
app = create_app()
with TestClient(app) as c:
yield c
def test_index_renders(client) -> None:
r = client.get("/")
assert r.status_code == 200
assert "arbitrage" in r.text
assert "paper" in r.text
def test_pnl_fragment_renders(client) -> None:
r = client.get("/fragments/pnl")
assert r.status_code == 200
assert "paper pnl" in r.text
def test_opportunities_fragment_empty_state(client) -> None:
r = client.get("/fragments/opportunities")
assert r.status_code == 200
assert "no opportunities yet" in r.text
def test_baskets_fragment_empty_state(client) -> None:
r = client.get("/fragments/baskets")
assert r.status_code == 200
assert "no baskets yet" in r.text
def test_kill_toggle_round_trip(client, _tmp_arb_env) -> None:
import arbitrage.config as cfg
assert not cfg.settings.kill_switch_file.exists()
r = client.post("/kill")
assert r.status_code == 200
assert "KILL SWITCH ACTIVE" in r.text
assert cfg.settings.kill_switch_file.exists()
r = client.post("/unkill")
assert r.status_code == 200
assert "KILL SWITCH ACTIVE" not in r.text
assert not cfg.settings.kill_switch_file.exists()
+96
View File
@@ -0,0 +1,96 @@
"""Polymarket CLOB WS message dispatch (parse-only, no live socket)."""
from __future__ import annotations
from decimal import Decimal as D
from arbitrage.book.l2 import BookRegistry
from arbitrage.clients.polymarket_ws import MarketChannel, shard_tokens
def _channel(reg: BookRegistry) -> MarketChannel:
return MarketChannel(["tok1", "tok2"], registry=reg)
def test_book_snapshot_message_populates_registry() -> None:
reg = BookRegistry()
_channel(reg)._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"market": "m1",
"bids": [{"price": "0.10", "size": "100"}, {"price": "0.12", "size": "50"}],
"asks": [{"price": "0.20", "size": "80"}],
"timestamp": "1700000000000",
"hash": "0xaa",
}
)
book = reg.get("tok1")
assert book is not None
assert book.best_bid() == (D("0.12"), D("50"))
assert book.best_ask() == (D("0.20"), D("80"))
assert book.last_hash == "0xaa"
def test_price_change_applies_delta_per_asset() -> None:
reg = BookRegistry()
ch = _channel(reg)
ch._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"market": "m",
"bids": [],
"asks": [{"price": "0.20", "size": "80"}],
"timestamp": "1",
}
)
ch._dispatch(
{
"event_type": "price_change",
"market": "m",
"timestamp": "2",
"price_changes": [
{"asset_id": "tok1", "price": "0.20", "size": "0", "side": "SELL"},
{"asset_id": "tok1", "price": "0.19", "size": "30", "side": "SELL"},
{"asset_id": "tok1", "price": "0.11", "size": "60", "side": "BUY"},
],
}
)
book = reg.get("tok1")
assert book.best_ask() == (D("0.19"), D("30"))
assert book.best_bid() == (D("0.11"), D("60"))
def test_zero_size_removes_level() -> None:
reg = BookRegistry()
ch = _channel(reg)
ch._dispatch(
{
"event_type": "book",
"asset_id": "tok1",
"bids": [],
"asks": [{"price": "0.20", "size": "10"}],
"timestamp": "1",
}
)
ch._dispatch(
{
"event_type": "price_change",
"timestamp": "2",
"price_changes": [
{"asset_id": "tok1", "price": "0.20", "size": "0", "side": "SELL"}
],
}
)
assert reg.get("tok1").best_ask() is None
def test_unknown_event_type_is_ignored() -> None:
reg = BookRegistry()
_channel(reg)._dispatch({"event_type": "who_knows", "asset_id": "tok1"})
assert reg.get("tok1") is None
def test_shard_tokens_splits_evenly() -> None:
shards = shard_tokens([str(i) for i in range(250)], shard_size=100)
assert [len(s) for s in shards] == [100, 100, 50]