refactor: migrate database schema from TIMESTAMP to TIMESTAMPTZ for better timezone handling
CI / lint-test-build (push) Successful in 1m25s

This commit is contained in:
2026-06-07 15:20:27 +02:00
parent af0ac94a12
commit c1dda187af
2 changed files with 71 additions and 45 deletions
+28 -26
View File
@@ -8,18 +8,19 @@ from time import perf_counter
from arbitrade.config.settings import Settings from arbitrade.config.settings import Settings
from arbitrade.metrics import MetricsCalculator from arbitrade.metrics import MetricsCalculator
from arbitrade.storage.db import DuckDBStore from arbitrade.storage.pg_store import PgStore
def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float | None]: async def _python_scan_compute(store: PgStore) -> tuple[float, float | None, float | None]:
with store.connect() as conn: sql_s = """
trade_rows = conn.execute(""" SELECT started_at, finished_at, realized_pnl
SELECT started_at, finished_at, realized_pnl FROM trades
FROM trades WHERE finished_at IS NOT NULL
WHERE finished_at IS NOT NULL """
""").fetchall() sql_d = "SELECT detected_at FROM opportunities"
sql_d = "SELECT detected_at FROM opportunities" async with store.pool.acquire() as conn:
orows = conn.execute(sql_d).fetchall() trade_rows = await conn.fetch(sql_s)
orows = await conn.fetch(sql_d)
realized = sum(float(row[2]) for row in trade_rows if row[2] is not None) realized = sum(float(row[2]) for row in trade_rows if row[2] is not None)
durations = [ durations = [
@@ -41,7 +42,7 @@ def _python_scan_compute(store: DuckDBStore) -> tuple[float, float | None, float
return realized, avg_duration, opm return realized, avg_duration, opm
def _seed_dataset(store: DuckDBStore) -> None: async def _seed_dataset(store: PgStore) -> None:
now = datetime.now(UTC) now = datetime.now(UTC)
trade_rows: list[tuple[object, ...]] = [] trade_rows: list[tuple[object, ...]] = []
@@ -66,7 +67,8 @@ def _seed_dataset(store: DuckDBStore) -> None:
opportunity_rows: list[tuple[object, ...]] = [] opportunity_rows: list[tuple[object, ...]] = []
for i in range(5000): for i in range(5000):
detected_at = now + timedelta(milliseconds=200 * i) detected_at = now + timedelta(milliseconds=200 * i)
opportunity_rows.append((detected_at, "USD->BTC->ETH->USD", 2.5, 1.2, 0.03, bool(i % 2))) opportunity_rows.append(
(detected_at, "USD->BTC->ETH->USD", 2.5, 1.2, 0.03, bool(i % 2)))
order_rows: list[tuple[object, ...]] = [] order_rows: list[tuple[object, ...]] = []
for i in range(3500): for i in range(3500):
@@ -87,11 +89,11 @@ def _seed_dataset(store: DuckDBStore) -> None:
) )
) )
with store.connect() as conn: async with store.pool.acquire() as conn:
conn.execute("DELETE FROM trades") await conn.execute("DELETE FROM trades")
conn.execute("DELETE FROM opportunities") await conn.execute("DELETE FROM opportunities")
conn.execute("DELETE FROM orders") await conn.execute("DELETE FROM orders")
conn.executemany( await conn.executemany(
""" """
INSERT INTO trades ( INSERT INTO trades (
trade_ref, trade_ref,
@@ -107,7 +109,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
""", """,
trade_rows, trade_rows,
) )
conn.executemany( await conn.executemany(
""" """
INSERT INTO opportunities ( INSERT INTO opportunities (
detected_at, detected_at,
@@ -120,7 +122,7 @@ def _seed_dataset(store: DuckDBStore) -> None:
""", """,
opportunity_rows, opportunity_rows,
) )
conn.executemany( await conn.executemany(
""" """
INSERT INTO orders ( INSERT INTO orders (
trade_ref, trade_ref,
@@ -141,28 +143,28 @@ def _seed_dataset(store: DuckDBStore) -> None:
) )
def main() -> int: async def main() -> int:
db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb" db_path = Path(gettempdir()) / "arbitrade_metrics_bench.duckdb"
settings = Settings(_env_file=None, DUCKDB_PATH=db_path) settings = Settings(_env_file=None, DUCKDB_PATH=db_path)
store = DuckDBStore(settings) store = PgStore(settings)
store.migrate() store.migrate()
_seed_dataset(store) await _seed_dataset(store)
calculator = MetricsCalculator(store) calculator = MetricsCalculator(store)
for _ in range(3): for _ in range(3):
_python_scan_compute(store) await _python_scan_compute(store)
calculator.compute() await calculator.compute()
runs = 20 runs = 20
start = perf_counter() start = perf_counter()
for _ in range(runs): for _ in range(runs):
_python_scan_compute(store) await _python_scan_compute(store)
python_ms = (perf_counter() - start) * 1000.0 / runs python_ms = (perf_counter() - start) * 1000.0 / runs
start = perf_counter() start = perf_counter()
for _ in range(runs): for _ in range(runs):
calculator.compute() await calculator.compute()
sql_ms = (perf_counter() - start) * 1000.0 / runs sql_ms = (perf_counter() - start) * 1000.0 / runs
speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0 speedup = (python_ms / sql_ms) if sql_ms > 0.0 else 0.0
+42 -18
View File
@@ -8,7 +8,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ======================================== -- ========================================
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP applied_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
); );
-- ======================================== -- ========================================
@@ -18,7 +18,7 @@ CREATE TABLE IF NOT EXISTS config_sections (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR UNIQUE NOT NULL, name VARCHAR UNIQUE NOT NULL,
description TEXT, description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
); );
CREATE TABLE IF NOT EXISTS config_settings ( CREATE TABLE IF NOT EXISTS config_settings (
@@ -28,7 +28,7 @@ CREATE TABLE IF NOT EXISTS config_settings (
value_type VARCHAR NOT NULL, value_type VARCHAR NOT NULL,
is_secret BOOLEAN DEFAULT FALSE, is_secret BOOLEAN DEFAULT FALSE,
is_runtime_reloadable BOOLEAN DEFAULT FALSE, is_runtime_reloadable BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR updated_by VARCHAR
); );
@@ -38,8 +38,8 @@ CREATE TABLE IF NOT EXISTS config_pairings (
quote_asset VARCHAR NOT NULL, quote_asset VARCHAR NOT NULL,
enabled BOOLEAN DEFAULT TRUE, enabled BOOLEAN DEFAULT TRUE,
source VARCHAR NOT NULL, source VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(base_asset, quote_asset) UNIQUE(base_asset, quote_asset)
); );
@@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS config_backtesting_defaults (
-- ======================================== -- ========================================
CREATE TABLE IF NOT EXISTS opportunities ( CREATE TABLE IF NOT EXISTS opportunities (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
detected_at TIMESTAMP NOT NULL, detected_at TIMESTAMPTZ NOT NULL,
cycle VARCHAR NOT NULL, cycle VARCHAR NOT NULL,
gross_pct DOUBLE PRECISION, gross_pct DOUBLE PRECISION,
net_pct DOUBLE PRECISION, net_pct DOUBLE PRECISION,
@@ -69,8 +69,8 @@ CREATE TABLE IF NOT EXISTS opportunities (
CREATE TABLE IF NOT EXISTS trades ( CREATE TABLE IF NOT EXISTS trades (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL, trade_ref VARCHAR NOT NULL,
started_at TIMESTAMP NOT NULL, started_at TIMESTAMPTZ NOT NULL,
finished_at TIMESTAMP, finished_at TIMESTAMPTZ,
status VARCHAR NOT NULL, status VARCHAR NOT NULL,
realized_pnl DOUBLE PRECISION, realized_pnl DOUBLE PRECISION,
estimated_pnl DOUBLE PRECISION, estimated_pnl DOUBLE PRECISION,
@@ -92,13 +92,13 @@ CREATE TABLE IF NOT EXISTS orders (
filled_volume DOUBLE PRECISION, filled_volume DOUBLE PRECISION,
avg_price DOUBLE PRECISION, avg_price DOUBLE PRECISION,
raw_response JSONB, raw_response JSONB,
recorded_at TIMESTAMP NOT NULL recorded_at TIMESTAMPTZ NOT NULL
); );
CREATE TABLE IF NOT EXISTS pnl_events ( CREATE TABLE IF NOT EXISTS pnl_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
trade_ref VARCHAR NOT NULL, trade_ref VARCHAR NOT NULL,
recorded_at TIMESTAMP NOT NULL, recorded_at TIMESTAMPTZ NOT NULL,
kind VARCHAR NOT NULL, kind VARCHAR NOT NULL,
pnl_usd DOUBLE PRECISION NOT NULL, pnl_usd DOUBLE PRECISION NOT NULL,
source VARCHAR NOT NULL source VARCHAR NOT NULL
@@ -108,13 +108,13 @@ CREATE TABLE IF NOT EXISTS pnl_events (
-- Snapshots & Monitoring -- Snapshots & Monitoring
-- ======================================== -- ========================================
CREATE TABLE IF NOT EXISTS portfolio_snapshots ( CREATE TABLE IF NOT EXISTS portfolio_snapshots (
snapshot_at TIMESTAMP NOT NULL, snapshot_at TIMESTAMPTZ NOT NULL,
balances JSONB, balances JSONB,
total_value_usd DOUBLE PRECISION total_value_usd DOUBLE PRECISION
); );
CREATE TABLE IF NOT EXISTS market_snapshots ( CREATE TABLE IF NOT EXISTS market_snapshots (
snapshot_at TIMESTAMP NOT NULL, snapshot_at TIMESTAMPTZ NOT NULL,
symbol VARCHAR NOT NULL, symbol VARCHAR NOT NULL,
source VARCHAR NOT NULL, source VARCHAR NOT NULL,
payload JSONB NOT NULL, payload JSONB NOT NULL,
@@ -123,7 +123,7 @@ CREATE TABLE IF NOT EXISTS market_snapshots (
CREATE TABLE IF NOT EXISTS audit_events ( CREATE TABLE IF NOT EXISTS audit_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY, id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
occurred_at TIMESTAMP NOT NULL, occurred_at TIMESTAMPTZ NOT NULL,
actor VARCHAR NOT NULL, actor VARCHAR NOT NULL,
event_type VARCHAR NOT NULL, event_type VARCHAR NOT NULL,
decision VARCHAR NOT NULL, decision VARCHAR NOT NULL,
@@ -132,7 +132,7 @@ CREATE TABLE IF NOT EXISTS audit_events (
); );
CREATE TABLE IF NOT EXISTS runtime_state_snapshots ( CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
snapshot_at TIMESTAMP NOT NULL, snapshot_at TIMESTAMPTZ NOT NULL,
is_running BOOLEAN NOT NULL, is_running BOOLEAN NOT NULL,
kill_switch_active BOOLEAN NOT NULL, kill_switch_active BOOLEAN NOT NULL,
kill_switch_reason VARCHAR, kill_switch_reason VARCHAR,
@@ -142,7 +142,7 @@ CREATE TABLE IF NOT EXISTS runtime_state_snapshots (
); );
CREATE TABLE IF NOT EXISTS kraken_account_snapshots ( CREATE TABLE IF NOT EXISTS kraken_account_snapshots (
snapshot_at TIMESTAMP NOT NULL, snapshot_at TIMESTAMPTZ NOT NULL,
fee_tier VARCHAR, fee_tier VARCHAR,
maker_fee DOUBLE PRECISION, maker_fee DOUBLE PRECISION,
taker_fee DOUBLE PRECISION, taker_fee DOUBLE PRECISION,
@@ -161,7 +161,31 @@ CREATE TABLE IF NOT EXISTS backtest_jobs (
config JSONB, config JSONB,
report JSONB, report JSONB,
error VARCHAR, error VARCHAR,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP, started_at TIMESTAMPTZ,
finished_at TIMESTAMP finished_at TIMESTAMPTZ
); );
-- ========================================
-- Migration: convert legacy TIMESTAMP→TIMESTAMPTZ
-- for databases created before the fix.
-- These are idempotent (no-op when already TIMESTAMPTZ).
-- ========================================
ALTER TABLE audit_events ALTER COLUMN occurred_at TYPE TIMESTAMPTZ USING occurred_at AT TIME ZONE 'UTC';
ALTER TABLE runtime_state_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE schema_migrations ALTER COLUMN applied_at TYPE TIMESTAMPTZ USING applied_at AT TIME ZONE 'UTC';
ALTER TABLE config_sections ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_settings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE config_pairings ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC';
ALTER TABLE opportunities ALTER COLUMN detected_at TYPE TIMESTAMPTZ USING detected_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE trades ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';
ALTER TABLE orders ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE pnl_events ALTER COLUMN recorded_at TYPE TIMESTAMPTZ USING recorded_at AT TIME ZONE 'UTC';
ALTER TABLE portfolio_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE market_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE kraken_account_snapshots ALTER COLUMN snapshot_at TYPE TIMESTAMPTZ USING snapshot_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC';
ALTER TABLE backtest_jobs ALTER COLUMN finished_at TYPE TIMESTAMPTZ USING finished_at AT TIME ZONE 'UTC';