Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s

- Implemented integration tests for the execution writer to ensure trade orders and PnL are persisted correctly.
- Created integration tests for the metrics calculator to summarize execution data accurately.
- Added integration tests for the opportunity writer to verify event persistence.
- Established PostgreSQL schema validation tests to ensure all expected tables, columns, and constraints exist.
- Removed outdated unit tests that relied on DuckDB and replaced them with tests using PgStore.
This commit is contained in:
2026-06-07 14:37:53 +02:00
parent 54feb2ecd4
commit 529ff967cc
44 changed files with 1955 additions and 1386 deletions
+1
View File
@@ -0,0 +1 @@
"""End-to-end tests — require full app startup with PostgreSQL."""
+1
View File
@@ -0,0 +1 @@
"""Integration tests for PostgreSQL schema and connectivity."""
+25
View File
@@ -0,0 +1,25 @@
"""pytest configuration for integration tests.
Integration tests require a live PostgreSQL server at the configured host.
They are skipped automatically if the server is unreachable.
"""
from __future__ import annotations
import pytest
def pytest_ignore_collect(path: str, config: pytest.Config) -> bool:
"""Skip integration tests unless --integration is passed."""
if "integration" in path and not config.getoption("--integration", False):
return True
return False
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--integration",
action="store_true",
default=False,
help="Run integration tests (requires PostgreSQL)",
)
@@ -0,0 +1,52 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
import pytest_asyncio
from arbitrade.config.settings import get_settings
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_audit_repository_inserts_and_lists_recent() -> None:
async with _pg() as store:
repository = AuditRepository(store)
await repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = await repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
@@ -0,0 +1,103 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
pytestmark = pytest.mark.integration
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl() -> None:
async with _pg() as store:
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
async with store.pool.acquire() as conn:
trades = await conn.fetch(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
)
orders = await conn.fetch(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
)
pnls = await conn.fetch("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events")
assert len(trades) == 1
assert trades[0]["status"] == "filled"
assert trades[0]["estimated_pnl"] == 0.03
assert trades[0]["capital_used"] == 1.0
assert trades[0]["cycle"] == "USD->BTC->ETH->USD"
assert trades[0]["leg_count"] == 3
assert len(orders) == 3
assert orders[0]["leg_index"] == 0
assert orders[1]["leg_index"] == 1
assert orders[2]["leg_index"] == 2
assert orders[0]["status"] == "submitted"
assert len(pnls) == 1
assert pnls[0]["kind"] == "estimated"
assert pnls[0]["pnl_usd"] == 0.03
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from arbitrade.config.settings import get_settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_metrics_calculator_summarizes_execution_data() -> None:
async with _pg() as store:
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
async with store.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO trades (
trade_ref, started_at, finished_at, status,
realized_pnl, estimated_pnl, capital_used, cycle, leg_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9),
($10, $11, $12, $13, $14, $15, $16, $17, $18)
""",
"trade-1", started, finished, "filled", 12.5, 10.0, 100.0, "USD->BTC->ETH->USD", 3,
"trade-2", started_two, finished_two, "filled", -
4.5, -2.0, 200.0, "USD->ETH->BTC->USD", 3,
)
await conn.execute(
"""
INSERT INTO opportunities (detected_at, cycle, gross_pct, net_pct, est_profit, executed)
VALUES ($1, $2, $3, $4, $5, $6),
($7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18)
""",
started, "USD->BTC->ETH->USD", 4.0, 3.0, 0.03, True,
started_two, "USD->ETH->BTC->USD", 2.0, 1.0, 0.01, False,
started_two +
timedelta(
seconds=30), "USD->BTC->ETH->USD", 5.0, 4.0, 0.04, True,
)
await conn.execute(
"""
INSERT INTO orders (
trade_ref, order_ref, leg_index, pair, side, volume,
user_ref, status, filled_volume, avg_price, raw_response, recorded_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12),
($13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
""",
"trade-1", "order-1", 0, "BTC/USD", "buy", 2.0, 101, "closed", 2.0, 100.0, "{}", started,
"trade-2", "order-2", 0, "ETH/USD", "sell", 4.0, 202, "closed", 3.0, 200.0, "{}", started_two,
)
metrics = await MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
@@ -0,0 +1,61 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import get_settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import OpportunityRepository
pytestmark = pytest.mark.integration
@asynccontextmanager
async def _pg() -> AsyncIterator[PgStore]:
s = get_settings()
store = PgStore(s)
try:
await store.start()
await store.migrate()
yield store
finally:
await store.stop()
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events() -> None:
async with _pg() as store:
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
)
assert len(rows) == 1
assert rows[0]["cycle"] == "USD->BTC->ETH->USD"
assert rows[0]["gross_pct"] == 4.0
assert rows[0]["net_pct"] == 3.0
assert rows[0]["est_profit"] == 0.03
assert rows[0]["executed"] is False
+359
View File
@@ -0,0 +1,359 @@
"""Integration tests: verify PostgreSQL schema and connection.
These tests connect to the PostgreSQL server at 192.168.88.35 and
validate that all expected tables, columns, and constraints exist.
They are skipped if the server is unreachable.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import pytest
import pytest_asyncio
from arbitrade.config.settings import Settings, get_settings
from arbitrade.storage.pg_store import PgStore
pytestmark = pytest.mark.integration
# ── expected schema ──────────────────────────────────────────────────────────
EXPECTED_TABLES: dict[str, list[str]] = {
"schema_migrations": ["version", "applied_at"],
"config_sections": ["id", "name", "description", "updated_at"],
"config_settings": [
"key", "section", "value_json", "value_type", "is_secret",
"is_runtime_reloadable", "updated_at", "updated_by",
],
"config_pairings": [
"id", "base_asset", "quote_asset", "enabled", "source",
"created_at", "updated_at",
],
"config_backtesting_defaults": [
"id", "starting_balances", "trade_capital", "min_profit_threshold",
"slippage_bps", "execution_latency_ms", "fee_source",
],
"opportunities": [
"id", "detected_at", "cycle", "gross_pct", "net_pct",
"est_profit", "executed",
],
"trades": [
"id", "trade_ref", "started_at", "finished_at", "status",
"realized_pnl", "estimated_pnl", "capital_used", "cycle", "leg_count",
],
"orders": [
"id", "trade_ref", "order_ref", "leg_index", "pair", "side",
"volume", "user_ref", "status", "filled_volume", "avg_price",
"raw_response", "recorded_at",
],
"pnl_events": [
"id", "trade_ref", "recorded_at", "kind", "pnl_usd", "source",
],
"portfolio_snapshots": ["snapshot_at", "balances", "total_value_usd"],
"market_snapshots": ["snapshot_at", "symbol", "source", "payload", "latency_ms"],
"audit_events": [
"id", "occurred_at", "actor", "event_type", "decision",
"payload", "correlation_id",
],
"runtime_state_snapshots": [
"snapshot_at", "is_running", "kill_switch_active", "kill_switch_reason",
"open_trade_count", "last_known_balances", "note",
],
"kraken_account_snapshots": [
"snapshot_at", "fee_tier", "maker_fee", "taker_fee",
"thirty_day_volume", "trade_balance_raw", "fee_schedule_raw",
],
"backtest_jobs": [
"id", "status", "events_path", "config", "report", "error",
"created_at", "started_at", "finished_at",
],
}
# Tables that should have a primary key
TABLES_WITH_PRIMARY_KEY: dict[str, str | list[str]] = {
"schema_migrations": "version",
"config_sections": "id",
"config_settings": "key",
"config_pairings": "id",
"config_backtesting_defaults": "id",
"opportunities": "id",
"trades": "id",
"orders": "id",
"pnl_events": "id",
"audit_events": "id",
"backtest_jobs": "id",
}
# Tables with a UNIQUE constraint beyond the primary key
TABLES_WITH_UNIQUE_CONSTRAINTS: dict[str, list[str]] = {
"config_sections": ["name"],
"config_pairings": ["base_asset, quote_asset"],
}
# ── fixtures ────────────────────────────────────────────────────────────────
@asynccontextmanager
async def _pg_lifecycle() -> AsyncIterator[PgStore]:
"""Connect, yield store, then disconnect."""
settings = get_settings()
store = PgStore(settings)
try:
await store.start()
yield store
finally:
await store.stop()
@pytest_asyncio.fixture(name="pg")
async def pg_fixture() -> AsyncIterator[PgStore]:
async with _pg_lifecycle() as store:
yield store
# ── helpers ─────────────────────────────────────────────────────────────────
async def _get_actual_tables(store: PgStore) -> dict[str, list[str]]:
"""Return {table_name: [column_name, ...]} for the public schema."""
actual: dict[str, list[str]] = {}
async with store.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT table_name, column_name FROM information_schema.columns "
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position"
)
for row in rows:
tbl: str = row["table_name"]
col: str = row["column_name"]
actual.setdefault(tbl, []).append(col)
return actual
async def _table_row_count(store: PgStore, table: str) -> int:
async with store.pool.acquire() as conn:
row = await conn.fetchrow(f"SELECT COUNT(*) AS cnt FROM {table}")
return int(row["cnt"]) if row else 0
# ── tests ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pg_connect(pg: PgStore) -> None:
"""Can connect to PostgreSQL and ping the server."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT 1 AS val")
assert val == 1
@pytest.mark.asyncio
async def test_pgcrypto_extension(pg: PgStore) -> None:
"""The pgcrypto extension is available (gen_random_uuid)."""
async with pg.pool.acquire() as conn:
val = await conn.fetchval("SELECT gen_random_uuid()")
assert val is not None
# The result should be a UUID object
assert len(str(val)) == 36 # UUID string length
@pytest.mark.asyncio
async def test_schema_migration_applies(pg: PgStore) -> None:
"""Migrate creates all expected tables."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual, (
f"Table '{table}' missing after migration. "
f"Found tables: {sorted(actual)}"
)
@pytest.mark.asyncio
async def test_migration_is_idempotent(pg: PgStore) -> None:
"""Running migrate twice does not raise."""
await pg.migrate()
await pg.migrate() # second call should be a no-op
actual = await _get_actual_tables(pg)
for table in EXPECTED_TABLES:
assert table in actual
@pytest.mark.asyncio
async def test_table_columns(pg: PgStore) -> None:
"""Every expected table has the correct columns."""
await pg.migrate()
actual = await _get_actual_tables(pg)
for table, expected_cols in EXPECTED_TABLES.items():
actual_cols = actual.get(table, [])
for col in expected_cols:
assert col in actual_cols, (
f"Column '{col}' missing from table '{table}'. "
f"Actual columns: {actual_cols}"
)
@pytest.mark.asyncio
async def test_primary_keys(pg: PgStore) -> None:
"""Tables that should have primary keys do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_pk in TABLES_WITH_PRIMARY_KEY.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'PRIMARY KEY' "
"ORDER BY kcu.ordinal_position",
table,
)
pk_columns = [r["column_name"] for r in rows]
expected_list = [expected_pk] if isinstance(expected_pk, str) else expected_pk
for col in expected_list:
assert col in pk_columns, (
f"Table '{table}' should have PK column '{col}'. "
f"Actual PK columns: {pk_columns}"
)
@pytest.mark.asyncio
async def test_unique_constraints(pg: PgStore) -> None:
"""Tables that should have UNIQUE constraints do."""
await pg.migrate()
async with pg.pool.acquire() as conn:
for table, expected_ucs in TABLES_WITH_UNIQUE_CONSTRAINTS.items():
rows = await conn.fetch(
"SELECT kcu.column_name FROM information_schema.table_constraints tc "
"JOIN information_schema.key_column_usage kcu "
"ON tc.constraint_name = kcu.constraint_name "
"WHERE tc.table_schema = 'public' AND tc.table_name = $1 "
"AND tc.constraint_type = 'UNIQUE'",
table,
)
uc_columns = {r["column_name"] for r in rows}
for expected_cols in expected_ucs:
cols = [c.strip() for c in expected_cols.split(",")]
for col in cols:
assert col in uc_columns, (
f"Table '{table}' should have UNIQUE column '{col}'. "
f"Actual UNIQUE columns: {uc_columns}"
)
@pytest.mark.asyncio
async def test_table_row_count_is_zero(pg: PgStore) -> None:
"""All tables start empty after migration."""
await pg.migrate()
for table in EXPECTED_TABLES:
count = await _table_row_count(pg, table)
assert count == 0, (
f"Table '{table}' should be empty after migration, "
f"but has {count} rows"
)
@pytest.mark.asyncio
async def test_schema_migration_version_recorded(pg: PgStore) -> None:
"""schema_migrations has the expected version after migrate."""
from arbitrade.storage.pg_store import SCHEMA_VERSION
await pg.migrate()
async with pg.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT MAX(version) AS v FROM schema_migrations"
)
assert row is not None
assert row["v"] == SCHEMA_VERSION, (
f"Expected schema version {SCHEMA_VERSION}, "
f"got {row['v']}"
)
@pytest.mark.asyncio
async def test_create_and_query_row(pg: PgStore) -> None:
"""Can INSERT a row and SELECT it back (round-trip for a simple table)."""
await pg.migrate()
async with pg.pool.acquire() as conn:
# ConfigSections round-trip
await conn.execute(
"INSERT INTO config_sections (name, description) VALUES ($1, $2)",
"test_section", "A test section for integration test",
)
row = await conn.fetchrow(
"SELECT name, description FROM config_sections WHERE name = $1",
"test_section",
)
assert row is not None
assert row["name"] == "test_section"
assert row["description"] == "A test section for integration test"
# Clean up
await conn.execute(
"DELETE FROM config_sections WHERE name = $1",
"test_section",
)
@pytest.mark.asyncio
async def test_config_pairings_upsert(pg: PgStore) -> None:
"""ON CONFLICT ... DO UPDATE works on config_pairings (unique constraint)."""
await pg.migrate()
from arbitrade.config.service import ConfigPairing
from arbitrade.storage.repositories import ConfigPairingRepository
repo = ConfigPairingRepository(pg)
# Insert
p1 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=True, source="kraken")
)
assert p1.id is not None
assert p1.base_asset == "XBT"
assert p1.enabled is True
# Upsert (update)
p2 = await repo.upsert_pairing(
ConfigPairing(base_asset="XBT", quote_asset="USD", enabled=False, source="manual")
)
assert p2.id == p1.id # same row
assert p2.enabled is False
assert p2.source == "manual"
# Clean up
deleted = await repo.delete_pairing("XBT", "USD")
assert deleted is True
@pytest.mark.asyncio
async def test_audit_list_recent(pg: PgStore) -> None:
"""AuditRepository.list_recent returns records in desc order."""
await pg.migrate()
from datetime import UTC, datetime
from arbitrade.storage.repositories import AuditRecord, AuditRepository
repo = AuditRepository(pg)
now = datetime.now(UTC)
# Insert a few records
for i in range(3):
await repo.insert(
AuditRecord(
occurred_at=now,
actor="test",
event_type="integration_test",
decision=f"decision_{i}",
payload={"index": i},
correlation_id=f"corr_{i}",
)
)
recent = await repo.list_recent(limit=5)
assert len(recent) >= 3
assert recent[0].decision in ("decision_2", "decision_1", "decision_0")
# Verify payload serialization worked
first = recent[0]
if first.payload:
assert "index" in first.payload
-34
View File
@@ -1,34 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from arbitrade.config.settings import Settings
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.repositories import AuditRecord, AuditRepository
def test_audit_repository_inserts_and_lists_recent(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "audit.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = AuditRepository(store)
repository.insert(
AuditRecord(
occurred_at=datetime.now(UTC),
actor="dashboard_user",
event_type="dashboard.control.start",
decision="approved",
payload={"execution_status": "running"},
correlation_id="req-1",
)
)
recent = repository.list_recent(limit=5)
assert len(recent) == 1
assert recent[0].actor == "dashboard_user"
assert recent[0].event_type == "dashboard.control.start"
assert recent[0].decision == "approved"
assert recent[0].payload == {"execution_status": "running"}
assert recent[0].correlation_id == "req-1"
+4 -3
View File
@@ -8,7 +8,7 @@ from arbitrade.config.service import (
ConfigPairing,
ConfigSetting,
)
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.pg_store import PgStore
from arbitrade.storage.repositories import (
ConfigBacktestingDefaultsRepository,
ConfigPairingRepository,
@@ -19,7 +19,7 @@ from arbitrade.storage.repositories import (
@pytest.fixture
def mock_store():
"""Create a mock database store."""
store = Mock(spec=DuckDBStore)
store = Mock(spec=PgStore)
return store
@@ -244,7 +244,8 @@ def test_config_pairing_repository_create_pairing(mock_store):
]
# Create pairing
pairing = ConfigPairing(base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken")
pairing = ConfigPairing(
base_asset="BTC", quote_asset="USD", enabled=True, source="Kraken")
result = repo.create_pairing(pairing)
-89
View File
@@ -1,89 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.execution.sequencer import TriangularExecutionSequencer
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.executions import AsyncExecutionWriter
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
@dataclass(slots=True)
class _FakeRestClient:
calls: int = 0
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
self.calls += 1
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
def _sample_event() -> OpportunityEvent:
return OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
@pytest.mark.asyncio
async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "exec.duckdb")
store = DuckDBStore(settings)
store.migrate()
writer = AsyncExecutionWriter(
TradeRepository(store),
OrderRepository(store),
PnLRepository(store),
max_queue_size=10,
)
await writer.start()
client = _FakeRestClient()
sequencer = TriangularExecutionSequencer(
client,
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
execution_writer=writer,
)
result = await sequencer.execute(_sample_event())
await writer.stop()
assert result.success
assert client.calls == 3
with store.connect() as conn:
trades = conn.execute(
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
).fetchall()
orders = conn.execute(
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
"FROM orders ORDER BY leg_index"
).fetchall()
pnls = conn.execute("SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
assert len(trades) == 1
assert trades[0][1] == "filled"
assert trades[0][2] == 0.03
assert trades[0][3] == 1.0
assert trades[0][4] == "USD->BTC->ETH->USD"
assert trades[0][5] == 3
assert len(orders) == 3
assert orders[0][2] == 0
assert orders[1][2] == 1
assert orders[2][2] == 2
assert orders[0][6] == "submitted"
assert len(pnls) == 1
assert pnls[0][1] == "estimated"
assert pnls[0][2] == 0.03
-144
View File
@@ -1,144 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.db import DuckDBStore
def test_metrics_calculator_summarizes_execution_data(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "metrics.duckdb")
store = DuckDBStore(settings)
store.migrate()
started = datetime.now(UTC)
finished = started + timedelta(seconds=30)
started_two = started + timedelta(minutes=1)
finished_two = started_two + timedelta(seconds=90)
with store.connect() as conn:
conn.execute(
"""
INSERT INTO trades (
trade_ref,
started_at,
finished_at,
status,
realized_pnl,
estimated_pnl,
capital_used,
cycle,
leg_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
started,
finished,
"filled",
12.5,
10.0,
100.0,
"USD->BTC->ETH->USD",
3,
"trade-2",
started_two,
finished_two,
"filled",
-4.5,
-2.0,
200.0,
"USD->ETH->BTC->USD",
3,
],
)
conn.execute(
"""
INSERT INTO opportunities (
detected_at,
cycle,
gross_pct,
net_pct,
est_profit,
executed
) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
""",
[
started,
"USD->BTC->ETH->USD",
4.0,
3.0,
0.03,
True,
started_two,
"USD->ETH->BTC->USD",
2.0,
1.0,
0.01,
False,
started_two + timedelta(seconds=30),
"USD->BTC->ETH->USD",
5.0,
4.0,
0.04,
True,
],
)
conn.execute(
"""
INSERT INTO orders (
trade_ref,
order_ref,
leg_index,
pair,
side,
volume,
user_ref,
status,
filled_volume,
avg_price,
raw_response,
recorded_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
"trade-1",
"order-1",
0,
"BTC/USD",
"buy",
2.0,
101,
"closed",
2.0,
100.0,
"{}",
started,
"trade-2",
"order-2",
0,
"ETH/USD",
"sell",
4.0,
202,
"closed",
3.0,
200.0,
"{}",
started_two,
],
)
metrics = MetricsCalculator(store).compute()
assert metrics.realized_pnl_usd == 8.0
assert metrics.win_rate == 0.5
assert metrics.avg_trade_duration_seconds == 60.0
assert metrics.opportunities_per_minute == 2.0
assert metrics.fill_rate == 0.875
assert metrics.latency_p50_seconds == 60.0
assert metrics.latency_p95_seconds == 87.0
assert metrics.latency_p99_seconds == pytest.approx(89.4)
-48
View File
@@ -1,48 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from arbitrade.config.settings import Settings
from arbitrade.detection.engine import OpportunityEvent
from arbitrade.storage.db import DuckDBStore
from arbitrade.storage.opportunities import AsyncOpportunityWriter
from arbitrade.storage.repositories import OpportunityRepository
@pytest.mark.asyncio
async def test_async_opportunity_writer_persists_events(tmp_path) -> None:
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "test.duckdb")
store = DuckDBStore(settings)
store.migrate()
repository = OpportunityRepository(store)
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
await writer.start()
event = OpportunityEvent(
detected_at=datetime.now(UTC),
cycle="USD->BTC->ETH->USD",
updated_pair="BTC/USD",
gross_rate=1.04,
net_rate=1.03,
gross_pct=4.0,
net_pct=3.0,
est_profit=0.03,
)
await writer.enqueue(event)
await writer.stop()
with store.connect() as conn:
rows = conn.execute(
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
).fetchall()
assert len(rows) == 1
assert rows[0][0] == "USD->BTC->ETH->USD"
assert rows[0][1] == 4.0
assert rows[0][2] == 3.0
assert rows[0][3] == 0.03
assert rows[0][4] is False