Files
eth_hedge_sim/backend/app/models/db.py
T

308 lines
10 KiB
Python

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,
exit_target_usdt REAL,
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,
settle_index_px REAL,
perp_margin_mode TEXT
);
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,
exit_target_usdt REAL,
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)
);
CREATE TABLE IF NOT EXISTS funds_wallets (
id INTEGER PRIMARY KEY CHECK (id = 1),
funding_usdt REAL NOT NULL DEFAULT 0,
trading_usdt REAL NOT NULL DEFAULT 0,
options_funding_usdc REAL NOT NULL DEFAULT 0,
options_trading_usdc REAL NOT NULL DEFAULT 0,
options_funding_usdt REAL NOT NULL DEFAULT 0,
options_trading_usdt REAL NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL
);
"""
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"),
("groups", "settle_index_px", "REAL"),
("groups", "perp_margin_mode", "TEXT"),
("groups", "exit_target_usdt", "REAL"),
("positions", "exit_target_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,),
)
fw = self._conn.execute("SELECT id FROM funds_wallets WHERE id=1").fetchone()
if fw is None:
led = self._conn.execute(
"SELECT equity FROM ledger_meta WHERE id=1"
).fetchone()
eq = float(led["equity"]) if led else float(s.initial_equity)
self._conn.execute(
"""INSERT INTO funds_wallets(
id, funding_usdt, trading_usdt, options_funding_usdc, options_trading_usdc,
options_funding_usdt, options_trading_usdt, updated_at_ms
) VALUES (1,?,0,0,0,0,0,?)""",
(eq, 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),
"perp_margin_mode": str(s.perp_margin_mode),
"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),
"fixed_direction_enabled": str(s.fixed_direction_enabled),
"fixed_perp_side": str(s.fixed_perp_side),
"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