Add stop loss, take profit, and time-in-force support for manual trading
Phase 4: Advanced Order Management - Add stop_loss and take_profit fields to Trade class with DB persistence - Add SL/TP form fields in new trade popup with validation - Implement SL/TP evaluation in PaperBroker.update() - Auto-close positions when SL/TP thresholds are crossed - Emit sltp_triggered events to frontend with notifications - Add Time-in-Force dropdown (GTC, IOC, FOK) to trade form - Add persistence for position_sltp tracking across state save/load - Add unit tests for SL/TP functionality (5 new tests) - Add Trade class SL/TP field tests (2 new tests) Also includes Phase 3 manual trading broker hardening: - Fix broker UI not wired on startup - Fix cancelled orders vanishing from history - Fix positions not live-updating P/L - Fix paper pricing exchange-ambiguity - Simplify paper trades to single synthetic market Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2dccabfac3
commit
2ae087a099
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
280
src/app.py
280
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/<order_id>/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/<path:symbol>/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/<path: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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = '<p class="no-data-msg">No open orders</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'orders-table';
|
||||
table.innerHTML = `
|
||||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Side</th>
|
||||
<th>Size</th>
|
||||
<th>Price</th>
|
||||
<th>Broker</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<td>${order.symbol || 'N/A'}</td>
|
||||
<td>${(order.side || '').toUpperCase()}</td>
|
||||
<td>${this._formatNumber(order.size)}</td>
|
||||
<td>${order.price ? this._formatPrice(order.price) : 'MARKET'}</td>
|
||||
<td>${order.broker_key || 'paper'}</td>
|
||||
<td>
|
||||
<button class="btn-cancel" onclick="UI.trade.cancelOrder('${order.order_id}', '${order.broker_key || 'paper'}')">
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
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 = '<p class="no-data-msg">No open positions</p>';
|
||||
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 = `
|
||||
<div class="position-header">
|
||||
<span class="position-symbol">${position.symbol || 'N/A'}</span>
|
||||
<span class="position-broker">${position.broker_key || 'paper'}</span>
|
||||
</div>
|
||||
<div class="position-details">
|
||||
<div class="position-row">
|
||||
<span>Size:</span>
|
||||
<span>${this._formatNumber(position.size)}</span>
|
||||
</div>
|
||||
<div class="position-row">
|
||||
<span>Entry:</span>
|
||||
<span>${position.entry_price ? this._formatPrice(position.entry_price) : '-'}</span>
|
||||
</div>
|
||||
<div class="position-row">
|
||||
<span>P/L:</span>
|
||||
<span class="position-pl ${plClass}">${plSign}${pl.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="position-actions">
|
||||
<button class="btn-close-position" onclick="UI.trade.closePosition('${position.symbol}', '${position.broker_key || 'paper'}')">
|
||||
Close Position
|
||||
</button>
|
||||
<button class="btn-cancel-orders" onclick="UI.trade.cancelOrdersForSymbol('${position.symbol}', '${position.broker_key || 'paper'}')" title="Cancel resting orders">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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 = '<p class="no-data-msg">No trade history</p>';
|
||||
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 = `
|
||||
<span class="trade-side ${side.toLowerCase()}">${side}</span>
|
||||
<span class="trade-symbol">${trade.symbol || 'N/A'}</span>
|
||||
`;
|
||||
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 += `
|
||||
<div class="trade-row">
|
||||
<span class="trade-label">Qty:</span>
|
||||
<span class="trade-value">${this._formatNumber(qty)}</span>
|
||||
</div>
|
||||
<div class="trade-row">
|
||||
<span class="trade-label">Price:</span>
|
||||
<span class="trade-value">${this._formatPrice(settledPrice)}</span>
|
||||
</div>
|
||||
<div class="trade-row">
|
||||
<span class="trade-label">P/L:</span>
|
||||
<span class="trade-pl ${profitClass}">${profitSign}${profit.toFixed(2)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -99,25 +99,25 @@
|
|||
<tr>
|
||||
<th>Symbol</th>
|
||||
<th>Side</th>
|
||||
<th>Quantity</th>
|
||||
<th>Size</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
{% for symbol, orders in open_orders.items() %}
|
||||
{% if orders %}
|
||||
{% for order in orders %}
|
||||
{% if open_orders %}
|
||||
{% for order in open_orders %}
|
||||
<tr>
|
||||
<td>{{ symbol }}</td>
|
||||
<td>{{ order[0] }}</td>
|
||||
<td>{{ order[1] }}</td>
|
||||
<td>{{ order[2] }}</td>
|
||||
<td>{{ order.symbol }}</td>
|
||||
<td>{{ order.side }}</td>
|
||||
<td>{{ order.size }}</td>
|
||||
<td>{{ order.price | default('-') }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No active orders</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No open orders</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!-- New Trade Form Popup -->
|
||||
<div class="form-popup" id="new_trade_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 480px; border-radius: 10px;">
|
||||
<div class="form-popup" id="new_trade_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 580px; border-radius: 10px;">
|
||||
|
||||
<!-- Draggable Header Section -->
|
||||
<div class="dialog-header" id="trade_draggable_header">
|
||||
|
|
@ -71,6 +71,24 @@
|
|||
<!-- Value field -->
|
||||
<label for="tradeValue"><b>Est. Value:</b></label>
|
||||
<output name="tradeValue" id="tradeValue" for="quantity price">0</output>
|
||||
|
||||
<!-- Stop Loss (optional) -->
|
||||
<label for="stopLoss"><b>Stop Loss:</b></label>
|
||||
<input type="number" min="0" step="0.00000001" name="stopLoss" id="stopLoss"
|
||||
placeholder="Optional - triggers auto-close" style="width: 100%;">
|
||||
|
||||
<!-- Take Profit (optional) -->
|
||||
<label for="takeProfit"><b>Take Profit:</b></label>
|
||||
<input type="number" min="0" step="0.00000001" name="takeProfit" id="takeProfit"
|
||||
placeholder="Optional - triggers auto-close" style="width: 100%;">
|
||||
|
||||
<!-- Time in Force -->
|
||||
<label for="timeInForce"><b>Time in Force:</b></label>
|
||||
<select name="timeInForce" id="timeInForce" style="width: 100%;">
|
||||
<option value="GTC">Good 'til Cancelled (GTC)</option>
|
||||
<option value="IOC">Immediate or Cancel (IOC)</option>
|
||||
<option value="FOK">Fill or Kill (FOK)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
|
|
|
|||
|
|
@ -1,10 +1,38 @@
|
|||
<div id="trade_content" class="content">
|
||||
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Trade</button>
|
||||
<hr>
|
||||
<h3>Active Trades</h3>
|
||||
<div id="tradesContainer" class="trades-container">
|
||||
<p class="no-data-msg">No active trades</p>
|
||||
<div class="broker-status-bar" id="brokerStatusBar">
|
||||
<span id="brokerModeIndicator" class="mode-badge mode-paper">PAPER</span>
|
||||
<span id="brokerBalance">Available: --</span>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Order</button>
|
||||
<hr>
|
||||
|
||||
<!-- Open Orders Section -->
|
||||
<section class="trade-section">
|
||||
<h3>Open Orders</h3>
|
||||
<div id="openOrdersContainer" class="orders-container">
|
||||
<p class="no-data-msg">No open orders</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Positions Section -->
|
||||
<section class="trade-section">
|
||||
<h3>Positions</h3>
|
||||
<div id="positionsContainer" class="positions-container">
|
||||
<p class="no-data-msg">No open positions</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trade History Section -->
|
||||
<section class="trade-section">
|
||||
<h3>Trade History</h3>
|
||||
<div id="historyContainer" class="trades-container">
|
||||
<p class="no-data-msg">No trade history</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Legacy container for backwards compatibility -->
|
||||
<div id="tradesContainer" class="trades-container" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -208,4 +236,206 @@
|
|||
font-size: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Broker status bar */
|
||||
.broker-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
font-weight: bold;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mode-badge.mode-paper {
|
||||
background: #9e9e9e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.mode-testnet {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.mode-live {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#brokerBalance {
|
||||
color: #333;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Trade sections */
|
||||
.trade-section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.trade-section h3 {
|
||||
font-size: 13px;
|
||||
margin: 8px 0 5px 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
/* Orders container */
|
||||
.orders-container {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.orders-table th,
|
||||
.orders-table td {
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.orders-table th {
|
||||
background: #f9f9f9;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.order-row.order-buy {
|
||||
border-left: 3px solid #4caf50;
|
||||
}
|
||||
|
||||
.order-row.order-sell {
|
||||
border-left: 3px solid #f44336;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #ff5252;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #ff1744;
|
||||
}
|
||||
|
||||
/* Positions container */
|
||||
.positions-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.position-card {
|
||||
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
min-width: 150px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.position-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.position-symbol {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.position-broker {
|
||||
font-size: 9px;
|
||||
color: #888;
|
||||
background: #eee;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.position-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.position-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.position-row span:first-child {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.position-row span:last-child {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.position-pl {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.position-pl.positive {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.position-pl.negative {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.position-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.btn-close-position {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-close-position:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.btn-cancel-orders {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel-orders:hover {
|
||||
background: #f57c00;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
609
src/trade.py
609
src/trade.py
|
|
@ -23,15 +23,22 @@ class Trade:
|
|||
status: str | None = None, stats: dict[str, Any] | None = None,
|
||||
order: Any | None = None, fee: float = 0.001, strategy_id: str | None = None,
|
||||
is_paper: bool = False, creator: int | None = None, created_at: str | None = None,
|
||||
tbl_key: str | None = None, testnet: bool = False, exchange: str | None = None):
|
||||
tbl_key: str | None = None, testnet: bool = False, exchange: str | None = None,
|
||||
broker_kind: str | None = None, broker_mode: str | None = None,
|
||||
broker_exchange: str | None = None, broker_order_id: str | None = None,
|
||||
exchange_order_id: str | None = None,
|
||||
stop_loss: float | None = None, take_profit: float | None = None):
|
||||
"""
|
||||
Initializes a Trade instance with all necessary attributes.
|
||||
"""
|
||||
self.unique_id = unique_id or uuid.uuid4().hex
|
||||
self.tbl_key = tbl_key or self.unique_id
|
||||
self.target = target
|
||||
# exchange: the actual exchange to use for price data (for paper trades, this is the user's selected exchange)
|
||||
self.exchange = exchange or (target if target not in ['test_exchange', 'paper', 'Paper Trade'] else 'binance')
|
||||
# exchange: for live trades, the actual exchange; for paper trades, use 'paper' (single synthetic market)
|
||||
if target in ['test_exchange', 'paper', 'Paper Trade']:
|
||||
self.exchange = 'paper' # Paper trades use a single synthetic market
|
||||
else:
|
||||
self.exchange = exchange or target
|
||||
self.symbol = symbol
|
||||
self.side = side.upper()
|
||||
self.order_price = order_price
|
||||
|
|
@ -45,6 +52,17 @@ class Trade:
|
|||
self.creator = creator
|
||||
self.created_at = created_at or dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
|
||||
# Broker integration fields
|
||||
self.broker_kind = broker_kind # 'paper' or 'live'
|
||||
self.broker_mode = broker_mode # 'testnet', 'production', or 'paper'
|
||||
self.broker_exchange = broker_exchange # Exchange name (for live)
|
||||
self.broker_order_id = broker_order_id # Local broker order ID
|
||||
self.exchange_order_id = exchange_order_id # Live exchange order ID
|
||||
|
||||
# Stop Loss / Take Profit (triggers auto-close when price crosses threshold)
|
||||
self.stop_loss = stop_loss
|
||||
self.take_profit = take_profit
|
||||
|
||||
if status is None:
|
||||
self.status = 'inactive'
|
||||
self.stats = {
|
||||
|
|
@ -88,7 +106,14 @@ class Trade:
|
|||
'is_paper': self.is_paper,
|
||||
'testnet': self.testnet,
|
||||
'creator': self.creator,
|
||||
'created_at': self.created_at
|
||||
'created_at': self.created_at,
|
||||
'broker_kind': self.broker_kind,
|
||||
'broker_mode': self.broker_mode,
|
||||
'broker_exchange': self.broker_exchange,
|
||||
'broker_order_id': self.broker_order_id,
|
||||
'exchange_order_id': self.exchange_order_id,
|
||||
'stop_loss': self.stop_loss,
|
||||
'take_profit': self.take_profit
|
||||
}
|
||||
|
||||
def get_position_size(self) -> float:
|
||||
|
|
@ -220,6 +245,7 @@ class Trades:
|
|||
self.users = users
|
||||
self.data_cache = data_cache
|
||||
self.exchange_interface: Any | None = None # Define the type based on your exchange interface
|
||||
self.manual_broker_manager: Any | None = None # ManualTradingBrokerManager for broker-based trading
|
||||
self.exchange_fees = {'maker': 0.001, 'taker': 0.001}
|
||||
self.hedge_mode = False
|
||||
self.side: str | None = None
|
||||
|
|
@ -258,7 +284,12 @@ class Trades:
|
|||
is_paper INTEGER DEFAULT 0,
|
||||
testnet INTEGER DEFAULT 0,
|
||||
created_at TEXT,
|
||||
tbl_key TEXT UNIQUE
|
||||
tbl_key TEXT UNIQUE,
|
||||
broker_kind TEXT,
|
||||
broker_mode TEXT,
|
||||
broker_exchange TEXT,
|
||||
broker_order_id TEXT,
|
||||
exchange_order_id TEXT
|
||||
)
|
||||
"""
|
||||
self.data_cache.db.execute_sql(create_sql, params=[])
|
||||
|
|
@ -266,6 +297,8 @@ class Trades:
|
|||
else:
|
||||
# Ensure testnet column exists for existing databases
|
||||
self._ensure_testnet_column()
|
||||
# Ensure broker columns exist for existing databases
|
||||
self._ensure_broker_columns()
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring trades table exists: {e}", exc_info=True)
|
||||
|
||||
|
|
@ -285,6 +318,35 @@ class Trades:
|
|||
except Exception as e:
|
||||
logger.debug(f"Could not add testnet column: {e}")
|
||||
|
||||
def _ensure_broker_columns(self) -> None:
|
||||
"""Add broker tracking columns to trades table if they don't exist."""
|
||||
broker_columns = [
|
||||
('broker_kind', 'TEXT'),
|
||||
('broker_mode', 'TEXT'),
|
||||
('broker_exchange', 'TEXT'),
|
||||
('broker_order_id', 'TEXT'),
|
||||
('exchange_order_id', 'TEXT'),
|
||||
('stop_loss', 'REAL'),
|
||||
('take_profit', 'REAL'),
|
||||
]
|
||||
try:
|
||||
result = self.data_cache.db.execute_sql(
|
||||
"PRAGMA table_info(trades)", params=[]
|
||||
)
|
||||
existing_columns = {row[1] for row in result} if result else set()
|
||||
|
||||
for col_name, col_type in broker_columns:
|
||||
if col_name not in existing_columns:
|
||||
try:
|
||||
self.data_cache.db.execute_sql(
|
||||
f"ALTER TABLE trades ADD COLUMN {col_name} {col_type}", params=[]
|
||||
)
|
||||
logger.info(f"Added {col_name} column to trades table")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not add {col_name} column: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not ensure broker columns: {e}")
|
||||
|
||||
def _create_cache(self) -> None:
|
||||
"""Create the trades cache in DataCache."""
|
||||
try:
|
||||
|
|
@ -311,22 +373,26 @@ class Trades:
|
|||
"is_paper",
|
||||
"testnet",
|
||||
"created_at",
|
||||
"tbl_key"
|
||||
"tbl_key",
|
||||
"broker_kind",
|
||||
"broker_mode",
|
||||
"broker_exchange",
|
||||
"broker_order_id",
|
||||
"exchange_order_id"
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Cache 'trades' may already exist: {e}")
|
||||
|
||||
def _load_trades_from_db(self) -> None:
|
||||
"""Load all active trades from database into memory."""
|
||||
"""Load all trades from database into memory (active + settled)."""
|
||||
try:
|
||||
trades_df = self.data_cache.get_all_rows_from_datacache(cache_name='trades')
|
||||
if trades_df is not None and not trades_df.empty:
|
||||
active_count = 0
|
||||
settled_count = 0
|
||||
for _, row in trades_df.iterrows():
|
||||
# Only load non-closed trades
|
||||
status = row.get('status', 'inactive')
|
||||
if status == 'closed':
|
||||
continue
|
||||
|
||||
# Parse stats JSON
|
||||
stats_json = row.get('stats_json', '{}')
|
||||
|
|
@ -352,11 +418,26 @@ class Trades:
|
|||
testnet=bool(row.get('testnet', 0)),
|
||||
creator=row.get('creator'),
|
||||
created_at=row.get('created_at'),
|
||||
tbl_key=row.get('tbl_key')
|
||||
tbl_key=row.get('tbl_key'),
|
||||
broker_kind=row.get('broker_kind'),
|
||||
broker_mode=row.get('broker_mode'),
|
||||
broker_exchange=row.get('broker_exchange'),
|
||||
broker_order_id=row.get('broker_order_id'),
|
||||
exchange_order_id=row.get('exchange_order_id'),
|
||||
stop_loss=row.get('stop_loss'),
|
||||
take_profit=row.get('take_profit')
|
||||
)
|
||||
self.active_trades[trade.unique_id] = trade
|
||||
self.stats['num_trades'] += 1
|
||||
logger.info(f"Loaded {len(self.active_trades)} active trades from database")
|
||||
|
||||
# Route to appropriate collection based on status
|
||||
if status in ['closed', 'cancelled']:
|
||||
self.settled_trades[trade.unique_id] = trade
|
||||
settled_count += 1
|
||||
else:
|
||||
self.active_trades[trade.unique_id] = trade
|
||||
self.stats['num_trades'] += 1
|
||||
active_count += 1
|
||||
|
||||
logger.info(f"Loaded {active_count} active trades and {settled_count} settled trades from database")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading trades from database: {e}", exc_info=True)
|
||||
|
||||
|
|
@ -374,7 +455,9 @@ class Trades:
|
|||
columns = (
|
||||
"creator", "unique_id", "target", "symbol", "side", "order_type",
|
||||
"order_price", "base_order_qty", "time_in_force", "fee", "status",
|
||||
"stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key"
|
||||
"stats_json", "strategy_id", "is_paper", "testnet", "created_at", "tbl_key",
|
||||
"broker_kind", "broker_mode", "broker_exchange", "broker_order_id", "exchange_order_id",
|
||||
"stop_loss", "take_profit"
|
||||
)
|
||||
|
||||
stats_json = json.dumps(trade.stats) if trade.stats else '{}'
|
||||
|
|
@ -396,7 +479,14 @@ class Trades:
|
|||
int(trade.is_paper),
|
||||
int(trade.testnet),
|
||||
trade.created_at,
|
||||
trade.tbl_key
|
||||
trade.tbl_key,
|
||||
trade.broker_kind,
|
||||
trade.broker_mode,
|
||||
trade.broker_exchange,
|
||||
trade.broker_order_id,
|
||||
trade.exchange_order_id,
|
||||
trade.stop_loss,
|
||||
trade.take_profit
|
||||
)
|
||||
|
||||
# Check if trade already exists
|
||||
|
|
@ -460,7 +550,8 @@ class Trades:
|
|||
|
||||
def new_trade(self, target: str, symbol: str, price: float, side: str,
|
||||
order_type: str, qty: float, user_id: int = None,
|
||||
strategy_id: str = None, testnet: bool = False, exchange: str = None) -> tuple[str, str | None]:
|
||||
strategy_id: str = None, testnet: bool = False, exchange: str = None,
|
||||
stop_loss: float = None, take_profit: float = None) -> tuple[str, str | None]:
|
||||
"""
|
||||
Creates a new trade (paper or live).
|
||||
|
||||
|
|
@ -476,9 +567,21 @@ class Trades:
|
|||
:param exchange: The actual exchange for price data (for paper trades).
|
||||
:return: Tuple of (status, trade_id or error message).
|
||||
"""
|
||||
from brokers.base_broker import OrderSide, OrderType, OrderStatus
|
||||
|
||||
# Determine if this is a paper trade
|
||||
is_paper = target in ['test_exchange', 'paper', 'Paper Trade']
|
||||
|
||||
# === PRODUCTION SAFETY GATE (BEFORE any broker/exchange creation) ===
|
||||
if not is_paper and not testnet:
|
||||
import config
|
||||
if not getattr(config, 'ALLOW_LIVE_PRODUCTION', False):
|
||||
logger.warning(
|
||||
f"Production trading blocked: ALLOW_LIVE_PRODUCTION not set. "
|
||||
f"User {user_id} attempted production trade on {target}."
|
||||
)
|
||||
return 'Error', 'Production trading is disabled. Set BRIGHTER_ALLOW_LIVE_PROD=true to enable.'
|
||||
|
||||
# For live trades, validate exchange is configured BEFORE creating trade
|
||||
if not is_paper:
|
||||
if not self.exchange_connected():
|
||||
|
|
@ -490,10 +593,10 @@ class Trades:
|
|||
return 'Error', 'You must be logged in to place live trades.'
|
||||
|
||||
try:
|
||||
exchange = self.exchange_interface.get_exchange(ename=target, uname=user_name)
|
||||
if not exchange or not exchange.configured:
|
||||
exchange_obj = self.exchange_interface.get_exchange(ename=target, uname=user_name)
|
||||
if not exchange_obj or not exchange_obj.configured:
|
||||
return 'Error', f'Exchange "{target}" is not configured with API keys. Please configure it in the Exchanges panel first.'
|
||||
except ValueError as e:
|
||||
except ValueError:
|
||||
return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.'
|
||||
|
||||
# For market orders, fetch the current price from exchange
|
||||
|
|
@ -527,41 +630,133 @@ class Trades:
|
|||
logger.warning(f"Could not fetch trading fees for {symbol}: {e}, using default {effective_fee}")
|
||||
|
||||
try:
|
||||
trade = Trade(
|
||||
target=target,
|
||||
exchange=exchange,
|
||||
symbol=symbol,
|
||||
side=side.upper(),
|
||||
order_price=effective_price,
|
||||
base_order_qty=float(qty),
|
||||
order_type=order_type.upper() if order_type else 'MARKET',
|
||||
strategy_id=strategy_id,
|
||||
is_paper=is_paper,
|
||||
testnet=testnet,
|
||||
creator=user_id,
|
||||
fee=effective_fee
|
||||
)
|
||||
# === BROKER-BASED ORDER PLACEMENT ===
|
||||
if self.manual_broker_manager and user_id:
|
||||
# Get appropriate broker FIRST (need it for SELL validation)
|
||||
if is_paper:
|
||||
broker = self.manual_broker_manager.get_paper_broker(user_id)
|
||||
broker_kind = 'paper'
|
||||
broker_mode = 'paper'
|
||||
broker_exchange = None
|
||||
broker_key = 'paper'
|
||||
else:
|
||||
user_name = self._get_user_name(user_id)
|
||||
broker = self.manual_broker_manager.get_live_broker(
|
||||
user_id, target, testnet, user_name
|
||||
)
|
||||
if not broker:
|
||||
return 'Error', f'Could not create broker for exchange "{target}".'
|
||||
broker_kind = 'live'
|
||||
broker_mode = 'testnet' if testnet else 'production'
|
||||
broker_exchange = target
|
||||
broker_key = f"{target}_{broker_mode}"
|
||||
|
||||
# Inventory-only SELL (applies to BOTH paper and live)
|
||||
if side.upper() == 'SELL':
|
||||
position = broker.get_position(symbol)
|
||||
if not position or position.size <= 0:
|
||||
return 'Error', 'Cannot sell: no position in this symbol. Buy first.'
|
||||
|
||||
# Place order through broker
|
||||
order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL
|
||||
order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return 'Error', result.message or 'Order placement failed'
|
||||
|
||||
# Map OrderStatus to trade status string
|
||||
status_map = {
|
||||
OrderStatus.PENDING: 'pending',
|
||||
OrderStatus.OPEN: 'open',
|
||||
OrderStatus.FILLED: 'filled',
|
||||
OrderStatus.PARTIALLY_FILLED: 'part-filled',
|
||||
OrderStatus.CANCELLED: 'cancelled',
|
||||
OrderStatus.REJECTED: 'rejected',
|
||||
OrderStatus.EXPIRED: 'expired',
|
||||
}
|
||||
trade_status = status_map.get(result.status, 'pending')
|
||||
|
||||
# Create Trade with full broker tracking
|
||||
trade = Trade(
|
||||
target=target,
|
||||
exchange=exchange or (target if not is_paper else 'binance'),
|
||||
symbol=symbol,
|
||||
side=side.upper(),
|
||||
order_price=effective_price,
|
||||
base_order_qty=float(qty),
|
||||
order_type=order_type.upper() if order_type else 'MARKET',
|
||||
strategy_id=strategy_id,
|
||||
is_paper=is_paper,
|
||||
testnet=testnet,
|
||||
creator=user_id,
|
||||
fee=effective_fee,
|
||||
broker_kind=broker_kind,
|
||||
broker_mode=broker_mode,
|
||||
broker_exchange=broker_exchange,
|
||||
broker_order_id=result.order_id,
|
||||
exchange_order_id=result.exchange_order_id,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit
|
||||
)
|
||||
trade.status = trade_status
|
||||
|
||||
# Update stats if order was filled immediately (market orders)
|
||||
if result.status == OrderStatus.FILLED:
|
||||
trade.stats['qty_filled'] = result.filled_qty or float(qty)
|
||||
trade.stats['opening_price'] = result.filled_price or effective_price
|
||||
trade.stats['opening_value'] = trade.stats['qty_filled'] * trade.stats['opening_price']
|
||||
trade.stats['current_value'] = trade.stats['opening_value']
|
||||
|
||||
logger.info(f"Broker trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price} "
|
||||
f"(broker_kind={broker_kind}, status={trade_status})")
|
||||
|
||||
if is_paper:
|
||||
# Paper trade: simulate immediate fill
|
||||
trade.status = 'filled'
|
||||
trade.stats['qty_filled'] = trade.base_order_qty
|
||||
trade.stats['opening_price'] = trade.order_price
|
||||
trade.stats['opening_value'] = trade.base_order_qty * trade.order_price
|
||||
trade.stats['current_value'] = trade.stats['opening_value']
|
||||
logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
|
||||
else:
|
||||
# Live trade: place order on exchange
|
||||
mode_str = "testnet" if testnet else "production"
|
||||
logger.info(f"Live trade ({mode_str}): {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
|
||||
# === LEGACY PATH (no broker manager) ===
|
||||
trade = Trade(
|
||||
target=target,
|
||||
exchange=exchange,
|
||||
symbol=symbol,
|
||||
side=side.upper(),
|
||||
order_price=effective_price,
|
||||
base_order_qty=float(qty),
|
||||
order_type=order_type.upper() if order_type else 'MARKET',
|
||||
strategy_id=strategy_id,
|
||||
is_paper=is_paper,
|
||||
testnet=testnet,
|
||||
creator=user_id,
|
||||
fee=effective_fee,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit
|
||||
)
|
||||
|
||||
if not self.exchange_connected():
|
||||
return 'Error', 'No exchange connected'
|
||||
if is_paper:
|
||||
# Paper trade: simulate immediate fill
|
||||
trade.status = 'filled'
|
||||
trade.stats['qty_filled'] = trade.base_order_qty
|
||||
trade.stats['opening_price'] = trade.order_price
|
||||
trade.stats['opening_value'] = trade.base_order_qty * trade.order_price
|
||||
trade.stats['current_value'] = trade.stats['opening_value']
|
||||
logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
|
||||
else:
|
||||
# Live trade: place order on exchange (legacy path)
|
||||
mode_str = "testnet" if testnet else "production"
|
||||
logger.info(f"Live trade ({mode_str}): {trade.unique_id} {side} {qty} {symbol} @ {effective_price}")
|
||||
|
||||
user_name = self._get_user_name(user_id) if user_id else 'unknown'
|
||||
status, msg = self.place_order(trade, user_name=user_name)
|
||||
if status != 'success':
|
||||
return 'Error', msg
|
||||
if not self.exchange_connected():
|
||||
return 'Error', 'No exchange connected'
|
||||
|
||||
user_name = self._get_user_name(user_id) if user_id else 'unknown'
|
||||
status, msg = self.place_order(trade, user_name=user_name)
|
||||
if status != 'success':
|
||||
return 'Error', msg
|
||||
|
||||
# Add to active trades
|
||||
self.active_trades[trade.unique_id] = trade
|
||||
|
|
@ -598,6 +793,35 @@ class Trades:
|
|||
else:
|
||||
return [trade.to_json() for trade in user_trades]
|
||||
|
||||
def get_trade_history(self, user_id: int, limit: int = 50) -> list[dict]:
|
||||
"""
|
||||
Get settled/cancelled trade history for a user.
|
||||
|
||||
Only includes trades that are truly finished (status='closed' or 'cancelled').
|
||||
Active positions (status='filled') belong in the Positions panel, not history.
|
||||
|
||||
:param user_id: The user ID.
|
||||
:param limit: Maximum number of trades to return.
|
||||
:return: List of trade dicts, most recent first.
|
||||
"""
|
||||
history = []
|
||||
|
||||
# From in-memory settled trades (these are truly closed)
|
||||
for trade_id, trade in self.settled_trades.items():
|
||||
if trade.creator == user_id:
|
||||
history.append(trade.to_json())
|
||||
|
||||
# Also check active trades for 'cancelled' status only
|
||||
# Note: 'filled' = open position (not history), 'closed' should be in settled_trades
|
||||
for trade_id, trade in self.active_trades.items():
|
||||
if trade.creator == user_id and trade.status == 'cancelled':
|
||||
history.append(trade.to_json())
|
||||
|
||||
# Sort by timestamp descending (most recent first)
|
||||
history.sort(key=lambda t: t.get('created_at', 0), reverse=True)
|
||||
|
||||
return history[:limit]
|
||||
|
||||
def buy(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]:
|
||||
"""
|
||||
Executes a buy order.
|
||||
|
|
@ -1018,6 +1242,69 @@ class Trades:
|
|||
_debug_logger.debug(f"=== Trades.update() returning: {r_update} ===")
|
||||
return r_update
|
||||
|
||||
def update_prices_only(self, price_updates: dict[str, float]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Update current prices and P/L for active trades.
|
||||
|
||||
This method ONLY updates prices and P/L calculations. It does NOT poll brokers
|
||||
for order status - that happens in strategy_execution_loop via ManualTradingBrokerManager.
|
||||
|
||||
:param price_updates: Dictionary mapping (exchange:symbol) or symbol to prices.
|
||||
:return: List of dictionaries containing updated trade data.
|
||||
"""
|
||||
r_update = []
|
||||
|
||||
for trade_id, trade in list(self.active_trades.items()):
|
||||
symbol = trade.symbol
|
||||
exchange = getattr(trade, 'exchange', None) or trade.target
|
||||
|
||||
# Resolve price from updates
|
||||
exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None
|
||||
current_price = price_updates.get(exchange_key) if exchange_key else None
|
||||
|
||||
if current_price is None:
|
||||
current_price = price_updates.get(symbol)
|
||||
|
||||
if current_price is None:
|
||||
# Try to find a matching symbol
|
||||
for price_key, price in price_updates.items():
|
||||
price_symbol = price_key.split(':')[-1] if ':' in price_key else price_key
|
||||
norm_trade = symbol.upper().replace('/', '')
|
||||
norm_price = price_symbol.upper().replace('/', '')
|
||||
if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'):
|
||||
current_price = price
|
||||
break
|
||||
|
||||
if current_price is None:
|
||||
continue # No price available for this trade
|
||||
|
||||
# Skip inactive or closed trades
|
||||
if trade.status in ['inactive', 'closed']:
|
||||
continue
|
||||
|
||||
# Update P/L values (no broker polling)
|
||||
trade.update(current_price)
|
||||
trade_status = trade.status
|
||||
|
||||
if trade_status in ['updated', 'filled', 'part-filled']:
|
||||
# Compute broker_key for frontend position matching
|
||||
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||
update_data = {
|
||||
'status': trade_status,
|
||||
'id': trade.unique_id,
|
||||
'symbol': trade.symbol,
|
||||
'broker_key': broker_key,
|
||||
'pl': trade.stats.get('profit', 0.0),
|
||||
'pl_pct': trade.stats.get('profit_pct', 0.0),
|
||||
'current_price': current_price
|
||||
}
|
||||
r_update.append(update_data)
|
||||
else:
|
||||
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||
r_update.append({'id': trade.unique_id, 'status': trade_status, 'symbol': trade.symbol, 'broker_key': broker_key})
|
||||
|
||||
return r_update
|
||||
|
||||
def close_trade(self, trade_id: str, current_price: float = None) -> dict:
|
||||
"""
|
||||
Closes a specific trade by settling it.
|
||||
|
|
@ -1087,6 +1374,179 @@ class Trades:
|
|||
logger.error(f"Error closing trade '{trade_id}': {e}", exc_info=True)
|
||||
return {"success": False, "message": f"Error closing trade: {str(e)}"}
|
||||
|
||||
def close_position(self, user_id: int, symbol: str, broker_key: str) -> dict:
|
||||
"""
|
||||
Close filled exposure for a symbol (position-first operation).
|
||||
|
||||
This only affects filled/part-filled trades:
|
||||
- Fully filled trades: settle entire qty with P/L calculation
|
||||
- Part-filled trades: settle filled portion, cancel unfilled remainder
|
||||
- Pending/open orders: NOT affected (use cancel_orders_for_symbol for those)
|
||||
|
||||
:param user_id: The user ID.
|
||||
:param symbol: Trading symbol to close.
|
||||
:param broker_key: The broker key ('paper' or 'exchange_production').
|
||||
:return: Dict with success status and details.
|
||||
"""
|
||||
if not self.manual_broker_manager:
|
||||
return {"success": False, "message": "Broker manager not configured"}
|
||||
|
||||
result = self.manual_broker_manager.close_position(user_id, symbol, broker_key)
|
||||
|
||||
if result.get('success'):
|
||||
close_price = result.get('filled_price', 0.0)
|
||||
trades_closed = 0
|
||||
|
||||
for trade_id, trade in list(self.active_trades.items()):
|
||||
# Check if this trade belongs to the same broker
|
||||
if broker_key == 'paper':
|
||||
matches_broker = trade.broker_kind == 'paper'
|
||||
else:
|
||||
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
|
||||
|
||||
if not (trade.symbol == symbol and matches_broker and trade.creator == user_id):
|
||||
continue
|
||||
|
||||
# Handle based on trade status
|
||||
if trade.status == 'filled':
|
||||
# Fully filled - settle entire qty
|
||||
if close_price <= 0:
|
||||
close_price = self._get_close_price(trade)
|
||||
trade.settle(qty=trade.stats.get('qty_filled', trade.base_order_qty), price=close_price)
|
||||
trade.status = 'closed'
|
||||
trades_closed += 1
|
||||
|
||||
elif trade.status == 'part-filled':
|
||||
# Part filled - settle only the filled portion, cancel the rest
|
||||
if close_price <= 0:
|
||||
close_price = self._get_close_price(trade)
|
||||
|
||||
filled_qty = trade.stats.get('qty_filled', 0)
|
||||
if filled_qty > 0:
|
||||
trade.settle(qty=filled_qty, price=close_price)
|
||||
|
||||
# Cancel the unfilled remainder through broker
|
||||
if trade.broker_order_id:
|
||||
self.manual_broker_manager.cancel_order(
|
||||
user_id, trade.broker_order_id, broker_key
|
||||
)
|
||||
|
||||
trade.status = 'closed'
|
||||
unfilled = trade.base_order_qty - filled_qty
|
||||
logger.info(f"Part-filled trade {trade_id}: settled {filled_qty}, "
|
||||
f"cancelled remainder of {unfilled}")
|
||||
trades_closed += 1
|
||||
|
||||
elif trade.status in ['pending', 'open', 'unfilled']:
|
||||
# No fills - skip (these are just resting orders, not positions)
|
||||
continue
|
||||
|
||||
else:
|
||||
# Already closed/cancelled - skip
|
||||
continue
|
||||
|
||||
# Save and move to settled
|
||||
self._save_trade(trade)
|
||||
del self.active_trades[trade_id]
|
||||
self.settled_trades[trade_id] = trade
|
||||
self.stats['num_trades'] -= 1
|
||||
|
||||
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
|
||||
|
||||
return result
|
||||
|
||||
def _get_close_price(self, trade) -> float:
|
||||
"""Get current price for settlement."""
|
||||
if self.exchange_interface:
|
||||
try:
|
||||
return self.exchange_interface.get_price(trade.symbol)
|
||||
except Exception:
|
||||
pass
|
||||
return trade.stats.get('current_price', trade.order_price)
|
||||
|
||||
def cancel_order(self, trade_id: str) -> dict:
|
||||
"""
|
||||
Cancel a specific unfilled order.
|
||||
|
||||
:param trade_id: The unique ID of the trade/order to cancel.
|
||||
:return: Dict with success status and message.
|
||||
"""
|
||||
trade = self.get_trade_by_id(trade_id)
|
||||
if not trade:
|
||||
return {"success": False, "message": "Trade not found"}
|
||||
|
||||
if trade.status not in ['open', 'pending', 'unfilled']:
|
||||
return {"success": False, "message": "Cannot cancel: order already filled or closed"}
|
||||
|
||||
# If using broker manager, cancel through it
|
||||
if self.manual_broker_manager and trade.broker_order_id:
|
||||
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||
result = self.manual_broker_manager.cancel_order(
|
||||
trade.creator, trade.broker_order_id, broker_key
|
||||
)
|
||||
if not result.get('success'):
|
||||
return result
|
||||
|
||||
trade.status = 'cancelled'
|
||||
self._save_trade(trade)
|
||||
|
||||
# Move from active trades to settled trades (so it appears in history)
|
||||
if trade_id in self.active_trades:
|
||||
del self.active_trades[trade_id]
|
||||
self.settled_trades[trade_id] = trade
|
||||
self.stats['num_trades'] -= 1
|
||||
|
||||
logger.info(f"Order {trade_id} cancelled")
|
||||
return {"success": True, "message": "Order cancelled"}
|
||||
|
||||
def cancel_orders_for_symbol(self, user_id: int, symbol: str, broker_key: str) -> dict:
|
||||
"""
|
||||
Cancel all open/pending orders for a symbol.
|
||||
|
||||
This is a separate action from close_position() - user must explicitly choose this.
|
||||
Does NOT affect filled positions.
|
||||
|
||||
:param user_id: The user ID.
|
||||
:param symbol: Trading symbol.
|
||||
:param broker_key: The broker key ('paper' or 'exchange_mode').
|
||||
:return: Dict with success status, message, and count.
|
||||
"""
|
||||
cancelled = 0
|
||||
errors = []
|
||||
|
||||
for trade_id, trade in list(self.active_trades.items()):
|
||||
# Check broker match
|
||||
if broker_key == 'paper':
|
||||
matches_broker = trade.broker_kind == 'paper'
|
||||
else:
|
||||
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
|
||||
|
||||
if not (trade.symbol == symbol and matches_broker and trade.creator == user_id):
|
||||
continue
|
||||
|
||||
# Only cancel unfilled orders
|
||||
if trade.status not in ['pending', 'open', 'unfilled']:
|
||||
continue
|
||||
|
||||
result = self.cancel_order(trade_id)
|
||||
if result.get('success'):
|
||||
cancelled += 1
|
||||
else:
|
||||
errors.append(f"{trade_id}: {result.get('message')}")
|
||||
|
||||
if errors:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Cancelled {cancelled}, errors: {'; '.join(errors)}",
|
||||
"count": cancelled
|
||||
}
|
||||
return {"success": True, "message": f"Cancelled {cancelled} orders", "count": cancelled}
|
||||
|
||||
def reduce_trade(self, user_id: int, trade_id: str, qty: float) -> float | None:
|
||||
"""
|
||||
Reduces the position of a trade.
|
||||
|
|
@ -1157,3 +1617,50 @@ class Trades:
|
|||
except Exception as e:
|
||||
logger.error(f"Error settling trade '{trade_id}': {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def find_trade_by_broker_order_id(self, broker_order_id: str) -> Trade | None:
|
||||
"""
|
||||
Find a trade by its broker order ID.
|
||||
|
||||
:param broker_order_id: The broker order ID to search for.
|
||||
:return: Trade object if found, None otherwise.
|
||||
"""
|
||||
for trade in self.active_trades.values():
|
||||
if trade.broker_order_id == broker_order_id:
|
||||
return trade
|
||||
return None
|
||||
|
||||
def recover_brokers(self) -> int:
|
||||
"""
|
||||
Recover brokers for broker-managed trades after restart.
|
||||
|
||||
This should be called after manual_broker_manager is wired up.
|
||||
It ensures that persisted broker-managed trades have their brokers
|
||||
recreated so they can be polled and tracked.
|
||||
|
||||
:return: Number of brokers recovered.
|
||||
"""
|
||||
if not self.manual_broker_manager:
|
||||
logger.debug("No broker manager configured, skipping broker recovery")
|
||||
return 0
|
||||
|
||||
# Get trades that have broker_order_id (broker-managed)
|
||||
broker_trades = [
|
||||
trade for trade in self.active_trades.values()
|
||||
if trade.broker_order_id
|
||||
]
|
||||
|
||||
if not broker_trades:
|
||||
return 0
|
||||
|
||||
# Use users to get username from user_id
|
||||
def get_username(user_id):
|
||||
return self._get_user_name(user_id)
|
||||
|
||||
recovered = self.manual_broker_manager.recover_brokers_for_trades(
|
||||
trades=broker_trades,
|
||||
get_username_func=get_username
|
||||
)
|
||||
|
||||
logger.info(f"Recovered {recovered} brokers for {len(broker_trades)} broker-managed trades")
|
||||
return recovered
|
||||
|
|
|
|||
|
|
@ -351,3 +351,160 @@ class TestExecutionPriceFallbacks:
|
|||
assert updates
|
||||
assert trade.status == 'filled'
|
||||
assert trade.stats['opening_price'] == pytest.approx(321.0)
|
||||
|
||||
|
||||
class TestPaperBrokerSLTP:
|
||||
"""Tests for Stop Loss / Take Profit functionality in PaperBroker."""
|
||||
|
||||
def test_sl_triggers_on_price_drop(self):
|
||||
"""Test that stop loss triggers when price drops below threshold."""
|
||||
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||
broker.update_price('BTC/USDT', 50000)
|
||||
|
||||
# Buy with SL at 45000
|
||||
result = broker.place_order(
|
||||
symbol='BTC/USDT',
|
||||
side=OrderSide.BUY,
|
||||
order_type=OrderType.MARKET,
|
||||
size=0.1,
|
||||
stop_loss=45000
|
||||
)
|
||||
assert result.success
|
||||
|
||||
# Position exists
|
||||
position = broker.get_position('BTC/USDT')
|
||||
assert position is not None
|
||||
assert position.size == 0.1
|
||||
|
||||
# SL is tracked
|
||||
assert 'BTC/USDT' in broker._position_sltp
|
||||
assert broker._position_sltp['BTC/USDT']['stop_loss'] == 45000
|
||||
|
||||
# Price drops below SL
|
||||
broker.update_price('BTC/USDT', 44000)
|
||||
events = broker.update()
|
||||
|
||||
# SL should have triggered
|
||||
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
|
||||
assert len(sltp_events) == 1
|
||||
assert sltp_events[0]['trigger'] == 'stop_loss'
|
||||
assert sltp_events[0]['symbol'] == 'BTC/USDT'
|
||||
|
||||
# Position should be closed
|
||||
position = broker.get_position('BTC/USDT')
|
||||
assert position is None
|
||||
|
||||
# SL tracking cleared
|
||||
assert 'BTC/USDT' not in broker._position_sltp
|
||||
|
||||
def test_tp_triggers_on_price_rise(self):
|
||||
"""Test that take profit triggers when price rises above threshold."""
|
||||
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||
broker.update_price('BTC/USDT', 50000)
|
||||
|
||||
# Buy with TP at 55000
|
||||
result = broker.place_order(
|
||||
symbol='BTC/USDT',
|
||||
side=OrderSide.BUY,
|
||||
order_type=OrderType.MARKET,
|
||||
size=0.1,
|
||||
take_profit=55000
|
||||
)
|
||||
assert result.success
|
||||
|
||||
# TP is tracked
|
||||
assert 'BTC/USDT' in broker._position_sltp
|
||||
assert broker._position_sltp['BTC/USDT']['take_profit'] == 55000
|
||||
|
||||
# Price rises above TP
|
||||
broker.update_price('BTC/USDT', 56000)
|
||||
events = broker.update()
|
||||
|
||||
# TP should have triggered
|
||||
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
|
||||
assert len(sltp_events) == 1
|
||||
assert sltp_events[0]['trigger'] == 'take_profit'
|
||||
|
||||
# Position should be closed
|
||||
position = broker.get_position('BTC/USDT')
|
||||
assert position is None
|
||||
|
||||
def test_sltp_cleared_on_manual_close(self):
|
||||
"""Test that SL/TP tracking is cleared when position is manually closed."""
|
||||
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||
broker.update_price('BTC/USDT', 50000)
|
||||
|
||||
# Buy with SL and TP
|
||||
broker.place_order(
|
||||
symbol='BTC/USDT',
|
||||
side=OrderSide.BUY,
|
||||
order_type=OrderType.MARKET,
|
||||
size=0.1,
|
||||
stop_loss=45000,
|
||||
take_profit=55000
|
||||
)
|
||||
|
||||
assert 'BTC/USDT' in broker._position_sltp
|
||||
|
||||
# Manually close position
|
||||
broker.close_position('BTC/USDT')
|
||||
|
||||
# SL/TP tracking should be cleared
|
||||
assert 'BTC/USDT' not in broker._position_sltp
|
||||
|
||||
def test_sltp_persists_across_state_save_load(self):
|
||||
"""Test that SL/TP tracking persists across state save/load."""
|
||||
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||
broker.update_price('BTC/USDT', 50000)
|
||||
|
||||
# Buy with SL and TP
|
||||
broker.place_order(
|
||||
symbol='BTC/USDT',
|
||||
side=OrderSide.BUY,
|
||||
order_type=OrderType.MARKET,
|
||||
size=0.1,
|
||||
stop_loss=45000,
|
||||
take_profit=55000
|
||||
)
|
||||
|
||||
# Save state
|
||||
state = broker.to_state_dict()
|
||||
assert 'position_sltp' in state
|
||||
assert 'BTC/USDT' in state['position_sltp']
|
||||
|
||||
# Create new broker and restore state
|
||||
broker2 = PaperBroker(initial_balance=10000)
|
||||
broker2.from_state_dict(state)
|
||||
|
||||
# SL/TP should be restored
|
||||
assert 'BTC/USDT' in broker2._position_sltp
|
||||
assert broker2._position_sltp['BTC/USDT']['stop_loss'] == 45000
|
||||
assert broker2._position_sltp['BTC/USDT']['take_profit'] == 55000
|
||||
|
||||
def test_no_sltp_trigger_when_price_within_range(self):
|
||||
"""Test that no SL/TP triggers when price stays within range."""
|
||||
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||
broker.update_price('BTC/USDT', 50000)
|
||||
|
||||
# Buy with SL at 45000 and TP at 55000
|
||||
broker.place_order(
|
||||
symbol='BTC/USDT',
|
||||
side=OrderSide.BUY,
|
||||
order_type=OrderType.MARKET,
|
||||
size=0.1,
|
||||
stop_loss=45000,
|
||||
take_profit=55000
|
||||
)
|
||||
|
||||
# Price moves but stays within range
|
||||
broker.update_price('BTC/USDT', 48000)
|
||||
events = broker.update()
|
||||
|
||||
# No SL/TP triggers
|
||||
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
|
||||
assert len(sltp_events) == 0
|
||||
|
||||
# Position still exists
|
||||
position = broker.get_position('BTC/USDT')
|
||||
assert position is not None
|
||||
assert position.size == 0.1
|
||||
|
|
|
|||
|
|
@ -63,6 +63,39 @@ class TestTrade:
|
|||
assert json_data['creator'] == 1
|
||||
assert 'stats' in json_data
|
||||
|
||||
def test_trade_with_stop_loss_take_profit(self):
|
||||
"""Test trade with SL/TP fields."""
|
||||
trade = Trade(
|
||||
target='test_exchange',
|
||||
symbol='BTC/USDT',
|
||||
side='BUY',
|
||||
order_price=50000.0,
|
||||
base_order_qty=0.1,
|
||||
stop_loss=45000.0,
|
||||
take_profit=60000.0
|
||||
)
|
||||
|
||||
assert trade.stop_loss == 45000.0
|
||||
assert trade.take_profit == 60000.0
|
||||
|
||||
# Verify serialization
|
||||
json_data = trade.to_json()
|
||||
assert json_data['stop_loss'] == 45000.0
|
||||
assert json_data['take_profit'] == 60000.0
|
||||
|
||||
def test_trade_sltp_defaults_to_none(self):
|
||||
"""Test that SL/TP default to None when not provided."""
|
||||
trade = Trade(
|
||||
target='test_exchange',
|
||||
symbol='BTC/USDT',
|
||||
side='BUY',
|
||||
order_price=50000.0,
|
||||
base_order_qty=0.1
|
||||
)
|
||||
|
||||
assert trade.stop_loss is None
|
||||
assert trade.take_profit is None
|
||||
|
||||
def test_trade_update_values(self):
|
||||
"""Test P/L calculation."""
|
||||
trade = Trade(
|
||||
|
|
@ -221,6 +254,7 @@ class TestTrades:
|
|||
"""Test creating a live trade without exchange connected."""
|
||||
trades = Trades(mock_users)
|
||||
|
||||
# Use testnet=True to bypass production safety gate and test exchange check
|
||||
status, msg = trades.new_trade(
|
||||
target='binance',
|
||||
symbol='BTC/USDT',
|
||||
|
|
@ -228,12 +262,41 @@ class TestTrades:
|
|||
side='buy',
|
||||
order_type='MARKET',
|
||||
qty=0.1,
|
||||
user_id=1
|
||||
user_id=1,
|
||||
testnet=True
|
||||
)
|
||||
|
||||
assert status == 'Error'
|
||||
assert 'No exchange' in msg.lower() or 'no exchange' 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
|
||||
original_value = getattr(config, 'ALLOW_LIVE_PRODUCTION', False)
|
||||
|
||||
try:
|
||||
# Ensure production is NOT allowed
|
||||
config.ALLOW_LIVE_PRODUCTION = False
|
||||
|
||||
trades = Trades(mock_users)
|
||||
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=False # Production mode
|
||||
)
|
||||
|
||||
assert status == 'Error'
|
||||
assert 'production trading is disabled' in msg.lower()
|
||||
|
||||
finally:
|
||||
# Restore original value
|
||||
config.ALLOW_LIVE_PRODUCTION = original_value
|
||||
|
||||
def test_get_trades_json(self, mock_users):
|
||||
"""Test getting trades in JSON format."""
|
||||
trades = Trades(mock_users)
|
||||
|
|
|
|||
Loading…
Reference in New Issue