import json import logging import uuid import datetime as dt from typing import Any from Users import Users # Configure logging logger = logging.getLogger(__name__) # Debug file logger for trade updates _debug_logger = logging.getLogger('trade_debug') _debug_logger.setLevel(logging.DEBUG) _debug_handler = logging.FileHandler('/home/rob/PycharmProjects/BrighterTrading/trade_debug.log', mode='w') _debug_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) _debug_logger.addHandler(_debug_handler) class Trade: def __init__(self, target: str, symbol: str, side: str, order_price: float, base_order_qty: float, order_type: str = 'MARKET', time_in_force: str = 'GTC', unique_id: str | None = None, status: str | None = None, stats: dict[str, Any] | None = None, order: Any | None = None, fee: float = 0.001, strategy_id: str | None = None, is_paper: bool = False, creator: int | None = None, created_at: str | None = None, tbl_key: str | None = None, testnet: bool = False, exchange: str | None = None, broker_kind: str | None = None, broker_mode: str | None = None, broker_exchange: str | None = None, broker_order_id: str | None = None, exchange_order_id: str | None = None, stop_loss: float | None = None, take_profit: float | None = None): """ Initializes a Trade instance with all necessary attributes. """ self.unique_id = unique_id or uuid.uuid4().hex self.tbl_key = tbl_key or self.unique_id self.target = target # exchange: for live trades, the actual exchange; for paper trades, use 'paper' (single synthetic market) if target in ['test_exchange', 'paper', 'Paper Trade']: self.exchange = 'paper' # Paper trades use a single synthetic market else: self.exchange = exchange or target self.symbol = symbol self.side = side.upper() self.order_price = order_price self.order_type = order_type.upper() self.time_in_force = time_in_force.upper() self.base_order_qty = base_order_qty self.fee = fee self.strategy_id = strategy_id self.is_paper = is_paper self.testnet = testnet self.creator = creator self.created_at = created_at or dt.datetime.now(dt.timezone.utc).isoformat() # Broker integration fields self.broker_kind = broker_kind # 'paper' or 'live' self.broker_mode = broker_mode # 'testnet', 'production', or 'paper' self.broker_exchange = broker_exchange # Exchange name (for live) self.broker_order_id = broker_order_id # Local broker order ID self.exchange_order_id = exchange_order_id # Live exchange order ID # Stop Loss / Take Profit (triggers auto-close when price crosses threshold) self.stop_loss = stop_loss self.take_profit = take_profit if status is None: self.status = 'inactive' self.stats = { 'opening_price': order_price, 'opening_value': self.base_order_qty * order_price, 'current_price': order_price, 'current_value': self.base_order_qty * order_price, 'settled_price': 0.0, 'settled_value': 0.0, 'qty_filled': 0.0, 'qty_settled': 0.0, 'profit': 0.0, 'profit_pct': 0.0, 'fee_paid': 0.0, 'realized_profit': 0.0, 'unrealized_profit': 0.0 } self.order = None else: self.status = status self.stats = stats self.order = order def to_json(self) -> dict[str, Any]: """ Serializes the Trade object to a JSON-compatible dictionary. """ return { 'unique_id': self.unique_id, 'tbl_key': self.tbl_key, 'strategy_id': self.strategy_id, 'target': self.target, 'exchange': self.exchange, 'symbol': self.symbol, 'side': self.side, 'order_price': self.order_price, 'base_order_qty': self.base_order_qty, 'order_type': self.order_type, 'time_in_force': self.time_in_force, 'status': self.status, 'stats': self.stats, 'order': self.order, 'is_paper': self.is_paper, 'testnet': self.testnet, 'creator': self.creator, 'created_at': self.created_at, 'broker_kind': self.broker_kind, 'broker_mode': self.broker_mode, 'broker_exchange': self.broker_exchange, 'broker_order_id': self.broker_order_id, 'exchange_order_id': self.exchange_order_id, 'stop_loss': self.stop_loss, 'take_profit': self.take_profit } def get_position_size(self) -> float: """ Position_size is the value of the trade in the quote currency. """ return self.stats['current_value'] def get_pl(self) -> float: """ Returns a positive profit or a negative loss. """ return self.stats['profit'] def get_pl_pct(self) -> float: """ Returns the profit/loss percentage. """ return self.stats['profit_pct'] def get_status(self) -> str: """ Returns the current status of the trade. """ return self.status @staticmethod def _percent(part: float, whole: float) -> float: if whole == 0: return 0.0 return 100.0 * float(part) / float(whole) @staticmethod def _calculate_pl(entry_price: float, exit_price: float, qty: float, side: str, fee: float) -> float: entry_value = qty * entry_price exit_value = qty * exit_price profit = exit_value - entry_value if side == 'SELL': profit *= -1 fees = (entry_value * fee) + (exit_value * fee) return profit - fees def _filled_qty(self) -> float: return float(self.stats.get('qty_filled', 0.0) or 0.0) def _settled_qty(self) -> float: return float(self.stats.get('qty_settled', 0.0) or 0.0) def _open_qty(self) -> float: return max(self._filled_qty() - self._settled_qty(), 0.0) def update_values(self, current_price: float) -> None: """ Updates the P/L values and percentages based on the current price. """ self.stats['current_price'] = current_price opening_price = float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0) filled_qty = self._filled_qty() open_qty = self._open_qty() realized_profit = float(self.stats.get('realized_profit', 0.0) or 0.0) if open_qty > 0: self.stats['current_value'] = open_qty * current_price unrealized_profit = self._calculate_pl( entry_price=opening_price, exit_price=current_price, qty=open_qty, side=self.side, fee=self.fee ) else: # Keep legacy order-notional display for resting orders, but they should not show P/L. if filled_qty <= 0 and self.status in ['inactive', 'pending', 'open', 'unfilled']: self.stats['current_value'] = self.base_order_qty * current_price else: self.stats['current_value'] = 0.0 unrealized_profit = 0.0 logger.debug(f"Trade {self.unique_id}: Updated current value to {self.stats['current_value']}") self.stats['unrealized_profit'] = unrealized_profit self.stats['profit'] = realized_profit + unrealized_profit basis_qty = filled_qty if filled_qty > 0 else 0.0 basis_value = basis_qty * opening_price self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value) logger.debug(f"Trade {self.unique_id}: Profit updated to {self.stats['profit']} ({self.stats['profit_pct']}%)") def update(self, current_price: float) -> str: """ Updates the trade based on the current price and returns the updated status. """ if self.status == 'closed': return self.status self.update_values(current_price) # Determine if the trade has been filled or partially filled if self.status in ['filled', 'part-filled']: return self.status else: return 'updated' # Indicates that the trade has been updated but not filled def order_placed(self, order: Any) -> None: """ Updates the trade status and stores the order after it has been placed on the exchange. """ self.status = 'unfilled' self.order = order def trade_filled(self, qty: float, price: float) -> None: """ Records the quantity filled, updates trade statistics, and sets the status. """ if self.status == 'inactive': self.status = 'unfilled' current_filled = self._filled_qty() if current_filled <= 0: self.stats['qty_filled'] = qty self.stats['opening_price'] = price else: sum_of_values = (qty * price) + (current_filled * self.stats['opening_price']) t_qty = current_filled + qty weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0 self.stats['opening_price'] = weighted_average self.stats['qty_filled'] = t_qty self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price'] if self.stats['qty_filled'] >= self.base_order_qty: self.status = 'filled' else: self.status = 'part-filled' current_price = float(self.stats.get('current_price', 0.0) or 0.0) if current_price <= 0: current_price = price self.update_values(current_price) def settle(self, qty: float, price: float) -> None: """ Settles all or part of the trade based on the provided quantity and price. """ qty = float(qty or 0.0) if qty <= 0: return filled_qty = self._filled_qty() open_qty = self._open_qty() if filled_qty > 0 and open_qty > 0: qty = min(qty, open_qty) if qty <= 0: return if self.stats['qty_settled'] == 0: self.stats['settled_price'] = price self.stats['settled_value'] = qty * price self.stats['qty_settled'] = qty else: sum_of_values = (qty * price) + self.stats['settled_value'] t_qty = self.stats['qty_settled'] + qty weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0 self.stats['settled_price'] = weighted_average self.stats['qty_settled'] += qty self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price'] realized_increment = self._calculate_pl( entry_price=float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0), exit_price=price, qty=qty, side=self.side, fee=self.fee ) self.stats['realized_profit'] = float(self.stats.get('realized_profit', 0.0) or 0.0) + realized_increment if self._open_qty() <= 0: self.stats['current_price'] = price self.stats['current_value'] = 0.0 self.stats['unrealized_profit'] = 0.0 self.stats['profit'] = self.stats['realized_profit'] basis_qty = self._filled_qty() if self._filled_qty() > 0 else self.base_order_qty basis_value = basis_qty * float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0) self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value) else: self.update_values(float(self.stats.get('current_price', price) or price)) close_qty = filled_qty if filled_qty > 0 else self.base_order_qty if self.stats['qty_settled'] >= close_qty: self.status = 'closed' class Trades: def __init__(self, users: Users, data_cache: Any = None): """ Initializes the Trades class with necessary attributes. :param users: Users instance for user lookups. :param data_cache: DataCache instance for persistence. """ self.users = users self.data_cache = data_cache self.exchange_interface: Any | None = None # Define the type based on your exchange interface self.manual_broker_manager: Any | None = None # ManualTradingBrokerManager for broker-based trading self.exchange_fees = {'maker': 0.001, 'taker': 0.001} self.hedge_mode = False self.side: str | None = None self.active_trades: dict[str, Trade] = {} # Keyed by trade.unique_id self.settled_trades: dict[str, Trade] = {} self.stats = {'num_trades': 0, 'total_position': 0, 'total_position_value': 0} self.balances: dict[str, float] = {} # Track balances per strategy self.locked_funds: dict[str, float] = {} # Track locked funds per strategy # Initialize database persistence if data_cache is available if self.data_cache: self._ensure_table_exists() self._create_cache() self._load_trades_from_db() def _ensure_table_exists(self) -> None: """Create the trades table in the database if it doesn't exist.""" try: if not self.data_cache.db.table_exists('trades'): create_sql = """ CREATE TABLE IF NOT EXISTS trades ( id INTEGER PRIMARY KEY AUTOINCREMENT, creator INTEGER, unique_id TEXT UNIQUE, target TEXT NOT NULL, symbol TEXT NOT NULL, side TEXT NOT NULL, order_type TEXT NOT NULL, order_price REAL, base_order_qty REAL NOT NULL, time_in_force TEXT DEFAULT 'GTC', fee REAL DEFAULT 0.001, status TEXT DEFAULT 'inactive', stats_json TEXT, strategy_id TEXT, is_paper INTEGER DEFAULT 0, testnet INTEGER DEFAULT 0, created_at TEXT, tbl_key TEXT UNIQUE, broker_kind TEXT, broker_mode TEXT, broker_exchange TEXT, broker_order_id TEXT, exchange_order_id TEXT, stop_loss REAL, take_profit REAL ) """ self.data_cache.db.execute_sql(create_sql, params=[]) logger.info("Created trades table in database") else: # Ensure testnet column exists for existing databases self._ensure_testnet_column() # Ensure broker columns exist for existing databases self._ensure_broker_columns() except Exception as e: logger.error(f"Error ensuring trades table exists: {e}", exc_info=True) def _ensure_testnet_column(self) -> None: """Add testnet column to trades table if it doesn't exist.""" try: # Check if testnet column exists result = self.data_cache.db.execute_sql( "PRAGMA table_info(trades)", params=[] ) columns = {row[1] for row in result} if result else set() if 'testnet' not in columns: self.data_cache.db.execute_sql( "ALTER TABLE trades ADD COLUMN testnet INTEGER DEFAULT 0", params=[] ) logger.info("Added testnet column to trades table") except Exception as e: logger.debug(f"Could not add testnet column: {e}") def _ensure_broker_columns(self) -> None: """Add broker tracking columns to trades table if they don't exist.""" broker_columns = [ ('broker_kind', 'TEXT'), ('broker_mode', 'TEXT'), ('broker_exchange', 'TEXT'), ('broker_order_id', 'TEXT'), ('exchange_order_id', 'TEXT'), ('stop_loss', 'REAL'), ('take_profit', 'REAL'), ] try: result = self.data_cache.db.execute_sql( "PRAGMA table_info(trades)", params=[] ) existing_columns = {row[1] for row in result} if result else set() for col_name, col_type in broker_columns: if col_name not in existing_columns: try: self.data_cache.db.execute_sql( f"ALTER TABLE trades ADD COLUMN {col_name} {col_type}", params=[] ) logger.info(f"Added {col_name} column to trades table") except Exception as e: logger.debug(f"Could not add {col_name} column: {e}") except Exception as e: logger.debug(f"Could not ensure broker columns: {e}") def _create_cache(self) -> None: """Create the trades cache in DataCache.""" try: self.data_cache.create_cache( name='trades', cache_type='table', size_limit=1000, eviction_policy='deny', default_expiration=dt.timedelta(hours=24), columns=[ "creator", "unique_id", "target", "symbol", "side", "order_type", "order_price", "base_order_qty", "time_in_force", "fee", "status", "stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key", "broker_kind", "broker_mode", "broker_exchange", "broker_order_id", "exchange_order_id", "stop_loss", "take_profit" ] ) except Exception as e: logger.debug(f"Cache 'trades' may already exist: {e}") def _load_trades_from_db(self) -> None: """Load all trades from database into memory (active + settled).""" try: trades_df = self.data_cache.get_all_rows_from_datacache(cache_name='trades') if trades_df is not None and not trades_df.empty: active_count = 0 settled_count = 0 for _, row in trades_df.iterrows(): status = row.get('status', 'inactive') # Parse stats JSON stats_json = row.get('stats_json', '{}') try: stats = json.loads(stats_json) if stats_json else {} except (json.JSONDecodeError, TypeError): stats = {} trade = Trade( target=row.get('target', ''), symbol=row.get('symbol', ''), side=row.get('side', 'BUY'), order_price=float(row.get('order_price', 0)), base_order_qty=float(row.get('base_order_qty', 0)), order_type=row.get('order_type', 'MARKET'), time_in_force=row.get('time_in_force', 'GTC'), unique_id=row.get('unique_id'), status=status, stats=stats if stats else None, fee=float(row.get('fee', 0.001)), strategy_id=row.get('strategy_id'), is_paper=bool(row.get('is_paper', 0)), testnet=bool(row.get('testnet', 0)), creator=row.get('creator'), created_at=row.get('created_at'), tbl_key=row.get('tbl_key'), broker_kind=row.get('broker_kind'), broker_mode=row.get('broker_mode'), broker_exchange=row.get('broker_exchange'), broker_order_id=row.get('broker_order_id'), exchange_order_id=row.get('exchange_order_id'), stop_loss=row.get('stop_loss'), take_profit=row.get('take_profit') ) # Route to appropriate collection based on status if status in ['closed', 'cancelled']: self.settled_trades[trade.unique_id] = trade settled_count += 1 else: self.active_trades[trade.unique_id] = trade self.stats['num_trades'] += 1 active_count += 1 logger.info(f"Loaded {active_count} active trades and {settled_count} settled trades from database") except Exception as e: logger.error(f"Error loading trades from database: {e}", exc_info=True) def _save_trade(self, trade: Trade) -> bool: """ Save a trade to the database. :param trade: Trade object to save. :return: True if successful, False otherwise. """ if not self.data_cache: return True # No persistence, just return success try: columns = ( "creator", "unique_id", "target", "symbol", "side", "order_type", "order_price", "base_order_qty", "time_in_force", "fee", "status", "stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key", "broker_kind", "broker_mode", "broker_exchange", "broker_order_id", "exchange_order_id", "stop_loss", "take_profit" ) stats_json = json.dumps(trade.stats) if trade.stats else '{}' values = ( trade.creator, trade.unique_id, trade.target, trade.symbol, trade.side, trade.order_type, trade.order_price, trade.base_order_qty, trade.time_in_force, trade.fee, trade.status, stats_json, trade.strategy_id, int(trade.is_paper), int(trade.testnet), trade.created_at, trade.tbl_key, trade.broker_kind, trade.broker_mode, trade.broker_exchange, trade.broker_order_id, trade.exchange_order_id, trade.stop_loss, trade.take_profit ) # Check if trade already exists existing = self.data_cache.get_rows_from_datacache( cache_name='trades', filter_vals=[('tbl_key', trade.tbl_key)], include_tbl_key=True ) if existing.empty: # Insert new trade self.data_cache.insert_row_into_datacache( cache_name='trades', columns=columns, values=values ) else: # Update existing trade self.data_cache.modify_datacache_item( cache_name='trades', filter_vals=[('tbl_key', trade.tbl_key)], field_names=columns, new_values=values, key=trade.tbl_key, overwrite='tbl_key' ) return True except Exception as e: logger.error(f"Failed to save trade {trade.unique_id}: {e}", exc_info=True) return False def _update_trade_in_db(self, trade: Trade) -> bool: """ Update a trade's stats in the database. :param trade: Trade object to update. :return: True if successful, False otherwise. """ return self._save_trade(trade) def _delete_trade_from_db(self, trade_id: str) -> bool: """ Delete a trade from the database. :param trade_id: The unique ID of the trade to delete. :return: True if successful, False otherwise. """ if not self.data_cache: return True try: self.data_cache.remove_row_from_datacache( cache_name='trades', filter_vals=[('unique_id', trade_id)] ) return True except Exception as e: logger.error(f"Failed to delete trade {trade_id}: {e}", exc_info=True) return False def new_trade(self, target: str, symbol: str, price: float, side: str, order_type: str, qty: float, user_id: int = None, strategy_id: str = None, testnet: bool = False, exchange: str = None, stop_loss: float = None, take_profit: float = None, time_in_force: str = 'GTC') -> tuple[str, str | None]: """ Creates a new trade (paper or live). :param target: The exchange target ('test_exchange' for paper, exchange name for live). :param symbol: The trading pair symbol. :param price: The price to trade at (ignored for market orders). :param side: 'BUY' or 'SELL'. :param order_type: 'MARKET' or 'LIMIT'. :param qty: The quantity to trade. :param user_id: The user creating the trade. :param strategy_id: Optional strategy ID if from a strategy. :param testnet: Whether to use testnet/sandbox mode for live trades. :param exchange: The actual exchange for price data (for paper trades). :param stop_loss: Optional stop loss price. :param take_profit: Optional take profit price. :param time_in_force: Order time-in-force ('GTC', 'IOC', 'FOK'). :return: Tuple of (status, trade_id or error message). """ from brokers.base_broker import OrderSide, OrderType, OrderStatus # Determine if this is a paper trade is_paper = target in ['test_exchange', 'paper', 'Paper Trade'] time_in_force = (time_in_force or 'GTC').upper() # === PRODUCTION SAFETY GATE (BEFORE any broker/exchange creation) === if not is_paper and not testnet: import config if not getattr(config, 'ALLOW_LIVE_PRODUCTION', False): logger.warning( f"Production trading blocked: ALLOW_LIVE_PRODUCTION not set. " f"User {user_id} attempted production trade on {target}." ) return 'Error', 'Production trading is disabled. Set BRIGHTER_ALLOW_LIVE_PROD=true to enable.' # For live trades, validate exchange is configured BEFORE creating trade if not is_paper: if not self.exchange_connected(): return 'Error', 'No exchange interface connected. Cannot place live trades.' # Check if user has this exchange configured user_name = self._get_user_name(user_id) if user_id else None if not user_name: return 'Error', 'You must be logged in to place live trades.' try: exchange_obj = self.exchange_interface.get_exchange(ename=target, uname=user_name) if not exchange_obj or not exchange_obj.configured: return 'Error', f'Exchange "{target}" is not configured with API keys. Please configure it in the Exchanges panel first.' except ValueError: return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.' if not is_paper and (stop_loss is not None or take_profit is not None): return 'Error', 'Manual live Stop Loss / Take Profit is not supported yet. Use paper trading for SL/TP for now.' # For market orders, fetch the current price from exchange # For paper trades, use the specified exchange for consistent pricing effective_price = float(price) if price else 0.0 if order_type and order_type.upper() == 'MARKET' and self.exchange_interface: try: # Use exchange-aware price lookup for paper trades price_exchange = exchange if is_paper and exchange else (target if not is_paper else None) current_price = self.exchange_interface.get_price(symbol, price_exchange) if current_price: effective_price = float(current_price) logger.debug(f"Market order: using current price {effective_price} for {symbol} from {price_exchange or 'default'}") except Exception as e: logger.warning(f"Could not fetch current price for {symbol}: {e}, using provided price {price}") # Fetch trading fees from exchange (falls back to defaults if unavailable) effective_fee = self.exchange_fees.get('taker', 0.001) # Default to taker fee if self.exchange_interface: try: user_name = self._get_user_name(user_id) if user_id else None fee_info = self.exchange_interface.get_trading_fees( symbol=symbol, user_name=user_name, exchange_name=target if not is_paper else None ) # Use taker fee for market orders, maker fee for limit orders if order_type and order_type.upper() == 'LIMIT': effective_fee = fee_info.get('maker', 0.001) else: effective_fee = fee_info.get('taker', 0.001) logger.debug(f"Trade fee for {symbol}: {effective_fee} (source: {fee_info.get('source', 'unknown')})") except Exception as e: logger.warning(f"Could not fetch trading fees for {symbol}: {e}, using default {effective_fee}") try: # === BROKER-BASED ORDER PLACEMENT === if self.manual_broker_manager and user_id: # Get appropriate broker FIRST (need it for SELL validation) if is_paper: broker = self.manual_broker_manager.get_paper_broker(user_id) broker_kind = 'paper' broker_mode = 'paper' broker_exchange = None broker_key = 'paper' else: user_name = self._get_user_name(user_id) broker = self.manual_broker_manager.get_live_broker( user_id, target, testnet, user_name ) if not broker: return 'Error', f'Could not create broker for exchange "{target}".' broker_kind = 'live' broker_mode = 'testnet' if testnet else 'production' broker_exchange = target broker_key = f"{target}_{broker_mode}" # Inventory-only SELL (applies to BOTH paper and live) if side.upper() == 'SELL': position = broker.get_position(symbol) if not position or position.size <= 0: return 'Error', 'Cannot sell: no position in this symbol. Buy first.' # Place order through broker order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT # Build order kwargs - paper trades get exchange for price source tracking order_kwargs = { 'symbol': symbol, 'side': order_side, 'order_type': order_type_enum, 'size': float(qty), 'price': effective_price if order_type.upper() == 'LIMIT' else None, 'stop_loss': stop_loss, 'take_profit': take_profit, 'time_in_force': time_in_force, } if is_paper and exchange: # Paper trades track exchange for price source order_kwargs['exchange'] = exchange result = broker.place_order(**order_kwargs) if not result.success: return 'Error', result.message or 'Order placement failed' # Map OrderStatus to trade status string status_map = { OrderStatus.PENDING: 'pending', OrderStatus.OPEN: 'open', OrderStatus.FILLED: 'filled', OrderStatus.PARTIALLY_FILLED: 'part-filled', OrderStatus.CANCELLED: 'cancelled', OrderStatus.REJECTED: 'rejected', OrderStatus.EXPIRED: 'expired', } trade_status = status_map.get(result.status, 'pending') # Create Trade with full broker tracking # Note: Trade.__init__ normalizes exchange to 'paper' for paper trades trade = Trade( target=target, exchange=exchange, symbol=symbol, side=side.upper(), order_price=effective_price, base_order_qty=float(qty), order_type=order_type.upper() if order_type else 'MARKET', time_in_force=time_in_force, strategy_id=strategy_id, is_paper=is_paper, testnet=testnet, creator=user_id, fee=effective_fee, broker_kind=broker_kind, broker_mode=broker_mode, broker_exchange=broker_exchange, broker_order_id=result.order_id, exchange_order_id=result.exchange_order_id, stop_loss=stop_loss, take_profit=take_profit ) trade.status = trade_status # Update stats if order was filled immediately (market orders) if result.status == OrderStatus.FILLED: trade.stats['qty_filled'] = result.filled_qty or float(qty) # Validate filled_price - detect unreasonable deviations from effective_price filled_price = result.filled_price or effective_price if effective_price > 0 and filled_price > 0: price_ratio = filled_price / effective_price # Price shouldn't deviate by more than 10% from market price for market orders if price_ratio > 1.1 or price_ratio < 0.9: logger.warning( f"[PRICE VALIDATION] Suspicious filled_price detected! " f"filled_price={filled_price}, effective_price={effective_price}, " f"ratio={price_ratio:.2f}. Using effective_price instead." ) filled_price = effective_price trade.stats['opening_price'] = filled_price trade.stats['opening_value'] = trade.stats['qty_filled'] * trade.stats['opening_price'] trade.stats['current_value'] = trade.stats['opening_value'] logger.debug( f"[FILL STATS] trade={trade.unique_id[:8]}, qty_filled={trade.stats['qty_filled']}, " f"opening_price={trade.stats['opening_price']}, result.filled_price={result.filled_price}" ) logger.info(f"Broker trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price} " f"(broker_kind={broker_kind}, status={trade_status})") else: # === LEGACY PATH (no broker manager) === trade = Trade( target=target, exchange=exchange, symbol=symbol, side=side.upper(), order_price=effective_price, base_order_qty=float(qty), order_type=order_type.upper() if order_type else 'MARKET', time_in_force=time_in_force, strategy_id=strategy_id, is_paper=is_paper, testnet=testnet, creator=user_id, fee=effective_fee, stop_loss=stop_loss, take_profit=take_profit ) if is_paper: # Paper trade: simulate immediate fill trade.status = 'filled' trade.stats['qty_filled'] = trade.base_order_qty trade.stats['opening_price'] = trade.order_price trade.stats['opening_value'] = trade.base_order_qty * trade.order_price trade.stats['current_value'] = trade.stats['opening_value'] logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}") else: # Live trade: place order on exchange (legacy path) mode_str = "testnet" if testnet else "production" logger.info(f"Live trade ({mode_str}): {trade.unique_id} {side} {qty} {symbol} @ {effective_price}") if not self.exchange_connected(): return 'Error', 'No exchange connected' user_name = self._get_user_name(user_id) if user_id else 'unknown' status, msg = self.place_order(trade, user_name=user_name) if status != 'success': return 'Error', msg # Add to active trades self.active_trades[trade.unique_id] = trade self.stats['num_trades'] += 1 # Persist to database self._save_trade(trade) return 'Success', trade.unique_id except Exception as e: logger.error(f"Error creating new trade: {e}", exc_info=True) return 'Error', str(e) def settle_broker_closed_position(self, user_id: int, symbol: str, broker_key: str, close_price: float) -> list[str]: """ Reconcile local trades after a broker-side position close. This is used when the broker closes the position outside the normal close_position() API flow, such as paper SL/TP triggers. """ settled_ids = [] for trade_id, trade in list(self.active_trades.items()): if broker_key == 'paper': matches_broker = trade.broker_kind == 'paper' else: trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}" matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key) if not (trade.creator == user_id and trade.symbol == symbol and matches_broker): continue if trade.status not in ['filled', 'part-filled']: continue qty_to_settle = trade.stats.get('qty_filled', trade.base_order_qty) if qty_to_settle <= 0: continue trade.settle(qty=qty_to_settle, price=close_price) trade.status = 'closed' self._save_trade(trade) del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 settled_ids.append(trade.unique_id) return settled_ids def get_trades_for_user(self, user_id: int, form: str = 'json') -> list: """ Returns trades visible to a specific user. :param user_id: The user ID to filter trades for. :param form: Output format ('json', 'obj', 'dict'). :return: List of trades. """ user_trades = [ trade for trade in self.active_trades.values() if trade.creator == user_id or trade.creator is None ] if form == 'obj': return user_trades elif form == 'json': return [trade.to_json() for trade in user_trades] elif form == 'dict': return [trade.__dict__ for trade in user_trades] else: return [trade.to_json() for trade in user_trades] def get_trade_history(self, user_id: int, limit: int = 50) -> list[dict]: """ Get settled/cancelled trade history for a user. Only includes trades that are truly finished (status='closed' or 'cancelled'). Active positions (status='filled') belong in the Positions panel, not history. :param user_id: The user ID. :param limit: Maximum number of trades to return. :return: List of trade dicts, most recent first. """ history = [] # From in-memory settled trades (these are truly closed) for trade_id, trade in self.settled_trades.items(): if trade.creator == user_id: history.append(trade.to_json()) # Also check active trades for 'cancelled' status only # Note: 'filled' = open position (not history), 'closed' should be in settled_trades for trade_id, trade in self.active_trades.items(): if trade.creator == user_id and trade.status == 'cancelled': history.append(trade.to_json()) # Sort by timestamp descending (most recent first) history.sort(key=lambda t: t.get('created_at', 0), reverse=True) return history[:limit] def buy(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]: """ Executes a buy order. """ return self._execute_order(order_data, user_id, side='BUY') def sell(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]: """ Executes a sell order. """ return self._execute_order(order_data, user_id, side='SELL') def _execute_order(self, order_data: dict[str, Any], user_id: int, side: str) -> tuple[str, str | None]: """ Internal method to handle order execution. """ try: # Create a new trade trade = Trade( target=order_data.get('target', 'exchange_interface'), symbol=order_data['symbol'], side=side, order_price=order_data.get('price', 0.0), base_order_qty=order_data['size'], order_type=order_data.get('order_type', 'MARKET'), time_in_force=order_data.get('tif', 'GTC'), strategy_id=order_data.get('strategy_id') ) # Add the trade to active trades self.active_trades[trade.unique_id] = trade self.stats['num_trades'] += 1 # Place the order status, msg = self.place_order(trade, user_name=self._get_user_name(user_id)) if status == 'success': logger.info(f"Order placed successfully: Trade ID {trade.unique_id}") return 'success', None else: # Remove the trade from active_trades if placement failed del self.active_trades[trade.unique_id] self.stats['num_trades'] -= 1 logger.error(f"Order placement failed for Trade ID {trade.unique_id}: {msg}") return 'fail', msg except Exception as e: logger.error(f"Exception during {side} order execution: {e}", exc_info=True) return 'fail', str(e) def _get_user_name(self, user_id: int) -> str: """ Retrieves the username based on user_id. """ try: return self.users.get_username(user_id) except Exception as e: logger.error(f"Failed to retrieve username for user ID {user_id}: {e}", exc_info=True) return "unknown_user" def exit_strategy_all(self, strategy_id: str) -> tuple[str, str | None]: """ Exits all trades associated with a specific strategy. """ try: trades_to_exit = [trade for trade in self.active_trades.values() if trade.strategy_id == strategy_id] for trade in trades_to_exit: self.close_trade(trade.unique_id) logger.info(f"All trades for strategy {strategy_id} have been exited.") return 'success', None except Exception as e: logger.error(f"Error exiting all trades for strategy '{strategy_id}': {e}", exc_info=True) return 'fail', str(e) def exit_strategy_in_profit(self, strategy_id: str) -> tuple[str, str | None]: """ Exits trades that are in profit for a specific strategy. """ try: trades_to_exit = [trade for trade in self.active_trades.values() if trade.strategy_id == strategy_id and trade.stats.get('profit', 0.0) > 0] for trade in trades_to_exit: self.close_trade(trade.unique_id) logger.info(f"Profitable trades for strategy {strategy_id} have been exited.") return 'success', None except Exception as e: logger.error(f"Error exiting profitable trades for strategy '{strategy_id}': {e}", exc_info=True) return 'fail', str(e) def exit_strategy_in_loss(self, strategy_id: str) -> tuple[str, str | None]: """ Exits trades that are in loss for a specific strategy. """ try: trades_to_exit = [trade for trade in self.active_trades.values() if trade.strategy_id == strategy_id and trade.stats.get('profit', 0.0) < 0] for trade in trades_to_exit: self.close_trade(trade.unique_id) logger.info(f"Losing trades for strategy {strategy_id} have been exited.") return 'success', None except Exception as e: logger.error(f"Error exiting losing trades for strategy '{strategy_id}': {e}", exc_info=True) return 'fail', str(e) def all_trades_closed(self, strategy_id: str) -> bool: """ Checks if all trades for the given strategy are closed. :param strategy_id: Identifier of the strategy. :return: True if all trades are closed, False otherwise. """ try: trades = self.active_trades.get(strategy_id, []) return len(trades) == 0 except Exception as e: logger.error(f"Error checking trades for strategy '{strategy_id}': {e}", exc_info=True) return False def notify_user(self, user_id: int, message: str) -> tuple[str, str | None]: """ Sends a notification to the specified user. """ try: self.users.notify_user(user_id, message) return 'success', None except Exception as e: logger.error(f"Error notifying user '{user_id}': {e}", exc_info=True) return 'fail', str(e) def get_profit(self, strategy_id: str) -> float: """ Calculates the total profit for a specific strategy. """ total_profit = sum(trade.stats.get('profit', 0.0) for trade in self.active_trades.values() if trade.strategy_id == strategy_id) logger.info(f"Total profit for strategy {strategy_id}: {total_profit}") return total_profit def get_current_balance(self, user_id: int) -> float: """ Retrieves the current balance for a user. """ try: # Implement logic to fetch the user's current balance. # This could involve querying the exchange interface or a database. balance = self.exchange_interface.get_user_balance(user_id) logger.info(f"Current balance for user {user_id}: {balance}") return balance except Exception as e: logger.error(f"Error fetching current balance for user '{user_id}': {e}", exc_info=True) return 0.0 def connect_exchanges(self, exchanges: Any) -> None: """ Connects an exchange interface. """ self.exchange_interface = exchanges logger.info("Exchange interface connected.") def exchange_connected(self) -> bool: """ Reports if an exchange interface has been connected. """ return self.exchange_interface is not None def get_trades(self, form: str) -> Any | None: """ Returns stored trades in various formats. """ if form == 'obj': return list(self.active_trades.values()) elif form == 'json': return [trade.to_json() for trade in self.active_trades.values()] elif form == 'dict': return [trade.__dict__ for trade in self.active_trades.values()] else: logger.error(f"Invalid form '{form}' requested for get_trades.") return None def get_trades_by_status(self, status: str) -> list[Trade]: """ Returns a list of active trades with the specified status. """ return [trade for trade in self.active_trades.values() if trade.status == status] def is_valid_trade_id(self, trade_id: str) -> bool: """ Validates if a trade ID exists among active trades. """ return trade_id in self.active_trades def get_trade_by_id(self, trade_id: str) -> Trade | None: """ Retrieves a trade by its unique ID. """ return self.active_trades.get(trade_id, None) def load_trades(self, trades: list[dict[str, Any]]) -> None: """ Loads trades from a list of trade dictionaries. """ for trade_data in trades: trade = Trade(**trade_data) self.active_trades[trade.unique_id] = trade self.stats['num_trades'] += 1 logger.info(f"Loaded {len(trades)} trades.") def get_filled_orders_count(self, strategy_id: str) -> int: """ Returns the number of filled orders for the given strategy. """ try: filled_orders = [order for order in self.orders if order['strategy_id'] == strategy_id and order['status'] == 'filled'] return len(filled_orders) except Exception as e: logger.error(f"Error retrieving filled orders for strategy '{strategy_id}': {e}", exc_info=True) return 0 def get_available_balance(self, strategy_id: str) -> float: """ Returns the available balance for the given strategy. """ try: # Implement logic to calculate available balance # This is a placeholder and should be replaced with actual implementation return self.balances.get(strategy_id, 0.0) - self.locked_funds.get(strategy_id, 0.0) except Exception as e: logger.error(f"Error retrieving available balance for strategy '{strategy_id}': {e}", exc_info=True) return 0.0 def get_total_filled_order_volume(self, strategy_id: str) -> float: """ Returns the total volume of filled orders for the given strategy. """ try: filled_orders = [order for order in self.orders if order['strategy_id'] == strategy_id and order['status'] == 'filled'] total_volume = sum(order['size'] for order in filled_orders) return total_volume except Exception as e: logger.error(f"Error retrieving filled order volume for strategy '{strategy_id}': {e}", exc_info=True) return 0.0 def get_total_unfilled_order_volume(self, strategy_id: str) -> float: """ Returns the total volume of unfilled orders for the given strategy. """ try: unfilled_orders = [order for order in self.orders if order['strategy_id'] == strategy_id and order['status'] in ['pending', 'open']] total_volume = sum(order['size'] for order in unfilled_orders) return total_volume except Exception as e: logger.error(f"Error retrieving unfilled order volume for strategy '{strategy_id}': {e}", exc_info=True) return 0.0 def get_unfilled_orders_count(self, strategy_id: str) -> int: """ Returns the number of unfilled orders for the given strategy. """ try: unfilled_orders = [order for order in self.orders if order['strategy_id'] == strategy_id and order['status'] in ['pending', 'open']] return len(unfilled_orders) except Exception as e: logger.error(f"Error retrieving unfilled orders for strategy '{strategy_id}': {e}", exc_info=True) return 0 def place_order(self, trade: Trade, user_name: str) -> tuple[str, str | None]: """ Executes a place order command on the exchange interface. """ if not self.exchange_connected(): error_msg = 'No exchange_interface connected.' logger.error(f"Trades:place_order(): {error_msg}") return 'fail', error_msg exchange = self.exchange_interface.get_exchange(ename=trade.target, uname=user_name) try: if trade.order_type in ['MARKET', 'LIMIT']: order = exchange.place_order( symbol=trade.symbol, side=trade.side, type=trade.order_type, timeInForce=trade.time_in_force, quantity=trade.base_order_qty, price=trade.order_price if trade.order_type == 'LIMIT' else None ) elif trade.order_type == 'CHASE': error_msg = 'Trades:place_order(): CHASE orders not implemented yet.' logger.error(error_msg) return 'fail', error_msg else: error_msg = f"Trades:place_order(): No implementation for order type: {trade.order_type}" logger.error(error_msg) return 'fail', error_msg except Exception as e: error_msg = f"Trades:place_order(): {e}" logger.error(error_msg, exc_info=True) return 'fail', error_msg # Assign the exchange's order ID to the trade if hasattr(order, 'orderId'): trade.unique_id = order.orderId else: logger.warning(f"Order object does not have 'orderId'. Using existing unique_id: {trade.unique_id}") # Update the trade status and store the order trade.order_placed(order) logger.info(f"Trade {trade.unique_id} placed: {trade.side} {trade.symbol} at {trade.order_price}") return 'success', None def update(self, price_updates: dict[str, float]) -> list[dict[str, Any]]: """ Updates the price for all active trades based on provided price updates. :param price_updates: Dictionary mapping (exchange:symbol) or symbol to prices. Keys can be "binance:BTC/USDT" for exchange-specific prices, or just "BTC/USDT" for generic prices. :return: List of dictionaries containing updated trade data. """ _debug_logger.debug(f"=== Trades.update() called ===") _debug_logger.debug(f"price_updates: {price_updates}") _debug_logger.debug(f"active_trades count: {len(self.active_trades)}") _debug_logger.debug(f"active_trades keys: {list(self.active_trades.keys())}") r_update = [] for trade_id, trade in list(self.active_trades.items()): symbol = trade.symbol exchange = getattr(trade, 'exchange', None) or trade.target _debug_logger.debug(f"Processing trade_id={trade_id}, symbol={symbol}, exchange={exchange}, status={trade.status}") # First try exchange-specific price key exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None current_price = price_updates.get(exchange_key) if exchange_key else None _debug_logger.debug(f"Tried exchange_key '{exchange_key}': {current_price}") # Fall back to symbol-only lookup if current_price is None: current_price = price_updates.get(symbol) _debug_logger.debug(f"Tried symbol '{symbol}': {current_price}") if current_price is None: # Try to find a matching symbol (handle format differences like BTC/USD vs BTC/USDT) for price_key, price in price_updates.items(): # Extract symbol part if key is exchange:symbol format price_symbol = price_key.split(':')[-1] if ':' in price_key else price_key # Normalize both symbols for comparison norm_trade = symbol.upper().replace('/', '') norm_price = price_symbol.upper().replace('/', '') if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'): current_price = price logger.debug(f"Matched trade symbol '{symbol}' to price key '{price_key}'") break if current_price is None: _debug_logger.debug(f"current_price is None after matching, skipping trade {trade_id}") logger.warning(f"No price update for symbol '{symbol}' (exchange: {exchange}). Available: {list(price_updates.keys())}. Skipping trade {trade_id}.") continue _debug_logger.debug(f"current_price resolved to: {current_price}") _debug_logger.debug(f"Checking trade.status: '{trade.status}' in ['unfilled', 'part-filled']") if trade.status in ['unfilled', 'part-filled']: status = self.exchange_interface.get_trade_status(trade) if status in ['FILLED', 'PARTIALLY_FILLED']: executed_qty = self.exchange_interface.get_trade_executed_qty(trade) try: executed_price = self.exchange_interface.get_trade_executed_price( trade, fallback_price=current_price ) except Exception as e: logger.error( f"Trades:update() unable to resolve executed price for trade {trade_id}: {e}", exc_info=True ) continue if executed_price <= 0: logger.error( f"Trades:update() received non-positive executed price for trade {trade_id}: " f"{executed_price}" ) continue trade.trade_filled(qty=executed_qty, price=executed_price) elif status in ['CANCELED', 'EXPIRED', 'REJECTED']: logger.warning(f"Trade {trade_id} status: {status}") # Handle according to your business logic, e.g., mark as closed or retry trade.status = status.lower() continue # Skip further processing for this trade _debug_logger.debug(f"Checking if trade.status == 'inactive': {trade.status == 'inactive'}") if trade.status == 'inactive': _debug_logger.debug(f"Trade {trade_id} is inactive, skipping") logger.error(f"Trades:update() - inactive trade encountered: {trade_id}") continue # Skip processing for inactive trades _debug_logger.debug(f"Calling trade.update({current_price})") trade.update(current_price) trade_status = trade.status _debug_logger.debug(f"After trade.update(), trade_status={trade_status}, trade.stats={trade.stats}") if trade_status in ['updated', 'filled', 'part-filled']: update_data = { 'status': trade_status, 'id': trade.unique_id, 'pl': trade.stats.get('profit', 0.0), 'pl_pct': trade.stats.get('profit_pct', 0.0) } r_update.append(update_data) _debug_logger.debug(f"Appended update_data: {update_data}") logger.info(f"Trade {trade_id} updated: price={current_price}, P/L={update_data['pl']:.2f} ({update_data['pl_pct']:.2f}%)") else: _debug_logger.debug(f"trade_status '{trade_status}' not in update list, appending minimal data") r_update.append({'id': trade.unique_id, 'status': trade_status}) _debug_logger.debug(f"=== Trades.update() returning: {r_update} ===") return r_update def update_prices_only(self, price_updates: dict[str, float]) -> list[dict[str, Any]]: """ Update current prices and P/L for active trades. This method ONLY updates prices and P/L calculations. It does NOT poll brokers for order status - that happens in strategy_execution_loop via ManualTradingBrokerManager. :param price_updates: Dictionary mapping (exchange:symbol) or symbol to prices. :return: List of dictionaries containing updated trade data. """ r_update = [] for trade_id, trade in list(self.active_trades.items()): symbol = trade.symbol exchange = getattr(trade, 'exchange', None) or trade.target # Resolve price from updates exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None current_price = price_updates.get(exchange_key) if exchange_key else None # DEBUG: Log price resolution for live trades if trade.broker_kind == 'live': logger.info(f"[PRICE DEBUG] trade={trade_id[:8]}, exchange={exchange}, " f"exchange_key={exchange_key}, price_updates_keys={list(price_updates.keys())}, " f"found_price={current_price}") if current_price is None: current_price = price_updates.get(symbol) if current_price is None: # Try to find a matching symbol for price_key, price in price_updates.items(): price_symbol = price_key.split(':')[-1] if ':' in price_key else price_key norm_trade = symbol.upper().replace('/', '') norm_price = price_symbol.upper().replace('/', '') if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'): current_price = price break if current_price is None: continue # No price available for this trade # Skip inactive or closed trades if trade.status in ['inactive', 'closed']: continue # Update P/L values (no broker polling) trade.update(current_price) trade_status = trade.status if trade_status in ['updated', 'filled', 'part-filled']: # Compute broker_key for frontend position matching broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}" update_data = { 'status': trade_status, 'id': trade.unique_id, 'symbol': trade.symbol, 'broker_key': broker_key, 'pl': trade.stats.get('profit', 0.0), 'pl_pct': trade.stats.get('profit_pct', 0.0), 'current_price': current_price } r_update.append(update_data) else: broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}" r_update.append({'id': trade.unique_id, 'status': trade_status, 'symbol': trade.symbol, 'broker_key': broker_key}) return r_update def close_trade(self, trade_id: str, current_price: float = None) -> dict: """ Closes a specific trade by settling it. :param trade_id: The unique ID of the trade. :param current_price: Optional current price (used for paper trades). :return: Dict with success status and trade info. """ trade = self.get_trade_by_id(trade_id) if not trade: logger.error(f"close_trade(): Trade ID {trade_id} not found.") return {"success": False, "message": f"Trade {trade_id} not found."} if trade.status == 'closed': logger.warning(f"close_trade(): Trade ID {trade_id} is already closed.") return {"success": False, "message": f"Trade {trade_id} is already closed."} try: # Get current price if current_price is None: if trade.is_paper: # For paper trades without a price, use the last known current price current_price = trade.stats.get('current_price', trade.order_price) elif self.exchange_interface: current_price = self.exchange_interface.get_price(trade.symbol) else: current_price = trade.stats.get('current_price', trade.order_price) # Settle the trade trade.settle(qty=trade.base_order_qty, price=current_price) # Calculate final P/L final_pl = trade.stats.get('profit', 0.0) final_pl_pct = trade.stats.get('profit_pct', 0.0) # Move from active to settled if trade.status == 'closed': del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 # Update database - either delete or mark as closed if self.data_cache: self._save_trade(trade) logger.info(f"Trade {trade_id} closed. P/L: {final_pl:.2f} ({final_pl_pct:.2f}%)") return { "success": True, "message": "Trade closed successfully.", "trade_id": trade_id, "final_pl": final_pl, "final_pl_pct": final_pl_pct, "settled_price": current_price } else: # Partial settlement self._save_trade(trade) logger.info(f"Trade {trade_id} partially settled.") return { "success": True, "message": "Trade partially settled.", "trade_id": trade_id } except Exception as e: logger.error(f"Error closing trade '{trade_id}': {e}", exc_info=True) return {"success": False, "message": f"Error closing trade: {str(e)}"} def close_position(self, user_id: int, symbol: str, broker_key: str) -> dict: """ Close filled exposure for a symbol (position-first operation). This only affects filled/part-filled trades: - Fully filled trades: settle entire qty with P/L calculation - Part-filled trades: settle filled portion, cancel unfilled remainder - Pending/open orders: NOT affected (use cancel_orders_for_symbol for those) :param user_id: The user ID. :param symbol: Trading symbol to close. :param broker_key: The broker key ('paper' or 'exchange_production'). :return: Dict with success status and details. """ if not self.manual_broker_manager: return {"success": False, "message": "Broker manager not configured"} result = self.manual_broker_manager.close_position(user_id, symbol, broker_key) if result.get('success'): close_status = str(result.get('status') or '').lower() close_price = result.get('filled_price', 0.0) trades_closed = 0 closed_trade_ids = [] # Live close-position requests may place a market order that is still pending/open. # Only settle/remove the local trade immediately if the broker reports it filled. if close_status not in ['', 'filled', 'partially_filled']: result['trades_closed'] = 0 result['closed_trades'] = [] result['message'] = result.get('message') or 'Close order submitted.' return result for trade_id, trade in list(self.active_trades.items()): # Check if this trade belongs to the same broker if broker_key == 'paper': matches_broker = trade.broker_kind == 'paper' else: trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}" matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key) if not (trade.symbol == symbol and matches_broker and trade.creator == user_id): continue # Handle based on trade status if trade.status == 'filled': # Fully filled - settle entire qty if close_price <= 0: close_price = self._get_close_price(trade) trade.settle(qty=trade.stats.get('qty_filled', trade.base_order_qty), price=close_price) trade.status = 'closed' trades_closed += 1 elif trade.status == 'part-filled': # Part filled - settle only the filled portion, cancel the rest if close_price <= 0: close_price = self._get_close_price(trade) filled_qty = trade.stats.get('qty_filled', 0) if filled_qty > 0: trade.settle(qty=filled_qty, price=close_price) # Cancel the unfilled remainder through broker if trade.broker_order_id: self.manual_broker_manager.cancel_order( user_id, trade.broker_order_id, broker_key ) trade.status = 'closed' unfilled = trade.base_order_qty - filled_qty logger.info(f"Part-filled trade {trade_id}: settled {filled_qty}, " f"cancelled remainder of {unfilled}") trades_closed += 1 elif trade.status in ['pending', 'open', 'unfilled']: # No fills - skip (these are just resting orders, not positions) continue else: # Already closed/cancelled - skip continue # Save and move to settled self._save_trade(trade) del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 closed_trade_ids.append(trade_id) final_pl = trade.stats.get('profit', 0.0) logger.info(f"Trade {trade_id} closed via position close. P/L: {final_pl:.2f}") result['trades_closed'] = trades_closed result['closed_trades'] = closed_trade_ids return result def _get_close_price(self, trade) -> float: """Get current price for settlement.""" if self.exchange_interface: try: return self.exchange_interface.get_price(trade.symbol) except Exception: pass return trade.stats.get('current_price', trade.order_price) def cancel_order(self, trade_id: str) -> dict: """ Cancel a specific unfilled order. :param trade_id: The unique ID of the trade/order to cancel. :return: Dict with success status and message. """ trade = self.get_trade_by_id(trade_id) if not trade: return {"success": False, "message": "Trade not found"} if trade.status not in ['open', 'pending', 'unfilled']: return {"success": False, "message": "Cannot cancel: order already filled or closed"} # If using broker manager, cancel through it if self.manual_broker_manager and trade.broker_order_id: broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}" result = self.manual_broker_manager.cancel_order( trade.creator, trade.broker_order_id, broker_key ) if not result.get('success'): return result trade.status = 'cancelled' self._save_trade(trade) # Move from active trades to settled trades (so it appears in history) if trade_id in self.active_trades: del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 logger.info(f"Order {trade_id} cancelled") return {"success": True, "message": "Order cancelled"} def cancel_orders_for_symbol(self, user_id: int, symbol: str, broker_key: str) -> dict: """ Cancel all open/pending orders for a symbol. This is a separate action from close_position() - user must explicitly choose this. Does NOT affect filled positions. :param user_id: The user ID. :param symbol: Trading symbol. :param broker_key: The broker key ('paper' or 'exchange_mode'). :return: Dict with success status, message, and count. """ cancelled = 0 errors = [] for trade_id, trade in list(self.active_trades.items()): # Check broker match if broker_key == 'paper': matches_broker = trade.broker_kind == 'paper' else: trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}" matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key) if not (trade.symbol == symbol and matches_broker and trade.creator == user_id): continue # Only cancel unfilled orders if trade.status not in ['pending', 'open', 'unfilled']: continue result = self.cancel_order(trade_id) if result.get('success'): cancelled += 1 else: errors.append(f"{trade_id}: {result.get('message')}") if errors: return { "success": False, "message": f"Cancelled {cancelled}, errors: {'; '.join(errors)}", "count": cancelled } return {"success": True, "message": f"Cancelled {cancelled} orders", "count": cancelled} def reduce_trade(self, user_id: int, trade_id: str, qty: float) -> float | None: """ Reduces the position of a trade. :param user_id: ID of the user. :param trade_id: The unique ID of the trade. :param qty: The quantity to reduce the trade by. :return: The remaining quantity after reduction or None if unsuccessful. """ trade = self.get_trade_by_id(trade_id) if not trade: logger.error(f"reduce_trade(): Trade ID {trade_id} not found.") return None if trade.status == 'closed': logger.warning(f"reduce_trade(): Cannot reduce a closed trade {trade_id}.") return None if trade.status in ['inactive', 'unfilled']: if trade.status == 'unfilled' and trade.order: try: self.exchange_interface.cancel_order(symbol=trade.symbol, orderId=trade.order.orderId) logger.info(f"Cancelled unfilled order {trade.unique_id} for trade {trade_id}.") except Exception as e: logger.error(f"Failed to cancel order {trade.unique_id} for trade {trade_id}: {e}", exc_info=True) return None # Reduce the order quantity if qty > trade.base_order_qty: qty = trade.base_order_qty trade.base_order_qty -= qty trade.stats['opening_value'] = trade.base_order_qty * trade.stats['opening_price'] trade.update_values(trade.stats['opening_price']) logger.info(f"Trade {trade_id} reduced by {qty}. New quantity: {trade.base_order_qty}") # If trade was unfilled, attempt to place a new order with the reduced quantity if trade.status == 'unfilled': try: status, msg = self.place_order(trade, user_name=self._get_user_name(user_id)) if status != 'success': logger.error(f"Failed to place reduced order for trade {trade_id}: {msg}") return None except Exception as e: logger.error(f"Exception while placing reduced order for trade {trade_id}: {e}", exc_info=True) return None return trade.base_order_qty # Settling more than owned is not allowed if qty > trade.stats.get('qty_filled', 0.0): qty = trade.stats.get('qty_filled', 0.0) try: current_price = self.exchange_interface.get_price(trade.symbol) trade.settle(qty=qty, price=current_price) if trade.status == 'closed': del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 logger.info(f"Trade {trade_id} has been closed after reduction.") return 0.0 else: left = trade.stats['qty_filled'] - trade.stats['qty_settled'] logger.info(f"Trade {trade_id} partially settled. Remaining quantity: {left}") return float(f"{left:.3f}") except Exception as e: logger.error(f"Error settling trade '{trade_id}': {e}", exc_info=True) return None def find_trade_by_broker_order_id(self, broker_order_id: str) -> Trade | None: """ Find a trade by its broker order ID. :param broker_order_id: The broker order ID to search for. :return: Trade object if found, None otherwise. """ for trade in self.active_trades.values(): if trade.broker_order_id == broker_order_id: return trade return None def recover_brokers(self) -> int: """ Recover brokers for broker-managed trades after restart. This should be called after manual_broker_manager is wired up. It ensures that persisted broker-managed trades have their brokers recreated so they can be polled and tracked. :return: Number of brokers recovered. """ if not self.manual_broker_manager: logger.debug("No broker manager configured, skipping broker recovery") return 0 # Get trades that have broker_order_id (broker-managed) broker_trades = [ trade for trade in self.active_trades.values() if trade.broker_order_id ] if not broker_trades: return 0 # Use users to get username from user_id def get_username(user_id): return self._get_user_name(user_id) recovered = self.manual_broker_manager.recover_brokers_for_trades( trades=broker_trades, get_username_func=get_username ) logger.info(f"Recovered {recovered} brokers for {len(broker_trades)} broker-managed trades") return recovered