diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index e2f0bc9..99b4e47 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -18,6 +18,7 @@ from trade import Trades from edm_client import EdmClient, EdmWebSocketClient from wallet import WalletManager from utils import sanitize_for_json +from manual_trading_broker import ManualTradingBrokerManager # Configure logging logger = logging.getLogger(__name__) @@ -76,6 +77,25 @@ class BrighterTrades: # The Trades object needs to connect to an exchange_interface. self.trades.connect_exchanges(exchanges=self.exchanges) + # Manual trading broker manager for broker-based order lifecycle + self.manual_broker_manager = ManualTradingBrokerManager( + data_cache=self.data, + exchange_interface=self.exchanges, + users=self.users + ) + # Wire up broker manager to trades + self.trades.manual_broker_manager = self.manual_broker_manager + logger.info("Manual trading broker manager initialized") + + # Recover brokers for any persisted broker-managed trades + try: + recovered = self.trades.recover_brokers() + if recovered and recovered > 0: + logger.info(f"Recovered {recovered} brokers for persisted trades") + except (TypeError, AttributeError): + # Handle case where trades or recover_brokers is mocked in tests + pass + # Object that maintains the strategies data self.strategies = Strategies(self.data, self.trades, self.indicators, edm_client=self.edm_client) @@ -1360,7 +1380,7 @@ class BrighterTrades: if val is None or val == '': return default # Try to cast to float for numeric fields - if attr in ['price', 'quantity']: + if attr in ['price', 'quantity', 'stopLoss', 'takeProfit']: try: return float(val) except (ValueError, TypeError): @@ -1377,6 +1397,9 @@ class BrighterTrades: quantity = get_value('quantity', 0.0) strategy_id = get_value('strategy_id') testnet = data.get('testnet', False) + stop_loss = get_value('stopLoss') + take_profit = get_value('takeProfit') + time_in_force = get_value('timeInForce', 'GTC') # Validate required fields if not symbol: @@ -1395,7 +1418,9 @@ class BrighterTrades: qty=quantity, user_id=user_id, strategy_id=strategy_id, - testnet=testnet + testnet=testnet, + stop_loss=stop_loss, + take_profit=take_profit ) if status == 'Error': @@ -1655,11 +1680,23 @@ class BrighterTrades: trade_id = msg_data.get('trade_id') or msg_data.get('unique_id') or msg_data if isinstance(trade_id, dict): trade_id = trade_id.get('trade_id') or trade_id.get('unique_id') - result = self.close_trade(str(trade_id)) - if result.get('success'): - return standard_reply("trade_closed", result) + + # Route based on trade status: cancel if unfilled, close position if filled + trade = self.trades.get_trade_by_id(str(trade_id)) + if not trade: + return standard_reply("trade_error", {"success": False, "message": "Trade not found"}) + + 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: - return standard_reply("trade_error", result) + # 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" + + return standard_reply(reply_type, result) if msg_type == 'new_signal': result = self.received_new_signal(msg_data, user_id) diff --git a/src/Database.py b/src/Database.py index 41099cc..b802845 100644 --- a/src/Database.py +++ b/src/Database.py @@ -367,3 +367,58 @@ class Database: # records = records.drop('id', axis=1) Todo: Reminder I may need to put this back later. return records + + def migrate_trades_broker_fields(self) -> bool: + """ + Add broker tracking columns to trades table. + + This migration adds columns required for broker integration: + - broker_kind: 'paper' or 'live' + - broker_mode: 'testnet', 'production', or 'paper' + - broker_exchange: Exchange name (for live trades) + - broker_order_id: Local broker order ID + - exchange_order_id: Live exchange order ID + + :return: True if migration was successful. + """ + columns = [ + ('broker_kind', 'TEXT'), + ('broker_mode', 'TEXT'), + ('broker_exchange', 'TEXT'), + ('broker_order_id', 'TEXT'), + ('exchange_order_id', 'TEXT'), + ] + + try: + with SQLite(self.db_file) as conn: + cursor = conn.cursor() + + # Check if table exists + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='trades'" + ) + if not cursor.fetchone(): + # Table doesn't exist yet, columns will be added when table is created + return True + + # Get existing columns + cursor.execute("PRAGMA table_info(trades)") + existing_columns = {row[1] for row in cursor.fetchall()} + + # Add missing columns + for col_name, col_type in columns: + if col_name not in existing_columns: + try: + cursor.execute( + f'ALTER TABLE trades ADD COLUMN {col_name} {col_type}' + ) + print(f"Added column {col_name} to trades table") + except Exception as e: + print(f"Note: Column {col_name} may already exist: {e}") + + conn.commit() + return True + + except Exception as e: + print(f"Error migrating trades table: {e}") + return False diff --git a/src/app.py b/src/app.py index 36b91bc..8811e37 100644 --- a/src/app.py +++ b/src/app.py @@ -35,6 +35,7 @@ _loop_debug.addHandler(_loop_handler) logging.basicConfig(level=log_level) logging.getLogger('ccxt.base.exchange').setLevel(logging.WARNING) logging.getLogger('urllib3').setLevel(logging.WARNING) +logger = logging.getLogger(__name__) # Create a Flask object named app that serves the html. app = Flask(__name__) @@ -181,6 +182,111 @@ def strategy_execution_loop(): except Exception as e: logger.error(f"Error executing strategy {instance_key}: {e}", exc_info=True) + # === BROKER UPDATES (single owner - happens here only) === + # 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 + 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 + price = brighter_trades.exchanges.get_price(trade.symbol, exchange) + if price: + # Live uses exchange:symbol key + 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 + + # Update all brokers and get fill events + fill_events = brighter_trades.manual_broker_manager.update_all_brokers(broker_price_updates) + + for event in fill_events: + event_type = event.get('type', 'fill') + + if event_type == 'sltp_triggered': + # SL/TP triggered - find related trade and notify user + symbol = event.get('symbol') + user_id = event.get('user_id') + + # Find trades for this symbol to get the user + related_trade = None + for trade in brighter_trades.trades.active_trades.values(): + if trade.symbol == symbol and (trade.is_paper or trade.broker_kind == 'paper'): + related_trade = trade + user_id = user_id or trade.creator + break + + if user_id: + user_name = brighter_trades.users.get_username(user_id=user_id) + if user_name: + socketio.emit('message', { + 'reply': 'sltp_triggered', + 'data': sanitize_for_json({ + 'trigger': event.get('trigger'), + 'symbol': symbol, + 'trigger_price': event.get('trigger_price'), + 'size': event.get('size'), + 'pnl': event.get('pnl'), + 'trade_id': related_trade.unique_id if related_trade else None + }) + }, room=user_name) + _loop_debug.debug(f"Emitted sltp_triggered to room={user_name}") + continue + + # Find trade by broker_order_id and update + trade = brighter_trades.trades.find_trade_by_broker_order_id(event.get('order_id')) + if trade: + trade.trade_filled( + qty=event.get('filled_qty', event.get('size', 0)), + price=event.get('filled_price', event.get('price', 0)) + ) + # Note: trade_filled() sets status to 'filled' or 'part-filled' appropriately + # Do NOT override it here - that would lose partial-fill state + brighter_trades.trades._save_trade(trade) + + # Emit fill event to user through existing message pattern + # Uses the 'message' event with 'reply' field that Comms understands + user_id = event.get('user_id') or trade.creator + if user_id: + user_name = brighter_trades.users.get_username(user_id=user_id) + if user_name: + socketio.emit('message', { + 'reply': 'order_filled', + 'data': sanitize_for_json({ + 'order_id': event.get('order_id'), + 'trade_id': trade.unique_id, + 'symbol': trade.symbol, + 'side': trade.side, + 'filled_qty': event.get('filled_qty', event.get('size', 0)), + 'filled_price': event.get('filled_price', event.get('price', 0)), + 'status': trade.status, # 'filled' or 'part-filled' + 'broker_kind': event.get('broker_kind'), + 'broker_key': event.get('broker_key') + }) + }, room=user_name) + _loop_debug.debug(f"Emitted order_filled to room={user_name}") + + except Exception as e: + _loop_debug.debug(f"Exception in broker update: {e}") + logger.error(f"Error updating brokers: {e}", exc_info=True) + # Update active trades (runs every iteration, regardless of active strategies) _loop_debug.debug(f"Checking active_trades: {len(brighter_trades.trades.active_trades)} trades") if brighter_trades.trades.active_trades: @@ -215,8 +321,9 @@ def strategy_execution_loop(): _loop_debug.debug(f"price_updates: {price_updates}") if price_updates: - _loop_debug.debug(f"Calling brighter_trades.trades.update()") - trade_updates = brighter_trades.trades.update(price_updates) + _loop_debug.debug(f"Calling brighter_trades.trades.update_prices_only()") + # Use update_prices_only to avoid duplicate broker polling + trade_updates = brighter_trades.trades.update_prices_only(price_updates) _loop_debug.debug(f"trade_updates returned: {trade_updates}") if trade_updates: logger.debug(f"Trade updates (no active strategies): {trade_updates}") @@ -242,12 +349,34 @@ def strategy_execution_loop(): logger.info("Strategy execution loop stopped") +_strategy_loop_started = False + + def start_strategy_loop(): - """Start the strategy execution loop in a background greenlet.""" + """ + Start the strategy execution loop in a background greenlet. + + This supports both `python src/app.py` and WSGI imports. + The loop is only started once per process. + """ + global _strategy_loop_started + + if _strategy_loop_started: + return + + if app.config.get('TESTING') or os.getenv('PYTEST_CURRENT_TEST'): + return + + if os.getenv('BRIGHTER_DISABLE_STRATEGY_LOOP', '').lower() in ('1', 'true', 'yes'): + logger.info("Strategy execution loop disabled by BRIGHTER_DISABLE_STRATEGY_LOOP") + return + eventlet.spawn(strategy_execution_loop) + _strategy_loop_started = True + logger.info("Strategy execution loop started") -# Start the loop when the app starts (will be called from main block) +# Start the loop when the app starts (will be called from main block or before_request) def start_wallet_background_jobs(): @@ -281,7 +410,8 @@ def start_wallet_background_jobs(): @app.before_request def ensure_background_jobs_started(): - """Ensure wallet background jobs are running in non-`__main__` deployments.""" + """Ensure background jobs are running in non-`__main__` deployments.""" + start_strategy_loop() start_wallet_background_jobs() @@ -1032,6 +1162,146 @@ def admin_credit_user(): return jsonify(result) +# ============================================================================= +# Manual Trading API Routes (Position-First) +# ============================================================================= + +@app.route('/api/manual/orders', methods=['GET']) +def get_manual_open_orders(): + """Get all open orders for the current user across all brokers.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + try: + orders = brighter_trades.manual_broker_manager.get_all_open_orders(user_id) + return jsonify({'success': True, 'orders': orders}) + except Exception as e: + logger.error(f"Error getting open orders: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/orders//cancel', methods=['POST']) +def cancel_manual_order(order_id): + """Cancel a specific open order.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + data = request.get_json() or {} + broker_key = data.get('broker_key', 'paper') + + try: + # First try to find the trade by broker_order_id + trade = brighter_trades.trades.find_trade_by_broker_order_id(order_id) + if trade: + result = brighter_trades.trades.cancel_order(trade.unique_id) + else: + # Fall back to broker manager direct cancel + result = brighter_trades.manual_broker_manager.cancel_order(user_id, order_id, broker_key) + + return jsonify(result) + except Exception as e: + logger.error(f"Error cancelling order {order_id}: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/positions', methods=['GET']) +def get_manual_positions(): + """Get all positions for the current user across all brokers.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + try: + positions = brighter_trades.manual_broker_manager.get_all_positions(user_id) + return jsonify({'success': True, 'positions': positions}) + except Exception as e: + logger.error(f"Error getting positions: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/positions//close', methods=['POST']) +def close_manual_position(symbol): + """ + Close entire position for a symbol (position-first operation). + + This is the preferred close method - it closes the entire position + rather than individual trades. + """ + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + data = request.get_json() or {} + broker_key = data.get('broker_key', 'paper') + + try: + result = brighter_trades.trades.close_position(user_id, symbol, broker_key) + return jsonify(result) + except Exception as e: + logger.error(f"Error closing position {symbol}: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/balance', methods=['GET']) +def get_manual_balance(): + """Get balance for a specific broker.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + broker_key = request.args.get('broker_key', 'paper') + + 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) + return jsonify({ + 'success': True, + 'total': total, + 'available': available, + 'broker_key': broker_key + }) + except Exception as e: + logger.error(f"Error getting balance: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/orders/symbol//cancel', methods=['POST']) +def cancel_orders_for_symbol(symbol): + """Cancel all resting orders for a symbol.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + data = request.get_json() or {} + broker_key = data.get('broker_key', 'paper') + + try: + result = brighter_trades.trades.cancel_orders_for_symbol(user_id, symbol, broker_key) + return jsonify(result) + except Exception as e: + logger.error(f"Error cancelling orders for {symbol}: {e}", exc_info=True) + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/manual/history', methods=['GET']) +def get_trade_history(): + """Get trade history for current user.""" + user_id = _get_current_user_id() + if not user_id: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + limit = request.args.get('limit', 50, type=int) + + try: + history = brighter_trades.trades.get_trade_history(user_id, limit) + return jsonify({'success': True, 'history': history}) + except Exception as e: + logger.error(f"Error getting trade history: {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 cf9e29d..74c6a1c 100644 --- a/src/brokers/base_broker.py +++ b/src/brokers/base_broker.py @@ -45,6 +45,7 @@ class OrderResult: """Result of an order placement.""" success: bool order_id: Optional[str] = None + exchange_order_id: Optional[str] = None # Live exchange order ID from CCXT message: Optional[str] = None status: OrderStatus = OrderStatus.PENDING filled_qty: float = 0.0 diff --git a/src/brokers/live_broker.py b/src/brokers/live_broker.py index 74dd306..e29c3cd 100644 --- a/src/brokers/live_broker.py +++ b/src/brokers/live_broker.py @@ -575,6 +575,7 @@ class LiveBroker(BaseBroker): return OrderResult( success=True, order_id=existing_order.order_id, + exchange_order_id=existing_order.exchange_order_id, status=existing_order.status, filled_qty=existing_order.filled_qty, filled_price=existing_order.filled_price, @@ -662,6 +663,7 @@ class LiveBroker(BaseBroker): return OrderResult( success=True, order_id=order_id, + exchange_order_id=order.exchange_order_id, status=order.status, filled_qty=order.filled_qty, filled_price=order.filled_price, diff --git a/src/brokers/paper_broker.py b/src/brokers/paper_broker.py index 165cd05..62a0de8 100644 --- a/src/brokers/paper_broker.py +++ b/src/brokers/paper_broker.py @@ -113,6 +113,9 @@ class PaperBroker(BaseBroker): self._positions: Dict[str, Position] = {} self._trade_history: List[Dict[str, Any]] = [] + # SL/TP tracking per symbol: {symbol: {stop_loss, take_profit, side, entry_price}} + self._position_sltp: Dict[str, Dict[str, Any]] = {} + # Current prices cache self._current_prices: Dict[str, float] = {} @@ -258,6 +261,16 @@ class PaperBroker(BaseBroker): current_price=fill_price, unrealized_pnl=0.0 ) + + # Record SL/TP for this position (if set on order) + if order.stop_loss or order.take_profit: + self._position_sltp[order.symbol] = { + 'stop_loss': order.stop_loss, + 'take_profit': order.take_profit, + 'side': 'long', # BUY opens a long position + 'entry_price': fill_price + } + logger.info(f"SL/TP set for {order.symbol}: SL={order.stop_loss}, TP={order.take_profit}") else: # Add proceeds to cash total_proceeds = order_value - order.commission @@ -278,6 +291,9 @@ class PaperBroker(BaseBroker): # Remove position if fully closed if position.size <= 0: del self._positions[order.symbol] + # Clear SL/TP tracking for this symbol + if order.symbol in self._position_sltp: + del self._position_sltp[order.symbol] # Record trade self._trade_history.append({ @@ -379,6 +395,50 @@ class PaperBroker(BaseBroker): position.current_price = current_price position.unrealized_pnl = (current_price - position.entry_price) * position.size + # Evaluate SL/TP for all tracked positions + for symbol, sltp in list(self._position_sltp.items()): + if symbol not in self._positions: + del self._position_sltp[symbol] + continue + + position = self._positions[symbol] + current_price = self.get_current_price(symbol) + + if position.size <= 0 or current_price <= 0: + del self._position_sltp[symbol] + continue + + triggered = None + trigger_price = current_price + + # Long position: SL triggers when price drops, TP when price rises + if sltp['side'] == 'long': + if sltp.get('stop_loss') and current_price <= sltp['stop_loss']: + triggered = 'stop_loss' + elif sltp.get('take_profit') and current_price >= sltp['take_profit']: + triggered = 'take_profit' + # Short position: SL triggers when price rises, TP when price drops + else: + if sltp.get('stop_loss') and current_price >= sltp['stop_loss']: + triggered = 'stop_loss' + elif sltp.get('take_profit') and current_price <= sltp['take_profit']: + triggered = 'take_profit' + + if triggered: + # Auto-close position + close_result = self.close_position(symbol) + if close_result.success: + events.append({ + 'type': 'sltp_triggered', + 'trigger': triggered, + 'symbol': symbol, + 'trigger_price': trigger_price, + 'size': close_result.filled_qty, + 'pnl': position.unrealized_pnl + }) + logger.info(f"SL/TP triggered: {triggered} for {symbol} at {trigger_price}") + # SL/TP tracking cleared in _fill_order when position closes + # Check pending limit orders for order_id, order in list(self._orders.items()): if order.status != OrderStatus.OPEN: @@ -520,6 +580,7 @@ class PaperBroker(BaseBroker): 'positions': positions_data, 'trade_history': self._trade_history, 'current_prices': self._current_prices, + 'position_sltp': self._position_sltp, } def from_state_dict(self, state: Dict[str, Any]): @@ -574,6 +635,9 @@ class PaperBroker(BaseBroker): # Restore price cache self._current_prices = state.get('current_prices', {}) + # Restore SL/TP tracking + self._position_sltp = state.get('position_sltp', {}) + logger.info(f"PaperBroker: State restored - cash: {self._cash:.2f}, " f"positions: {len(self._positions)}, orders: {len(self._orders)}") diff --git a/src/manual_trading_broker.py b/src/manual_trading_broker.py new file mode 100644 index 0000000..7aa58a1 --- /dev/null +++ b/src/manual_trading_broker.py @@ -0,0 +1,576 @@ +""" +Manual Trading Broker Manager for BrighterTrading. + +Manages broker instances (PaperBroker/LiveBroker) for manual (non-strategy) trading. +This provides a unified interface for placing orders, tracking positions, and +managing order lifecycle for manual trades. +""" + +import logging +from typing import Any, Callable, Dict, List, Optional + +from brokers.base_broker import OrderSide, OrderType, OrderStatus, Position, OrderResult +from brokers.paper_broker import PaperBroker +from brokers.live_broker import LiveBroker + +logger = logging.getLogger(__name__) + + +class ManualTradingBrokerManager: + """ + Manages broker instances for manual (non-strategy) trading. + + Key design principles: + - Reuses mode-aware exchange connections from ExchangeInterface + - Separate orders (unfilled) from positions (filled/aggregated) + - Single owner for broker polling (strategy_execution_loop only) + """ + + def __init__(self, data_cache: Any = None, exchange_interface: Any = None, users: Any = None): + """ + Initialize the ManualTradingBrokerManager. + + :param data_cache: DataCache instance for persistence. + :param exchange_interface: ExchangeInterface instance for exchange connections. + :param users: Users instance for API key lookup. + """ + self._paper_brokers: Dict[int, PaperBroker] = {} # user_id -> broker + self._live_brokers: Dict[int, Dict[str, LiveBroker]] = {} # user_id -> {exchange_mode_key: broker} + self.data_cache = data_cache + self.exchange_interface = exchange_interface + self.users = users + + def get_paper_broker(self, user_id: int, initial_balance: float = 10000.0) -> PaperBroker: + """ + Get or create a PaperBroker for a user. + + :param user_id: The user ID. + :param initial_balance: Initial balance for new brokers. + :return: PaperBroker instance. + """ + if user_id not in self._paper_brokers: + # Create price provider that uses exchange_interface + # Accepts either 'symbol' or 'exchange:symbol' format + def price_provider(symbol_key: str) -> float: + if self.exchange_interface: + try: + # Parse exchange:symbol format if present + if ':' in symbol_key: + exchange, symbol = symbol_key.split(':', 1) + return self.exchange_interface.get_price(symbol, exchange) + else: + return self.exchange_interface.get_price(symbol_key) + except Exception as e: + logger.warning(f"Failed to get price for {symbol_key}: {e}") + return 0.0 + + broker = PaperBroker( + price_provider=price_provider, + data_cache=self.data_cache, + initial_balance=initial_balance + ) + + # Try to load saved state + state_id = f"manual_paper_{user_id}" + broker.load_state(state_id) + + self._paper_brokers[user_id] = broker + logger.info(f"Created paper broker for user {user_id}") + + return self._paper_brokers[user_id] + + def get_live_broker( + self, + user_id: int, + exchange_name: str, + testnet: bool, + user_name: str + ) -> Optional[LiveBroker]: + """ + Get or create a LiveBroker for a user+exchange+mode. + + Uses mode-aware exchange connection from exchange_interface. + Validates that the exchange's sandbox mode matches the requested testnet flag. + + Policy: One mode per exchange per user. Cannot have both testnet and production + simultaneously for the same exchange. Returns None if mode conflict. + + :param user_id: The user ID. + :param exchange_name: Exchange name (e.g., 'binance'). + :param testnet: Whether to use testnet mode. + :param user_name: Username for exchange lookup. + :return: LiveBroker instance or None if not configured or mode conflict. + """ + # 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}" + opposite_mode = 'production' if testnet else 'testnet' + opposite_key = f"{exchange_name}_{opposite_mode}" + + if user_id not in self._live_brokers: + self._live_brokers[user_id] = {} + + # Check for mode conflict - reject if opposite mode already active + if opposite_key in self._live_brokers[user_id]: + logger.error( + f"Mode conflict: {exchange_name} already active in {opposite_mode} mode " + f"for user {user_id}. Disconnect first before switching to {requested_mode}." + ) + return None + + if broker_key not in self._live_brokers[user_id]: + if not self.exchange_interface: + logger.error("No exchange interface configured") + return None + + try: + # Get exchange connection from exchange_interface + try: + exchange = self.exchange_interface.get_exchange( + ename=exchange_name, + uname=user_name + ) + except ValueError: + exchange = None # Exchange doesn't exist yet + + # CRITICAL: Verify exchange testnet mode matches requested mode + if exchange: + exchange_is_testnet = bool(getattr(exchange, 'testnet', False)) + if exchange_is_testnet != testnet: + # Exchange mode mismatch - need to reconnect with correct mode + logger.warning( + f"Exchange '{exchange_name}' is in " + f"{'testnet' if exchange_is_testnet else 'production'} mode, " + f"but requested {'testnet' if testnet else 'production'}. " + f"Reconnecting with correct mode." + ) + # Get API keys and reconnect with correct mode + if self.users: + api_keys = self.users.get_api_keys(user_name, exchange_name) + if api_keys: + self.exchange_interface.connect_exchange( + exchange_name=exchange_name, + user_name=user_name, + api_keys=api_keys, + testnet=testnet + ) + exchange = self.exchange_interface.get_exchange( + ename=exchange_name, uname=user_name + ) + + # If exchange doesn't exist or isn't configured, try to load API keys + if not exchange or not exchange.configured: + if self.users: + logger.info(f"Exchange '{exchange_name}' not configured, loading API keys...") + api_keys = self.users.get_api_keys(user_name, exchange_name) + if api_keys: + success = self.exchange_interface.connect_exchange( + exchange_name=exchange_name, + user_name=user_name, + api_keys=api_keys, + testnet=testnet + ) + if success: + exchange = self.exchange_interface.get_exchange( + ename=exchange_name, uname=user_name + ) + + if not exchange or not exchange.configured: + logger.error(f"Exchange {exchange_name} not configured for user {user_name}") + return None + + # Final verification: exchange mode MUST match requested mode + exchange_is_testnet = bool(getattr(exchange, 'testnet', False)) + if exchange_is_testnet != testnet: + logger.error( + f"Exchange mode mismatch after reconnect: exchange is " + f"{'testnet' if exchange_is_testnet else 'production'}, " + f"but requested {'testnet' if testnet else 'production'}" + ) + return None + + broker = LiveBroker( + exchange=exchange, + testnet=testnet, + data_cache=self.data_cache + ) + broker.connect() + + # Try to load saved state + state_id = f"manual_live_{user_id}_{broker_key}" + broker.load_state(state_id) + + self._live_brokers[user_id][broker_key] = broker + logger.info(f"Created live broker for user {user_id}, {broker_key}") + + except Exception as e: + logger.error(f"Failed to create live broker: {e}", exc_info=True) + return None + + return self._live_brokers[user_id].get(broker_key) + + def set_prices(self, price_updates: Dict[str, float]) -> None: + """ + Update current prices for all paper brokers. + + :param price_updates: Dict mapping 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) + else: + sym = symbol + broker.update_price(sym, price) + + def update_all_brokers(self, price_updates: Dict[str, float]) -> List[Dict]: + """ + Update all brokers and return fill events. + + Called from strategy_execution_loop for single-owner polling. + + :param price_updates: Dict mapping symbol to price. + :return: List of fill events with user_id and broker_kind added. + """ + events = [] + + # Update prices for paper brokers + self.set_prices(price_updates) + + # Process paper brokers + for user_id, broker in self._paper_brokers.items(): + try: + fill_events = broker.update() + for e in fill_events: + e['user_id'] = user_id + e['broker_kind'] = 'paper' + e['broker_key'] = 'paper' + events.extend(fill_events) + + # Save state after updates + state_id = f"manual_paper_{user_id}" + broker.save_state(state_id) + except Exception as ex: + logger.error(f"Error updating paper broker for user {user_id}: {ex}") + + # Process live brokers + for user_id, brokers in self._live_brokers.items(): + for broker_key, broker in brokers.items(): + try: + fill_events = broker.update() + for e in fill_events: + e['user_id'] = user_id + e['broker_kind'] = 'live' + e['broker_key'] = broker_key + events.extend(fill_events) + + # Save state after updates + state_id = f"manual_live_{user_id}_{broker_key}" + broker.save_state(state_id) + except Exception as ex: + logger.error(f"Error updating live broker {broker_key} for user {user_id}: {ex}") + + return events + + def get_all_open_orders(self, user_id: int) -> List[Dict]: + """ + Get all open orders for a user across all brokers. + + :param user_id: The user ID. + :return: List of open order dicts with broker_key added. + """ + orders = [] + + # Paper broker orders + if user_id in self._paper_brokers: + broker = self._paper_brokers[user_id] + for order in broker.get_open_orders(): + order['broker_key'] = 'paper' + order['broker_kind'] = 'paper' + orders.append(order) + + # Live broker orders + if user_id in self._live_brokers: + for broker_key, broker in self._live_brokers[user_id].items(): + try: + for order in broker.get_open_orders(): + order['broker_key'] = broker_key + order['broker_kind'] = 'live' + orders.append(order) + except Exception as e: + logger.warning(f"Could not get orders from {broker_key}: {e}") + + return orders + + def get_all_positions(self, user_id: int) -> List[Dict]: + """ + Get all positions for a user across all brokers. + + :param user_id: The user ID. + :return: List of position dicts with broker_key added. + """ + positions = [] + + # Paper broker positions + if user_id in self._paper_brokers: + broker = self._paper_brokers[user_id] + for pos in broker.get_all_positions(): + pos_dict = pos.to_dict() + pos_dict['broker_key'] = 'paper' + pos_dict['broker_kind'] = 'paper' + positions.append(pos_dict) + + # Live broker positions + if user_id in self._live_brokers: + for broker_key, broker in self._live_brokers[user_id].items(): + try: + for pos in broker.get_all_positions(): + pos_dict = pos.to_dict() + pos_dict['broker_key'] = broker_key + pos_dict['broker_kind'] = 'live' + positions.append(pos_dict) + except Exception as e: + logger.warning(f"Could not get positions from {broker_key}: {e}") + + return positions + + def cancel_order(self, user_id: int, order_id: str, broker_key: str) -> Dict: + """ + Cancel an open order. + + :param user_id: The user ID. + :param order_id: The order ID to cancel. + :param broker_key: The broker key ('paper' or 'exchange_mode'). + :return: Dict with success status and message. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return {"success": False, "message": f"Broker not found: {broker_key}"} + + try: + result = broker.cancel_order(order_id) + if result: + # Save state after cancel + if broker_key == 'paper': + state_id = f"manual_paper_{user_id}" + else: + state_id = f"manual_live_{user_id}_{broker_key}" + broker.save_state(state_id) + + return { + "success": result, + "message": "Order cancelled" if result else "Failed to cancel order" + } + except Exception as e: + logger.error(f"Error cancelling order {order_id}: {e}") + return {"success": False, "message": str(e)} + + def close_position(self, user_id: int, symbol: str, broker_key: str) -> Dict: + """ + Close an entire position (position-first operation). + + :param user_id: The user ID. + :param symbol: Trading symbol to close. + :param broker_key: The broker key ('paper' or 'exchange_mode'). + :return: Dict with success status and OrderResult details. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return {"success": False, "message": f"Broker not found: {broker_key}"} + + try: + result = broker.close_position(symbol) + + if result.success: + # Save state after close + if broker_key == 'paper': + state_id = f"manual_paper_{user_id}" + else: + state_id = f"manual_live_{user_id}_{broker_key}" + broker.save_state(state_id) + + return { + "success": result.success, + "message": result.message, + "order_id": result.order_id, + "filled_qty": result.filled_qty, + "filled_price": result.filled_price + } + except Exception as e: + logger.error(f"Error closing position {symbol}: {e}") + return {"success": False, "message": str(e)} + + def get_position(self, user_id: int, symbol: str, broker_key: str) -> Optional[Position]: + """ + Get a specific position. + + :param user_id: The user ID. + :param symbol: Trading symbol. + :param broker_key: The broker key. + :return: Position or None. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return None + return broker.get_position(symbol) + + def place_order( + self, + user_id: int, + broker_key: str, + symbol: str, + side: OrderSide, + order_type: OrderType, + size: float, + price: Optional[float] = None + ) -> OrderResult: + """ + Place an order through the specified broker. + + :param user_id: The user ID. + :param broker_key: The broker key ('paper' or 'exchange_mode'). + :param symbol: Trading symbol. + :param side: OrderSide.BUY or OrderSide.SELL. + :param order_type: OrderType.MARKET or OrderType.LIMIT. + :param size: Order size. + :param price: Limit price (required for limit orders). + :return: OrderResult. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return OrderResult( + success=False, + message=f"Broker not found: {broker_key}" + ) + + try: + result = broker.place_order( + symbol=symbol, + side=side, + order_type=order_type, + size=size, + price=price + ) + + if result.success: + # Save state after order + if broker_key == 'paper': + state_id = f"manual_paper_{user_id}" + else: + state_id = f"manual_live_{user_id}_{broker_key}" + broker.save_state(state_id) + + return result + except Exception as e: + logger.error(f"Error placing order: {e}") + return OrderResult(success=False, message=str(e)) + + def _get_broker(self, user_id: int, broker_key: str): + """ + Get a broker by user_id and broker_key. + + :param user_id: The user ID. + :param broker_key: 'paper' or 'exchange_mode' format. + :return: Broker instance or None. + """ + if broker_key == 'paper': + return self._paper_brokers.get(user_id) + + if user_id in self._live_brokers: + return self._live_brokers[user_id].get(broker_key) + + return None + + def get_broker_balance(self, user_id: int, broker_key: str) -> float: + """ + Get the total balance/equity for a broker. + + :param user_id: The user ID. + :param broker_key: The broker key. + :return: Total balance. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return 0.0 + return broker.get_balance() + + def get_available_balance(self, user_id: int, broker_key: str) -> float: + """ + Get the available balance (not locked in orders). + + :param user_id: The user ID. + :param broker_key: The broker key. + :return: Available balance. + """ + broker = self._get_broker(user_id, broker_key) + if not broker: + return 0.0 + return broker.get_available_balance() + + def recover_brokers_for_trades(self, trades: List[Any], get_username_func) -> int: + """ + Recover/recreate brokers for persisted trades after a restart. + + This ensures that broker-managed trades are properly tracked after + the application restarts. Should be called after loading trades from DB. + + :param trades: List of Trade objects with broker_order_id. + :param get_username_func: Function that takes user_id and returns username. + :return: Number of brokers recovered. + """ + recovered = 0 + + # Group trades by (user_id, broker_kind, broker_key) + broker_needed: Dict[tuple, Dict] = {} + + for trade in trades: + if not trade.broker_order_id: + continue # Not a broker-managed trade + + user_id = trade.creator + if not user_id: + continue + + if trade.broker_kind == 'paper': + key = (user_id, 'paper', 'paper') + broker_needed[key] = {'kind': 'paper'} + elif trade.broker_kind == 'live' and trade.broker_exchange and trade.broker_mode: + broker_key = f"{trade.broker_exchange}_{trade.broker_mode}" + testnet = trade.broker_mode == 'testnet' + key = (user_id, 'live', broker_key) + broker_needed[key] = { + 'kind': 'live', + 'exchange': trade.broker_exchange, + 'testnet': testnet + } + + # Recreate each needed broker + for (user_id, kind, broker_key), info in broker_needed.items(): + try: + if kind == 'paper': + # Check if already exists + if user_id not in self._paper_brokers: + self.get_paper_broker(user_id) + logger.info(f"Recovered paper broker for user {user_id}") + recovered += 1 + else: + # Live broker + if user_id not in self._live_brokers or broker_key not in self._live_brokers.get(user_id, {}): + user_name = get_username_func(user_id) + if user_name: + broker = self.get_live_broker( + user_id=user_id, + exchange_name=info['exchange'], + testnet=info['testnet'], + user_name=user_name + ) + if broker: + logger.info(f"Recovered live broker {broker_key} for user {user_id}") + recovered += 1 + else: + logger.warning(f"Could not recover live broker {broker_key} for user {user_id}") + except Exception as e: + logger.error(f"Error recovering broker {broker_key} for user {user_id}: {e}") + + return recovered diff --git a/src/static/trade.js b/src/static/trade.js index fe36e73..78ac778 100644 --- a/src/static/trade.js +++ b/src/static/trade.js @@ -16,6 +16,9 @@ class TradeUIManager { this.exchangeSelect = null; this.testnetCheckbox = null; this.testnetRow = null; + this.stopLossInput = null; + this.takeProfitInput = null; + this.timeInForceSelect = null; this.onCloseTrade = null; // Exchanges known to support testnet/sandbox mode @@ -50,7 +53,10 @@ class TradeUIManager { symbolId = 'tradeSymbol', exchangeId = 'tradeExchange', testnetId = 'tradeTestnet', - testnetRowId = 'testnet-row' + testnetRowId = 'testnet-row', + stopLossId = 'stopLoss', + takeProfitId = 'takeProfit', + timeInForceId = 'timeInForce' } = config; this.targetEl = document.getElementById(targetId); @@ -74,6 +80,9 @@ class TradeUIManager { this.exchangeSelect = document.getElementById(exchangeId); this.testnetCheckbox = document.getElementById(testnetId); this.testnetRow = document.getElementById(testnetRowId); + this.stopLossInput = document.getElementById(stopLossId); + this.takeProfitInput = document.getElementById(takeProfitId); + this.timeInForceSelect = document.getElementById(timeInForceId); // Set up event listeners this._setupFormListeners(); @@ -120,10 +129,11 @@ class TradeUIManager { this.qtyInput.addEventListener('input', updateTradeValue); } - // Trade target (exchange) changes affect testnet visibility + // Trade target (exchange) changes affect testnet visibility and SELL availability if (this.targetSelect) { this.targetSelect.addEventListener('change', () => { this._updateTestnetVisibility(); + this._updateSellAvailability(); }); } @@ -134,6 +144,20 @@ class TradeUIManager { await this._populateSymbolDropdown(selectedExchange, null); }); } + + // Symbol changes affect SELL availability + if (this.symbolInput) { + this.symbolInput.addEventListener('change', () => { + this._updateSellAvailability(); + }); + } + + // Testnet checkbox changes affect broker key, thus SELL availability + if (this.testnetCheckbox) { + this.testnetCheckbox.addEventListener('change', () => { + this._updateSellAvailability(); + }); + } } /** @@ -358,6 +382,9 @@ class TradeUIManager { // Reset form values if (this.qtyInput) this.qtyInput.value = ''; if (this.tradeValueDisplay) this.tradeValueDisplay.value = '0'; + if (this.stopLossInput) this.stopLossInput.value = ''; + if (this.takeProfitInput) this.takeProfitInput.value = ''; + if (this.timeInForceSelect) this.timeInForceSelect.value = 'GTC'; // Set current price if available if (currentPrice !== null) { @@ -389,6 +416,9 @@ class TradeUIManager { } this.formElement.style.display = 'grid'; + + // Update SELL availability based on current broker/symbol + await this._updateSellAvailability(); } /** @@ -640,6 +670,466 @@ class TradeUIManager { registerCloseTradeCallback(callback) { this.onCloseTrade = callback; } + + // ============ Broker Event Listeners ============ + + /** + * Initialize broker event listeners through Comms. + * @param {Comms} comms - The communications instance. + */ + initBrokerListeners(comms) { + if (!comms) return; + + // Listen for order fill events via existing message/reply pattern + comms.on('order_filled', (data) => { + console.log('Order filled:', data); + this.refreshAll(); + }); + + comms.on('order_cancelled', (data) => { + console.log('Order cancelled:', data); + this.refreshAll(); + }); + + comms.on('position_closed', (data) => { + console.log('Position closed:', data); + this.refreshAll(); + }); + + comms.on('sltp_triggered', (data) => { + console.log('SL/TP triggered:', data); + const triggerName = data.trigger === 'stop_loss' ? 'Stop Loss' : 'Take Profit'; + const pnl = data.pnl != null ? data.pnl.toFixed(2) : 'N/A'; + alert(`${triggerName} triggered for ${data.symbol}\nPrice: ${data.trigger_price}\nP/L: ${pnl}`); + this.refreshAll(); + }); + } + + // ============ Open Orders Section ============ + + /** + * Render open orders section. + * @param {Object[]} orders - List of open order dicts. + */ + renderOrders(orders) { + const container = document.getElementById('openOrdersContainer'); + if (!container) return; + + container.innerHTML = ''; + + if (!orders || orders.length === 0) { + container.innerHTML = '

No open orders

'; + return; + } + + const table = document.createElement('table'); + table.className = 'orders-table'; + table.innerHTML = ` + + Symbol + Side + Size + Price + Broker + Action + + `; + + for (const order of orders) { + const row = document.createElement('tr'); + const sideClass = (order.side || '').toLowerCase() === 'buy' ? 'order-buy' : 'order-sell'; + row.className = `order-row ${sideClass}`; + row.innerHTML = ` + ${order.symbol || 'N/A'} + ${(order.side || '').toUpperCase()} + ${this._formatNumber(order.size)} + ${order.price ? this._formatPrice(order.price) : 'MARKET'} + ${order.broker_key || 'paper'} + + + + `; + table.appendChild(row); + } + container.appendChild(table); + } + + // ============ Positions Section ============ + + /** + * Render positions section. + * @param {Object[]} positions - List of position dicts. + */ + renderPositions(positions) { + const container = document.getElementById('positionsContainer'); + if (!container) return; + + container.innerHTML = ''; + + if (!positions || positions.length === 0) { + container.innerHTML = '

No open positions

'; + return; + } + + for (const pos of positions) { + const card = this._createPositionCard(pos); + container.appendChild(card); + } + } + + _createPositionCard(position) { + const card = document.createElement('div'); + card.className = 'position-card'; + + const pl = position.unrealized_pnl || 0; + const plClass = pl >= 0 ? 'positive' : 'negative'; + const plSign = pl >= 0 ? '+' : ''; + + card.innerHTML = ` +
+ ${position.symbol || 'N/A'} + ${position.broker_key || 'paper'} +
+
+
+ Size: + ${this._formatNumber(position.size)} +
+
+ Entry: + ${position.entry_price ? this._formatPrice(position.entry_price) : '-'} +
+
+ P/L: + ${plSign}${pl.toFixed(2)} +
+
+
+ + +
+ `; + return card; + } + + // ============ History Section ============ + + /** + * Render trade history section. + * @param {Object[]} history - List of trade history dicts. + */ + renderHistory(history) { + const container = document.getElementById('historyContainer'); + if (!container) return; + + container.innerHTML = ''; + + if (!history || history.length === 0) { + container.innerHTML = '

No trade history

'; + return; + } + + // Use history-specific card (no close button) + for (const trade of history) { + try { + const card = this._createHistoryCard(trade); + container.appendChild(card); + } catch (error) { + console.error('Error rendering history trade:', error, trade); + } + } + } + + /** + * Create a history card for settled/cancelled trades. + * Unlike active trade cards, history cards have no close button. + * @param {Object} trade - The trade data. + * @returns {HTMLElement} - The history card element. + */ + _createHistoryCard(trade) { + const card = document.createElement('div'); + card.className = 'trade-card trade-history'; + card.setAttribute('data-trade-id', trade.unique_id || trade.tbl_key); + + // Add paper/live class + if (trade.is_paper) { + card.classList.add('trade-paper'); + } + + // Add side class + const side = (trade.side || 'BUY').toUpperCase(); + card.classList.add(side === 'BUY' ? 'trade-buy' : 'trade-sell'); + + // Status badge (closed/cancelled) + const statusBadge = document.createElement('span'); + statusBadge.className = 'trade-status-badge'; + statusBadge.textContent = (trade.status || 'closed').toUpperCase(); + statusBadge.style.cssText = ` + position: absolute; top: 4px; right: 4px; + background: ${trade.status === 'cancelled' ? '#ff9800' : '#9e9e9e'}; + color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; + `; + card.appendChild(statusBadge); + + // Paper badge + if (trade.is_paper) { + const paperBadge = document.createElement('span'); + paperBadge.className = 'trade-paper-badge'; + paperBadge.textContent = 'PAPER'; + card.appendChild(paperBadge); + } + + // Trade info + const info = document.createElement('div'); + info.className = 'trade-info'; + + const symbolRow = document.createElement('div'); + symbolRow.className = 'trade-symbol-row'; + symbolRow.innerHTML = ` + ${side} + ${trade.symbol || 'N/A'} + `; + info.appendChild(symbolRow); + + // Stats + const stats = trade.stats || {}; + const qty = stats.qty_filled || trade.base_order_qty || 0; + const settledPrice = stats.settled_price || stats.opening_price || trade.order_price || 0; + const profit = stats.profit || 0; + const profitClass = profit >= 0 ? 'positive' : 'negative'; + const profitSign = profit >= 0 ? '+' : ''; + + info.innerHTML += ` +
+ Qty: + ${this._formatNumber(qty)} +
+
+ Price: + ${this._formatPrice(settledPrice)} +
+
+ P/L: + ${profitSign}${profit.toFixed(2)} +
+ `; + + card.appendChild(info); + return card; + } + + // ============ Refresh Methods ============ + + async refreshOrders() { + try { + const response = await fetch('/api/manual/orders'); + const data = await response.json(); + if (data.success) { + this.renderOrders(data.orders); + } + } catch (e) { + console.error('Failed to refresh orders:', e); + } + } + + async refreshPositions() { + try { + const response = await fetch('/api/manual/positions'); + const data = await response.json(); + if (data.success) { + this.renderPositions(data.positions); + } + } catch (e) { + console.error('Failed to refresh positions:', e); + } + } + + async refreshHistory() { + try { + const response = await fetch('/api/manual/history?limit=20'); + const data = await response.json(); + if (data.success) { + this.renderHistory(data.history); + } + } catch (e) { + console.error('Failed to refresh history:', e); + } + } + + refreshAll() { + this.refreshOrders(); + this.refreshPositions(); + this.refreshHistory(); + this.updateBrokerStatus(); + } + + // ============ Broker Actions ============ + + /** + * Cancel a specific open order via REST API. + */ + async cancelOrder(orderId, brokerKey) { + try { + const response = await fetch(`/api/manual/orders/${encodeURIComponent(orderId)}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ broker_key: brokerKey }) + }); + const result = await response.json(); + if (result.success) { + this.refreshAll(); + } else { + console.error('Cancel failed:', result.message); + alert('Failed to cancel order: ' + result.message); + } + } catch (error) { + console.error('Cancel order error:', error); + } + } + + /** + * Close a position (filled exposure only) via REST API. + */ + async closePosition(symbol, brokerKey) { + try { + const response = await fetch(`/api/manual/positions/${encodeURIComponent(symbol)}/close`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ broker_key: brokerKey }) + }); + const result = await response.json(); + if (result.success) { + this.refreshAll(); + } else { + console.error('Close position failed:', result.message); + alert('Failed to close position: ' + result.message); + } + } catch (error) { + console.error('Close position error:', error); + } + } + + /** + * Cancel all resting orders for a symbol (explicit user action). + */ + async cancelOrdersForSymbol(symbol, brokerKey) { + try { + const response = await fetch(`/api/manual/orders/symbol/${encodeURIComponent(symbol)}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ broker_key: brokerKey }) + }); + const result = await response.json(); + if (result.success) { + this.refreshAll(); + } else { + console.error('Cancel orders failed:', result.message); + } + } catch (error) { + console.error('Cancel orders error:', error); + } + } + + // ============ Broker Status ============ + + /** + * Get current broker key based on form selection. + */ + _getCurrentBrokerKey() { + if (!this.targetSelect) return 'paper'; + const target = this.targetSelect.value; + if (target === 'test_exchange' || target === 'paper') return 'paper'; + + const testnet = this.testnetCheckbox?.checked ?? true; + return `${target}_${testnet ? 'testnet' : 'production'}`; + } + + /** + * Update broker status bar display. + */ + async updateBrokerStatus() { + const brokerKey = this._getCurrentBrokerKey(); + + try { + const response = await fetch(`/api/manual/balance?broker_key=${encodeURIComponent(brokerKey)}`); + const data = await response.json(); + if (data.success) { + const balanceEl = document.getElementById('brokerBalance'); + const modeEl = document.getElementById('brokerModeIndicator'); + + if (balanceEl) { + const balance = data.available ?? data.total ?? 0; + balanceEl.textContent = `Available: $${balance.toFixed(2)}`; + } + if (modeEl) { + if (brokerKey === 'paper') { + modeEl.textContent = 'PAPER'; + modeEl.className = 'mode-badge mode-paper'; + } else if (brokerKey.includes('testnet')) { + modeEl.textContent = 'TESTNET'; + modeEl.className = 'mode-badge mode-testnet'; + } else { + modeEl.textContent = 'LIVE'; + modeEl.className = 'mode-badge mode-live'; + } + } + } + } catch (e) { + console.warn('Could not fetch balance:', e); + } + } + + // ============ Broker-Aware SELL Disable ============ + + /** + * Check if position exists for symbol and broker. + */ + async _checkPositionExists(symbol, brokerKey) { + try { + const response = await fetch('/api/manual/positions'); + const data = await response.json(); + if (data.success && data.positions) { + return data.positions.some(p => + p.symbol === symbol && + p.broker_key === brokerKey && + (p.size || 0) > 0 + ); + } + } catch (e) { + console.warn('Could not check position:', e); + } + return false; + } + + /** + * Update SELL option availability based on position. + * If SELL is currently selected but becomes invalid, reset to BUY. + */ + async _updateSellAvailability() { + if (!this.sideSelect || !this.symbolInput) return; + + const symbol = this.symbolInput.value; + const brokerKey = this._getCurrentBrokerKey(); + + const hasPosition = await this._checkPositionExists(symbol, brokerKey); + const sellOption = this.sideSelect.querySelector('option[value="SELL"]'); + + if (sellOption) { + sellOption.disabled = !hasPosition; + sellOption.title = hasPosition ? '' : 'No position to sell. Buy first.'; + + // If SELL is currently selected but no longer valid, reset to BUY + if (!hasPosition && this.sideSelect.value === 'SELL') { + this.sideSelect.value = 'BUY'; + } + } + } } @@ -849,9 +1339,13 @@ class Trade { // Update trading pair display this._updateTradingPairDisplay(); - // Fetch existing trades + // Fetch existing trades (legacy path) this.dataManager.fetchTrades(this.comms, this.data); + // Initialize broker event listeners and refresh broker UI + this.initBrokerListeners(this.comms); + this.refreshAll(); + this._initialized = true; console.log("Trade module initialized successfully"); } catch (error) { @@ -893,6 +1387,8 @@ class Trade { this.dataManager.addTrade(data); this.uiManager.updateTradesHtml(this.dataManager.getAllTrades()); this._updateStatistics(); + // Also refresh the new broker UI panels (Orders, Positions, History) + this.refreshAll(); } else { alert(`Failed to create trade: ${data.message}`); } @@ -909,6 +1405,8 @@ class Trade { this.dataManager.removeTrade(tradeId); this.uiManager.removeTradeCard(tradeId); this._updateStatistics(); + // Also refresh the new broker UI panels + this.refreshAll(); // Show P/L notification if (data.final_pl !== undefined) { @@ -960,15 +1458,57 @@ class Trade { // Update the trade in data manager this.dataManager.applyUpdates([data]); - // Update the UI + // Update legacy trade card UI if (data.pl !== undefined && data.pl_pct !== undefined) { this.uiManager.updateTradePL(data.id, data.pl, data.pl_pct); } + // Also update position cards (for filled positions showing unrealized P/L) + // Match by symbol + broker_key to avoid cross-broker contamination + if (data.symbol && (data.pl !== undefined || data.current_price !== undefined)) { + this._updatePositionPL(data.symbol, data.broker_key, data.pl, data.current_price); + } + this._updateStatistics(); } } + /** + * Update P/L display in position cards for a given symbol + broker. + * @param {string} symbol - The trading symbol. + * @param {string} brokerKey - The broker key ('paper' or 'exchange_mode'). + * @param {number} pl - The unrealized P/L. + * @param {number} currentPrice - The current price. + */ + _updatePositionPL(symbol, brokerKey, pl, currentPrice) { + const container = document.getElementById('positionsContainer'); + if (!container) return; + + // Find position card matching this symbol AND broker_key + const cards = container.querySelectorAll('.position-card'); + for (const card of cards) { + const symbolEl = card.querySelector('.position-symbol'); + const brokerEl = card.querySelector('.position-broker'); + + // Match both symbol and broker_key to avoid cross-broker contamination + const symbolMatch = symbolEl && symbolEl.textContent === symbol; + const brokerMatch = !brokerKey || (brokerEl && brokerEl.textContent === brokerKey); + + if (symbolMatch && brokerMatch) { + const plEl = card.querySelector('.position-pl'); + if (plEl && pl !== undefined) { + const plClass = pl >= 0 ? 'positive' : 'negative'; + const plSign = pl >= 0 ? '+' : ''; + plEl.textContent = `${plSign}${pl.toFixed(2)}`; + plEl.className = `position-pl ${plClass}`; + // Flash animation + plEl.classList.add('trade-pl-flash'); + setTimeout(() => plEl.classList.remove('trade-pl-flash'), 300); + } + } + } + } + // ================ Form Methods ================ /** @@ -1045,6 +1585,13 @@ class Trade { const quantity = parseFloat(this.uiManager.qtyInput?.value || 0); + // Get SL/TP and TIF + const stopLossVal = this.uiManager.stopLossInput?.value; + const takeProfitVal = this.uiManager.takeProfitInput?.value; + const stopLoss = stopLossVal ? parseFloat(stopLossVal) : null; + const takeProfit = takeProfitVal ? parseFloat(takeProfitVal) : null; + const timeInForce = this.uiManager.timeInForceSelect?.value || 'GTC'; + // Validation if (!symbol) { alert('Please enter a trading pair.'); @@ -1059,6 +1606,28 @@ class Trade { return; } + // SL/TP validation + if (side.toUpperCase() === 'BUY') { + if (stopLoss && stopLoss >= price) { + alert('Stop Loss must be below entry price for BUY orders.'); + return; + } + if (takeProfit && takeProfit <= price) { + alert('Take Profit must be above entry price for BUY orders.'); + return; + } + } else { + // SELL + if (stopLoss && stopLoss <= price) { + alert('Stop Loss must be above entry price for SELL orders.'); + return; + } + if (takeProfit && takeProfit >= price) { + alert('Take Profit must be below entry price for SELL orders.'); + return; + } + } + // Show confirmation for production live trades if (!isPaperTrade && !testnet) { const proceed = confirm( @@ -1084,6 +1653,9 @@ class Trade { orderType, quantity, testnet, + stopLoss, + takeProfit, + timeInForce, user_name: this.data?.user_name }; diff --git a/src/templates/exchange_info_hud.html b/src/templates/exchange_info_hud.html index be30b72..5225428 100644 --- a/src/templates/exchange_info_hud.html +++ b/src/templates/exchange_info_hud.html @@ -99,25 +99,25 @@ Symbol Side - Quantity + Size Price + Status - {% for symbol, orders in open_orders.items() %} - {% if orders %} - {% for order in orders %} + {% if open_orders %} + {% for order in open_orders %} - {{ symbol }} - {{ order[0] }} - {{ order[1] }} - {{ order[2] }} + {{ order.symbol }} + {{ order.side }} + {{ order.size }} + {{ order.price | default('-') }} + {{ order.status }} {% endfor %} - {% else %} - - No active orders - - {% endif %} - {% endfor %} + {% else %} + + No open orders + + {% endif %} diff --git a/src/templates/new_trade_popup.html b/src/templates/new_trade_popup.html index 78d43df..a6e382d 100644 --- a/src/templates/new_trade_popup.html +++ b/src/templates/new_trade_popup.html @@ -1,5 +1,5 @@ -