From d3bbb36dc248e9b55bf8f0c3bac38ae50f751e38 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 12 Mar 2026 12:15:56 -0300 Subject: [PATCH] Fix live trade P/L bugs, improve UI refresh, add balance toggle Key fixes: - Add price validation to detect/correct doubled filled_price from broker - Emit position_closed socket event to refresh UI after closing positions - Fix statistics not updating when positions are closed - Filter out zero-size positions from display - Add USD/USDT toggle for balance display (click to switch) Trade system improvements: - Refactor Trade class P/L calculation with realized/unrealized tracking - Add settle() method for proper position closing - Improve trade_filled() to use current filled qty for averaging - Add fallback to exchange balances when broker balance unavailable Paper broker enhancements: - Add exchange-qualified price storage for multi-exchange support - Add price_source tracking for positions - Improve state persistence Tests: - Add comprehensive tests for trade P/L calculations - Add tests for paper broker price source tracking Co-Authored-By: Claude Opus 4.5 --- src/BrighterTrades.py | 6 +- src/Exchange.py | 8 +- src/ExchangeInterface.py | 3 + src/app.py | 184 +++++++++++++++++++------ src/brokers/base_broker.py | 6 + src/brokers/live_broker.py | 18 ++- src/brokers/paper_broker.py | 134 ++++++++++++++---- src/candles.py | 42 ++++-- src/manual_trading_broker.py | 94 +++++++++++-- src/static/charts.js | 105 +++++++++++++- src/static/communication.js | 77 +++++++++-- src/static/trade.js | 212 +++++++++++++++++++++++++---- src/templates/trading_hud.html | 21 +++ src/trade.py | 242 ++++++++++++++++++++++++++++----- tests/test_brokers.py | 81 +++++++++++ tests/test_trade.py | 234 +++++++++++++++++++++++++++++++ tests/test_trade2.py | 7 +- 17 files changed, 1304 insertions(+), 170 deletions(-) diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 7c23be0..ffdafe0 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -1690,12 +1690,14 @@ class BrighterTrades: if trade.status in ['pending', 'open', 'unfilled']: # Cancel the unfilled order result = self.trades.cancel_order(str(trade_id)) - reply_type = "order_cancelled" if result.get('success') else "trade_error" else: # Close the position for this trade's symbol broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}" result = self.trades.close_position(trade.creator, trade.symbol, broker_key) - reply_type = "position_closed" if result.get('success') else "trade_error" + + # Always include trade_id and use consistent event name for frontend + result['trade_id'] = str(trade_id) + reply_type = "trade_closed" if result.get('success') else "trade_error" return standard_reply(reply_type, result) diff --git a/src/Exchange.py b/src/Exchange.py index 7063fbc..a43c9db 100644 --- a/src/Exchange.py +++ b/src/Exchange.py @@ -85,7 +85,13 @@ class Exchange: client.set_sandbox_mode(True) logger.info(f"Sandbox mode enabled for {self.exchange_id}") except Exception as e: - logger.warning(f"Could not enable sandbox mode for {self.exchange_id}: {e}") + # CRITICAL: Do NOT continue with production if testnet was requested + # This prevents users from accidentally trading real money + logger.error(f"TESTNET UNAVAILABLE: {self.exchange_id} does not support sandbox mode: {e}") + raise ValueError( + f"Testnet/sandbox mode is not available for {self.exchange_id}. " + f"Please use paper trading mode instead, or trade on production with caution." + ) return client diff --git a/src/ExchangeInterface.py b/src/ExchangeInterface.py index 7961b3c..8281987 100644 --- a/src/ExchangeInterface.py +++ b/src/ExchangeInterface.py @@ -126,6 +126,9 @@ class ExchangeInterface: pass # No existing entry, that's fine # Create new exchange with explicit testnet setting + if not exchange_name: + logger.error("Cannot create exchange: exchange_name is required") + return False logger.info(f"Creating {exchange_name} for {user_name} with testnet={testnet}") exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(), testnet=testnet) diff --git a/src/app.py b/src/app.py index e58ce69..28d38a1 100644 --- a/src/app.py +++ b/src/app.py @@ -186,30 +186,35 @@ def strategy_execution_loop(): # This is the only place where brokers are polled for order fills if brighter_trades.manual_broker_manager: try: - # Collect prices for broker updates - # Paper trades use symbol-only keys (single synthetic market) - # Live trades use exchange:symbol keys + # Collect prices for broker updates from positions/orders (not Trade.exchange) broker_price_updates = {} - for trade in brighter_trades.trades.active_trades.values(): - if trade.broker_order_id: # Only broker-managed trades - try: - is_paper = trade.broker_kind == 'paper' - exchange = getattr(trade, 'exchange', None) or trade.target - if is_paper: - # Paper trades: single synthetic market, use first available exchange - price = brighter_trades.exchanges.get_price(trade.symbol) - if price: - # Paper uses symbol-only key - broker_price_updates[trade.symbol] = price - else: - # Live trades: use specific exchange + # Get required price feeds from paper broker positions/orders + paper_user_ids = brighter_trades.manual_broker_manager.get_active_paper_user_ids() + for user_id in paper_user_ids: + feeds = brighter_trades.manual_broker_manager.get_required_price_feeds(user_id) + for exchange, symbol in feeds: + if exchange: + # Exchange-qualified: fetch from specific exchange + price = brighter_trades.exchanges.get_price(symbol, exchange) + if price and price > 0: + broker_price_updates[f"{exchange.lower()}:{symbol}"] = price + else: + # No exchange: use default price source + price = brighter_trades.exchanges.get_price(symbol) + if price and price > 0: + broker_price_updates[symbol] = price + + # Also collect for live trades (unchanged logic) + for trade in brighter_trades.trades.active_trades.values(): + if trade.broker_order_id and trade.broker_kind == 'live': + try: + exchange = getattr(trade, 'exchange', None) or trade.target + if exchange: price = brighter_trades.exchanges.get_price(trade.symbol, exchange) - if price: - # Live uses exchange:symbol key + if price and price > 0: price_key = f"{exchange.lower()}:{trade.symbol}" broker_price_updates[price_key] = price - # Also add symbol-only as fallback broker_price_updates[trade.symbol] = price except Exception: pass @@ -221,34 +226,26 @@ def strategy_execution_loop(): event_type = event.get('type', 'fill') if event_type == 'sltp_triggered': - # SL/TP triggered - find and settle related trades + # SL/TP triggered - reconcile matching paper trades only. symbol = event.get('symbol') trigger_price = event.get('trigger_price', 0) user_id = event.get('user_id') - - # Find ALL matching paper trades for this symbol and settle them - trades_to_settle = [] - for trade in list(brighter_trades.trades.active_trades.values()): - if trade.symbol == symbol and (trade.is_paper or trade.broker_kind == 'paper'): - trades_to_settle.append(trade) - user_id = user_id or trade.creator - - # Settle each matching trade - for trade in trades_to_settle: - # Settle the trade at the trigger price - trade.settle(qty=trade.stats.get('qty_filled', trade.base_order_qty), price=trigger_price) - # Move from active to settled - if trade.unique_id in brighter_trades.trades.active_trades: - del brighter_trades.trades.active_trades[trade.unique_id] - brighter_trades.trades.settled_trades[trade.unique_id] = trade - brighter_trades.trades._save_trade(trade) - _loop_debug.debug(f"Settled trade {trade.unique_id} via SL/TP at {trigger_price}") + trade_ids = [] + if user_id and trigger_price: + trade_ids = brighter_trades.trades.settle_broker_closed_position( + user_id=user_id, + symbol=symbol, + broker_key='paper', + close_price=trigger_price + ) + _loop_debug.debug( + f"Reconciled SL/TP close for user={user_id} symbol={symbol}: {trade_ids}" + ) # Notify user if user_id: user_name = brighter_trades.users.get_username(user_id=user_id) if user_name: - trade_ids = [t.unique_id for t in trades_to_settle] socketio.emit('message', { 'reply': 'sltp_triggered', 'data': sanitize_for_json({ @@ -295,6 +292,52 @@ def strategy_execution_loop(): }) }, room=user_name) _loop_debug.debug(f"Emitted order_filled to room={user_name}") + else: + user_id = event.get('user_id') + broker_key = event.get('broker_key') + symbol = event.get('symbol') + side = str(event.get('side') or '').lower() + filled_price = event.get('filled_price', event.get('price', 0)) + + if user_id: + user_name = brighter_trades.users.get_username(user_id=user_id) + + # Always emit order_filled so broker-backed panels refresh even when + # the fill belongs to a close order that has no local opening trade ID. + if user_name: + socketio.emit('message', { + 'reply': 'order_filled', + 'data': sanitize_for_json({ + 'order_id': event.get('order_id'), + 'trade_id': None, + 'symbol': symbol, + 'side': event.get('side'), + 'filled_qty': event.get('filled_qty', event.get('size', 0)), + 'filled_price': filled_price, + 'status': 'filled', + 'broker_kind': event.get('broker_kind'), + 'broker_key': broker_key + }) + }, room=user_name) + + # A live sell fill without a matching opening trade is typically a + # broker-initiated close order from the position-close flow. + if side == 'sell' and broker_key and symbol and filled_price: + settled_ids = brighter_trades.trades.settle_broker_closed_position( + user_id=user_id, + symbol=symbol, + broker_key=broker_key, + close_price=filled_price + ) + if settled_ids and user_name: + socketio.emit('message', { + 'reply': 'position_closed', + 'data': sanitize_for_json({ + 'symbol': symbol, + 'broker_key': broker_key, + 'closed_trades': settled_ids + }) + }, room=user_name) except Exception as e: _loop_debug.debug(f"Exception in broker update: {e}") @@ -317,6 +360,9 @@ def strategy_execution_loop(): exchange_symbols.add((None, trade.symbol)) _loop_debug.debug(f"Exchange+symbols to fetch: {exchange_symbols}") + # Log at INFO level for live trades debugging + if any(ex and ex.lower() not in ['paper', 'test_exchange'] for ex, _ in exchange_symbols): + logger.info(f"[PRICE FETCH] exchange_symbols to fetch: {exchange_symbols}") price_updates = {} for exchange, symbol in exchange_symbols: try: @@ -1251,6 +1297,20 @@ def close_manual_position(symbol): try: result = brighter_trades.trades.close_position(user_id, symbol, broker_key) + + # Emit position_closed event to refresh UI + if result.get('success'): + user_name = brighter_trades.users.get_username(user_id=user_id) + if user_name: + socketio.emit('message', { + 'reply': 'position_closed', + 'data': { + 'symbol': symbol, + 'broker_key': broker_key, + 'closed_trades': result.get('closed_trades', []) + } + }, room=user_name) + return jsonify(result) except Exception as e: logger.error(f"Error closing position {symbol}: {e}", exc_info=True) @@ -1265,15 +1325,44 @@ def get_manual_balance(): return jsonify({'success': False, 'message': 'Not authenticated'}), 401 broker_key = request.args.get('broker_key', 'paper') + chart_exchange = (request.args.get('exchange') or '').strip().lower() or None try: total = brighter_trades.manual_broker_manager.get_broker_balance(user_id, broker_key) available = brighter_trades.manual_broker_manager.get_available_balance(user_id, broker_key) + + # Fallback for live mode: if manual broker balance is unavailable/stale, use the + # cached direct exchange balances for the exchange currently shown in the chart. + fallback_source = None + if broker_key != 'paper' and chart_exchange and total == 0.0 and available == 0.0: + user_name = brighter_trades.users.get_username(user_id=user_id) + exchange_balances = brighter_trades.exchanges.get_exchange_balances(user_name, chart_exchange) + + quote_balance = 0.0 + if exchange_balances is not None: + for asset in ('USDT', 'USD', 'BUSD', 'USDC'): + match = next( + ( + bal for bal in exchange_balances + if str(bal.get('asset', '')).upper() == asset + ), + None + ) + if match: + quote_balance = float(match.get('balance', 0.0) or 0.0) + break + + if quote_balance > 0: + total = quote_balance + available = quote_balance + fallback_source = 'exchange' + return jsonify({ 'success': True, 'total': total, 'available': available, - 'broker_key': broker_key + 'broker_key': broker_key, + 'source': fallback_source or 'broker' }) except Exception as e: logger.error(f"Error getting balance: {e}", exc_info=True) @@ -1315,6 +1404,21 @@ def get_trade_history(): return jsonify({'success': False, 'message': str(e)}), 500 +@app.route('/api/manual/paper/reset', methods=['POST']) +def reset_paper_balance(): + """Reset the paper trading broker to initial state.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + try: + result = brighter_trades.manual_broker_manager.reset_paper_broker(user_id) + return jsonify(result) + except Exception as e: + logger.error(f"Error resetting paper broker: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + # ============================================================================= # External Sources API Routes # ============================================================================= diff --git a/src/brokers/base_broker.py b/src/brokers/base_broker.py index 74c6a1c..93bdc8f 100644 --- a/src/brokers/base_broker.py +++ b/src/brokers/base_broker.py @@ -62,6 +62,8 @@ class Position: current_price: float unrealized_pnl: float realized_pnl: float = 0.0 + entry_commission: float = 0.0 # Fee paid on entry, included in P&L + price_source: Optional[str] = None # Exchange to use for price lookups (e.g., 'kucoin') def to_dict(self) -> Dict[str, Any]: """Convert position to dictionary for persistence.""" @@ -72,6 +74,8 @@ class Position: 'current_price': self.current_price, 'unrealized_pnl': self.unrealized_pnl, 'realized_pnl': self.realized_pnl, + 'entry_commission': self.entry_commission, + 'price_source': self.price_source, } @classmethod @@ -84,6 +88,8 @@ class Position: current_price=data['current_price'], unrealized_pnl=data['unrealized_pnl'], realized_pnl=data.get('realized_pnl', 0.0), + entry_commission=data.get('entry_commission', 0.0), + price_source=data.get('price_source'), ) diff --git a/src/brokers/live_broker.py b/src/brokers/live_broker.py index e29c3cd..7f05816 100644 --- a/src/brokers/live_broker.py +++ b/src/brokers/live_broker.py @@ -425,8 +425,8 @@ class LiveBroker(BaseBroker): # Create local order tracking symbol = ex_order['symbol'] - side = OrderSide.BUY if ex_order['side'].lower() == 'buy' else OrderSide.SELL - order_type = OrderType.LIMIT if ex_order.get('type', 'limit').lower() == 'limit' else OrderType.MARKET + side = OrderSide.BUY if (ex_order.get('side') or 'buy').lower() == 'buy' else OrderSide.SELL + order_type = OrderType.LIMIT if (ex_order.get('type') or 'limit').lower() == 'limit' else OrderType.MARKET size = float(ex_order['quantity']) price = float(ex_order.get('price', 0) or 0) @@ -640,13 +640,20 @@ class LiveBroker(BaseBroker): ) # Parse order status from exchange response - ex_status = exchange_order.get('status', 'open').lower() + # Use 'or' to handle case where status key exists but value is None + ex_status = (exchange_order.get('status') or 'open').lower() if ex_status == 'closed' or ex_status == 'filled': order.status = OrderStatus.FILLED order.filled_qty = float(exchange_order.get('filled', size)) - order.filled_price = float(exchange_order.get('average', price or 0)) + raw_avg = exchange_order.get('average') + order.filled_price = float(raw_avg if raw_avg is not None else (price or 0)) order.filled_at = datetime.now(timezone.utc) + # DEBUG: Log exchange response for price validation + logger.info(f"[FILL DEBUG] Exchange response: average={raw_avg}, price={price}, " + f"filled={order.filled_qty}, cost={exchange_order.get('cost')}, " + f"final filled_price={order.filled_price}") + # Calculate commission fee = exchange_order.get('fee', {}) if fee: @@ -752,7 +759,8 @@ class LiveBroker(BaseBroker): def _update_order_from_exchange(self, order: LiveOrder, ex_order: Dict[str, Any]): """Update local order with exchange data.""" - ex_status = ex_order.get('status', 'open').lower() + # Use 'or' to handle case where status key exists but value is None + ex_status = (ex_order.get('status') or 'open').lower() if ex_status == 'closed' or ex_status == 'filled': order.status = OrderStatus.FILLED diff --git a/src/brokers/paper_broker.py b/src/brokers/paper_broker.py index 62a0de8..ec1036b 100644 --- a/src/brokers/paper_broker.py +++ b/src/brokers/paper_broker.py @@ -32,7 +32,8 @@ class PaperOrder: price: Optional[float] = None, stop_loss: Optional[float] = None, take_profit: Optional[float] = None, - time_in_force: str = 'GTC' + time_in_force: str = 'GTC', + exchange: Optional[str] = None ): self.order_id = order_id self.symbol = symbol @@ -43,6 +44,7 @@ class PaperOrder: self.stop_loss = stop_loss self.take_profit = take_profit self.time_in_force = time_in_force + self.exchange = exchange # Exchange for price feed (e.g., 'kucoin') self.status = OrderStatus.PENDING self.filled_qty = 0.0 self.filled_price = 0.0 @@ -68,7 +70,8 @@ class PaperOrder: 'commission': self.commission, 'locked_funds': self.locked_funds, 'created_at': self.created_at.isoformat(), - 'filled_at': self.filled_at.isoformat() if self.filled_at else None + 'filled_at': self.filled_at.isoformat() if self.filled_at else None, + 'exchange': self.exchange, } @@ -123,10 +126,21 @@ class PaperBroker(BaseBroker): """Set the price provider callable.""" self._price_provider = provider - def update_price(self, symbol: str, price: float): - """Update the current price for a symbol.""" + def update_price(self, symbol: str, price: float, exchange: Optional[str] = None): + """Update price, optionally qualified by exchange.""" + if exchange: + # Store exchange-qualified price + self._current_prices[f"{exchange}:{symbol}"] = price + # Always store symbol-only for backward compat self._current_prices[symbol] = price + @staticmethod + def _should_fill_limit_order(side: OrderSide, current_price: float, limit_price: float) -> bool: + """Return True when the current price crosses a limit order's fill threshold.""" + if side == OrderSide.BUY: + return current_price <= limit_price + return current_price >= limit_price + def place_order( self, symbol: str, @@ -136,13 +150,14 @@ class PaperBroker(BaseBroker): price: Optional[float] = None, stop_loss: Optional[float] = None, take_profit: Optional[float] = None, - time_in_force: str = 'GTC' + time_in_force: str = 'GTC', + exchange: Optional[str] = None ) -> OrderResult: """Place a paper trading order.""" order_id = str(uuid.uuid4())[:8] - # Validate order - current_price = self.get_current_price(symbol) + # Validate order - use exchange-aware price lookup + current_price = self.get_current_price(symbol, exchange) if current_price <= 0: return OrderResult( success=False, @@ -189,7 +204,8 @@ class PaperBroker(BaseBroker): price=price, stop_loss=stop_loss, take_profit=take_profit, - time_in_force=time_in_force + time_in_force=time_in_force, + exchange=exchange ) # For market orders, fill immediately @@ -198,6 +214,39 @@ class PaperBroker(BaseBroker): self._fill_order(order, fill_price) logger.info(f"PaperBroker: Market order filled: {side.value} {size} {symbol} @ {fill_price:.4f}") else: + is_marketable = self._should_fill_limit_order(side, current_price, execution_price) + + # IOC/FOK must fill immediately or fail immediately. + if time_in_force in ['IOC', 'FOK']: + if is_marketable: + self._fill_order(order, execution_price) + logger.info( + f"PaperBroker: {time_in_force} limit order filled immediately: " + f"{side.value} {size} {symbol} @ {execution_price}" + ) + else: + order.status = OrderStatus.CANCELLED if time_in_force == 'IOC' else OrderStatus.EXPIRED + self._orders[order_id] = order + logger.info( + f"PaperBroker: {time_in_force} limit order not fillable immediately: " + f"{side.value} {size} {symbol} @ {price}" + ) + return OrderResult( + success=False, + order_id=order_id, + status=order.status, + message=f"{time_in_force} limit order could not be filled immediately" + ) + return OrderResult( + success=True, + order_id=order_id, + status=order.status, + filled_qty=order.filled_qty, + filled_price=order.filled_price, + commission=order.commission, + message=f"Order {order_id} filled" + ) + # Store pending order order.status = OrderStatus.OPEN self._orders[order_id] = order @@ -253,13 +302,19 @@ class PaperBroker(BaseBroker): new_entry = (existing.entry_price * existing.size + fill_price * order.size) / new_size existing.size = new_size existing.entry_price = new_entry + existing.entry_commission += order.commission # Accumulate entry fees + # Most recent fill wins: update price source + if order.exchange: + existing.price_source = order.exchange else: self._positions[order.symbol] = Position( symbol=order.symbol, size=order.size, entry_price=fill_price, current_price=fill_price, - unrealized_pnl=0.0 + unrealized_pnl=-order.commission, # Start with entry fee as loss + entry_commission=order.commission, + price_source=order.exchange ) # Record SL/TP for this position (if set on order) @@ -280,10 +335,22 @@ class PaperBroker(BaseBroker): if order.symbol in self._positions: position = self._positions[order.symbol] order.entry_price = position.entry_price # Store for fee calculation - realized_pnl = (fill_price - position.entry_price) * order.size - order.commission + + # Calculate proportional entry commission for partial closes + if position.size > 0: + proportion = order.size / position.size + proportional_entry_commission = position.entry_commission * proportion + else: + proportional_entry_commission = position.entry_commission + + # Realized P&L includes both entry and exit fees + realized_pnl = (fill_price - position.entry_price) * order.size - proportional_entry_commission - order.commission position.realized_pnl += realized_pnl position.size -= order.size + # Reduce remaining entry commission proportionally + position.entry_commission -= proportional_entry_commission + # Track profitability for fee calculation order.realized_pnl = realized_pnl order.is_profitable = realized_pnl > 0 @@ -363,15 +430,30 @@ class PaperBroker(BaseBroker): """Get all open positions.""" return list(self._positions.values()) - def get_current_price(self, symbol: str) -> float: - """Get current price for a symbol.""" - # First check cache + def get_current_price(self, symbol: str, exchange: Optional[str] = None) -> float: + """Get price, preferring exchange-qualified if available.""" + # First try exchange-qualified lookup + if exchange: + key = f"{exchange}:{symbol}" + if key in self._current_prices: + return self._current_prices[key] + + # Fall back to symbol-only lookup if symbol in self._current_prices: return self._current_prices[symbol] # Then try price provider if self._price_provider: try: + # Try exchange-qualified first, then symbol-only + if exchange: + try: + price = self._price_provider(f"{exchange}:{symbol}") + if price > 0: + self._current_prices[f"{exchange}:{symbol}"] = price + return price + except Exception: + pass price = self._price_provider(symbol) self._current_prices[symbol] = price return price @@ -388,12 +470,14 @@ class PaperBroker(BaseBroker): """ events = [] - # Update position P&L + # Update position P&L (includes entry commission for accurate fee reflection) for symbol, position in self._positions.items(): - current_price = self.get_current_price(symbol) + # Use position's price_source for exchange-aware price lookup + current_price = self.get_current_price(symbol, position.price_source) if current_price > 0: position.current_price = current_price - position.unrealized_pnl = (current_price - position.entry_price) * position.size + # P&L = price movement - entry fees already paid + position.unrealized_pnl = (current_price - position.entry_price) * position.size - position.entry_commission # Evaluate SL/TP for all tracked positions for symbol, sltp in list(self._position_sltp.items()): @@ -402,7 +486,8 @@ class PaperBroker(BaseBroker): continue position = self._positions[symbol] - current_price = self.get_current_price(symbol) + # Use position's price_source for exchange-aware price lookup + current_price = self.get_current_price(symbol, position.price_source) if position.size <= 0 or current_price <= 0: del self._position_sltp[symbol] @@ -444,17 +529,15 @@ class PaperBroker(BaseBroker): if order.status != OrderStatus.OPEN: continue - current_price = self.get_current_price(order.symbol) + # Use order's exchange for price lookup + current_price = self.get_current_price(order.symbol, order.exchange) if current_price <= 0: continue - should_fill = False - if order.order_type == OrderType.LIMIT: - if order.side == OrderSide.BUY and current_price <= order.price: - should_fill = True - elif order.side == OrderSide.SELL and current_price >= order.price: - should_fill = True + should_fill = self._should_fill_limit_order(order.side, current_price, order.price) + else: + should_fill = False if should_fill: # Release locked funds first (for buy orders) @@ -497,6 +580,7 @@ class PaperBroker(BaseBroker): self._positions.clear() self._trade_history.clear() self._current_prices.clear() + self._position_sltp.clear() # Clear SL/TP state to prevent stale triggers logger.info(f"PaperBroker: Reset with balance {self.initial_balance}") # ==================== State Persistence Methods ==================== @@ -609,6 +693,8 @@ class PaperBroker(BaseBroker): price=order_dict.get('price'), stop_loss=order_dict.get('stop_loss'), take_profit=order_dict.get('take_profit'), + time_in_force=order_dict.get('time_in_force', 'GTC'), + exchange=order_dict.get('exchange'), ) order.status = OrderStatus(order_dict['status']) order.filled_qty = order_dict.get('filled_qty', 0.0) diff --git a/src/candles.py b/src/candles.py index 9f00ae0..5ec4498 100644 --- a/src/candles.py +++ b/src/candles.py @@ -1,12 +1,11 @@ import datetime as dt -import logging as log +import logging import pytz from shared_utilities import timeframe_to_minutes, ts_of_n_minutes_ago - -# log.basicConfig(level=log.ERROR) +logger = logging.getLogger(__name__) class Candles: def __init__(self, exchanges, users, datacache, config, edm_client=None): @@ -24,6 +23,8 @@ class Candles: # Cache the last received candle to detect duplicates self.cached_last_candle = None + # Avoid repeating the same expected EDM cap warning on every refresh. + self._edm_cap_warned_scopes: set[tuple[str, str, str]] = set() # size_limit is the max number of lists of candle(ohlc) data allowed. self.data.create_cache(name='candles', cache_type='row', default_expiration=dt.timedelta(days=5), @@ -57,10 +58,21 @@ class Candles: # EDM API has a maximum limit of 1000 candles EDM_MAX_CANDLES = 1000 if num_candles > EDM_MAX_CANDLES: - log.warning(f'Requested {num_candles} candles, capping to EDM limit of {EDM_MAX_CANDLES}') + warning_scope = (asset, exchange, timeframe) + if warning_scope not in self._edm_cap_warned_scopes: + logger.warning( + "Requested %s candles for %s/%s/%s, capping to EDM limit of %s", + num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES + ) + self._edm_cap_warned_scopes.add(warning_scope) + else: + logger.debug( + "Requested %s candles for %s/%s/%s, capped to %s", + num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES + ) num_candles = EDM_MAX_CANDLES - log.info(f'[GET CANDLES] {asset} {exchange} {timeframe} limit={num_candles}') + logger.debug("Fetching candles from EDM: %s %s %s limit=%s", asset, exchange, timeframe, num_candles) if self.edm is None: raise RuntimeError("EDM client not initialized. Cannot fetch candle data.") @@ -76,10 +88,10 @@ class Candles: ) if candles.empty: - log.warning(f"No candles returned from EDM for {asset}/{timeframe}/{exchange}") + logger.warning("No candles returned from EDM for %s/%s/%s", asset, timeframe, exchange) return self.convert_candles(candles) - log.info(f"Fetched {len(candles)} candles from EDM for {asset}/{timeframe}/{exchange}") + logger.debug("Fetched %s candles from EDM for %s/%s/%s", len(candles), asset, timeframe, exchange) return self.convert_candles(candles[-num_candles:]) def set_new_candle(self, cdata: dict) -> bool: @@ -93,7 +105,7 @@ class Candles: """ # Update the cached last candle self.cached_last_candle = cdata - log.debug(f"Candle updated: {cdata.get('symbol', 'unknown')} @ {cdata.get('close', 0)}") + logger.debug("Candle updated: %s @ %s", cdata.get('symbol', 'unknown'), cdata.get('close', 0)) return True def set_cache(self, symbol=None, interval=None, exchange_name=None, user_name=None): """ @@ -110,24 +122,24 @@ class Candles: if not symbol: assert user_name is not None symbol = self.users.get_chart_view(user_name=user_name, prop='market') - log.info(f'set_candle_history(): No symbol provided. Using{symbol}') + logger.info('set_candle_history(): No symbol provided. Using %s', symbol) if not interval: assert user_name is not None interval = self.users.get_chart_view(user_name=user_name, prop='timeframe') - log.info(f'set_candle_history(): No timeframe provided. Using{interval}') + logger.info('set_candle_history(): No timeframe provided. Using %s', interval) if not exchange_name: assert user_name is not None exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name') # Log the completion to the console. - log.info('set_candle_history(): Loading candle data...') + logger.info('set_candle_history(): Loading candle data...') # Load candles from database _cdata = self.get_last_n_candles(num_candles=self.max_records, asset=symbol, timeframe=interval, exchange=exchange_name, user_name=user_name) # Log the completion to the console. - log.info('set_candle_history(): Candle data Loaded.') + logger.info('set_candle_history(): Candle data Loaded.') return @staticmethod @@ -213,17 +225,17 @@ class Candles: if not symbol: assert user_name is not None symbol = self.users.get_chart_view(user_name=user_name, prop='market') - log.info(f'get_candle_history(): No symbol provided. Using {symbol}') + logger.info('get_candle_history(): No symbol provided. Using %s', symbol) if not interval: assert user_name is not None interval = self.users.get_chart_view(user_name=user_name, prop='timeframe') - log.info(f'get_candle_history(): No timeframe provided. Using {interval}') + logger.info('get_candle_history(): No timeframe provided. Using %s', interval) if not exchange_name: assert user_name is not None exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name') - log.info(f'get_candle_history(): No exchange name provided. Using {exchange_name}') + logger.info('get_candle_history(): No exchange name provided. Using %s', exchange_name) candlesticks = self.get_last_n_candles(num_candles=num_records, asset=symbol, timeframe=interval, exchange=exchange_name, user_name=user_name) diff --git a/src/manual_trading_broker.py b/src/manual_trading_broker.py index 7aa58a1..6373473 100644 --- a/src/manual_trading_broker.py +++ b/src/manual_trading_broker.py @@ -101,6 +101,10 @@ class ManualTradingBrokerManager: :param user_name: Username for exchange lookup. :return: LiveBroker instance or None if not configured or mode conflict. """ + if not exchange_name: + logger.error("Cannot create live broker: exchange_name is required") + return None + # Use 'testnet'/'production' to match what's stored in trade.broker_mode requested_mode = 'testnet' if testnet else 'production' broker_key = f"{exchange_name}_{requested_mode}" @@ -213,16 +217,17 @@ class ManualTradingBrokerManager: """ Update current prices for all paper brokers. - :param price_updates: Dict mapping symbol to price. + :param price_updates: Dict mapping symbol or exchange:symbol to price. """ for broker in self._paper_brokers.values(): - for symbol, price in price_updates.items(): - # Handle exchange:symbol format - if ':' in symbol: - _, sym = symbol.split(':', 1) + for key, price in price_updates.items(): + if ':' in key: + # Exchange-qualified key (e.g., 'kucoin:BTC/USDT') + exchange, symbol = key.split(':', 1) + broker.update_price(symbol, price, exchange) else: - sym = symbol - broker.update_price(sym, price) + # Symbol-only key + broker.update_price(key, price) def update_all_brokers(self, price_updates: Dict[str, float]) -> List[Dict]: """ @@ -394,6 +399,7 @@ class ManualTradingBrokerManager: "success": result.success, "message": result.message, "order_id": result.order_id, + "status": getattr(result.status, 'value', result.status), "filled_qty": result.filled_qty, "filled_price": result.filled_price } @@ -466,16 +472,22 @@ class ManualTradingBrokerManager: logger.error(f"Error placing order: {e}") return OrderResult(success=False, message=str(e)) - def _get_broker(self, user_id: int, broker_key: str): + def _get_broker(self, user_id: int, broker_key: str, create_paper: bool = True): """ Get a broker by user_id and broker_key. :param user_id: The user ID. :param broker_key: 'paper' or 'exchange_mode' format. + :param create_paper: If True, create paper broker on-demand (loads saved state). :return: Broker instance or None. """ if broker_key == 'paper': - return self._paper_brokers.get(user_id) + if user_id in self._paper_brokers: + return self._paper_brokers[user_id] + # Create paper broker on-demand (this loads saved state) + if create_paper: + return self.get_paper_broker(user_id) + return None if user_id in self._live_brokers: return self._live_brokers[user_id].get(broker_key) @@ -495,6 +507,37 @@ class ManualTradingBrokerManager: return 0.0 return broker.get_balance() + def reset_paper_broker(self, user_id: int) -> Dict: + """ + Reset the paper broker for a user to initial state. + + Clears all positions, orders, and restores the initial balance. + + :param user_id: The user ID. + :return: Dict with success status and new balance. + """ + broker = self._paper_brokers.get(user_id) + if not broker: + # Create a fresh broker if one doesn't exist + broker = self.get_paper_broker(user_id) + + try: + broker.reset() + + # Save the reset state + state_id = f"manual_paper_{user_id}" + broker.save_state(state_id) + + logger.info(f"Reset paper broker for user {user_id}, balance: {broker.initial_balance}") + return { + "success": True, + "message": "Paper trading balance reset successfully", + "balance": broker.initial_balance + } + except Exception as e: + logger.error(f"Error resetting paper broker for user {user_id}: {e}") + return {"success": False, "message": str(e)} + def get_available_balance(self, user_id: int, broker_key: str) -> float: """ Get the available balance (not locked in orders). @@ -574,3 +617,36 @@ class ManualTradingBrokerManager: logger.error(f"Error recovering broker {broker_key} for user {user_id}: {e}") return recovered + + def get_active_paper_user_ids(self) -> List[int]: + """Return user IDs with active paper brokers.""" + return list(self._paper_brokers.keys()) + + def get_required_price_feeds(self, user_id: int) -> List[tuple]: + """ + Get (exchange, symbol) pairs needed for P&L updates. + + Derived from positions and open orders, NOT from Trade.exchange. + Returns list of (exchange, symbol) tuples where exchange may be None + for positions/orders without a specified price source. + + :param user_id: The user ID. + :return: List of (exchange, symbol) tuples. + """ + feeds = set() + broker = self._paper_brokers.get(user_id) + if broker: + # From positions + for pos in broker.get_all_positions(): + if pos.price_source: + feeds.add((pos.price_source, pos.symbol)) + else: + # Fallback: no exchange specified, use symbol only + feeds.add((None, pos.symbol)) + # From open orders + for order in broker.get_open_orders(): + exchange = order.get('exchange') + symbol = order.get('symbol') + if symbol: + feeds.add((exchange, symbol)) + return list(feeds) diff --git a/src/static/charts.js b/src/static/charts.js index 3443fc6..485f026 100644 --- a/src/static/charts.js +++ b/src/static/charts.js @@ -36,6 +36,7 @@ class Charts { this.candleSeries = this.chart_1.addSeries(LightweightCharts.CandlestickSeries); // Initialize the candlestick series if price_history is available + this.price_history = this._normalizeCandles(this.price_history); if (this.price_history && this.price_history.length > 0) { this.candleSeries.setData(this.price_history); console.log(`Candle series initialized with ${this.price_history.length} candles`); @@ -48,8 +49,110 @@ class Charts { } update_main_chart(new_candle){ + const normalizedCandle = this._normalizeCandle(new_candle); + if (!normalizedCandle) { + console.warn('Skipping invalid candle update:', new_candle); + return; + } + + const lastCandle = Array.isArray(this.price_history) && this.price_history.length > 0 + ? this.price_history[this.price_history.length - 1] + : null; + if (lastCandle && normalizedCandle.time < lastCandle.time) { + console.warn('Skipping stale candle update:', normalizedCandle, 'last:', lastCandle); + return; + } + // Update candlestick series - this.candleSeries.update(new_candle); + this.candleSeries.update(normalizedCandle); + + // Keep local price history aligned with the live chart series. + if (!Array.isArray(this.price_history)) { + this.price_history = []; + } + + const lastIndex = this.price_history.length - 1; + if (lastIndex >= 0 && this.price_history[lastIndex].time === normalizedCandle.time) { + this.price_history[lastIndex] = normalizedCandle; + } else if (lastIndex < 0 || this.price_history[lastIndex].time < normalizedCandle.time) { + this.price_history.push(normalizedCandle); + } + } + + _normalizeCandleTime(rawTime) { + if (rawTime === null || rawTime === undefined) { + return null; + } + + if (typeof rawTime === 'number' && Number.isFinite(rawTime)) { + return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime); + } + + if (typeof rawTime === 'string') { + const numericValue = Number(rawTime); + if (Number.isFinite(numericValue)) { + return this._normalizeCandleTime(numericValue); + } + + const parsedTime = Date.parse(rawTime); + if (!Number.isNaN(parsedTime)) { + return Math.floor(parsedTime / 1000); + } + return null; + } + + if (rawTime instanceof Date) { + return Math.floor(rawTime.getTime() / 1000); + } + + if (typeof rawTime === 'object') { + if ( + Object.prototype.hasOwnProperty.call(rawTime, 'year') && + Object.prototype.hasOwnProperty.call(rawTime, 'month') && + Object.prototype.hasOwnProperty.call(rawTime, 'day') + ) { + return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000); + } + + for (const key of ['timestamp', 'time', 'value', '$date']) { + if (Object.prototype.hasOwnProperty.call(rawTime, key)) { + return this._normalizeCandleTime(rawTime[key]); + } + } + } + + return null; + } + + _normalizeCandle(candle) { + if (!candle) { + return null; + } + + const time = this._normalizeCandleTime(candle.time); + if (time === null) { + return null; + } + + return { + ...candle, + time, + open: parseFloat(candle.open), + high: parseFloat(candle.high), + low: parseFloat(candle.low), + close: parseFloat(candle.close) + }; + } + + _normalizeCandles(candles) { + if (!Array.isArray(candles)) { + return []; + } + + return candles + .map(candle => this._normalizeCandle(candle)) + .filter(candle => candle !== null) + .sort((a, b) => a.time - b.time); } create_RSI_chart(){ diff --git a/src/static/communication.js b/src/static/communication.js index 0a4dd6a..953b716 100644 --- a/src/static/communication.js +++ b/src/static/communication.js @@ -216,6 +216,57 @@ class Comms { } } + /** + * Normalize incoming candle times to UTC seconds for lightweight-charts. + * EDM data occasionally arrives nested or as ISO strings. + * @param {*} rawTime + * @returns {number|null} + */ + _normalizeCandleTime(rawTime) { + if (rawTime === null || rawTime === undefined) { + return null; + } + + if (typeof rawTime === 'number' && Number.isFinite(rawTime)) { + return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime); + } + + if (typeof rawTime === 'string') { + const numericValue = Number(rawTime); + if (Number.isFinite(numericValue)) { + return this._normalizeCandleTime(numericValue); + } + + const parsedTime = Date.parse(rawTime); + if (!Number.isNaN(parsedTime)) { + return Math.floor(parsedTime / 1000); + } + return null; + } + + if (rawTime instanceof Date) { + return Math.floor(rawTime.getTime() / 1000); + } + + if (typeof rawTime === 'object') { + if ( + Object.prototype.hasOwnProperty.call(rawTime, 'year') && + Object.prototype.hasOwnProperty.call(rawTime, 'month') && + Object.prototype.hasOwnProperty.call(rawTime, 'day') + ) { + return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000); + } + + for (const key of ['timestamp', 'time', 'value', '$date']) { + if (Object.prototype.hasOwnProperty.call(rawTime, key)) { + return this._normalizeCandleTime(rawTime[key]); + } + } + } + + return null; + } + /* Callback declarations */ candleUpdate(newCandle) { @@ -345,14 +396,17 @@ class Comms { const candles = data.candles || []; // EDM already sends time in seconds, no conversion needed - return candles.map(c => ({ - time: c.time, - open: c.open, - high: c.high, - low: c.low, - close: c.close, - volume: c.volume - })); + return candles + .map(c => ({ + time: this._normalizeCandleTime(c.time), + open: parseFloat(c.open), + high: parseFloat(c.high), + low: parseFloat(c.low), + close: parseFloat(c.close), + volume: parseFloat(c.volume) + })) + .filter(c => c.time !== null) + .sort((a, b) => a.time - b.time); } /** @@ -571,8 +625,13 @@ class Comms { if (messageType === 'candle') { const candle = message.data; + const candleTime = this._normalizeCandleTime(candle.time); + if (candleTime === null) { + console.warn('Skipping candle with invalid time payload:', candle.time); + return; + } const newCandle = { - time: candle.time, // EDM sends time in seconds + time: candleTime, open: parseFloat(candle.open), high: parseFloat(candle.high), low: parseFloat(candle.low), diff --git a/src/static/trade.js b/src/static/trade.js index 8638b5f..cb5be5c 100644 --- a/src/static/trade.js +++ b/src/static/trade.js @@ -23,17 +23,18 @@ class TradeUIManager { this.sltpRow = null; this.onCloseTrade = null; - // Exchanges known to support testnet/sandbox mode + // Exchanges known to support testnet/sandbox mode in ccxt + // IMPORTANT: Only include exchanges with verified working sandbox URLs + // KuCoin does NOT have sandbox support - removed to prevent real trades! this.testnetSupportedExchanges = [ 'binance', 'binanceus', 'binanceusdm', 'binancecoinm', - 'kucoin', 'kucoinfutures', 'bybit', 'okx', 'okex', 'bitget', 'bitmex', 'deribit', - 'phemex', - 'mexc' + 'phemex' + // Removed: 'kucoin', 'kucoinfutures', 'mexc' - no sandbox support ]; } @@ -141,6 +142,7 @@ class TradeUIManager { this._updateTestnetVisibility(); this._updateExchangeRowVisibility(); this._updateSellAvailability(); + this._updateSltpVisibility(); }); } @@ -332,23 +334,23 @@ class TradeUIManager { } /** - * Updates SL/TP row visibility based on order side. - * SL/TP only applies to BUY orders (opening positions). - * SELL orders close existing positions, so SL/TP is not applicable. + * Updates SL/TP row visibility based on supported mode and side. + * Manual SL/TP is currently supported for paper BUY orders only. */ _updateSltpVisibility() { - if (!this.sltpRow || !this.sideSelect) return; + if (!this.sltpRow || !this.sideSelect || !this.targetSelect) return; const side = this.sideSelect.value.toLowerCase(); + const isPaperTrade = this.targetSelect.value === 'test_exchange'; - if (side === 'sell') { - // Hide SL/TP for SELL (closing positions) + if (!isPaperTrade || side === 'sell') { + // Hide SL/TP when unsupported or not applicable. this.sltpRow.style.display = 'none'; - // Clear any values + // Clear values to avoid submitting stale unsupported inputs. if (this.stopLossInput) this.stopLossInput.value = ''; if (this.takeProfitInput) this.takeProfitInput.value = ''; } else { - // Show SL/TP for BUY (opening positions) + // Show SL/TP for paper BUY orders. this.sltpRow.style.display = 'contents'; } } @@ -473,18 +475,16 @@ class TradeUIManager { if (this.testnetCheckbox) { this.testnetCheckbox.checked = true; } - // Reset side to BUY and show SL/TP row + // Reset side to BUY if (this.sideSelect) { this.sideSelect.value = 'buy'; } - if (this.sltpRow) { - this.sltpRow.style.display = 'contents'; - } this.formElement.style.display = 'grid'; // Update SELL availability based on current broker/symbol await this._updateSellAvailability(); + this._updateSltpVisibility(); } /** @@ -737,6 +737,22 @@ class TradeUIManager { this.onCloseTrade = callback; } + /** + * Sets the callback function for full refresh (trades + statistics). + * @param {Function} callback - The callback function. + */ + registerRefreshCallback(callback) { + this.onRefresh = callback; + } + + /** + * Sets the callback function for position-close updates. + * @param {Function} callback - The callback function. + */ + registerPositionClosedCallback(callback) { + this.onPositionClosed = callback; + } + // ============ Broker Event Listeners ============ /** @@ -759,7 +775,11 @@ class TradeUIManager { comms.on('position_closed', (data) => { console.log('Position closed:', data); - this.refreshAll(); + if (this.onPositionClosed) { + this.onPositionClosed(data); + } else { + this.refreshAll(); + } }); comms.on('sltp_triggered', (data) => { @@ -834,12 +854,15 @@ class TradeUIManager { container.innerHTML = ''; - if (!positions || positions.length === 0) { + // Filter out closed positions (size <= 0) + const openPositions = (positions || []).filter(pos => pos.size && Math.abs(pos.size) > 0); + + if (openPositions.length === 0) { container.innerHTML = '

No open positions

'; return; } - for (const pos of positions) { + for (const pos of openPositions) { const card = this._createPositionCard(pos); container.appendChild(card); } @@ -853,6 +876,10 @@ class TradeUIManager { const plClass = pl >= 0 ? 'positive' : 'negative'; const plSign = pl >= 0 ? '+' : ''; + // Price source for tooltip (shows which exchange's prices are used for P&L) + const priceSource = position.price_source || 'default'; + card.title = `P&L uses ${priceSource} prices`; + card.innerHTML = `
${position.symbol || 'N/A'} @@ -1033,6 +1060,10 @@ class TradeUIManager { this.refreshPositions(); this.refreshHistory(); this.updateBrokerStatus(); + // Call refresh callback to update trades and statistics + if (this.onRefresh) { + this.onRefresh(); + } } // ============ Broker Actions ============ @@ -1121,9 +1152,14 @@ class TradeUIManager { */ async updateBrokerStatus() { const brokerKey = this._getCurrentBrokerKey(); + const chartExchange = this.data?.exchange || ''; try { - const response = await fetch(`/api/manual/balance?broker_key=${encodeURIComponent(brokerKey)}`); + const params = new URLSearchParams({ + broker_key: brokerKey, + exchange: chartExchange + }); + const response = await fetch(`/api/manual/balance?${params.toString()}`); const data = await response.json(); if (data.success) { const balanceEl = document.getElementById('brokerBalance'); @@ -1131,7 +1167,24 @@ class TradeUIManager { if (balanceEl) { const balance = data.available ?? data.total ?? 0; - balanceEl.textContent = `Available: $${balance.toFixed(2)}`; + // Get currency preference from localStorage (default: USD for paper, USDT for live) + const defaultCurrency = brokerKey === 'paper' ? 'USD' : 'USDT'; + const currency = localStorage.getItem('balanceCurrency') || defaultCurrency; + balanceEl.textContent = `Available: $${balance.toFixed(2)} ${currency}`; + balanceEl.style.cursor = 'pointer'; + balanceEl.title = data.source === 'exchange' + ? `Using ${chartExchange || 'exchange'} chart balance. Click to toggle USD/USDT` + : 'Click to toggle USD/USDT'; + // Add click handler if not already added + if (!balanceEl.dataset.clickHandler) { + balanceEl.dataset.clickHandler = 'true'; + balanceEl.addEventListener('click', () => { + const current = localStorage.getItem('balanceCurrency') || defaultCurrency; + const newCurrency = current === 'USD' ? 'USDT' : 'USD'; + localStorage.setItem('balanceCurrency', newCurrency); + this.updateBrokerStatus(); + }); + } } if (modeEl) { if (brokerKey === 'paper') { @@ -1149,6 +1202,40 @@ class TradeUIManager { } catch (e) { console.warn('Could not fetch balance:', e); } + + // Update status bar class for reset button visibility + const statusBar = document.getElementById('brokerStatusBar'); + if (statusBar) { + statusBar.className = `broker-status-bar mode-${brokerKey === 'paper' ? 'paper' : brokerKey.includes('testnet') ? 'testnet' : 'live'}`; + } + } + + /** + * Reset paper trading balance to initial state ($10,000). + */ + async resetPaperBalance() { + if (!confirm('Reset paper trading? This will clear all positions, orders, and restore your balance to $10,000 USD.')) { + return; + } + + try { + const response = await fetch('/api/manual/paper/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + + if (data.success) { + console.log('Paper balance reset:', data); + alert(`Paper trading reset! New balance: $${data.balance.toFixed(2)} USD`); + this.refreshAll(); + } else { + alert(`Reset failed: ${data.message}`); + } + } catch (e) { + console.error('Error resetting paper balance:', e); + alert('Failed to reset paper balance'); + } } // ============ Broker-Aware SELL Disable ============ @@ -1362,6 +1449,13 @@ class Trade { // Set up close callback this.uiManager.registerCloseTradeCallback(this.closeTrade.bind(this)); + // Set up refresh callback for trades and statistics + this.uiManager.registerRefreshCallback(() => { + this.fetchTrades(); + this._updateStatistics(); + }); + this.uiManager.registerPositionClosedCallback(this.handlePositionClosed.bind(this)); + // Bind methods this.submitNewTrade = this.submitNewTrade.bind(this); @@ -1410,7 +1504,7 @@ class Trade { this.dataManager.fetchTrades(this.comms, this.data); // Initialize broker event listeners and refresh broker UI - this.initBrokerListeners(this.comms); + this.uiManager.initBrokerListeners(this.comms); this.refreshAll(); this._initialized = true; @@ -1420,6 +1514,55 @@ class Trade { } } + /** + * Refresh all broker-backed panels through the UI manager. + */ + refreshAll() { + this.uiManager.refreshAll(); + // Also refresh trades and statistics + this.fetchTrades(); + this._updateStatistics(); + } + + /** + * Delegate broker balance refresh to the UI manager. + */ + updateBrokerStatus() { + return this.uiManager.updateBrokerStatus(); + } + + /** + * Delegate order cancellation to the UI manager. + * Kept here because DOM actions call UI.trade.* methods. + */ + cancelOrder(orderId, brokerKey) { + return this.uiManager.cancelOrder(orderId, brokerKey); + } + + /** + * Delegate position close to the UI manager. + * Kept here because DOM actions call UI.trade.* methods. + */ + closePosition(symbol, brokerKey) { + return this.uiManager.closePosition(symbol, brokerKey); + } + + /** + * Delegate symbol-wide order cancellation to the UI manager. + * Kept here because DOM actions call UI.trade.* methods. + */ + cancelOrdersForSymbol(symbol, brokerKey) { + return this.uiManager.cancelOrdersForSymbol(symbol, brokerKey); + } + + /** + * Delegate paper balance reset to the UI manager. + * Kept here because DOM actions call UI.trade.* methods. + */ + resetPaperBalance() { + return this.uiManager.resetPaperBalance(); + } + /** * Updates the trading pair display in the form. * @private @@ -1485,6 +1628,23 @@ class Trade { } } + /** + * Handle position-closed event from the REST close-position flow. + * @param {Object} data - Position close payload with affected trade IDs. + */ + handlePositionClosed(data) { + console.log("Position closed event received:", data); + + const closedTrades = Array.isArray(data?.closed_trades) ? data.closed_trades : []; + for (const tradeId of closedTrades) { + this.dataManager.removeTrade(tradeId); + } + + this.uiManager.updateTradesHtml(this.dataManager.getAllTrades()); + this._updateStatistics(); + this.uiManager.refreshAll(); + } + /** * Handle trade error. * @param {Object} data - Error data. @@ -1673,8 +1833,8 @@ class Trade { return; } - // SL/TP validation (only for BUY orders - SELL closes existing positions) - if (side.toUpperCase() === 'BUY') { + // SL/TP validation (paper BUY only) + if (isPaperTrade && side.toUpperCase() === 'BUY') { if (stopLoss && stopLoss >= price) { alert('Stop Loss must be below entry price for BUY orders.'); return; @@ -1683,8 +1843,10 @@ class Trade { alert('Take Profit must be above entry price for BUY orders.'); return; } + } else if (!isPaperTrade && (stopLoss || takeProfit)) { + alert('Manual live Stop Loss / Take Profit is not supported yet.'); + return; } - // Note: SL/TP fields are hidden for SELL orders (inventory-only model) // Show confirmation for production live trades if (!isPaperTrade && !testnet) { diff --git a/src/templates/trading_hud.html b/src/templates/trading_hud.html index 869a87f..efba183 100644 --- a/src/templates/trading_hud.html +++ b/src/templates/trading_hud.html @@ -2,6 +2,7 @@
PAPER Available: -- +
@@ -276,6 +277,26 @@ font-family: monospace; } + .btn-reset-paper { + display: none; /* Hidden by default, shown only for paper mode */ + background: #ff9800; + color: white; + border: none; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + cursor: pointer; + margin-left: auto; + } + + .btn-reset-paper:hover { + background: #f57c00; + } + + .broker-status-bar.mode-paper .btn-reset-paper { + display: inline-block; + } + /* Trade sections */ .trade-section { margin-bottom: 15px; diff --git a/src/trade.py b/src/trade.py index 16b4ab6..6ccf79a 100644 --- a/src/trade.py +++ b/src/trade.py @@ -76,7 +76,9 @@ class Trade: 'qty_settled': 0.0, 'profit': 0.0, 'profit_pct': 0.0, - 'fee_paid': 0.0 + 'fee_paid': 0.0, + 'realized_profit': 0.0, + 'unrealized_profit': 0.0 } self.order = None else: @@ -140,30 +142,66 @@ class 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. """ - - def percent(part: float, whole: float) -> float: - if whole == 0: - return 0.0 - return 100.0 * float(part) / float(whole) - self.stats['current_price'] = current_price - initial_value = self.stats['opening_value'] - self.stats['current_value'] = self.base_order_qty * 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['profit'] = self.stats['current_value'] - initial_value + self.stats['unrealized_profit'] = unrealized_profit + self.stats['profit'] = realized_profit + unrealized_profit - if self.side == 'SELL': - self.stats['profit'] *= -1 - - projected_fees = (self.stats['current_value'] * self.fee) + (self.stats['opening_value'] * self.fee) - self.stats['profit'] -= projected_fees - - self.stats['profit_pct'] = percent(self.stats['profit'], initial_value) + 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: @@ -195,28 +233,45 @@ class Trade: if self.status == 'inactive': self.status = 'unfilled' - if 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) + self.stats['opening_value'] - t_qty = self.stats['qty_filled'] + qty + 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'] += qty + self.stats['qty_filled'] = t_qty self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price'] - self.stats['current_value'] = self.stats['qty_filled'] * self.stats['current_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 @@ -230,7 +285,28 @@ class Trade: self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price'] - if self.stats['qty_settled'] >= self.base_order_qty: + 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' @@ -380,7 +456,9 @@ class Trades: "broker_mode", "broker_exchange", "broker_order_id", - "exchange_order_id" + "exchange_order_id", + "stop_loss", + "take_profit" ] ) except Exception as e: @@ -577,6 +655,7 @@ class Trades: # 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: @@ -605,14 +684,20 @@ class Trades: 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: - current_price = self.exchange_interface.get_price(symbol) + # 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}") + 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}") @@ -667,16 +752,22 @@ class Trades: order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT - result = broker.place_order( - 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 - ) + # 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' @@ -703,6 +794,7 @@ class Trades: 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, @@ -721,10 +813,29 @@ class Trades: # Update stats if order was filled immediately (market orders) if result.status == OrderStatus.FILLED: trade.stats['qty_filled'] = result.filled_qty or float(qty) - trade.stats['opening_price'] = result.filled_price or effective_price + + # 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})") @@ -738,6 +849,7 @@ class Trades: 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, @@ -781,6 +893,44 @@ class Trades: 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. @@ -1272,6 +1422,12 @@ class Trades: 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) @@ -1404,8 +1560,18 @@ class Trades: 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 @@ -1461,11 +1627,13 @@ class Trades: 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 diff --git a/tests/test_brokers.py b/tests/test_brokers.py index 2567563..2dde1ec 100644 --- a/tests/test_brokers.py +++ b/tests/test_brokers.py @@ -119,6 +119,46 @@ class TestPaperBroker: assert position is not None assert position.size == 0.1 + def test_paper_broker_ioc_limit_cancels_if_not_marketable(self): + """IOC limit orders should fail immediately if not marketable.""" + broker = PaperBroker(initial_balance=10000, commission=0.001) + broker.update_price('BTC/USDT', 50000) + + result = broker.place_order( + symbol='BTC/USDT', + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + size=0.1, + price=49000, + time_in_force='IOC' + ) + + assert not result.success + assert result.status == OrderStatus.CANCELLED + assert broker.get_open_orders() == [] + assert broker.get_position('BTC/USDT') is None + + def test_paper_broker_fok_limit_fills_immediately_if_marketable(self): + """FOK limit orders should fill immediately when already marketable.""" + broker = PaperBroker(initial_balance=10000, commission=0.001) + broker.update_price('BTC/USDT', 50000) + + result = broker.place_order( + symbol='BTC/USDT', + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + size=0.1, + price=51000, + time_in_force='FOK' + ) + + assert result.success + assert result.status == OrderStatus.FILLED + assert broker.get_open_orders() == [] + position = broker.get_position('BTC/USDT') + assert position is not None + assert position.size == 0.1 + def test_paper_broker_cancel_order(self): """Test order cancellation.""" broker = PaperBroker(initial_balance=10000, commission=0, slippage=0) @@ -196,6 +236,47 @@ class TestPaperBroker: assert broker.get_available_balance() == 9900 assert broker.get_balance() == 10100 + def test_paper_broker_pnl_includes_fees(self): + """Test that P&L accurately reflects both entry and exit fees.""" + broker = PaperBroker(initial_balance=10000, commission=0.001, slippage=0) + broker.update_price('BTC/USDT', 1000) + + # Buy 1 unit at $1000, entry fee = $1 + broker.place_order( + symbol='BTC/USDT', + side=OrderSide.BUY, + order_type=OrderType.MARKET, + size=1.0 + ) + + # Immediately after buy, unrealized P&L should show -$1 (entry fee) + position = broker.get_position('BTC/USDT') + assert position is not None + assert position.entry_commission == 1.0 # 0.1% of $1000 + assert position.unrealized_pnl == -1.0 # Entry fee already reflected + + # Price hasn't moved, but we're down by entry fee + broker.update() + position = broker.get_position('BTC/USDT') + assert position.unrealized_pnl == -1.0 + + # Now sell at same price, exit fee = $1 + result = broker.place_order( + symbol='BTC/USDT', + side=OrderSide.SELL, + order_type=OrderType.MARKET, + size=1.0 + ) + + # Realized P&L should be -$2 (entry + exit fee) + assert result.success + # The realized_pnl on the order reflects both fees + # (price movement 0) - entry_fee ($1) - exit_fee ($1) = -$2 + + # Cash balance should reflect the loss + # Started with $10000, bought for $1001, sold for $999 = $9998 + assert broker.get_available_balance() == 9998.0 + def test_paper_broker_reset(self): """Test broker reset.""" broker = PaperBroker(initial_balance=10000) diff --git a/tests/test_trade.py b/tests/test_trade.py index 495ff32..d0c1e7f 100644 --- a/tests/test_trade.py +++ b/tests/test_trade.py @@ -166,6 +166,42 @@ class TestTrade: assert trade.status == 'part-filled' assert trade.stats['qty_filled'] == 0.05 + def test_trade_first_fill_from_open_order_uses_fill_price(self): + """First broker fill should not average against the original unfilled order notional.""" + trade = Trade( + target='kucoin', + symbol='BTC/USDT', + side='BUY', + order_price=69394.3, + base_order_qty=0.0001 + ) + trade.status = 'open' + + trade.trade_filled(qty=5.28e-06, price=69340.8) + + assert trade.status == 'part-filled' + assert trade.stats['qty_filled'] == pytest.approx(5.28e-06) + assert trade.stats['opening_price'] == pytest.approx(69340.8) + assert trade.stats['opening_value'] == pytest.approx(5.28e-06 * 69340.8) + + def test_trade_update_values_uses_filled_quantity_for_pl(self): + """Unrealized P/L should be based on filled exposure, not the original order size.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + fee=0.001 + ) + + trade.trade_filled(qty=0.05, price=50000.0) + trade.update_values(55000.0) + + assert trade.stats['current_value'] == pytest.approx(2750.0) + assert trade.stats['profit'] == pytest.approx(244.75) + assert trade.stats['profit_pct'] == pytest.approx(9.79) + def test_trade_settle(self): """Test trade settlement.""" trade = Trade( @@ -181,6 +217,8 @@ class TestTrade: assert trade.status == 'closed' assert trade.stats['settled_price'] == 55000.0 + assert trade.stats['profit'] == pytest.approx(489.5) + assert trade.stats['profit_pct'] == pytest.approx(9.79) class TestTrades: @@ -250,6 +288,25 @@ class TestTrades: assert trade.status == 'filled' assert trade.creator == 1 + def test_new_paper_trade_persists_time_in_force(self, mock_users): + """Manual trades should keep the selected time-in-force on the Trade object.""" + trades = Trades(mock_users) + + status, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='LIMIT', + qty=0.1, + user_id=1, + time_in_force='IOC' + ) + + assert status == 'Success' + trade = trades.get_trade_by_id(trade_id) + assert trade.time_in_force == 'IOC' + def test_new_live_trade_no_exchange(self, mock_users): """Test creating a live trade without exchange connected.""" trades = Trades(mock_users) @@ -269,6 +326,28 @@ class TestTrades: assert status == 'Error' assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower() + def test_new_live_trade_rejects_manual_sltp(self, mock_users): + """Manual live SL/TP should fail fast until exchange-native support exists.""" + trades = Trades(mock_users) + mock_exchange_interface = MagicMock() + mock_exchange_interface.get_exchange.return_value = MagicMock(configured=True) + trades.connect_exchanges(mock_exchange_interface) + + status, msg = trades.new_trade( + target='binance', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1, + user_id=1, + testnet=True, + stop_loss=45000.0 + ) + + assert status == 'Error' + assert 'not supported yet' in msg.lower() + def test_new_production_trade_blocked_without_env_var(self, mock_users): """Test that production trades are blocked without ALLOW_LIVE_PRODUCTION.""" import config @@ -368,6 +447,31 @@ class TestTrades: assert trade_id not in trades.active_trades assert trade_id in trades.settled_trades + def test_close_trade_recomputes_final_pl_from_close_price(self, mock_users): + """Closing should use the settlement price, not the last cached unrealized P/L.""" + trades = Trades(mock_users) + status, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1 + ) + + assert status == 'Success' + trade = trades.get_trade_by_id(trade_id) + trade.fee = 0.001 + + trade.update_values(45000.0) + assert trade.stats['profit'] < 0 + + result = trades.close_trade(trade_id, current_price=55000.0) + + assert result['success'] is True + assert result['final_pl'] == pytest.approx(489.5) + assert result['final_pl_pct'] == pytest.approx(9.79) + def test_close_nonexistent_trade(self, mock_users): """Test closing a trade that doesn't exist.""" trades = Trades(mock_users) @@ -377,6 +481,136 @@ class TestTrades: assert result['success'] is False assert 'not found' in result['message'] + def test_settle_broker_closed_position_filters_by_user_and_status(self, mock_users): + """Broker-side closes should only settle filled trades for the matching user/broker.""" + trades = Trades(mock_users) + + filled_trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + is_paper=True, + creator=1, + broker_kind='paper', + broker_mode='paper' + ) + filled_trade.trade_filled(qty=0.1, price=50000.0) + + other_user_trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + is_paper=True, + creator=2, + broker_kind='paper', + broker_mode='paper' + ) + other_user_trade.trade_filled(qty=0.1, price=50000.0) + + open_trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=49000.0, + base_order_qty=0.1, + is_paper=True, + creator=1, + broker_kind='paper', + broker_mode='paper' + ) + open_trade.status = 'open' + + trades.active_trades[filled_trade.unique_id] = filled_trade + trades.active_trades[other_user_trade.unique_id] = other_user_trade + trades.active_trades[open_trade.unique_id] = open_trade + trades.stats['num_trades'] = 3 + + settled_ids = trades.settle_broker_closed_position( + user_id=1, + symbol='BTC/USDT', + broker_key='paper', + close_price=44000.0 + ) + + assert settled_ids == [filled_trade.unique_id] + assert filled_trade.unique_id not in trades.active_trades + assert filled_trade.unique_id in trades.settled_trades + assert trades.settled_trades[filled_trade.unique_id].status == 'closed' + assert other_user_trade.unique_id in trades.active_trades + assert open_trade.unique_id in trades.active_trades + + def test_close_position_returns_closed_trade_ids(self, mock_users): + """Close-position API flow should report which trade IDs were removed locally.""" + trades = Trades(mock_users) + trades.manual_broker_manager = MagicMock() + trades.manual_broker_manager.close_position.return_value = { + 'success': True, + 'status': 'filled', + 'filled_price': 51000.0 + } + + trade = Trade( + target='kucoin', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + creator=1, + broker_kind='live', + broker_mode='production', + broker_exchange='kucoin' + ) + trade.trade_filled(qty=0.1, price=50000.0) + trades.active_trades[trade.unique_id] = trade + trades.stats['num_trades'] = 1 + + result = trades.close_position(1, 'BTC/USDT', 'kucoin_production') + + assert result['success'] is True + assert result['trades_closed'] == 1 + assert result['closed_trades'] == [trade.unique_id] + assert trade.unique_id not in trades.active_trades + assert trade.unique_id in trades.settled_trades + + def test_close_position_leaves_trade_active_when_close_order_is_still_open(self, mock_users): + """Live close-position should not settle/remove the local trade until the close order fills.""" + trades = Trades(mock_users) + trades.manual_broker_manager = MagicMock() + trades.manual_broker_manager.close_position.return_value = { + 'success': True, + 'status': 'open', + 'order_id': 'close123', + 'filled_price': 0.0, + 'message': 'Close order submitted' + } + + trade = Trade( + target='kucoin', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + creator=1, + broker_kind='live', + broker_mode='production', + broker_exchange='kucoin' + ) + trade.trade_filled(qty=0.1, price=50000.0) + trades.active_trades[trade.unique_id] = trade + trades.stats['num_trades'] = 1 + + result = trades.close_position(1, 'BTC/USDT', 'kucoin_production') + + assert result['success'] is True + assert result['trades_closed'] == 0 + assert result['closed_trades'] == [] + assert trade.unique_id in trades.active_trades + assert trade.unique_id not in trades.settled_trades + def test_is_valid_trade_id(self, mock_users): """Test trade ID validation.""" trades = Trades(mock_users) diff --git a/tests/test_trade2.py b/tests/test_trade2.py index 04cf3e3..207626b 100644 --- a/tests/test_trade2.py +++ b/tests/test_trade2.py @@ -68,10 +68,13 @@ def test_update_values(): assert position_size == 10 pl = trade_obj.get_pl() print(f'PL reported: {pl}') - assert pl == 0 + # With 0.1% fee (0.001): gross P/L 0, fees = (10*0.001) + (10*0.001) = 0.02 + # Net PL: -0.02 + assert pl == pytest.approx(-0.02) pl_pct = trade_obj.get_pl_pct() print(f'PL% reported: {pl_pct}') - assert pl_pct == 0 + # Should be -0.02/10 * 100 = -0.2% + assert pl_pct == pytest.approx(-0.2) # Divide the price of the quote symbol by 2. current_price = 50