from __future__ import annotations import sqlite3 import threading import time from pathlib import Path from typing import Any from ..config import Settings, get_settings _SCHEMA = """ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS ledger_meta ( id INTEGER PRIMARY KEY CHECK (id = 1), equity REAL NOT NULL, available REAL NOT NULL, reserved REAL NOT NULL DEFAULT 0, updated_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS groups ( group_id TEXT PRIMARY KEY, status TEXT NOT NULL, bias TEXT, option_side TEXT, perp_side TEXT, option_inst_id TEXT, perp_inst_id TEXT, strike REAL, expiry_ymd TEXT, entry_index_px REAL, initial_premium REAL DEFAULT 0, open_at_ms INTEGER, close_at_ms INTEGER, close_reason TEXT, realized_pnl REAL DEFAULT 0, fees REAL DEFAULT 0, slip_cost REAL DEFAULT 0, note TEXT, exec_mode TEXT, funding_usdt REAL ); CREATE TABLE IF NOT EXISTS fills ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id TEXT NOT NULL, leg TEXT NOT NULL, action TEXT NOT NULL, side TEXT NOT NULL, inst_id TEXT NOT NULL, qty_eth REAL NOT NULL, qty_contracts REAL, base_px REAL, fill_px REAL NOT NULL, fee REAL NOT NULL, slip REAL NOT NULL, notional REAL NOT NULL, ts_ms INTEGER NOT NULL, exec_mode TEXT, FOREIGN KEY(group_id) REFERENCES groups(group_id) ); CREATE TABLE IF NOT EXISTS positions ( id INTEGER PRIMARY KEY CHECK (id = 1), group_id TEXT, perp_side TEXT, perp_qty_eth REAL DEFAULT 0, perp_entry_px REAL, option_inst_id TEXT, option_side TEXT, option_qty_eth REAL DEFAULT 0, option_qty_contracts REAL DEFAULT 0, option_entry_px REAL, entry_index_px REAL, initial_premium REAL DEFAULT 0, status TEXT ); CREATE TABLE IF NOT EXISTS ledger_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id TEXT, kind TEXT NOT NULL, amount REAL NOT NULL, balance_after REAL NOT NULL, note TEXT, ts_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS strategy_state ( id INTEGER PRIMARY KEY CHECK (id = 1), running INTEGER NOT NULL DEFAULT 0, phase TEXT NOT NULL DEFAULT 'idle', rounds_done INTEGER NOT NULL DEFAULT 0, window_key TEXT, rest_until_ms INTEGER, last_error TEXT, updated_at_ms INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS residual_options ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id TEXT NOT NULL UNIQUE, option_inst_id TEXT NOT NULL, option_side TEXT NOT NULL, option_qty_eth REAL NOT NULL, option_qty_contracts REAL, option_entry_px REAL NOT NULL, strike REAL, expiry_ymd TEXT, expiry_ms INTEGER, entry_index_px REAL, initial_premium REAL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', created_at_ms INTEGER NOT NULL, settled_at_ms INTEGER, settle_px REAL, settle_pnl REAL, note TEXT, FOREIGN KEY(group_id) REFERENCES groups(group_id) ); """ def default_db_path(settings: Settings | None = None) -> Path: s = settings or get_settings() if s.db_path: return Path(s.db_path) root = Path(__file__).resolve().parents[2] # backend/ return root / "data" / "hedge.db" class Database: def __init__(self, path: Path | None = None) -> None: self.path = path or default_db_path() self.path.parent.mkdir(parents=True, exist_ok=True) self._lock = threading.RLock() self._conn = sqlite3.connect(str(self.path), check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL;") self._conn.executescript(_SCHEMA) self._conn.commit() self._migrate_columns() self._ensure_seed() def _migrate_columns(self) -> None: """幂等补列:exec_mode。""" with self._lock: for table, col, decl in ( ("groups", "exec_mode", "TEXT"), ("groups", "funding_usdt", "REAL"), ("fills", "exec_mode", "TEXT"), ("fills", "fee_ccy", "TEXT"), ): cols = { str(r[1]) for r in self._conn.execute(f"PRAGMA table_info({table})").fetchall() } if col not in cols: self._conn.execute( f"ALTER TABLE {table} ADD COLUMN {col} {decl}" ) self._conn.commit() def close(self) -> None: with self._lock: self._conn.close() def _ensure_seed(self) -> None: s = get_settings() now = int(time.time() * 1000) with self._lock: row = self._conn.execute("SELECT id FROM ledger_meta WHERE id=1").fetchone() if row is None: self._conn.execute( "INSERT INTO ledger_meta(id, equity, available, reserved, updated_at_ms) VALUES (1,?,?,0,?)", (s.initial_equity, s.initial_equity, now), ) pos = self._conn.execute("SELECT id FROM positions WHERE id=1").fetchone() if pos is None: self._conn.execute( "INSERT INTO positions(id, status) VALUES (1, 'flat')" ) st = self._conn.execute("SELECT id FROM strategy_state WHERE id=1").fetchone() if st is None: self._conn.execute( "INSERT INTO strategy_state(id, running, phase, rounds_done, updated_at_ms) VALUES (1,0,'idle',0,?)", (now,), ) defaults = { "fee_rate": str(s.fee_rate), "initial_equity": str(s.initial_equity), "exit_move_points": str(s.exit_move_points), "exit_move_pct": str(s.exit_move_pct), "exit_mode": str(s.exit_mode), "net_profit_target": str(s.net_profit_target), "premium_exit_multiple": str(s.premium_exit_multiple), "rest_seconds": str(s.rest_seconds), "live_order_interval_sec": str(s.live_order_interval_sec), "skip_weekends": str(s.skip_weekends), "max_rounds": str(s.max_rounds), "leverage": str(s.leverage), "min_option_hours": str(s.min_option_hours), "min_option_leverage": str(s.min_option_leverage), "atm_open_offset_enabled": str(s.atm_open_offset_enabled), "max_atm_open_offset": str(s.max_atm_open_offset), "close_bid_mark_max_pct": str(s.close_bid_mark_max_pct), "perp_qty_eth": str(s.perp_qty_eth), "option_qty_eth": str(s.option_qty_eth), "exchange": str(s.exchange), "perp_inst_id": str(s.perp_inst_id), "option_inst_family": str(s.option_inst_family), "index_inst_id": str(s.index_inst_id), "option_ct_mult_default": str(s.option_ct_mult_default), } for k, v in defaults.items(): exists = self._conn.execute( "SELECT key FROM settings WHERE key=?", (k,) ).fetchone() if exists is None: self._conn.execute( "INSERT INTO settings(key, value) VALUES (?,?)", (k, v) ) self._conn.commit() def execute(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> sqlite3.Cursor: with self._lock: cur = self._conn.execute(sql, params) self._conn.commit() return cur def executemany(self, sql: str, seq: list[tuple[Any, ...]]) -> None: with self._lock: self._conn.executemany(sql, seq) self._conn.commit() def fetchone(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> sqlite3.Row | None: with self._lock: return self._conn.execute(sql, params).fetchone() def fetchall(self, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> list[sqlite3.Row]: with self._lock: return list(self._conn.execute(sql, params).fetchall()) def get_setting(self, key: str, default: str | None = None) -> str | None: row = self.fetchone("SELECT value FROM settings WHERE key=?", (key,)) if row is None: return default return str(row["value"]) def set_setting(self, key: str, value: str) -> None: self.execute( "INSERT INTO settings(key, value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value), ) _db: Database | None = None def get_db() -> Database: global _db if _db is None: _db = Database() return _db def set_db(db: Database | None) -> None: global _db _db = db