import os import sqlite3 from datetime import date, datetime from typing import Any, Dict, List, Optional, Tuple import pandas as pd from pt_trading.trading_pair import TradingPair # Recommended replacement adapters and converters for Python 3.12+ # From: https://docs.python.org/3/library/sqlite3.html#sqlite3-adapter-converter-recipes def adapt_date_iso(val: date) -> str: """Adapt datetime.date to ISO 8601 date.""" return val.isoformat() def adapt_datetime_iso(val: datetime) -> str: """Adapt datetime.datetime to timezone-naive ISO 8601 date.""" return val.isoformat() def convert_date(val: bytes) -> date: """Convert ISO 8601 date to datetime.date object.""" return datetime.fromisoformat(val.decode()).date() def convert_datetime(val: bytes) -> datetime: """Convert ISO 8601 datetime to datetime.datetime object.""" return datetime.fromisoformat(val.decode()) # Register the adapters and converters sqlite3.register_adapter(date, adapt_date_iso) sqlite3.register_adapter(datetime, adapt_datetime_iso) sqlite3.register_converter("date", convert_date) sqlite3.register_converter("datetime", convert_datetime) def create_result_database(db_path: str) -> None: """ Create the SQLite database and required tables if they don't exist. """ try: # Create directory if it doesn't exist db_dir = os.path.dirname(db_path) if db_dir and not os.path.exists(db_dir): os.makedirs(db_dir, exist_ok=True) print(f"Created directory: {db_dir}") conn = sqlite3.connect(db_path) cursor = conn.cursor() # Create the pt_bt_results table for completed trades cursor.execute( """ CREATE TABLE IF NOT EXISTS pt_bt_results ( date DATE, pair TEXT, symbol TEXT, open_time DATETIME, open_side TEXT, open_price REAL, open_quantity INTEGER, open_disequilibrium REAL, close_time DATETIME, close_side TEXT, close_price REAL, close_quantity INTEGER, close_disequilibrium REAL, symbol_return REAL, pair_return REAL ) """ ) cursor.execute("DELETE FROM pt_bt_results;") # Create the outstanding_positions table for open positions cursor.execute( """ CREATE TABLE IF NOT EXISTS outstanding_positions ( date DATE, pair TEXT, symbol TEXT, position_quantity REAL, last_price REAL, unrealized_return REAL, open_price REAL, open_side TEXT ) """ ) cursor.execute("DELETE FROM outstanding_positions;") # Create the config table for storing configuration JSON for reference cursor.execute( """ CREATE TABLE IF NOT EXISTS config ( id INTEGER PRIMARY KEY AUTOINCREMENT, run_timestamp DATETIME, config_file_path TEXT, config_json TEXT, fit_method_class TEXT, datafiles TEXT, instruments TEXT ) """ ) cursor.execute("DELETE FROM config;") conn.commit() conn.close() except Exception as e: print(f"Error creating result database: {str(e)}") raise def store_config_in_database( db_path: str, config_file_path: str, config: Dict, fit_method_class: str, datafiles: List[str], instruments: List[str], ) -> None: """ Store configuration information in the database for reference. """ import json if db_path.upper() == "NONE": return try: conn = sqlite3.connect(db_path) cursor = conn.cursor() # Convert config to JSON string config_json = json.dumps(config, indent=2, default=str) # Convert lists to comma-separated strings for storage datafiles_str = ", ".join(datafiles) instruments_str = ", ".join(instruments) # Insert configuration record cursor.execute( """ INSERT INTO config ( run_timestamp, config_file_path, config_json, fit_method_class, datafiles, instruments ) VALUES (?, ?, ?, ?, ?, ?) """, ( datetime.now(), config_file_path, config_json, fit_method_class, datafiles_str, instruments_str, ), ) conn.commit() conn.close() print(f"Configuration stored in database") except Exception as e: print(f"Error storing configuration in database: {str(e)}") import traceback traceback.print_exc() def store_results_in_database( db_path: str, datafile: str, bt_result: "BacktestResult" ) -> None: """ Store backtest results in the SQLite database. """ if db_path.upper() == "NONE": return def convert_timestamp(timestamp: Any) -> Optional[datetime]: """Convert pandas Timestamp to Python datetime object for SQLite compatibility.""" if timestamp is None: return None if hasattr(timestamp, "to_pydatetime"): return timestamp.to_pydatetime() return timestamp try: # Extract date from datafile name (assuming format like 20250528.mktdata.ohlcv.db) filename = os.path.basename(datafile) date_str = filename.split(".")[0] # Extract date part # Convert to proper date format try: date_obj = datetime.strptime(date_str, "%Y%m%d").date() except ValueError: # If date parsing fails, use current date date_obj = datetime.now().date() conn = sqlite3.connect(db_path) cursor = conn.cursor() # Process each trade from bt_result trades = bt_result.get_trades() for pair_name, symbols in trades.items(): # Calculate pair return for this pair pair_return = 0.0 pair_trades = [] # First pass: collect all trades and calculate returns for symbol, symbol_trades in symbols.items(): if len(symbol_trades) == 0: # No trades for this symbol print( f"Warning: No trades found for symbol {symbol} in pair {pair_name}" ) continue elif len(symbol_trades) >= 2: # Completed trades (entry + exit) # Handle both old and new tuple formats if len(symbol_trades[0]) == 2: # Old format: (action, price) entry_action, entry_price = symbol_trades[0] exit_action, exit_price = symbol_trades[1] open_disequilibrium = 0.0 # Fallback for old format open_scaled_disequilibrium = 0.0 close_disequilibrium = 0.0 close_scaled_disequilibrium = 0.0 open_time = datetime.now() close_time = datetime.now() else: # New format: (action, price, disequilibrium, scaled_disequilibrium, timestamp) ( entry_action, entry_price, open_disequilibrium, open_scaled_disequilibrium, open_time, ) = symbol_trades[0] ( exit_action, exit_price, close_disequilibrium, close_scaled_disequilibrium, close_time, ) = symbol_trades[1] # Handle None values open_disequilibrium = ( open_disequilibrium if open_disequilibrium is not None else 0.0 ) open_scaled_disequilibrium = ( open_scaled_disequilibrium if open_scaled_disequilibrium is not None else 0.0 ) close_disequilibrium = ( close_disequilibrium if close_disequilibrium is not None else 0.0 ) close_scaled_disequilibrium = ( close_scaled_disequilibrium if close_scaled_disequilibrium is not None else 0.0 ) # Convert pandas Timestamps to Python datetime objects open_time = convert_timestamp(open_time) or datetime.now() close_time = convert_timestamp(close_time) or datetime.now() # Calculate actual share quantities based on funding per pair # Split funding equally between the two positions funding_per_position = bt_result.config["funding_per_pair"] / 2 shares = funding_per_position / entry_price # Calculate symbol return symbol_return = 0.0 if entry_action == "BUY" and exit_action == "SELL": symbol_return = (exit_price - entry_price) / entry_price * 100 elif entry_action == "SELL" and exit_action == "BUY": symbol_return = (entry_price - exit_price) / entry_price * 100 pair_return += symbol_return pair_trades.append( { "symbol": symbol, "entry_action": entry_action, "entry_price": entry_price, "exit_action": exit_action, "exit_price": exit_price, "symbol_return": symbol_return, "open_disequilibrium": open_disequilibrium, "open_scaled_disequilibrium": open_scaled_disequilibrium, "close_disequilibrium": close_disequilibrium, "close_scaled_disequilibrium": close_scaled_disequilibrium, "open_time": open_time, "close_time": close_time, "shares": shares, "is_completed": True, } ) # Skip one-sided trades - they will be handled by outstanding_positions table elif len(symbol_trades) == 1: print( f"Skipping one-sided trade for {symbol} in pair {pair_name} - will be stored in outstanding_positions table" ) continue else: # This should not happen, but handle unexpected cases print( f"Warning: Unexpected number of trades ({len(symbol_trades)}) for symbol {symbol} in pair {pair_name}" ) continue # Second pass: insert completed trade records into database for trade in pair_trades: # Only store completed trades in pt_bt_results table cursor.execute( """ INSERT INTO pt_bt_results ( date, pair, symbol, open_time, open_side, open_price, open_quantity, open_disequilibrium, close_time, close_side, close_price, close_quantity, close_disequilibrium, symbol_return, pair_return ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( date_obj, pair_name, trade["symbol"], trade["open_time"], trade["entry_action"], trade["entry_price"], trade["shares"], trade["open_scaled_disequilibrium"], trade["close_time"], trade["exit_action"], trade["exit_price"], trade["shares"], trade["close_scaled_disequilibrium"], trade["symbol_return"], pair_return, ), ) # Store outstanding positions in separate table outstanding_positions = bt_result.get_outstanding_positions() for pos in outstanding_positions: # Calculate position quantity (negative for SELL positions) position_qty_a = ( pos["shares_a"] if pos["side_a"] == "BUY" else -pos["shares_a"] ) position_qty_b = ( pos["shares_b"] if pos["side_b"] == "BUY" else -pos["shares_b"] ) # Calculate unrealized returns # For symbol A: (current_price - open_price) / open_price * 100 * position_direction unrealized_return_a = ( (pos["current_px_a"] - pos["open_px_a"]) / pos["open_px_a"] * 100 ) * (1 if pos["side_a"] == "BUY" else -1) unrealized_return_b = ( (pos["current_px_b"] - pos["open_px_b"]) / pos["open_px_b"] * 100 ) * (1 if pos["side_b"] == "BUY" else -1) # Store outstanding position for symbol A cursor.execute( """ INSERT INTO outstanding_positions ( date, pair, symbol, position_quantity, last_price, unrealized_return, open_price, open_side ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( date_obj, pos["pair"], pos["symbol_a"], position_qty_a, pos["current_px_a"], unrealized_return_a, pos["open_px_a"], pos["side_a"], ), ) # Store outstanding position for symbol B cursor.execute( """ INSERT INTO outstanding_positions ( date, pair, symbol, position_quantity, last_price, unrealized_return, open_price, open_side ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( date_obj, pos["pair"], pos["symbol_b"], position_qty_b, pos["current_px_b"], unrealized_return_b, pos["open_px_b"], pos["side_b"], ), ) conn.commit() conn.close() except Exception as e: print(f"Error storing results in database: {str(e)}") import traceback traceback.print_exc() class BacktestResult: """ Class to handle backtest results, trades tracking, PnL calculations, and reporting. """ def __init__(self, config: Dict[str, Any]): self.config = config self.trades: Dict[str, Dict[str, Any]] = {} self.total_realized_pnl = 0.0 self.outstanding_positions: List[Dict[str, Any]] = [] def add_trade( self, pair_nm: str, symbol: str, action: str, price: Any, disequilibrium: Optional[float] = None, scaled_disequilibrium: Optional[float] = None, timestamp: Optional[datetime] = None, ) -> None: """Add a trade to the results tracking.""" pair_nm = str(pair_nm) if pair_nm not in self.trades: self.trades[pair_nm] = {symbol: []} if symbol not in self.trades[pair_nm]: self.trades[pair_nm][symbol] = [] self.trades[pair_nm][symbol].append( (action, price, disequilibrium, scaled_disequilibrium, timestamp) ) def add_outstanding_position(self, position: Dict[str, Any]) -> None: """Add an outstanding position to tracking.""" self.outstanding_positions.append(position) def add_realized_pnl(self, realized_pnl: float) -> None: """Add realized PnL to the total.""" self.total_realized_pnl += realized_pnl def get_total_realized_pnl(self) -> float: """Get total realized PnL.""" return self.total_realized_pnl def get_outstanding_positions(self) -> List[Dict[str, Any]]: """Get all outstanding positions.""" return self.outstanding_positions def get_trades(self) -> Dict[str, Dict[str, Any]]: """Get all trades.""" return self.trades def clear_trades(self) -> None: """Clear all trades (used when processing new files).""" self.trades.clear() def collect_single_day_results(self, result: pd.DataFrame) -> None: """Collect and process single day trading results.""" print("\n -------------- Suggested Trades ") print(result) for row in result.itertuples(): action = row.action symbol = row.symbol price = row.price disequilibrium = getattr(row, "disequilibrium", None) scaled_disequilibrium = getattr(row, "scaled_disequilibrium", None) timestamp = getattr(row, "time", None) self.add_trade( pair_nm=str(row.pair), action=str(action), symbol=str(symbol), price=float(str(price)), disequilibrium=disequilibrium, scaled_disequilibrium=scaled_disequilibrium, timestamp=timestamp, ) def print_single_day_results(self) -> None: """Print single day results summary.""" for pair, symbols in self.trades.items(): print(f"\n--- {pair} ---") for symbol, trades in symbols.items(): for trade_data in trades: if len(trade_data) >= 2: side, price = trade_data[:2] print(f"{symbol} {side} at ${price}") def print_results_summary(self, all_results: Dict[str, Dict[str, Any]]) -> None: """Print summary of all processed files.""" print("\n====== Summary of All Processed Files ======") for filename, data in all_results.items(): trade_count = sum( len(trades) for symbol_trades in data["trades"].values() for trades in symbol_trades.values() ) print(f"{filename}: {trade_count} trades") def calculate_returns(self, all_results: Dict[str, Dict[str, Any]]) -> None: """Calculate and print returns by day and pair.""" print("\n====== Returns By Day and Pair ======") for filename, data in all_results.items(): day_return = 0 print(f"\n--- {filename} ---") self.outstanding_positions = data["outstanding_positions"] # Process each pair for pair, symbols in data["trades"].items(): pair_return = 0 pair_trades = [] # Calculate individual symbol returns in the pair for symbol, trades in symbols.items(): if len(trades) == 0: continue symbol_return = 0 symbol_trades = [] # Process all trades sequentially for this symbol for i, trade in enumerate(trades): # Handle both old and new tuple formats if len(trade) == 2: # Old format: (action, price) action, price = trade disequilibrium = None scaled_disequilibrium = None timestamp = None else: # New format: (action, price, disequilibrium, scaled_disequilibrium, timestamp) action, price = trade[:2] disequilibrium = trade[2] if len(trade) > 2 else None scaled_disequilibrium = trade[3] if len(trade) > 3 else None timestamp = trade[4] if len(trade) > 4 else None symbol_trades.append((action, price, disequilibrium, scaled_disequilibrium, timestamp)) # Calculate returns for all trade combinations for i in range(len(symbol_trades) - 1): trade1 = symbol_trades[i] trade2 = symbol_trades[i + 1] action1, price1, diseq1, scaled_diseq1, ts1 = trade1 action2, price2, diseq2, scaled_diseq2, ts2 = trade2 # Calculate return based on action combination trade_return = 0 if action1 == "BUY" and action2 == "SELL": # Long position trade_return = (price2 - price1) / price1 * 100 elif action1 == "SELL" and action2 == "BUY": # Short position trade_return = (price1 - price2) / price1 * 100 symbol_return += trade_return # Store trade details for reporting pair_trades.append( ( symbol, action1, price1, action2, price2, trade_return, scaled_diseq1, scaled_diseq2, i + 1, # Trade sequence number ) ) pair_return += symbol_return # Print pair returns with disequilibrium information if pair_trades: print(f" {pair}:") for ( symbol, action1, price1, action2, price2, trade_return, scaled_diseq1, scaled_diseq2, trade_num, ) in pair_trades: disequil_info = "" if ( scaled_diseq1 is not None and scaled_diseq2 is not None ): disequil_info = f" | Open Dis-eq: {scaled_diseq1:.2f}, Close Dis-eq: {scaled_diseq2:.2f}" print( f" {symbol} (Trade #{trade_num}): {action1} @ ${price1:.2f}, {action2} @ ${price2:.2f}, Return: {trade_return:.2f}%{disequil_info}" ) print(f" Pair Total Return: {pair_return:.2f}%") day_return += pair_return # Print day total return and add to global realized PnL if day_return != 0: print(f" Day Total Return: {day_return:.2f}%") self.add_realized_pnl(day_return) def print_outstanding_positions(self) -> None: """Print all outstanding positions with share quantities and current values.""" if not self.get_outstanding_positions(): print("\n====== NO OUTSTANDING POSITIONS ======") return print(f"\n====== OUTSTANDING POSITIONS ======") print( f"{'Pair':<15}" f" {'Symbol':<10}" f" {'Side':<4}" f" {'Shares':<10}" f" {'Open $':<8}" f" {'Current $':<10}" f" {'Value $':<12}" f" {'Disequilibrium':<15}" ) print("-" * 100) total_value = 0.0 for pos in self.get_outstanding_positions(): # Print position A print( f"{pos['pair']:<15}" f" {pos['symbol_a']:<10}" f" {pos['side_a']:<4}" f" {pos['shares_a']:<10.2f}" f" {pos['open_px_a']:<8.2f}" f" {pos['current_px_a']:<10.2f}" f" {pos['current_value_a']:<12.2f}" f" {'':<15}" ) # Print position B print( f"{'':<15}" f" {pos['symbol_b']:<10}" f" {pos['side_b']:<4}" f" {pos['shares_b']:<10.2f}" f" {pos['open_px_b']:<8.2f}" f" {pos['current_px_b']:<10.2f}" f" {pos['current_value_b']:<12.2f}" ) # Print pair totals with disequilibrium info print( f"{'':<15}" f" {'PAIR TOTAL':<10}" f" {'':<4}" f" {'':<10}" f" {'':<8}" f" {'':<10}" f" {pos['total_current_value']:<12.2f}" ) # Print disequilibrium details print( f"{'':<15}" f" {'DISEQUIL':<10}" f" {'':<4}" f" {'':<10}" f" {'':<8}" f" {'':<10}" f" Raw: {pos['current_disequilibrium']:<6.4f}" f" Scaled: {pos['current_scaled_disequilibrium']:<6.4f}" ) print("-" * 100) total_value += pos["total_current_value"] print(f"{'TOTAL OUTSTANDING VALUE':<80} ${total_value:<12.2f}") def print_grand_totals(self) -> None: """Print grand totals across all pairs.""" print(f"\n====== GRAND TOTALS ACROSS ALL PAIRS ======") print(f"Total Realized PnL: {self.get_total_realized_pnl():.2f}%") def handle_outstanding_position( self, pair: TradingPair, pair_result_df: pd.DataFrame, last_row_index: int, open_side_a: str, open_side_b: str, open_px_a: float, open_px_b: float, open_tstamp: datetime, ) -> Tuple[float, float, float]: """ Handle calculation and tracking of outstanding positions when no close signal is found. Args: pair: TradingPair object pair_result_df: DataFrame with pair results last_row_index: Index of the last row in the data open_side_a, open_side_b: Trading sides for symbols A and B open_px_a, open_px_b: Opening prices for symbols A and B open_tstamp: Opening timestamp """ if pair_result_df is None or pair_result_df.empty: return 0, 0, 0 last_row = pair_result_df.loc[last_row_index] last_tstamp = last_row["tstamp"] colname_a, colname_b = pair.colnames() last_px_a = last_row[colname_a] last_px_b = last_row[colname_b] # Calculate share quantities based on funding per pair # Split funding equally between the two positions funding_per_position = self.config["funding_per_pair"] / 2 shares_a = funding_per_position / open_px_a shares_b = funding_per_position / open_px_b # Calculate current position values (shares * current price) current_value_a = shares_a * last_px_a current_value_b = shares_b * last_px_b total_current_value = current_value_a + current_value_b # Get disequilibrium information current_disequilibrium = last_row["disequilibrium"] current_scaled_disequilibrium = last_row["scaled_disequilibrium"] # Store outstanding positions self.add_outstanding_position( { "pair": str(pair), "symbol_a": pair.symbol_a_, "symbol_b": pair.symbol_b_, "side_a": open_side_a, "side_b": open_side_b, "shares_a": shares_a, "shares_b": shares_b, "open_px_a": open_px_a, "open_px_b": open_px_b, "current_px_a": last_px_a, "current_px_b": last_px_b, "current_value_a": current_value_a, "current_value_b": current_value_b, "total_current_value": total_current_value, "open_time": open_tstamp, "last_time": last_tstamp, "current_abs_term": current_scaled_disequilibrium, "current_disequilibrium": current_disequilibrium, "current_scaled_disequilibrium": current_scaled_disequilibrium, } ) # Print position details print(f"{pair}: NO CLOSE SIGNAL FOUND - Position held until end of session") print(f" Open: {open_tstamp} | Last: {last_tstamp}") print( f" {pair.symbol_a_}: {open_side_a} {shares_a:.2f} shares @ ${open_px_a:.2f} -> ${last_px_a:.2f} | Value: ${current_value_a:.2f}" ) print( f" {pair.symbol_b_}: {open_side_b} {shares_b:.2f} shares @ ${open_px_b:.2f} -> ${last_px_b:.2f} | Value: ${current_value_b:.2f}" ) print(f" Total Value: ${total_current_value:.2f}") print( f" Disequilibrium: {current_disequilibrium:.4f} | Scaled: {current_scaled_disequilibrium:.4f}" ) return current_value_a, current_value_b, total_current_value