|
@@ -13,6 +13,7 @@ from typing import Dict, List, Any, Optional, Tuple, Union
|
|
|
import numpy as np
|
|
|
import math
|
|
|
from collections import defaultdict
|
|
|
+import uuid
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
@@ -80,19 +81,37 @@ class TradingStats:
|
|
|
value REAL NOT NULL,
|
|
|
trade_type TEXT NOT NULL,
|
|
|
pnl REAL DEFAULT 0.0,
|
|
|
- linked_order_table_id INTEGER
|
|
|
- )
|
|
|
- """,
|
|
|
- # pnl on trades table is for individual realized pnl if a trade closes a part of a position.
|
|
|
- # Overall PNL is derived from cycles or balance changes.
|
|
|
- """
|
|
|
- CREATE TABLE IF NOT EXISTS enhanced_positions (
|
|
|
- symbol TEXT PRIMARY KEY,
|
|
|
- contracts REAL NOT NULL,
|
|
|
- avg_entry_price REAL NOT NULL,
|
|
|
- total_cost_basis REAL NOT NULL,
|
|
|
- entry_count INTEGER NOT NULL,
|
|
|
- last_entry_timestamp TEXT
|
|
|
+ linked_order_table_id INTEGER,
|
|
|
+
|
|
|
+ -- 🆕 PHASE 4: Lifecycle tracking fields (merged from active_trades)
|
|
|
+ status TEXT DEFAULT 'executed', -- 'pending', 'executed', 'position_opened', 'position_closed', 'cancelled'
|
|
|
+ trade_lifecycle_id TEXT, -- Groups related trades into one lifecycle
|
|
|
+ position_side TEXT, -- 'long', 'short', 'flat' - the resulting position side
|
|
|
+
|
|
|
+ -- Position tracking
|
|
|
+ entry_price REAL,
|
|
|
+ current_position_size REAL DEFAULT 0,
|
|
|
+
|
|
|
+ -- Order IDs (exchange IDs)
|
|
|
+ entry_order_id TEXT,
|
|
|
+ stop_loss_order_id TEXT,
|
|
|
+ take_profit_order_id TEXT,
|
|
|
+
|
|
|
+ -- Risk management
|
|
|
+ stop_loss_price REAL,
|
|
|
+ take_profit_price REAL,
|
|
|
+
|
|
|
+ -- P&L tracking
|
|
|
+ realized_pnl REAL DEFAULT 0,
|
|
|
+ unrealized_pnl REAL DEFAULT 0,
|
|
|
+
|
|
|
+ -- Timestamps
|
|
|
+ position_opened_at TEXT,
|
|
|
+ position_closed_at TEXT,
|
|
|
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
+
|
|
|
+ -- Notes
|
|
|
+ notes TEXT
|
|
|
)
|
|
|
""",
|
|
|
"""
|
|
@@ -148,50 +167,16 @@ class TradingStats:
|
|
|
CREATE INDEX IF NOT EXISTS idx_orders_status_type ON orders (status, type);
|
|
|
""",
|
|
|
"""
|
|
|
- CREATE TABLE IF NOT EXISTS trade_cycles (
|
|
|
- id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
- symbol TEXT NOT NULL,
|
|
|
- side TEXT NOT NULL, -- 'long' or 'short'
|
|
|
- status TEXT NOT NULL, -- 'pending_open', 'open', 'closed', 'cancelled'
|
|
|
-
|
|
|
- -- Opening details
|
|
|
- entry_order_id INTEGER, -- FK to orders table
|
|
|
- entry_fill_id TEXT, -- FK to trades table (exchange_fill_id)
|
|
|
- entry_price REAL,
|
|
|
- entry_amount REAL,
|
|
|
- entry_timestamp TEXT,
|
|
|
-
|
|
|
- -- Closing details
|
|
|
- exit_order_id INTEGER, -- FK to orders table
|
|
|
- exit_fill_id TEXT, -- FK to trades table
|
|
|
- exit_price REAL,
|
|
|
- exit_amount REAL,
|
|
|
- exit_timestamp TEXT,
|
|
|
- exit_type TEXT, -- 'stop_loss', 'take_profit', 'manual', 'external'
|
|
|
-
|
|
|
- -- P&L and metrics
|
|
|
- realized_pnl REAL,
|
|
|
- pnl_percentage REAL,
|
|
|
- duration_seconds INTEGER,
|
|
|
-
|
|
|
- -- Risk management
|
|
|
- stop_loss_price REAL,
|
|
|
- stop_loss_order_id INTEGER, -- FK to orders table
|
|
|
- take_profit_price REAL,
|
|
|
- take_profit_order_id INTEGER,
|
|
|
-
|
|
|
- -- Metadata
|
|
|
- trade_type TEXT DEFAULT 'manual', -- 'manual', 'bot', 'external'
|
|
|
- notes TEXT,
|
|
|
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
- updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
|
-
|
|
|
- -- Foreign key constraints
|
|
|
- FOREIGN KEY (entry_order_id) REFERENCES orders (id),
|
|
|
- FOREIGN KEY (exit_order_id) REFERENCES orders (id),
|
|
|
- FOREIGN KEY (stop_loss_order_id) REFERENCES orders (id),
|
|
|
- FOREIGN KEY (take_profit_order_id) REFERENCES orders (id)
|
|
|
- )
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_trades_status ON trades (status);
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_trades_lifecycle_id ON trades (trade_lifecycle_id);
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_trades_position_side ON trades (position_side);
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ CREATE INDEX IF NOT EXISTS idx_trades_symbol_status ON trades (symbol, status);
|
|
|
"""
|
|
|
]
|
|
|
for query in queries:
|
|
@@ -255,161 +240,16 @@ class TradingStats:
|
|
|
exchange_fill_id: Optional[str] = None, trade_type: str = "manual",
|
|
|
pnl: Optional[float] = None, timestamp: Optional[str] = None,
|
|
|
linked_order_table_id_to_link: Optional[int] = None):
|
|
|
- """Record a trade (fill) in the database, including optional PNL, specific timestamp, and link to an orders table entry."""
|
|
|
- ts = timestamp if timestamp else datetime.now(timezone.utc).isoformat()
|
|
|
- value = amount * price
|
|
|
-
|
|
|
- db_pnl = pnl if pnl is not None else 0.0
|
|
|
-
|
|
|
- query = """
|
|
|
- INSERT INTO trades (exchange_fill_id, timestamp, symbol, side, amount, price, value, trade_type, pnl, linked_order_table_id)
|
|
|
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
- """
|
|
|
- params = (exchange_fill_id, ts, symbol, side.lower(), amount, price, value, trade_type, db_pnl, linked_order_table_id_to_link)
|
|
|
-
|
|
|
- try:
|
|
|
- self._execute_query(query, params)
|
|
|
- logger.info(f"Recorded trade: {side.upper()} {amount} {symbol} @ ${price:.2f} (Fill ID: {exchange_fill_id or 'N/A'}, PNL: ${db_pnl:.2f}, Linked Order ID: {linked_order_table_id_to_link or 'N/A'})")
|
|
|
- except sqlite3.IntegrityError as e:
|
|
|
- logger.warning(f"Failed to record trade due to IntegrityError (likely duplicate exchange_fill_id {exchange_fill_id}): {e}")
|
|
|
-
|
|
|
-
|
|
|
- def get_enhanced_position_state(self, symbol: str) -> Optional[Dict[str, Any]]:
|
|
|
- """Get current enhanced position state for a symbol from DB."""
|
|
|
- query = "SELECT * FROM enhanced_positions WHERE symbol = ?"
|
|
|
- return self._fetchone_query(query, (symbol,))
|
|
|
-
|
|
|
- def update_enhanced_position_state(self, symbol: str, side: str, amount: float, price: float,
|
|
|
- timestamp: Optional[str] = None) -> Tuple[str, float]:
|
|
|
- """Update enhanced position state with a new trade and return action type and realized PNL for this trade."""
|
|
|
+ """Record a trade in the database."""
|
|
|
if timestamp is None:
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
-
|
|
|
- position = self.get_enhanced_position_state(symbol)
|
|
|
- action_type = "unknown"
|
|
|
- realized_pnl_for_this_trade = 0.0
|
|
|
-
|
|
|
- current_contracts = position['contracts'] if position else 0.0
|
|
|
- current_avg_entry = position['avg_entry_price'] if position else 0.0
|
|
|
- current_cost_basis = position['total_cost_basis'] if position else 0.0
|
|
|
- current_entry_count = position['entry_count'] if position else 0
|
|
|
-
|
|
|
- new_contracts = current_contracts
|
|
|
- new_avg_entry = current_avg_entry
|
|
|
- new_cost_basis = current_cost_basis
|
|
|
- new_entry_count = current_entry_count
|
|
|
-
|
|
|
- if side.lower() == 'buy':
|
|
|
- if current_contracts >= 0: # Opening/adding to long
|
|
|
- action_type = 'long_opened' if current_contracts == 0 else 'long_increased'
|
|
|
- new_cost_basis += amount * price
|
|
|
- new_contracts += amount
|
|
|
- new_avg_entry = new_cost_basis / new_contracts if new_contracts > 0 else 0
|
|
|
- new_entry_count += 1
|
|
|
- else: # Reducing short position
|
|
|
- reduction = min(amount, abs(current_contracts))
|
|
|
- realized_pnl_for_this_trade = reduction * (current_avg_entry - price) # PNL from short covering
|
|
|
-
|
|
|
- new_contracts += reduction
|
|
|
- # Cost basis for shorts is tricky; avg_entry_price is more key for shorts.
|
|
|
- # For now, let's assume cost_basis is not directly managed for pure shorts in this way.
|
|
|
- # The avg_entry_price of the short remains.
|
|
|
-
|
|
|
- if new_contracts == 0: # Short fully closed
|
|
|
- action_type = 'short_closed'
|
|
|
- self._reset_enhanced_position_state(symbol) # Clears the row
|
|
|
- logger.info(f"📉 Enhanced position reset (closed): {symbol}. Realized PNL from this reduction: ${realized_pnl_for_this_trade:.2f}")
|
|
|
- return action_type, realized_pnl_for_this_trade
|
|
|
- elif new_contracts > 0: # Flipped to long
|
|
|
- action_type = 'short_closed_and_long_opened'
|
|
|
- # Reset for the new long position part
|
|
|
- new_cost_basis = new_contracts * price # Cost basis for the new long part
|
|
|
- new_avg_entry = price
|
|
|
- new_entry_count = 1
|
|
|
- logger.info(f"⚖️ Enhanced position flipped SHORT to LONG: {symbol}. Realized PNL from short part: ${realized_pnl_for_this_trade:.2f}")
|
|
|
- else: # Short reduced
|
|
|
- action_type = 'short_reduced'
|
|
|
|
|
|
- elif side.lower() == 'sell':
|
|
|
- if current_contracts <= 0: # Opening/adding to short
|
|
|
- action_type = 'short_opened' if current_contracts == 0 else 'short_increased'
|
|
|
- # For shorts, avg_entry_price tracks the average price we sold at to open/increase the short.
|
|
|
- # total_cost_basis is less intuitive for shorts.
|
|
|
- # We calculate new_avg_entry if adding to short:
|
|
|
- # (current_abs_contracts * current_avg_entry + amount * price) / (current_abs_contracts + amount)
|
|
|
- if current_contracts < 0 : # Adding to existing short
|
|
|
- total_sell_value = abs(current_contracts) * current_avg_entry + amount * price
|
|
|
- new_contracts -= amount
|
|
|
- new_avg_entry = total_sell_value / abs(new_contracts) if new_contracts != 0 else 0
|
|
|
- else: # Opening new short
|
|
|
- new_contracts -= amount
|
|
|
- new_avg_entry = price
|
|
|
- new_entry_count +=1
|
|
|
-
|
|
|
- else: # Reducing long position
|
|
|
- reduction = min(amount, current_contracts)
|
|
|
- realized_pnl_for_this_trade = reduction * (price - current_avg_entry) # PNL from long selling
|
|
|
-
|
|
|
- new_contracts -= reduction
|
|
|
- if new_contracts > 0: # Long reduced
|
|
|
- action_type = 'long_reduced'
|
|
|
- # Adjust cost basis proportionally: (new_contracts / old_contracts) * old_cost_basis
|
|
|
- # Or, simpler: new_cost_basis -= reduction * current_avg_entry
|
|
|
- new_cost_basis -= reduction * current_avg_entry
|
|
|
- # Avg entry price of remaining long doesn't change
|
|
|
- else: # Long position fully closed or flipped
|
|
|
- if new_contracts == 0: # Long fully closed
|
|
|
- action_type = 'long_closed'
|
|
|
- self._reset_enhanced_position_state(symbol)
|
|
|
- logger.info(f"📈 Enhanced position reset (closed): {symbol}. Realized PNL from this reduction: ${realized_pnl_for_this_trade:.2f}")
|
|
|
- return action_type, realized_pnl_for_this_trade
|
|
|
- else: # Flipped to short
|
|
|
- action_type = 'long_closed_and_short_opened'
|
|
|
- # Reset for new short part
|
|
|
- new_avg_entry = price # Avg price of this opening short leg
|
|
|
- new_entry_count = 1
|
|
|
- new_cost_basis = 0 # Not directly applicable for short open
|
|
|
- logger.info(f"⚖️ Enhanced position flipped LONG to SHORT: {symbol}. Realized PNL from long part: ${realized_pnl_for_this_trade:.2f}")
|
|
|
-
|
|
|
- # Save updated state to DB
|
|
|
- upsert_query = """
|
|
|
- INSERT INTO enhanced_positions (symbol, contracts, avg_entry_price, total_cost_basis, entry_count, last_entry_timestamp)
|
|
|
- VALUES (?, ?, ?, ?, ?, ?)
|
|
|
- ON CONFLICT(symbol) DO UPDATE SET
|
|
|
- contracts = excluded.contracts,
|
|
|
- avg_entry_price = excluded.avg_entry_price,
|
|
|
- total_cost_basis = excluded.total_cost_basis,
|
|
|
- entry_count = excluded.entry_count,
|
|
|
- last_entry_timestamp = excluded.last_entry_timestamp
|
|
|
- """
|
|
|
- self._execute_query(upsert_query, (symbol, new_contracts, new_avg_entry, new_cost_basis, new_entry_count, timestamp))
|
|
|
-
|
|
|
- side_log = "LONG" if new_contracts > 0 else "SHORT" if new_contracts < 0 else "FLAT"
|
|
|
- if new_contracts != 0:
|
|
|
- logger.info(f"📊 Enhanced position ({action_type}): {symbol} {side_log} {abs(new_contracts):.6f} @ avg ${(new_avg_entry if new_avg_entry else 0.0):.2f}")
|
|
|
-
|
|
|
- return action_type, realized_pnl_for_this_trade
|
|
|
-
|
|
|
- def _reset_enhanced_position_state(self, symbol: str):
|
|
|
- """Reset enhanced position state when position is fully closed by deleting from DB."""
|
|
|
- self._execute_query("DELETE FROM enhanced_positions WHERE symbol = ?", (symbol,))
|
|
|
-
|
|
|
- def record_trade_with_enhanced_tracking(self, symbol: str, side: str, amount: float, price: float,
|
|
|
- exchange_fill_id: Optional[str] = None, trade_type: str = "manual",
|
|
|
- timestamp: Optional[str] = None,
|
|
|
- linked_order_table_id_to_link: Optional[int] = None) -> str:
|
|
|
- """Record a trade and update enhanced position tracking.
|
|
|
- The linked_order_table_id_to_link should be passed if this fill corresponds to a known order in the 'orders' table.
|
|
|
- """
|
|
|
- trade_timestamp_to_use = timestamp if timestamp else datetime.now(timezone.utc).isoformat()
|
|
|
-
|
|
|
- action_type, realized_pnl = self.update_enhanced_position_state(symbol, side, amount, price, timestamp=trade_timestamp_to_use)
|
|
|
-
|
|
|
- self.record_trade(symbol, side, amount, price, exchange_fill_id, trade_type,
|
|
|
- pnl=realized_pnl, timestamp=trade_timestamp_to_use,
|
|
|
- linked_order_table_id_to_link=linked_order_table_id_to_link)
|
|
|
-
|
|
|
- return action_type
|
|
|
+ value = amount * price
|
|
|
+ self._execute_query(
|
|
|
+ "INSERT INTO trades (symbol, side, amount, price, value, trade_type, timestamp, exchange_fill_id, pnl, linked_order_table_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
+ (symbol, side, amount, price, value, trade_type, timestamp, exchange_fill_id, pnl or 0.0, linked_order_table_id_to_link)
|
|
|
+ )
|
|
|
+ logger.info(f"📈 Trade recorded: {side.upper()} {amount:.6f} {symbol} @ ${price:.2f} (${value:.2f}) [{trade_type}]")
|
|
|
|
|
|
def get_all_trades(self) -> List[Dict[str, Any]]:
|
|
|
"""Fetch all trades from the database, ordered by timestamp."""
|
|
@@ -742,8 +582,8 @@ class TradingStats:
|
|
|
}
|
|
|
|
|
|
def _get_open_positions_count_from_db(self) -> int:
|
|
|
- """Helper to get count of active enhanced positions."""
|
|
|
- row = self._fetchone_query("SELECT COUNT(*) as count FROM enhanced_positions WHERE contracts != 0")
|
|
|
+ """🧹 PHASE 4: Get count of open positions from enhanced trades table."""
|
|
|
+ row = self._fetchone_query("SELECT COUNT(DISTINCT symbol) as count FROM trades WHERE status = 'position_opened'")
|
|
|
return row['count'] if row else 0
|
|
|
|
|
|
def format_stats_message(self, current_balance: Optional[float] = None) -> str:
|
|
@@ -1393,286 +1233,267 @@ class TradingStats:
|
|
|
# --- End Order Table Management ---
|
|
|
|
|
|
# =============================================================================
|
|
|
- # TRADE LIFECYCLE MANAGEMENT
|
|
|
+ # TRADE LIFECYCLE MANAGEMENT - PHASE 4: UNIFIED TRADES TABLE
|
|
|
# =============================================================================
|
|
|
|
|
|
- def create_trade_cycle(self, symbol: str, side: str, entry_order_id: int,
|
|
|
- stop_loss_price: Optional[float] = None,
|
|
|
- take_profit_price: Optional[float] = None,
|
|
|
- trade_type: str = 'manual') -> Optional[int]:
|
|
|
- """Create a new trade cycle when an entry order is placed."""
|
|
|
+ def create_trade_lifecycle(self, symbol: str, side: str, entry_order_id: Optional[str] = None,
|
|
|
+ stop_loss_price: Optional[float] = None, take_profit_price: Optional[float] = None,
|
|
|
+ trade_type: str = 'manual') -> Optional[str]:
|
|
|
+ """Create a new trade lifecycle when an entry order is placed."""
|
|
|
try:
|
|
|
+ lifecycle_id = str(uuid.uuid4())
|
|
|
+
|
|
|
query = """
|
|
|
- INSERT INTO trade_cycles (
|
|
|
- symbol, side, status, entry_order_id, stop_loss_price,
|
|
|
- take_profit_price, trade_type, created_at, updated_at
|
|
|
- ) VALUES (?, ?, 'pending_open', ?, ?, ?, ?, ?, ?)
|
|
|
+ INSERT INTO trades (
|
|
|
+ symbol, side, amount, price, value, trade_type, timestamp,
|
|
|
+ status, trade_lifecycle_id, position_side, entry_order_id,
|
|
|
+ stop_loss_price, take_profit_price, updated_at
|
|
|
+ ) VALUES (?, ?, 0, 0, 0, ?, ?, 'pending', ?, 'flat', ?, ?, ?, ?)
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (symbol, side.lower(), entry_order_id, stop_loss_price,
|
|
|
- take_profit_price, trade_type, timestamp, timestamp)
|
|
|
+ params = (symbol, side.lower(), trade_type, timestamp, lifecycle_id,
|
|
|
+ entry_order_id, stop_loss_price, take_profit_price, timestamp)
|
|
|
|
|
|
- cursor = self.conn.execute(query, params)
|
|
|
- trade_cycle_id = cursor.lastrowid
|
|
|
- self.conn.commit()
|
|
|
+ self._execute_query(query, params)
|
|
|
|
|
|
- logger.info(f"📊 Created trade cycle {trade_cycle_id}: {side.upper()} {symbol} (pending open)")
|
|
|
- return trade_cycle_id
|
|
|
+ logger.info(f"📊 Created trade lifecycle {lifecycle_id}: {side.upper()} {symbol} (pending)")
|
|
|
+ return lifecycle_id
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error creating trade cycle: {e}")
|
|
|
+ logger.error(f"❌ Error creating trade lifecycle: {e}")
|
|
|
return None
|
|
|
|
|
|
- def update_trade_cycle_opened(self, trade_cycle_id: int, entry_fill_id: str,
|
|
|
- entry_price: float, entry_amount: float,
|
|
|
- entry_timestamp: str) -> bool:
|
|
|
- """Update trade cycle when entry order is filled (trade opened)."""
|
|
|
+ def update_trade_position_opened(self, lifecycle_id: str, entry_price: float,
|
|
|
+ entry_amount: float, exchange_fill_id: str) -> bool:
|
|
|
+ """Update trade when position is opened (entry order filled)."""
|
|
|
try:
|
|
|
query = """
|
|
|
- UPDATE trade_cycles
|
|
|
- SET status = 'open',
|
|
|
- entry_fill_id = ?,
|
|
|
+ UPDATE trades
|
|
|
+ SET status = 'position_opened',
|
|
|
+ amount = ?,
|
|
|
+ price = ?,
|
|
|
+ value = ?,
|
|
|
entry_price = ?,
|
|
|
- entry_amount = ?,
|
|
|
- entry_timestamp = ?,
|
|
|
+ current_position_size = ?,
|
|
|
+ position_side = CASE
|
|
|
+ WHEN side = 'buy' THEN 'long'
|
|
|
+ WHEN side = 'sell' THEN 'short'
|
|
|
+ ELSE position_side
|
|
|
+ END,
|
|
|
+ exchange_fill_id = ?,
|
|
|
+ position_opened_at = ?,
|
|
|
updated_at = ?
|
|
|
- WHERE id = ?
|
|
|
+ WHERE trade_lifecycle_id = ? AND status = 'pending'
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (entry_fill_id, entry_price, entry_amount, entry_timestamp, timestamp, trade_cycle_id)
|
|
|
+ value = entry_amount * entry_price
|
|
|
+ params = (entry_amount, entry_price, value, entry_price, entry_amount,
|
|
|
+ exchange_fill_id, timestamp, timestamp, lifecycle_id)
|
|
|
|
|
|
self._execute_query(query, params)
|
|
|
|
|
|
- logger.info(f"📈 Trade cycle {trade_cycle_id} opened: {entry_amount} @ ${entry_price:.2f}")
|
|
|
+ logger.info(f"📈 Trade lifecycle {lifecycle_id} position opened: {entry_amount} @ ${entry_price:.2f}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error updating trade cycle opened: {e}")
|
|
|
+ logger.error(f"❌ Error updating trade position opened: {e}")
|
|
|
return False
|
|
|
|
|
|
- def update_trade_cycle_closed(self, trade_cycle_id: int, exit_fill_id: str,
|
|
|
- exit_price: float, exit_amount: float,
|
|
|
- exit_timestamp: str, exit_type: str,
|
|
|
- exit_order_id: Optional[int] = None) -> bool:
|
|
|
- """Update trade cycle when exit order is filled (trade closed)."""
|
|
|
+ def update_trade_position_closed(self, lifecycle_id: str, exit_price: float,
|
|
|
+ realized_pnl: float, exchange_fill_id: str) -> bool:
|
|
|
+ """Update trade when position is fully closed."""
|
|
|
try:
|
|
|
- # Get trade cycle details to calculate P&L
|
|
|
- trade_cycle = self.get_trade_cycle(trade_cycle_id)
|
|
|
- if not trade_cycle:
|
|
|
- logger.error(f"Trade cycle {trade_cycle_id} not found for closing")
|
|
|
- return False
|
|
|
-
|
|
|
- # Calculate P&L and duration
|
|
|
- entry_price = trade_cycle['entry_price']
|
|
|
- entry_timestamp_str = trade_cycle['entry_timestamp']
|
|
|
- side = trade_cycle['side']
|
|
|
-
|
|
|
- # Calculate P&L
|
|
|
- if side == 'long':
|
|
|
- realized_pnl = exit_amount * (exit_price - entry_price)
|
|
|
- else: # short
|
|
|
- realized_pnl = exit_amount * (entry_price - exit_price)
|
|
|
-
|
|
|
- pnl_percentage = (realized_pnl / (exit_amount * entry_price)) * 100
|
|
|
-
|
|
|
- # Calculate duration
|
|
|
- duration_seconds = 0
|
|
|
- try:
|
|
|
- entry_dt = datetime.fromisoformat(entry_timestamp_str.replace('Z', '+00:00'))
|
|
|
- exit_dt = datetime.fromisoformat(exit_timestamp.replace('Z', '+00:00'))
|
|
|
- duration_seconds = int((exit_dt - entry_dt).total_seconds())
|
|
|
- except Exception as e:
|
|
|
- logger.warning(f"Could not calculate trade duration: {e}")
|
|
|
-
|
|
|
query = """
|
|
|
- UPDATE trade_cycles
|
|
|
- SET status = 'closed',
|
|
|
- exit_order_id = ?,
|
|
|
- exit_fill_id = ?,
|
|
|
- exit_price = ?,
|
|
|
- exit_amount = ?,
|
|
|
- exit_timestamp = ?,
|
|
|
- exit_type = ?,
|
|
|
+ UPDATE trades
|
|
|
+ SET status = 'position_closed',
|
|
|
+ current_position_size = 0,
|
|
|
+ position_side = 'flat',
|
|
|
realized_pnl = ?,
|
|
|
- pnl_percentage = ?,
|
|
|
- duration_seconds = ?,
|
|
|
+ position_closed_at = ?,
|
|
|
updated_at = ?
|
|
|
- WHERE id = ?
|
|
|
+ WHERE trade_lifecycle_id = ? AND status = 'position_opened'
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (exit_order_id, exit_fill_id, exit_price, exit_amount,
|
|
|
- exit_timestamp, exit_type, realized_pnl, pnl_percentage,
|
|
|
- duration_seconds, timestamp, trade_cycle_id)
|
|
|
+ params = (realized_pnl, timestamp, timestamp, lifecycle_id)
|
|
|
|
|
|
self._execute_query(query, params)
|
|
|
|
|
|
pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
|
|
|
- logger.info(f"{pnl_emoji} Trade cycle {trade_cycle_id} closed: {exit_type} @ ${exit_price:.2f} | P&L: ${realized_pnl:.2f} ({pnl_percentage:+.2f}%) | Duration: {duration_seconds}s")
|
|
|
+ logger.info(f"{pnl_emoji} Trade lifecycle {lifecycle_id} position closed: P&L ${realized_pnl:.2f}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error updating trade cycle closed: {e}")
|
|
|
+ logger.error(f"❌ Error updating trade position closed: {e}")
|
|
|
return False
|
|
|
|
|
|
- def update_trade_cycle_cancelled(self, trade_cycle_id: int, reason: str = "order_cancelled") -> bool:
|
|
|
- """Update trade cycle when entry order is cancelled (trade never opened)."""
|
|
|
+ def update_trade_cancelled(self, lifecycle_id: str, reason: str = "order_cancelled") -> bool:
|
|
|
+ """Update trade when entry order is cancelled (never opened)."""
|
|
|
try:
|
|
|
query = """
|
|
|
- UPDATE trade_cycles
|
|
|
+ UPDATE trades
|
|
|
SET status = 'cancelled',
|
|
|
notes = ?,
|
|
|
updated_at = ?
|
|
|
- WHERE id = ?
|
|
|
+ WHERE trade_lifecycle_id = ? AND status = 'pending'
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (f"Cancelled: {reason}", timestamp, trade_cycle_id)
|
|
|
+ params = (f"Cancelled: {reason}", timestamp, lifecycle_id)
|
|
|
|
|
|
self._execute_query(query, params)
|
|
|
|
|
|
- logger.info(f"❌ Trade cycle {trade_cycle_id} cancelled: {reason}")
|
|
|
+ logger.info(f"❌ Trade lifecycle {lifecycle_id} cancelled: {reason}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error updating trade cycle cancelled: {e}")
|
|
|
+ logger.error(f"❌ Error updating trade cancelled: {e}")
|
|
|
return False
|
|
|
|
|
|
- def link_stop_loss_to_trade_cycle(self, trade_cycle_id: int, stop_loss_order_id: int,
|
|
|
- stop_loss_price: float) -> bool:
|
|
|
- """Link a stop loss order to an open trade cycle."""
|
|
|
+ def link_stop_loss_to_trade(self, lifecycle_id: str, stop_loss_order_id: str,
|
|
|
+ stop_loss_price: float) -> bool:
|
|
|
+ """Link a stop loss order to a trade lifecycle."""
|
|
|
try:
|
|
|
query = """
|
|
|
- UPDATE trade_cycles
|
|
|
+ UPDATE trades
|
|
|
SET stop_loss_order_id = ?,
|
|
|
stop_loss_price = ?,
|
|
|
updated_at = ?
|
|
|
- WHERE id = ? AND status = 'open'
|
|
|
+ WHERE trade_lifecycle_id = ? AND status = 'position_opened'
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (stop_loss_order_id, stop_loss_price, timestamp, trade_cycle_id)
|
|
|
+ params = (stop_loss_order_id, stop_loss_price, timestamp, lifecycle_id)
|
|
|
|
|
|
self._execute_query(query, params)
|
|
|
|
|
|
- logger.info(f"🛑 Linked stop loss order {stop_loss_order_id} (${stop_loss_price:.2f}) to trade cycle {trade_cycle_id}")
|
|
|
+ logger.info(f"🛑 Linked stop loss order {stop_loss_order_id} (${stop_loss_price:.2f}) to trade {lifecycle_id}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error linking stop loss to trade cycle: {e}")
|
|
|
+ logger.error(f"❌ Error linking stop loss to trade: {e}")
|
|
|
return False
|
|
|
|
|
|
- def link_take_profit_to_trade_cycle(self, trade_cycle_id: int, take_profit_order_id: int,
|
|
|
- take_profit_price: float) -> bool:
|
|
|
- """Link a take profit order to an open trade cycle."""
|
|
|
+ def link_take_profit_to_trade(self, lifecycle_id: str, take_profit_order_id: str,
|
|
|
+ take_profit_price: float) -> bool:
|
|
|
+ """Link a take profit order to a trade lifecycle."""
|
|
|
try:
|
|
|
query = """
|
|
|
- UPDATE trade_cycles
|
|
|
+ UPDATE trades
|
|
|
SET take_profit_order_id = ?,
|
|
|
take_profit_price = ?,
|
|
|
updated_at = ?
|
|
|
- WHERE id = ? AND status = 'open'
|
|
|
+ WHERE trade_lifecycle_id = ? AND status = 'position_opened'
|
|
|
"""
|
|
|
timestamp = datetime.now(timezone.utc).isoformat()
|
|
|
- params = (take_profit_order_id, take_profit_price, timestamp, trade_cycle_id)
|
|
|
+ params = (take_profit_order_id, take_profit_price, timestamp, lifecycle_id)
|
|
|
|
|
|
self._execute_query(query, params)
|
|
|
|
|
|
- logger.info(f"🎯 Linked take profit order {take_profit_order_id} (${take_profit_price:.2f}) to trade cycle {trade_cycle_id}")
|
|
|
+ logger.info(f"🎯 Linked take profit order {take_profit_order_id} (${take_profit_price:.2f}) to trade {lifecycle_id}")
|
|
|
return True
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error linking take profit to trade cycle: {e}")
|
|
|
+ logger.error(f"❌ Error linking take profit to trade: {e}")
|
|
|
return False
|
|
|
|
|
|
- def get_trade_cycle(self, trade_cycle_id: int) -> Optional[Dict[str, Any]]:
|
|
|
- """Get a trade cycle by ID."""
|
|
|
- query = "SELECT * FROM trade_cycles WHERE id = ?"
|
|
|
- return self._fetchone_query(query, (trade_cycle_id,))
|
|
|
+ def get_trade_by_lifecycle_id(self, lifecycle_id: str) -> Optional[Dict[str, Any]]:
|
|
|
+ """Get trade by lifecycle ID."""
|
|
|
+ query = "SELECT * FROM trades WHERE trade_lifecycle_id = ?"
|
|
|
+ return self._fetchone_query(query, (lifecycle_id,))
|
|
|
|
|
|
- def get_trade_cycle_by_entry_order(self, entry_order_id: int) -> Optional[Dict[str, Any]]:
|
|
|
- """Get a trade cycle by entry order ID."""
|
|
|
- query = "SELECT * FROM trade_cycles WHERE entry_order_id = ?"
|
|
|
- return self._fetchone_query(query, (entry_order_id,))
|
|
|
+ def get_trade_by_symbol_and_status(self, symbol: str, status: str = 'position_opened') -> Optional[Dict[str, Any]]:
|
|
|
+ """Get trade by symbol and status."""
|
|
|
+ query = "SELECT * FROM trades WHERE symbol = ? AND status = ? ORDER BY updated_at DESC LIMIT 1"
|
|
|
+ return self._fetchone_query(query, (symbol, status))
|
|
|
|
|
|
- def get_open_trade_cycles(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
|
- """Get all open trade cycles, optionally filtered by symbol."""
|
|
|
+ def get_open_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
|
+ """Get all open positions, optionally filtered by symbol."""
|
|
|
if symbol:
|
|
|
- query = "SELECT * FROM trade_cycles WHERE status = 'open' AND symbol = ? ORDER BY created_at DESC"
|
|
|
+ query = "SELECT * FROM trades WHERE status = 'position_opened' AND symbol = ? ORDER BY position_opened_at DESC"
|
|
|
return self._fetch_query(query, (symbol,))
|
|
|
else:
|
|
|
- query = "SELECT * FROM trade_cycles WHERE status = 'open' ORDER BY created_at DESC"
|
|
|
+ query = "SELECT * FROM trades WHERE status = 'position_opened' ORDER BY position_opened_at DESC"
|
|
|
return self._fetch_query(query)
|
|
|
|
|
|
- def get_trade_cycles_by_status(self, status: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
|
- """Get trade cycles by status."""
|
|
|
- query = "SELECT * FROM trade_cycles WHERE status = ? ORDER BY updated_at DESC LIMIT ?"
|
|
|
+ def get_trades_by_status(self, status: str, limit: int = 50) -> List[Dict[str, Any]]:
|
|
|
+ """Get trades by status."""
|
|
|
+ query = "SELECT * FROM trades WHERE status = ? ORDER BY updated_at DESC LIMIT ?"
|
|
|
return self._fetch_query(query, (status, limit))
|
|
|
|
|
|
- def get_recent_trade_cycles(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
|
- """Get recent trade cycles (all statuses)."""
|
|
|
- query = "SELECT * FROM trade_cycles ORDER BY updated_at DESC LIMIT ?"
|
|
|
- return self._fetch_query(query, (limit,))
|
|
|
+ def get_pending_stop_loss_activations(self) -> List[Dict[str, Any]]:
|
|
|
+ """Get open positions that need stop loss activation."""
|
|
|
+ query = """
|
|
|
+ SELECT * FROM trades
|
|
|
+ WHERE status = 'position_opened'
|
|
|
+ AND stop_loss_price IS NOT NULL
|
|
|
+ AND stop_loss_order_id IS NULL
|
|
|
+ ORDER BY updated_at ASC
|
|
|
+ """
|
|
|
+ return self._fetch_query(query)
|
|
|
|
|
|
- def get_trade_cycle_performance_stats(self) -> Dict[str, Any]:
|
|
|
- """Get comprehensive trade cycle performance statistics."""
|
|
|
+ def cleanup_old_cancelled_trades(self, days_old: int = 7) -> int:
|
|
|
+ """Clean up old cancelled trades (optional - for housekeeping)."""
|
|
|
try:
|
|
|
- # Get closed trades for analysis
|
|
|
- closed_trades = self.get_trade_cycles_by_status('closed', limit=1000)
|
|
|
+ cutoff_date = (datetime.now(timezone.utc) - timedelta(days=days_old)).isoformat()
|
|
|
+
|
|
|
+ # Count before deletion
|
|
|
+ count_query = """
|
|
|
+ SELECT COUNT(*) as count FROM trades
|
|
|
+ WHERE status = 'cancelled' AND updated_at < ?
|
|
|
+ """
|
|
|
+ count_result = self._fetchone_query(count_query, (cutoff_date,))
|
|
|
+ count_to_delete = count_result['count'] if count_result else 0
|
|
|
|
|
|
- if not closed_trades:
|
|
|
- return {
|
|
|
- 'total_closed_trades': 0,
|
|
|
- 'win_rate': 0,
|
|
|
- 'avg_win': 0,
|
|
|
- 'avg_loss': 0,
|
|
|
- 'profit_factor': 0,
|
|
|
- 'total_pnl': 0,
|
|
|
- 'avg_duration_minutes': 0,
|
|
|
- 'best_trade': 0,
|
|
|
- 'worst_trade': 0,
|
|
|
- 'stop_loss_rate': 0,
|
|
|
- 'take_profit_rate': 0
|
|
|
- }
|
|
|
+ if count_to_delete > 0:
|
|
|
+ delete_query = """
|
|
|
+ DELETE FROM trades
|
|
|
+ WHERE status = 'cancelled' AND updated_at < ?
|
|
|
+ """
|
|
|
+ self._execute_query(delete_query, (cutoff_date,))
|
|
|
+ logger.info(f"🧹 Cleaned up {count_to_delete} old cancelled trades (older than {days_old} days)")
|
|
|
|
|
|
- # Calculate statistics
|
|
|
- wins = [t for t in closed_trades if t['realized_pnl'] > 0]
|
|
|
- losses = [t for t in closed_trades if t['realized_pnl'] < 0]
|
|
|
+ return count_to_delete
|
|
|
|
|
|
- total_pnl = sum(t['realized_pnl'] for t in closed_trades)
|
|
|
- win_rate = (len(wins) / len(closed_trades)) * 100 if closed_trades else 0
|
|
|
- avg_win = sum(t['realized_pnl'] for t in wins) / len(wins) if wins else 0
|
|
|
- avg_loss = sum(t['realized_pnl'] for t in losses) / len(losses) if losses else 0
|
|
|
- profit_factor = abs(avg_win * len(wins) / (avg_loss * len(losses))) if losses and avg_loss != 0 else float('inf')
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"❌ Error cleaning up old cancelled trades: {e}")
|
|
|
+ return 0
|
|
|
+
|
|
|
+ def confirm_position_with_exchange(self, symbol: str, exchange_position_size: float,
|
|
|
+ exchange_open_orders: List[Dict]) -> bool:
|
|
|
+ """🆕 PHASE 4: Confirm position status with exchange before updating status."""
|
|
|
+ try:
|
|
|
+ # Get current trade status
|
|
|
+ current_trade = self.get_trade_by_symbol_and_status(symbol, 'position_opened')
|
|
|
|
|
|
- # Duration analysis
|
|
|
- durations = [t['duration_seconds'] for t in closed_trades if t['duration_seconds']]
|
|
|
- avg_duration_minutes = (sum(durations) / len(durations) / 60) if durations else 0
|
|
|
+ if not current_trade:
|
|
|
+ return True # No open position to confirm
|
|
|
|
|
|
- # Best/worst trades
|
|
|
- best_trade = max(t['realized_pnl'] for t in closed_trades)
|
|
|
- worst_trade = min(t['realized_pnl'] for t in closed_trades)
|
|
|
+ lifecycle_id = current_trade['trade_lifecycle_id']
|
|
|
+ has_open_orders = len([o for o in exchange_open_orders if o.get('symbol') == symbol]) > 0
|
|
|
|
|
|
- # Exit type analysis
|
|
|
- stop_loss_trades = [t for t in closed_trades if t['exit_type'] == 'stop_loss']
|
|
|
- take_profit_trades = [t for t in closed_trades if t['exit_type'] == 'take_profit']
|
|
|
- stop_loss_rate = (len(stop_loss_trades) / len(closed_trades)) * 100 if closed_trades else 0
|
|
|
- take_profit_rate = (len(take_profit_trades) / len(closed_trades)) * 100 if closed_trades else 0
|
|
|
+ # Only close position if exchange confirms no position AND no pending orders
|
|
|
+ if abs(exchange_position_size) < 1e-8 and not has_open_orders:
|
|
|
+ # Calculate realized P&L based on position side
|
|
|
+ position_side = current_trade['position_side']
|
|
|
+ entry_price = current_trade['entry_price']
|
|
|
+ current_amount = current_trade['current_position_size']
|
|
|
+
|
|
|
+ # For a closed position, we need to calculate final P&L
|
|
|
+ # This would typically come from the closing trade, but for confirmation we estimate
|
|
|
+ estimated_pnl = current_trade.get('realized_pnl', 0)
|
|
|
+
|
|
|
+ success = self.update_trade_position_closed(
|
|
|
+ lifecycle_id,
|
|
|
+ entry_price, # Using entry price as estimate since position is confirmed closed
|
|
|
+ estimated_pnl,
|
|
|
+ "exchange_confirmed_closed"
|
|
|
+ )
|
|
|
+
|
|
|
+ if success:
|
|
|
+ logger.info(f"✅ Confirmed position closed for {symbol} with exchange")
|
|
|
+
|
|
|
+ return success
|
|
|
|
|
|
- return {
|
|
|
- 'total_closed_trades': len(closed_trades),
|
|
|
- 'win_rate': win_rate,
|
|
|
- 'avg_win': avg_win,
|
|
|
- 'avg_loss': avg_loss,
|
|
|
- 'profit_factor': profit_factor,
|
|
|
- 'total_pnl': total_pnl,
|
|
|
- 'avg_duration_minutes': avg_duration_minutes,
|
|
|
- 'best_trade': best_trade,
|
|
|
- 'worst_trade': worst_trade,
|
|
|
- 'stop_loss_rate': stop_loss_rate,
|
|
|
- 'take_profit_rate': take_profit_rate,
|
|
|
- 'winning_trades': len(wins),
|
|
|
- 'losing_trades': len(losses),
|
|
|
- 'breakeven_trades': len(closed_trades) - len(wins) - len(losses)
|
|
|
- }
|
|
|
+ return True # Position still exists on exchange, no update needed
|
|
|
|
|
|
except Exception as e:
|
|
|
- logger.error(f"❌ Error calculating trade cycle performance: {e}")
|
|
|
- return {}
|
|
|
+ logger.error(f"❌ Error confirming position with exchange: {e}")
|
|
|
+ return False
|