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.

581 lines
23 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
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
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 open_trade(
self,
session_id: str,
symbol: str,
engine_id: str | None,
capital_bucket_id: str | None,
entry_date: str,
entry_price: float,
shares: int,
) -> str:
"""진입 시점에 trade 레코드 생성 (exit 필드는 null)."""
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, entry_price, shares, entry_shares) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(trade_id, session_id, symbol, engine_id, capital_bucket_id,
entry_date, entry_price, shares, shares),
)
return trade_id
def close_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,
) -> None:
"""open_trade로 만들어진 레코드를 청산 정보로 업데이트.
기존 open 레코드가 없으면 (이전 버전 포지션) 완전한 레코드로 INSERT."""
with self._connect() as conn:
updated = conn.execute(
"""UPDATE trades SET exit_date=?, exit_price=?, exit_reason=?,
net_pnl=?, r_multiple=?, holding_days=?, shares=?
WHERE session_id=? AND symbol=? AND exit_date IS NULL""",
(exit_date, exit_price, exit_reason, net_pnl, r_multiple, holding_days, shares,
session_id, symbol),
).rowcount
if updated == 0:
trade_id = str(uuid.uuid4())
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),
)
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:
"""파킹 거래 등 open_trade 없이 완전한 레코드를 한 번에 INSERT할 때 사용."""
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 COALESCE(exit_date, '9999-12-31') DESC LIMIT ?",
(session_id, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM trades WHERE session_id = ? "
"ORDER BY COALESCE(exit_date, '9999-12-31')",
(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),
)