You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
518 lines
20 KiB
Python
518 lines
20 KiB
Python
"""SQLite state management for paper trading sessions."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import sqlite3
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from apps.paper_trader.models import create_schema
|
|
|
|
|
|
@dataclass
|
|
class SessionRow:
|
|
session_id: str
|
|
session_name: str
|
|
config_path: str
|
|
initial_equity: float
|
|
created_at: str
|
|
status: str
|
|
parking_preset: str | None = None
|
|
idle_alpha_preset: str | None = None
|
|
form4_sleeve_preset: str | None = None
|
|
ownership_sleeve_preset: str | None = None
|
|
risk_off_alpha_sleeve_preset: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class StrategyStateRow:
|
|
session_id: str
|
|
symbol: str
|
|
event_id: str
|
|
engine_id: str
|
|
entry_date: str
|
|
stop_price: float
|
|
target_price: float
|
|
current_stop: float
|
|
peak_price: float
|
|
days_held: int
|
|
trade_direction: str
|
|
candidate_json: str
|
|
plan_json: str
|
|
status: str
|
|
id: int | None = None
|
|
order_id: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class SessionStateRow:
|
|
session_id: str
|
|
consecutive_losses: int = 0
|
|
cooldown_remaining: int = 0
|
|
kill_switch_triggered: bool = False
|
|
daily_new_risk_used: float = 0.0
|
|
last_processed_date: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class DailySnapshotRow:
|
|
session_id: str
|
|
date: str
|
|
equity: float
|
|
cash: float
|
|
market_value: float
|
|
daily_pnl: float | None = None
|
|
total_pnl: float | None = None
|
|
drawdown_pct: float | None = None
|
|
open_position_count: int | None = None
|
|
|
|
|
|
class StateManager:
|
|
"""SQLite CRUD for paper trading state."""
|
|
|
|
def __init__(self, db_path: str | Path) -> None:
|
|
self.db_path = Path(db_path)
|
|
create_schema(self.db_path)
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(self.db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Sessions
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def create_session(
|
|
self,
|
|
session_name: str,
|
|
config_path: str,
|
|
initial_equity: float,
|
|
parking_preset: str | None = None,
|
|
idle_alpha_preset: str | None = None,
|
|
form4_sleeve_preset: str | None = None,
|
|
ownership_sleeve_preset: str | None = None,
|
|
risk_off_alpha_sleeve_preset: str | None = None,
|
|
) -> str:
|
|
session_id = str(uuid.uuid4())[:8]
|
|
created_at = dt.datetime.now(tz=dt.timezone.utc).isoformat()
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status, parking_preset, idle_alpha_preset, form4_sleeve_preset, ownership_sleeve_preset, risk_off_alpha_sleeve_preset) "
|
|
"VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)",
|
|
(
|
|
session_id,
|
|
session_name,
|
|
config_path,
|
|
initial_equity,
|
|
created_at,
|
|
parking_preset,
|
|
idle_alpha_preset,
|
|
form4_sleeve_preset,
|
|
ownership_sleeve_preset,
|
|
risk_off_alpha_sleeve_preset,
|
|
),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO session_state (session_id) VALUES (?)",
|
|
(session_id,),
|
|
)
|
|
return session_id
|
|
|
|
def get_session(self, session_name_or_id: str) -> SessionRow | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM sessions WHERE session_id = ? OR session_name = ? LIMIT 1",
|
|
(session_name_or_id, session_name_or_id),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return SessionRow(**dict(row))
|
|
|
|
def list_sessions(self) -> list[SessionRow]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM sessions ORDER BY created_at"
|
|
).fetchall()
|
|
return [SessionRow(**dict(r)) for r in rows]
|
|
|
|
def set_session_status(self, session_id: str, status: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE sessions SET status = ? WHERE session_id = ?",
|
|
(status, session_id),
|
|
)
|
|
|
|
def delete_session(self, session_id: str) -> None:
|
|
"""Delete a session and all related data."""
|
|
with self._connect() as conn:
|
|
for table in (
|
|
"processed_phases", "processed_dates", "daily_snapshots",
|
|
"trades", "processed_events", "strategy_states", "session_state",
|
|
):
|
|
conn.execute(f"DELETE FROM {table} WHERE session_id = ?", (session_id,))
|
|
conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Strategy states
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def get_open_strategy_states(self, session_id: str) -> list[StrategyStateRow]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM strategy_states WHERE session_id = ? AND status IN ('open', 'partial')",
|
|
(session_id,),
|
|
).fetchall()
|
|
return [StrategyStateRow(**dict(r)) for r in rows]
|
|
|
|
def get_strategy_state_by_symbol(
|
|
self, session_id: str, symbol: str
|
|
) -> StrategyStateRow | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM strategy_states WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial') LIMIT 1",
|
|
(session_id, symbol),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return StrategyStateRow(**dict(row))
|
|
|
|
def save_strategy_state(self, session_id: str, state: StrategyStateRow) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO strategy_states
|
|
(session_id, symbol, event_id, engine_id, order_id, entry_date,
|
|
stop_price, target_price, current_stop, peak_price, days_held,
|
|
trade_direction, candidate_json, plan_json, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, symbol, status) DO UPDATE SET
|
|
current_stop=excluded.current_stop,
|
|
peak_price=excluded.peak_price,
|
|
days_held=excluded.days_held,
|
|
order_id=excluded.order_id""",
|
|
(
|
|
session_id, state.symbol, state.event_id, state.engine_id,
|
|
state.order_id, state.entry_date, state.stop_price, state.target_price,
|
|
state.current_stop, state.peak_price, state.days_held,
|
|
state.trade_direction, state.candidate_json, state.plan_json, state.status,
|
|
),
|
|
)
|
|
|
|
def update_strategy_state(
|
|
self,
|
|
session_id: str,
|
|
symbol: str,
|
|
*,
|
|
days_held: int | None = None,
|
|
current_stop: float | None = None,
|
|
peak_price: float | None = None,
|
|
status: str | None = None,
|
|
) -> None:
|
|
updates: list[str] = []
|
|
values: list[Any] = []
|
|
if days_held is not None:
|
|
updates.append("days_held = ?")
|
|
values.append(days_held)
|
|
if current_stop is not None:
|
|
updates.append("current_stop = ?")
|
|
values.append(current_stop)
|
|
if peak_price is not None:
|
|
updates.append("peak_price = ?")
|
|
values.append(peak_price)
|
|
if status is not None:
|
|
updates.append("status = ?")
|
|
values.append(status)
|
|
if not updates:
|
|
return
|
|
values.extend([session_id, symbol])
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
f"UPDATE strategy_states SET {', '.join(updates)} "
|
|
"WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial')",
|
|
values,
|
|
)
|
|
|
|
def close_strategy_state(self, session_id: str, symbol: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"DELETE FROM strategy_states "
|
|
"WHERE session_id = ? AND symbol = ? AND status IN ('open', 'partial')",
|
|
(session_id, symbol),
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Session-level state
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def get_session_state(self, session_id: str) -> SessionStateRow:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM session_state WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return SessionStateRow(session_id=session_id)
|
|
d = dict(row)
|
|
d["kill_switch_triggered"] = bool(d["kill_switch_triggered"])
|
|
return SessionStateRow(**d)
|
|
|
|
def update_session_state(self, state: SessionStateRow) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO session_state
|
|
(session_id, consecutive_losses, cooldown_remaining,
|
|
kill_switch_triggered, daily_new_risk_used, last_processed_date)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
consecutive_losses=excluded.consecutive_losses,
|
|
cooldown_remaining=excluded.cooldown_remaining,
|
|
kill_switch_triggered=excluded.kill_switch_triggered,
|
|
daily_new_risk_used=excluded.daily_new_risk_used,
|
|
last_processed_date=excluded.last_processed_date""",
|
|
(
|
|
state.session_id,
|
|
state.consecutive_losses,
|
|
state.cooldown_remaining,
|
|
int(state.kill_switch_triggered),
|
|
state.daily_new_risk_used,
|
|
state.last_processed_date,
|
|
),
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Processed events
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def has_processed_event(self, session_id: str, event_id: str) -> bool:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM processed_events WHERE session_id = ? AND event_id = ?",
|
|
(session_id, event_id),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
def record_processed_event(
|
|
self,
|
|
session_id: str,
|
|
event_id: str,
|
|
processed_date: str,
|
|
action: str,
|
|
skip_reason: str | None = None,
|
|
) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO processed_events "
|
|
"(session_id, event_id, processed_date, action, skip_reason) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, event_id, processed_date, action, skip_reason),
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Trades
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def record_trade(
|
|
self,
|
|
session_id: str,
|
|
symbol: str,
|
|
engine_id: str | None,
|
|
capital_bucket_id: str | None,
|
|
entry_date: str | None,
|
|
exit_date: str,
|
|
entry_price: float | None,
|
|
exit_price: float,
|
|
exit_reason: str,
|
|
shares: int,
|
|
net_pnl: float,
|
|
r_multiple: float,
|
|
holding_days: int,
|
|
) -> str:
|
|
trade_id = str(uuid.uuid4())
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO trades (trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date, "
|
|
"entry_price, exit_price, exit_reason, shares, net_pnl, r_multiple, holding_days) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date,
|
|
entry_price, exit_price, exit_reason, shares,
|
|
net_pnl, r_multiple, holding_days,
|
|
),
|
|
)
|
|
return trade_id
|
|
|
|
def list_trades(self, session_id: str, limit: int | None = None) -> list[dict]:
|
|
with self._connect() as conn:
|
|
if limit:
|
|
rows = conn.execute(
|
|
"SELECT * FROM trades WHERE session_id = ? ORDER BY exit_date DESC LIMIT ?",
|
|
(session_id, limit),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM trades WHERE session_id = ? ORDER BY exit_date",
|
|
(session_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Daily snapshots
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def save_daily_snapshot(self, row: DailySnapshotRow) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO daily_snapshots
|
|
(session_id, date, equity, cash, market_value, daily_pnl,
|
|
total_pnl, drawdown_pct, open_position_count)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, date) DO UPDATE SET
|
|
equity=excluded.equity,
|
|
cash=excluded.cash,
|
|
market_value=excluded.market_value,
|
|
daily_pnl=excluded.daily_pnl,
|
|
total_pnl=excluded.total_pnl,
|
|
drawdown_pct=excluded.drawdown_pct,
|
|
open_position_count=excluded.open_position_count""",
|
|
(
|
|
row.session_id, row.date, row.equity, row.cash, row.market_value,
|
|
row.daily_pnl, row.total_pnl, row.drawdown_pct, row.open_position_count,
|
|
),
|
|
)
|
|
|
|
def list_snapshots(self, session_id: str) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM daily_snapshots WHERE session_id = ? ORDER BY date",
|
|
(session_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def get_peak_equity(self, session_id: str, initial_equity: float) -> float:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT MAX(equity) FROM daily_snapshots WHERE session_id = ?",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row and row[0] is not None:
|
|
return max(float(row[0]), initial_equity)
|
|
return initial_equity
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Idempotency: processed dates
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def is_phase_processed(self, session_id: str, date: dt.date, phase: str) -> bool:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM processed_phases WHERE session_id = ? AND date = ? AND phase = ?",
|
|
(session_id, date.isoformat(), phase),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
def mark_phase_processed(self, session_id: str, date: dt.date, phase: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO processed_phases (session_id, date, phase) VALUES (?, ?, ?)",
|
|
(session_id, date.isoformat(), phase),
|
|
)
|
|
|
|
def is_date_processed(self, session_id: str, date: dt.date) -> bool:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM processed_dates WHERE session_id = ? AND date = ?",
|
|
(session_id, date.isoformat()),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
def mark_date_processed(self, session_id: str, date: dt.date) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO processed_dates (session_id, date) VALUES (?, ?)",
|
|
(session_id, date.isoformat()),
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Cash Parking
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def get_parking_state(self, session_id: str) -> dict[str, Any] | None:
|
|
with self._connect() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM parking_state WHERE session_id = ? AND status = 'active'",
|
|
(session_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return dict(row)
|
|
|
|
def save_parking_state(
|
|
self, session_id: str, symbol: str, entry_date: dt.date,
|
|
qty: int, avg_price: float, entry_value: float,
|
|
peak_price: float = 0, gate_in_sgov: int = 0,
|
|
committed_target: str = "", sgov_entry_value: float = 0,
|
|
) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""INSERT OR REPLACE INTO parking_state
|
|
(session_id, symbol, entry_date, qty, avg_price, entry_value, status,
|
|
peak_price, gate_in_sgov, committed_target, pending_target, pending_days,
|
|
sgov_entry_value, sold_today)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, '', 0, ?, 0)""",
|
|
(session_id, symbol, entry_date.isoformat(), qty, avg_price, entry_value,
|
|
peak_price or avg_price, gate_in_sgov, committed_target or symbol.lower(),
|
|
sgov_entry_value or entry_value),
|
|
)
|
|
|
|
def list_parking_entries(self, session_id: str) -> list[dict]:
|
|
with self._connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM parking_state WHERE session_id = ? ORDER BY entry_date",
|
|
(session_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def close_parking_state(self, session_id: str) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE parking_state SET status = 'closed' WHERE session_id = ? AND status = 'active'",
|
|
(session_id,),
|
|
)
|
|
|
|
def update_parking_peak(self, session_id: str, peak_price: float) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE parking_state SET peak_price = ? WHERE session_id = ? AND status = 'active'",
|
|
(peak_price, session_id),
|
|
)
|
|
|
|
def update_parking_gate_state(
|
|
self, session_id: str, **kwargs: Any,
|
|
) -> None:
|
|
"""Update gate hysteresis fields. Accepts: gate_in_sgov, committed_target,
|
|
pending_target, pending_days, sold_today, overlay_brake_cooldown, overlay_hold_days."""
|
|
allowed = {"gate_in_sgov", "committed_target", "pending_target", "pending_days", "sold_today",
|
|
"overlay_brake_cooldown", "overlay_hold_days"}
|
|
updates, params = [], []
|
|
for k, v in kwargs.items():
|
|
if k in allowed:
|
|
updates.append(f"{k} = ?")
|
|
params.append(v)
|
|
if not updates:
|
|
return
|
|
params.append(session_id)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
f"UPDATE parking_state SET {', '.join(updates)} WHERE session_id = ? AND status = 'active'",
|
|
params,
|
|
)
|
|
|
|
def update_parking_sgov_value(self, session_id: str, new_value: float) -> None:
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE parking_state SET entry_value = ? WHERE session_id = ? AND status = 'active'",
|
|
(new_value, session_id),
|
|
)
|