532 lines
20 KiB
Python
532 lines
20 KiB
Python
import os
|
|
import sqlite3
|
|
from datetime import date, datetime
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import pandas as pd
|
|
from pt_strategy.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,
|
|
close_condition TEXT
|
|
)
|
|
"""
|
|
)
|
|
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,
|
|
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,
|
|
datafiles: List[Tuple[str, str]],
|
|
instruments: List[Dict[str, 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([f"{datafile}" for _, datafile in datafiles])
|
|
instruments_str = ", ".join(
|
|
[
|
|
f"{inst['symbol']}:{inst['instrument_type']}:{inst['exchange_id']}"
|
|
for inst in instruments
|
|
]
|
|
)
|
|
|
|
# Insert configuration record
|
|
cursor.execute(
|
|
"""
|
|
INSERT INTO config (
|
|
run_timestamp, config_file_path, config_json, datafiles, instruments
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
datetime.now(),
|
|
config_file_path,
|
|
config_json,
|
|
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 convert_timestamp(timestamp: Any) -> Optional[datetime]:
|
|
"""Convert pandas Timestamp to Python datetime object for SQLite compatibility."""
|
|
if timestamp is None:
|
|
return None
|
|
if isinstance(timestamp, pd.Timestamp):
|
|
return timestamp.to_pydatetime()
|
|
elif isinstance(timestamp, datetime):
|
|
return timestamp
|
|
elif isinstance(timestamp, date):
|
|
return datetime.combine(timestamp, datetime.min.time())
|
|
elif isinstance(timestamp, str):
|
|
return datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S")
|
|
elif isinstance(timestamp, int):
|
|
return datetime.fromtimestamp(timestamp)
|
|
else:
|
|
raise ValueError(f"Unsupported timestamp type: {type(timestamp)}")
|
|
|
|
|
|
|
|
DayT = str
|
|
TradeT = Dict[str, Any]
|
|
OutstandingPositionT = Dict[str, Any]
|
|
class PairResearchResult:
|
|
"""
|
|
Class to handle pair research results for a single pair across multiple days.
|
|
Simplified version of BacktestResult focused on single pair analysis.
|
|
"""
|
|
trades_: Dict[DayT, pd.DataFrame]
|
|
outstanding_positions_: Dict[DayT, List[OutstandingPositionT]]
|
|
symbol_roundtrip_trades_: Dict[str, List[Dict[str, Any]]]
|
|
|
|
|
|
def __init__(self, config: Dict[str, Any]) -> None:
|
|
self.config_ = config
|
|
self.trades_ = {}
|
|
self.outstanding_positions_ = {}
|
|
self.total_realized_pnl = 0.0
|
|
self.symbol_roundtrip_trades_ = {}
|
|
|
|
def add_day_results(self, day: DayT, trades: pd.DataFrame, outstanding_positions: List[Dict[str, Any]]) -> None:
|
|
assert isinstance(trades, pd.DataFrame)
|
|
self.trades_[day] = trades
|
|
self.outstanding_positions_[day] = outstanding_positions
|
|
|
|
# def all_trades(self) -> List[TradeT]:
|
|
# """Get all trades across all days as a flat list."""
|
|
# all_trades_list: List[TradeT] = []
|
|
# for day_trades in self.trades_.values():
|
|
# all_trades_list.extend(day_trades.to_dict(orient="records"))
|
|
# return all_trades_list
|
|
|
|
def outstanding_positions(self) -> List[OutstandingPositionT]:
|
|
"""Get all outstanding positions across all days as a flat list."""
|
|
res: List[Dict[str, Any]] = []
|
|
for day in self.outstanding_positions_.keys():
|
|
res.extend(self.outstanding_positions_[day])
|
|
return res
|
|
|
|
def calculate_returns(self) -> None:
|
|
"""Calculate and store total returns for the single pair across all days."""
|
|
self.extract_roundtrip_trades()
|
|
|
|
self.total_realized_pnl = 0.0
|
|
|
|
for day, day_trades in self.symbol_roundtrip_trades_.items():
|
|
for trade in day_trades:
|
|
self.total_realized_pnl += trade['symbol_return']
|
|
|
|
def extract_roundtrip_trades(self) -> None:
|
|
"""
|
|
Extract round-trip trades by day, grouping open/close pairs for each symbol.
|
|
Returns a dictionary with day as key and list of completed round-trip trades.
|
|
"""
|
|
def _symbol_return(trade1_side: str, trade1_px: float, trade2_side: str, trade2_px: float) -> float:
|
|
if trade1_side == "BUY" and trade2_side == "SELL":
|
|
return (trade2_px - trade1_px) / trade1_px * 100
|
|
elif trade1_side == "SELL" and trade2_side == "BUY":
|
|
return (trade1_px - trade2_px) / trade1_px * 100
|
|
else:
|
|
return 0
|
|
|
|
# Process each day separately
|
|
for day, day_trades in self.trades_.items():
|
|
|
|
# Sort trades by timestamp for the day
|
|
sorted_trades = day_trades #sorted(day_trades, key=lambda x: x["timestamp"] if x["timestamp"] else pd.Timestamp.min)
|
|
|
|
day_roundtrips = []
|
|
|
|
# Process trades in groups of 4 (open A, open B, close A, close B)
|
|
for idx in range(0, len(sorted_trades), 4):
|
|
if idx + 3 >= len(sorted_trades):
|
|
break
|
|
|
|
trade_a_1 = sorted_trades.iloc[idx] # Open A
|
|
trade_b_1 = sorted_trades.iloc[idx + 1] # Open B
|
|
trade_a_2 = sorted_trades.iloc[idx + 2] # Close A
|
|
trade_b_2 = sorted_trades.iloc[idx + 3] # Close B
|
|
|
|
# Validate trade sequence
|
|
if not (trade_a_1["action"] == "OPEN" and trade_a_2["action"] == "CLOSE"):
|
|
continue
|
|
if not (trade_b_1["action"] == "OPEN" and trade_b_2["action"] == "CLOSE"):
|
|
continue
|
|
|
|
# Calculate individual symbol returns
|
|
symbol_a_return = _symbol_return(
|
|
trade_a_1["side"], trade_a_1["price"],
|
|
trade_a_2["side"], trade_a_2["price"]
|
|
)
|
|
symbol_b_return = _symbol_return(
|
|
trade_b_1["side"], trade_b_1["price"],
|
|
trade_b_2["side"], trade_b_2["price"]
|
|
)
|
|
|
|
pair_return = symbol_a_return + symbol_b_return
|
|
|
|
# Create round-trip records for both symbols
|
|
funding_per_position = self.config_.get("funding_per_pair", 10000) / 2
|
|
|
|
# Symbol A round-trip
|
|
day_roundtrips.append({
|
|
"symbol": trade_a_1["symbol"],
|
|
"open_side": trade_a_1["side"],
|
|
"open_price": trade_a_1["price"],
|
|
"open_time": trade_a_1["time"],
|
|
"close_side": trade_a_2["side"],
|
|
"close_price": trade_a_2["price"],
|
|
"close_time": trade_a_2["time"],
|
|
"symbol_return": symbol_a_return,
|
|
"pair_return": pair_return,
|
|
"shares": funding_per_position / trade_a_1["price"],
|
|
"close_condition": trade_a_2.get("status", "UNKNOWN"),
|
|
"open_disequilibrium": trade_a_1.get("disequilibrium"),
|
|
"close_disequilibrium": trade_a_2.get("disequilibrium"),
|
|
})
|
|
|
|
# Symbol B round-trip
|
|
day_roundtrips.append({
|
|
"symbol": trade_b_1["symbol"],
|
|
"open_side": trade_b_1["side"],
|
|
"open_price": trade_b_1["price"],
|
|
"open_time": trade_b_1["time"],
|
|
"close_side": trade_b_2["side"],
|
|
"close_price": trade_b_2["price"],
|
|
"close_time": trade_b_2["time"],
|
|
"symbol_return": symbol_b_return,
|
|
"pair_return": pair_return,
|
|
"shares": funding_per_position / trade_b_1["price"],
|
|
"close_condition": trade_b_2.get("status", "UNKNOWN"),
|
|
"open_disequilibrium": trade_b_1.get("disequilibrium"),
|
|
"close_disequilibrium": trade_b_2.get("disequilibrium"),
|
|
})
|
|
|
|
if day_roundtrips:
|
|
self.symbol_roundtrip_trades_[day] = day_roundtrips
|
|
|
|
|
|
def print_returns_by_day(self) -> None:
|
|
"""
|
|
Print detailed return information for each day, grouped by day.
|
|
Shows individual symbol round-trips and daily totals.
|
|
"""
|
|
|
|
print("\n====== PAIR RESEARCH RETURNS BY DAY ======")
|
|
|
|
total_return_all_days = 0.0
|
|
|
|
for day, day_trades in sorted(self.symbol_roundtrip_trades_.items()):
|
|
|
|
print(f"\n--- {day} ---")
|
|
|
|
day_total_return = 0.0
|
|
pair_returns = []
|
|
|
|
# Group trades by pair (every 2 trades form a pair)
|
|
for idx in range(0, len(day_trades), 2):
|
|
if idx + 1 < len(day_trades):
|
|
trade_a = day_trades[idx]
|
|
trade_b = day_trades[idx + 1]
|
|
|
|
# Print individual symbol results
|
|
print(f" {trade_a['open_time'].time()}-{trade_a['close_time'].time()}")
|
|
print(f" {trade_a['symbol']}: {trade_a['open_side']} @ ${trade_a['open_price']:.2f} → "
|
|
f"{trade_a['close_side']} @ ${trade_a['close_price']:.2f} | "
|
|
f"Return: {trade_a['symbol_return']:+.2f}% | Shares: {trade_a['shares']:.2f}")
|
|
|
|
print(f" {trade_b['symbol']}: {trade_b['open_side']} @ ${trade_b['open_price']:.2f} → "
|
|
f"{trade_b['close_side']} @ ${trade_b['close_price']:.2f} | "
|
|
f"Return: {trade_b['symbol_return']:+.2f}% | Shares: {trade_b['shares']:.2f}")
|
|
|
|
# Show disequilibrium info if available
|
|
if trade_a.get('open_disequilibrium') is not None:
|
|
print(f" Disequilibrium: Open: {trade_a['open_disequilibrium']:.4f}, "
|
|
f"Close: {trade_a['close_disequilibrium']:.4f}")
|
|
|
|
pair_return = trade_a['pair_return']
|
|
print(f" Pair Return: {pair_return:+.2f}% | Close Condition: {trade_a['close_condition']}")
|
|
print()
|
|
|
|
pair_returns.append(pair_return)
|
|
day_total_return += pair_return
|
|
|
|
print(f" Day Total Return: {day_total_return:+.2f}% ({len(pair_returns)} pairs)")
|
|
total_return_all_days += day_total_return
|
|
|
|
print(f"\n====== TOTAL RETURN ACROSS ALL DAYS ======")
|
|
print(f"Total Return: {total_return_all_days:+.2f}%")
|
|
print(f"Total Days: {len(self.symbol_roundtrip_trades_)}")
|
|
if len(self.symbol_roundtrip_trades_) > 0:
|
|
print(f"Average Daily Return: {total_return_all_days / len(self.symbol_roundtrip_trades_):+.2f}%")
|
|
|
|
def get_return_summary(self) -> Dict[str, Any]:
|
|
"""
|
|
Get a summary of returns across all days.
|
|
Returns a dictionary with key metrics.
|
|
"""
|
|
if len(self.symbol_roundtrip_trades_) == 0:
|
|
return {
|
|
"total_return": 0.0,
|
|
"total_days": 0,
|
|
"total_pairs": 0,
|
|
"average_daily_return": 0.0,
|
|
"best_day": None,
|
|
"worst_day": None,
|
|
"daily_returns": {}
|
|
}
|
|
|
|
daily_returns = {}
|
|
total_return = 0.0
|
|
total_pairs = 0
|
|
|
|
for day, day_trades in self.symbol_roundtrip_trades_.items():
|
|
day_return = 0.0
|
|
day_pairs = len(day_trades) // 2 # Each pair has 2 symbol trades
|
|
|
|
for trade in day_trades:
|
|
day_return += trade['symbol_return']
|
|
|
|
daily_returns[day] = {
|
|
"return": day_return,
|
|
"pairs": day_pairs
|
|
}
|
|
total_return += day_return
|
|
total_pairs += day_pairs
|
|
|
|
best_day = max(daily_returns.items(), key=lambda x: x[1]["return"]) if daily_returns else None
|
|
worst_day = min(daily_returns.items(), key=lambda x: x[1]["return"]) if daily_returns else None
|
|
|
|
return {
|
|
"total_return": total_return,
|
|
"total_days": len(self.symbol_roundtrip_trades_),
|
|
"total_pairs": total_pairs,
|
|
"average_daily_return": total_return / len(self.symbol_roundtrip_trades_) if self.symbol_roundtrip_trades_ else 0.0,
|
|
"best_day": best_day,
|
|
"worst_day": worst_day,
|
|
"daily_returns": daily_returns
|
|
}
|
|
|
|
|
|
def print_grand_totals(self) -> None:
|
|
"""Print grand totals for the single pair analysis."""
|
|
summary = self.get_return_summary()
|
|
|
|
print(f"\n====== PAIR RESEARCH GRAND TOTALS ======")
|
|
print('---')
|
|
print(f"Total Return: {summary['total_return']:+.2f}%")
|
|
print('---')
|
|
print(f"Total Days Traded: {summary['total_days']}")
|
|
print(f"Total Open-Close Actions: {summary['total_pairs']}")
|
|
print(f"Total Trades: 4 * {summary['total_pairs']} = {4 * summary['total_pairs']}")
|
|
|
|
if summary['total_days'] > 0:
|
|
print(f"Average Daily Return: {summary['average_daily_return']:+.2f}%")
|
|
|
|
if summary['best_day']:
|
|
best_day, best_data = summary['best_day']
|
|
print(f"Best Day: {best_day} ({best_data['return']:+.2f}%)")
|
|
|
|
if summary['worst_day']:
|
|
worst_day, worst_data = summary['worst_day']
|
|
print(f"Worst Day: {worst_day} ({worst_data['return']:+.2f}%)")
|
|
|
|
# Update the total_realized_pnl for backward compatibility
|
|
self.total_realized_pnl = summary['total_return']
|
|
|
|
def analyze_pair_performance(self) -> None:
|
|
"""
|
|
Main method to perform comprehensive pair research analysis.
|
|
Extracts round-trip trades, calculates returns, groups by day, and prints results.
|
|
"""
|
|
print(f"\n{'='*60}")
|
|
print(f"PAIR RESEARCH PERFORMANCE ANALYSIS")
|
|
print(f"{'='*60}")
|
|
|
|
self.calculate_returns()
|
|
self.print_returns_by_day()
|
|
self.print_outstanding_positions()
|
|
self._print_additional_metrics()
|
|
self.print_grand_totals()
|
|
|
|
def _print_additional_metrics(self) -> None:
|
|
"""Print additional performance metrics."""
|
|
summary = self.get_return_summary()
|
|
|
|
if summary['total_days'] == 0:
|
|
return
|
|
|
|
print(f"\n====== ADDITIONAL METRICS ======")
|
|
|
|
# Calculate win rate
|
|
winning_days = sum(1 for day_data in summary['daily_returns'].values() if day_data['return'] > 0)
|
|
win_rate = (winning_days / summary['total_days']) * 100
|
|
print(f"Winning Days: {winning_days}/{summary['total_days']} ({win_rate:.1f}%)")
|
|
|
|
# Calculate average trade return
|
|
if summary['total_pairs'] > 0:
|
|
# Each pair has 2 symbol trades, so total symbol trades = total_pairs * 2
|
|
total_symbol_trades = summary['total_pairs'] * 2
|
|
avg_symbol_return = summary['total_return'] / total_symbol_trades
|
|
print(f"Average Symbol Return: {avg_symbol_return:+.2f}%")
|
|
|
|
avg_pair_return = summary['total_return'] / summary['total_pairs'] / 2 # Divide by 2 since we sum both symbols
|
|
print(f"Average Pair Return: {avg_pair_return:+.2f}%")
|
|
|
|
# Show daily return distribution
|
|
daily_returns_list = [data['return'] for data in summary['daily_returns'].values()]
|
|
if daily_returns_list:
|
|
print(f"Daily Return Range: {min(daily_returns_list):+.2f}% to {max(daily_returns_list):+.2f}%")
|
|
|
|
|
|
def print_outstanding_positions(self) -> None:
|
|
"""Print outstanding positions for the single pair."""
|
|
all_positions: List[OutstandingPositionT] = self.outstanding_positions()
|
|
if not all_positions:
|
|
print("\n====== NO OUTSTANDING POSITIONS ======")
|
|
return
|
|
|
|
print(f"\n====== OUTSTANDING POSITIONS ======")
|
|
print(f"{'Symbol':<10} {'Side':<4} {'Shares':<10} {'Open $':<8} {'Current $':<10} {'Value $':<12}")
|
|
print("-" * 70)
|
|
|
|
total_value = 0.0
|
|
for pos in all_positions:
|
|
current_value = pos.get("last_value", 0.0)
|
|
print(f"{pos['symbol']:<10} {pos['open_side']:<4} {pos['shares']:<10.2f} "
|
|
f"{pos['open_px']:<8.2f} {pos['last_px']:<10.2f} {current_value:<12.2f}")
|
|
total_value += current_value
|
|
|
|
print("-" * 70)
|
|
print(f"{'TOTAL VALUE':<60} ${total_value:<12.2f}")
|
|
|
|
def get_total_realized_pnl(self) -> float:
|
|
"""Get total realized PnL."""
|
|
return self.total_realized_pnl
|
|
|