first commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")))
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user