396 lines
16 KiB
Python
396 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
|
|
|
|
|
|
class Storage:
|
|
def __init__(self, path: str | Path):
|
|
self.path = Path(path)
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.init_schema()
|
|
|
|
@contextmanager
|
|
def connect(self) -> Iterator[sqlite3.Connection]:
|
|
conn = sqlite3.connect(self.path)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def init_schema(self) -> None:
|
|
with self.connect() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS positions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
symbol TEXT NOT NULL,
|
|
qty REAL NOT NULL,
|
|
entry_price REAL NOT NULL,
|
|
notional_usdt REAL NOT NULL,
|
|
entry_fee_usdt REAL NOT NULL DEFAULT 0,
|
|
stop_loss REAL NOT NULL,
|
|
take_profit REAL NOT NULL,
|
|
highest_price REAL NOT NULL,
|
|
opened_at TEXT NOT NULL,
|
|
entry_reason TEXT NOT NULL DEFAULT '',
|
|
entry_confidence REAL NOT NULL DEFAULT 0,
|
|
entry_pattern TEXT NOT NULL DEFAULT '',
|
|
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
|
status TEXT NOT NULL DEFAULT 'OPEN'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
symbol TEXT NOT NULL,
|
|
side TEXT NOT NULL,
|
|
qty REAL NOT NULL,
|
|
entry_price REAL,
|
|
exit_price REAL,
|
|
gross_pnl REAL NOT NULL DEFAULT 0,
|
|
fee_usdt REAL NOT NULL DEFAULT 0,
|
|
net_pnl REAL NOT NULL DEFAULT 0,
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
entry_pattern TEXT NOT NULL DEFAULT '',
|
|
entry_confidence REAL NOT NULL DEFAULT 0,
|
|
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
|
opened_at TEXT,
|
|
closed_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS signals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
symbol TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
confidence REAL NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS equity (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
equity REAL NOT NULL,
|
|
cash REAL NOT NULL,
|
|
exposure REAL NOT NULL,
|
|
drawdown REAL NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
level TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS runtime (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS llm_advice (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
symbol TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
prompt_json TEXT NOT NULL DEFAULT '{}',
|
|
response_text TEXT NOT NULL DEFAULT '',
|
|
advice_json TEXT NOT NULL DEFAULT '{}',
|
|
error TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
columns = {
|
|
row["name"]
|
|
for row in conn.execute("PRAGMA table_info(positions)").fetchall()
|
|
}
|
|
if "entry_fee_usdt" not in columns:
|
|
conn.execute(
|
|
"ALTER TABLE positions ADD COLUMN entry_fee_usdt REAL NOT NULL DEFAULT 0"
|
|
)
|
|
for column, definition in {
|
|
"entry_reason": "TEXT NOT NULL DEFAULT ''",
|
|
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
|
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
|
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
|
}.items():
|
|
if column not in columns:
|
|
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
|
|
trade_columns = {
|
|
row["name"]
|
|
for row in conn.execute("PRAGMA table_info(trades)").fetchall()
|
|
}
|
|
for column, definition in {
|
|
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
|
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
|
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
|
}.items():
|
|
if column not in trade_columns:
|
|
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
|
|
|
|
def insert_position(self, position: Position) -> int:
|
|
with self.connect() as conn:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO positions (
|
|
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
|
|
take_profit, highest_price, opened_at, entry_reason,
|
|
entry_confidence, entry_pattern, entry_diagnostics_json, status
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
|
|
""",
|
|
(
|
|
position.symbol,
|
|
position.qty,
|
|
position.entry_price,
|
|
position.notional_usdt,
|
|
position.entry_fee_usdt,
|
|
position.stop_loss,
|
|
position.take_profit,
|
|
position.highest_price,
|
|
position.opened_at.isoformat(),
|
|
position.entry_reason,
|
|
position.entry_confidence,
|
|
position.entry_pattern,
|
|
json.dumps(position.entry_diagnostics, ensure_ascii=False),
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
def close_position(self, position_id: int) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute("UPDATE positions SET status='CLOSED' WHERE id=?", (position_id,))
|
|
|
|
def update_position_highest(self, position_id: int, highest_price: float) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"UPDATE positions SET highest_price=? WHERE id=? AND status='OPEN'",
|
|
(highest_price, position_id),
|
|
)
|
|
|
|
def open_positions(self) -> list[Position]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
|
|
).fetchall()
|
|
return [
|
|
Position(
|
|
id=int(row["id"]),
|
|
symbol=row["symbol"],
|
|
qty=float(row["qty"]),
|
|
entry_price=float(row["entry_price"]),
|
|
notional_usdt=float(row["notional_usdt"]),
|
|
entry_fee_usdt=float(row["entry_fee_usdt"]),
|
|
stop_loss=float(row["stop_loss"]),
|
|
take_profit=float(row["take_profit"]),
|
|
highest_price=float(row["highest_price"]),
|
|
opened_at=_parse_datetime(row["opened_at"]),
|
|
entry_reason=row["entry_reason"],
|
|
entry_confidence=float(row["entry_confidence"]),
|
|
entry_pattern=row["entry_pattern"],
|
|
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def insert_trade(self, trade: Trade) -> int:
|
|
with self.connect() as conn:
|
|
cur = conn.execute(
|
|
"""
|
|
INSERT INTO trades (
|
|
symbol, side, qty, entry_price, exit_price, gross_pnl,
|
|
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
|
|
entry_diagnostics_json, opened_at, closed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
trade.symbol,
|
|
trade.side,
|
|
trade.qty,
|
|
trade.entry_price,
|
|
trade.exit_price,
|
|
trade.gross_pnl,
|
|
trade.fee_usdt,
|
|
trade.net_pnl,
|
|
trade.reason,
|
|
trade.entry_pattern,
|
|
trade.entry_confidence,
|
|
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
|
|
trade.opened_at.isoformat() if trade.opened_at else None,
|
|
trade.closed_at.isoformat() if trade.closed_at else None,
|
|
),
|
|
)
|
|
return int(cur.lastrowid)
|
|
|
|
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def closed_trades(self, limit: int = 200) -> list[dict[str, Any]]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM trades
|
|
WHERE side='SELL' AND closed_at IS NOT NULL
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def closed_trade_summary(self) -> dict[str, Any]:
|
|
with self.connect() as conn:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
COUNT(*) AS trades,
|
|
COALESCE(SUM(net_pnl), 0) AS net_pnl,
|
|
COALESCE(SUM(gross_pnl), 0) AS gross_pnl,
|
|
COALESCE(SUM(fee_usdt), 0) AS fee_usdt,
|
|
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) AS wins,
|
|
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
|
|
FROM trades
|
|
WHERE side='SELL' AND closed_at IS NOT NULL
|
|
"""
|
|
).fetchone()
|
|
trades = int(row["trades"] if row else 0)
|
|
wins = int(row["wins"] if row else 0)
|
|
losses = int(row["losses"] if row else 0)
|
|
return {
|
|
"trades": trades,
|
|
"net_pnl": round(float(row["net_pnl"] if row else 0.0), 6),
|
|
"gross_pnl": round(float(row["gross_pnl"] if row else 0.0), 6),
|
|
"fee_usdt": round(float(row["fee_usdt"] if row else 0.0), 6),
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"win_rate": round(wins / trades, 4) if trades else 0.0,
|
|
}
|
|
|
|
def insert_signal(self, signal: Signal) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO signals (symbol, action, confidence, reason, diagnostics_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
signal.symbol,
|
|
signal.action,
|
|
signal.confidence,
|
|
signal.reason,
|
|
json.dumps(signal.diagnostics, ensure_ascii=False),
|
|
signal.created_at.isoformat(),
|
|
),
|
|
)
|
|
|
|
def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def insert_equity(self, equity: float, cash: float, exposure: float, drawdown: float) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(equity, cash, exposure, drawdown, utc_now().isoformat()),
|
|
)
|
|
|
|
def latest_equity(self) -> dict[str, Any] | None:
|
|
with self.connect() as conn:
|
|
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def event(self, message: str, level: str = "INFO") -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"INSERT INTO events (level, message, created_at) VALUES (?, ?, ?)",
|
|
(level, message, utc_now().isoformat()),
|
|
)
|
|
|
|
def recent_events(self, limit: int = 80) -> list[dict[str, Any]]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute("SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def insert_llm_advice(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
model: str,
|
|
prompt_json: dict[str, Any],
|
|
response_text: str,
|
|
advice_json: dict[str, Any],
|
|
error: str = "",
|
|
) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO llm_advice (
|
|
symbol, model, prompt_json, response_text, advice_json, error, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
symbol,
|
|
model,
|
|
json.dumps(prompt_json, ensure_ascii=False),
|
|
response_text,
|
|
json.dumps(advice_json, ensure_ascii=False),
|
|
error,
|
|
utc_now().isoformat(),
|
|
),
|
|
)
|
|
|
|
def recent_llm_advice(self, limit: int = 80) -> list[dict[str, Any]]:
|
|
with self.connect() as conn:
|
|
rows = conn.execute("SELECT * FROM llm_advice ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
items: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
item = dict(row)
|
|
item["prompt"] = _json_or_default(item.pop("prompt_json", "{}"), {})
|
|
item["advice"] = _json_or_default(item.pop("advice_json", "{}"), {})
|
|
items.append(item)
|
|
return items
|
|
|
|
def set_runtime(self, key: str, value: Any) -> None:
|
|
with self.connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO runtime (key, value, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
|
""",
|
|
(key, json.dumps(value, ensure_ascii=False), utc_now().isoformat()),
|
|
)
|
|
|
|
def get_runtime(self, key: str, default: Any = None) -> Any:
|
|
with self.connect() as conn:
|
|
row = conn.execute("SELECT value FROM runtime WHERE key=?", (key,)).fetchone()
|
|
if not row:
|
|
return default
|
|
try:
|
|
return json.loads(row["value"])
|
|
except json.JSONDecodeError:
|
|
return default
|
|
|
|
def clear_all(self) -> None:
|
|
with self.connect() as conn:
|
|
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice"):
|
|
conn.execute(f"DELETE FROM {table}")
|
|
|
|
|
|
def _json_or_default(value: str, default: Any) -> Any:
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return default
|
|
|
|
|
|
def _parse_datetime(value: str):
|
|
from datetime import datetime
|
|
|
|
return datetime.fromisoformat(value)
|