database_manager.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. #!/usr/bin/env python3
  2. """
  3. Database Manager for Trading Statistics
  4. Handles database connections, schema creation, and basic CRUD operations.
  5. """
  6. import sqlite3
  7. import os
  8. import logging
  9. from datetime import datetime, timezone, timedelta
  10. from typing import Dict, List, Any, Optional
  11. from src.migrations.migrate_db import run_migrations as run_db_migrations
  12. from src.config.config import Config
  13. from src.utils.token_display_formatter import get_formatter
  14. logger = logging.getLogger(__name__)
  15. class DatabaseManager:
  16. """Manages SQLite database connections and basic operations."""
  17. def __init__(self, db_path: str = "data/trading_stats.sqlite"):
  18. """Initialize database connection and schema."""
  19. self.db_path = db_path
  20. self._ensure_data_directory()
  21. # Run migrations before connecting
  22. logger.info("Running database migrations if needed...")
  23. run_db_migrations(self.db_path)
  24. logger.info("Database migration check complete.")
  25. # Connect to database
  26. self.conn = sqlite3.connect(self.db_path, detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
  27. self.conn.row_factory = self._dict_factory
  28. # Create tables and initialize metadata
  29. self._create_tables()
  30. self._initialize_metadata()
  31. # Purge old data on startup
  32. self.purge_old_daily_aggregated_stats()
  33. self.purge_old_balance_history()
  34. def _dict_factory(self, cursor, row):
  35. """Convert SQLite rows to dictionaries."""
  36. d = {}
  37. for idx, col in enumerate(cursor.description):
  38. d[col[0]] = row[idx]
  39. return d
  40. def _ensure_data_directory(self):
  41. """Ensure the data directory for the SQLite file exists."""
  42. data_dir = os.path.dirname(self.db_path)
  43. if data_dir and not os.path.exists(data_dir):
  44. os.makedirs(data_dir)
  45. logger.info(f"Created data directory for TradingStats DB: {data_dir}")
  46. def _execute_query(self, query: str, params: tuple = ()):
  47. """Execute a query (INSERT, UPDATE, DELETE)."""
  48. with self.conn:
  49. self.conn.execute(query, params)
  50. def _fetch_query(self, query: str, params: tuple = ()) -> List[Dict[str, Any]]:
  51. """Execute a SELECT query and fetch all results."""
  52. cur = self.conn.execute(query, params)
  53. return cur.fetchall()
  54. def _fetchone_query(self, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
  55. """Execute a SELECT query and fetch one result."""
  56. cur = self.conn.execute(query, params)
  57. return cur.fetchone()
  58. def _create_tables(self):
  59. """Create SQLite tables if they don't exist."""
  60. queries = [
  61. """
  62. CREATE TABLE IF NOT EXISTS metadata (
  63. key TEXT PRIMARY KEY,
  64. value TEXT
  65. )
  66. """,
  67. """
  68. CREATE TABLE IF NOT EXISTS trades (
  69. id INTEGER PRIMARY KEY AUTOINCREMENT,
  70. exchange_fill_id TEXT UNIQUE,
  71. timestamp TEXT NOT NULL,
  72. symbol TEXT NOT NULL,
  73. side TEXT NOT NULL,
  74. amount REAL NOT NULL,
  75. price REAL NOT NULL,
  76. value REAL NOT NULL,
  77. trade_type TEXT NOT NULL,
  78. pnl REAL DEFAULT 0.0,
  79. linked_order_table_id INTEGER,
  80. -- Trade lifecycle tracking fields
  81. status TEXT DEFAULT 'executed',
  82. trade_lifecycle_id TEXT,
  83. position_side TEXT,
  84. -- Position tracking
  85. entry_price REAL,
  86. current_position_size REAL DEFAULT 0,
  87. -- Order IDs (exchange IDs)
  88. entry_order_id TEXT,
  89. stop_loss_order_id TEXT,
  90. take_profit_order_id TEXT,
  91. -- Risk management
  92. stop_loss_price REAL,
  93. take_profit_price REAL,
  94. -- P&L tracking
  95. realized_pnl REAL DEFAULT 0,
  96. unrealized_pnl REAL DEFAULT 0,
  97. mark_price REAL DEFAULT 0,
  98. position_value REAL DEFAULT NULL,
  99. unrealized_pnl_percentage REAL DEFAULT NULL,
  100. -- Risk Info from Exchange
  101. liquidation_price REAL DEFAULT NULL,
  102. margin_used REAL DEFAULT NULL,
  103. leverage REAL DEFAULT NULL,
  104. -- Timestamps
  105. position_opened_at TEXT,
  106. position_closed_at TEXT,
  107. updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  108. -- Notes
  109. notes TEXT
  110. )
  111. """,
  112. """
  113. CREATE TABLE IF NOT EXISTS balance_history (
  114. timestamp TEXT PRIMARY KEY,
  115. balance REAL NOT NULL
  116. )
  117. """,
  118. """
  119. CREATE TABLE IF NOT EXISTS balance_adjustments (
  120. id INTEGER PRIMARY KEY AUTOINCREMENT,
  121. adjustment_id TEXT UNIQUE,
  122. timestamp TEXT NOT NULL,
  123. type TEXT NOT NULL,
  124. amount REAL NOT NULL,
  125. description TEXT
  126. )
  127. """,
  128. """
  129. CREATE TABLE IF NOT EXISTS orders (
  130. id INTEGER PRIMARY KEY AUTOINCREMENT,
  131. bot_order_ref_id TEXT UNIQUE,
  132. exchange_order_id TEXT UNIQUE,
  133. symbol TEXT NOT NULL,
  134. side TEXT NOT NULL,
  135. type TEXT NOT NULL,
  136. amount_requested REAL NOT NULL,
  137. amount_filled REAL DEFAULT 0.0,
  138. price REAL,
  139. status TEXT NOT NULL,
  140. timestamp_created TEXT NOT NULL,
  141. timestamp_updated TEXT NOT NULL,
  142. parent_bot_order_ref_id TEXT NULLABLE
  143. )
  144. """,
  145. """
  146. CREATE TABLE IF NOT EXISTS daily_balances (
  147. date TEXT PRIMARY KEY,
  148. balance REAL NOT NULL,
  149. timestamp TEXT NOT NULL
  150. )
  151. """,
  152. """
  153. CREATE TABLE IF NOT EXISTS token_stats (
  154. token TEXT PRIMARY KEY,
  155. total_realized_pnl REAL DEFAULT 0.0,
  156. total_completed_cycles INTEGER DEFAULT 0,
  157. winning_cycles INTEGER DEFAULT 0,
  158. losing_cycles INTEGER DEFAULT 0,
  159. total_entry_volume REAL DEFAULT 0.0,
  160. total_exit_volume REAL DEFAULT 0.0,
  161. sum_of_winning_pnl REAL DEFAULT 0.0,
  162. sum_of_losing_pnl REAL DEFAULT 0.0,
  163. largest_winning_cycle_pnl REAL DEFAULT 0.0,
  164. largest_losing_cycle_pnl REAL DEFAULT 0.0,
  165. largest_winning_cycle_entry_volume REAL DEFAULT 0.0,
  166. largest_losing_cycle_entry_volume REAL DEFAULT 0.0,
  167. first_cycle_closed_at TEXT,
  168. last_cycle_closed_at TEXT,
  169. total_cancelled_cycles INTEGER DEFAULT 0,
  170. updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  171. total_duration_seconds INTEGER DEFAULT 0,
  172. roe_percentage REAL DEFAULT 0.0
  173. )
  174. """,
  175. """
  176. CREATE TABLE IF NOT EXISTS daily_aggregated_stats (
  177. date TEXT NOT NULL,
  178. token TEXT NOT NULL,
  179. realized_pnl REAL DEFAULT 0.0,
  180. completed_cycles INTEGER DEFAULT 0,
  181. entry_volume REAL DEFAULT 0.0,
  182. exit_volume REAL DEFAULT 0.0,
  183. PRIMARY KEY (date, token)
  184. )
  185. """
  186. ]
  187. # Create indexes
  188. indexes = [
  189. "CREATE INDEX IF NOT EXISTS idx_orders_bot_order_ref_id ON orders (bot_order_ref_id)",
  190. "CREATE INDEX IF NOT EXISTS idx_orders_exchange_order_id ON orders (exchange_order_id)",
  191. "CREATE INDEX IF NOT EXISTS idx_trades_exchange_fill_id ON trades (exchange_fill_id)",
  192. "CREATE INDEX IF NOT EXISTS idx_trades_linked_order_table_id ON trades (linked_order_table_id)",
  193. "CREATE INDEX IF NOT EXISTS idx_orders_parent_bot_order_ref_id ON orders (parent_bot_order_ref_id)",
  194. "CREATE INDEX IF NOT EXISTS idx_orders_status_type ON orders (status, type)",
  195. "CREATE INDEX IF NOT EXISTS idx_trades_status ON trades (status)",
  196. "CREATE INDEX IF NOT EXISTS idx_trades_lifecycle_id ON trades (trade_lifecycle_id)",
  197. "CREATE INDEX IF NOT EXISTS idx_trades_position_side ON trades (position_side)",
  198. "CREATE INDEX IF NOT EXISTS idx_trades_symbol_status ON trades (symbol, status)",
  199. "CREATE INDEX IF NOT EXISTS idx_daily_stats_date_token ON daily_aggregated_stats (date, token)"
  200. ]
  201. all_queries = queries + indexes
  202. for query in all_queries:
  203. self._execute_query(query)
  204. logger.info("SQLite tables ensured for TradingStats.")
  205. def _initialize_metadata(self):
  206. """Initialize metadata if not already present."""
  207. start_date = self._get_metadata('start_date')
  208. initial_balance = self._get_metadata('initial_balance')
  209. if start_date is None:
  210. self._set_metadata('start_date', datetime.now(timezone.utc).isoformat())
  211. logger.info("Initialized 'start_date' in metadata.")
  212. if initial_balance is None:
  213. self._set_metadata('initial_balance', '0.0')
  214. logger.info("Initialized 'initial_balance' in metadata.")
  215. logger.info(f"TradingStats initialized. Start Date: {self._get_metadata('start_date')}, Initial Balance: {self._get_metadata('initial_balance')}")
  216. def _get_metadata(self, key: str) -> Optional[str]:
  217. """Retrieve a value from the metadata table."""
  218. row = self._fetchone_query("SELECT value FROM metadata WHERE key = ?", (key,))
  219. return row['value'] if row else None
  220. def _set_metadata(self, key: str, value: str):
  221. """Set a value in the metadata table."""
  222. self._execute_query("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", (key, value))
  223. def set_initial_balance(self, balance: float):
  224. """Set the initial balance if it hasn't been set yet."""
  225. # Use a small tolerance for floating point comparison
  226. if self.get_initial_balance() < 1e-9: # Check if it's effectively zero
  227. self._set_metadata('initial_balance', str(balance))
  228. logger.info(f"Initial balance set to: {balance}")
  229. else:
  230. logger.info(f"Initial balance is already set. Current value: {self.get_initial_balance()}")
  231. def get_initial_balance(self) -> float:
  232. """Retrieve the initial balance from the metadata table."""
  233. balance_str = self._get_metadata('initial_balance')
  234. return float(balance_str) if balance_str is not None else 0.0
  235. def record_balance_snapshot(self, balance: float, unrealized_pnl: float = 0.0,
  236. timestamp: Optional[str] = None, notes: Optional[str] = None):
  237. """Records a balance snapshot to both balance_history and daily_balances."""
  238. now_utc = datetime.now(timezone.utc)
  239. # For logging, show the formatted values
  240. logger.info(f"Recorded balance snapshot: {balance} (unrealized: {unrealized_pnl})")
  241. # Store raw float values in the database
  242. self._execute_query(
  243. "INSERT INTO balance_history (balance, unrealized_pnl, timestamp, notes) VALUES (?, ?, ?, ?)",
  244. (balance, unrealized_pnl, now_utc.isoformat(), notes)
  245. )
  246. def purge_old_daily_aggregated_stats(self, months_to_keep: int = 10):
  247. """Purge records from daily_aggregated_stats older than specified months."""
  248. try:
  249. cutoff_date = datetime.now(timezone.utc).date() - timedelta(days=months_to_keep * 30)
  250. cutoff_datetime_str = cutoff_date.isoformat()
  251. query = "DELETE FROM daily_aggregated_stats WHERE date < ?"
  252. with self.conn:
  253. cursor = self.conn.cursor()
  254. cursor.execute(query, (cutoff_datetime_str,))
  255. rows_deleted = cursor.rowcount
  256. if rows_deleted > 0:
  257. logger.info(f"Purged {rows_deleted} old records from daily_aggregated_stats (older than {months_to_keep} months).")
  258. except Exception as e:
  259. logger.error(f"Error purging old daily_aggregated_stats: {e}", exc_info=True)
  260. def purge_old_balance_history(self):
  261. """Purge records from balance_history older than configured retention period."""
  262. days_to_keep = getattr(Config, 'BALANCE_HISTORY_RETENTION_DAYS', 30)
  263. if days_to_keep <= 0:
  264. return
  265. try:
  266. cutoff_date = datetime.now(timezone.utc).date() - timedelta(days=days_to_keep)
  267. cutoff_datetime_str = cutoff_date.isoformat()
  268. query = "DELETE FROM balance_history WHERE timestamp < ?"
  269. with self.conn:
  270. cursor = self.conn.cursor()
  271. cursor.execute(query, (cutoff_datetime_str,))
  272. rows_deleted = cursor.rowcount
  273. if rows_deleted > 0:
  274. logger.info(f"Purged {rows_deleted} old records from balance_history (older than {days_to_keep} days).")
  275. except Exception as e:
  276. logger.error(f"Error purging old balance_history: {e}", exc_info=True)
  277. def get_balance_history_record_count(self) -> int:
  278. """Get the total number of balance history records."""
  279. row = self._fetchone_query("SELECT COUNT(*) as count FROM balance_history")
  280. return row['count'] if row and 'count' in row else 0
  281. def get_daily_balance_record_count(self) -> int:
  282. """Get the total number of daily balance records."""
  283. row = self._fetchone_query("SELECT COUNT(*) as count FROM daily_balances")
  284. return row['count'] if row and 'count' in row else 0
  285. def close(self):
  286. """Close the SQLite database connection."""
  287. if self.conn:
  288. self.conn.close()
  289. logger.info("TradingStats SQLite connection closed.")
  290. def close_connection(self):
  291. """Close the SQLite database connection (alias for backward compatibility)."""
  292. self.close()
  293. def __del__(self):
  294. """Ensure connection is closed when object is deleted."""
  295. self.close_connection()