Add integration tests for execution persistence, metrics, and opportunity writing
CI / lint-test-build (push) Failing after 1m23s
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:
@@ -0,0 +1 @@
|
||||
"""End-to-end tests — require full app startup with PostgreSQL."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests for PostgreSQL schema and connectivity."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user