Fix live trade P/L bugs, improve UI refresh, add balance toggle
Key fixes: - Add price validation to detect/correct doubled filled_price from broker - Emit position_closed socket event to refresh UI after closing positions - Fix statistics not updating when positions are closed - Filter out zero-size positions from display - Add USD/USDT toggle for balance display (click to switch) Trade system improvements: - Refactor Trade class P/L calculation with realized/unrealized tracking - Add settle() method for proper position closing - Improve trade_filled() to use current filled qty for averaging - Add fallback to exchange balances when broker balance unavailable Paper broker enhancements: - Add exchange-qualified price storage for multi-exchange support - Add price_source tracking for positions - Improve state persistence Tests: - Add comprehensive tests for trade P/L calculations - Add tests for paper broker price source tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5866319b5e
commit
d3bbb36dc2
|
|
@ -1690,12 +1690,14 @@ class BrighterTrades:
|
||||||
if trade.status in ['pending', 'open', 'unfilled']:
|
if trade.status in ['pending', 'open', 'unfilled']:
|
||||||
# Cancel the unfilled order
|
# Cancel the unfilled order
|
||||||
result = self.trades.cancel_order(str(trade_id))
|
result = self.trades.cancel_order(str(trade_id))
|
||||||
reply_type = "order_cancelled" if result.get('success') else "trade_error"
|
|
||||||
else:
|
else:
|
||||||
# Close the position for this trade's symbol
|
# Close the position for this trade's symbol
|
||||||
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
|
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)
|
result = self.trades.close_position(trade.creator, trade.symbol, broker_key)
|
||||||
reply_type = "position_closed" if result.get('success') else "trade_error"
|
|
||||||
|
# Always include trade_id and use consistent event name for frontend
|
||||||
|
result['trade_id'] = str(trade_id)
|
||||||
|
reply_type = "trade_closed" if result.get('success') else "trade_error"
|
||||||
|
|
||||||
return standard_reply(reply_type, result)
|
return standard_reply(reply_type, result)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,13 @@ class Exchange:
|
||||||
client.set_sandbox_mode(True)
|
client.set_sandbox_mode(True)
|
||||||
logger.info(f"Sandbox mode enabled for {self.exchange_id}")
|
logger.info(f"Sandbox mode enabled for {self.exchange_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not enable sandbox mode for {self.exchange_id}: {e}")
|
# CRITICAL: Do NOT continue with production if testnet was requested
|
||||||
|
# This prevents users from accidentally trading real money
|
||||||
|
logger.error(f"TESTNET UNAVAILABLE: {self.exchange_id} does not support sandbox mode: {e}")
|
||||||
|
raise ValueError(
|
||||||
|
f"Testnet/sandbox mode is not available for {self.exchange_id}. "
|
||||||
|
f"Please use paper trading mode instead, or trade on production with caution."
|
||||||
|
)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,9 @@ class ExchangeInterface:
|
||||||
pass # No existing entry, that's fine
|
pass # No existing entry, that's fine
|
||||||
|
|
||||||
# Create new exchange with explicit testnet setting
|
# Create new exchange with explicit testnet setting
|
||||||
|
if not exchange_name:
|
||||||
|
logger.error("Cannot create exchange: exchange_name is required")
|
||||||
|
return False
|
||||||
logger.info(f"Creating {exchange_name} for {user_name} with testnet={testnet}")
|
logger.info(f"Creating {exchange_name} for {user_name} with testnet={testnet}")
|
||||||
exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(),
|
exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(),
|
||||||
testnet=testnet)
|
testnet=testnet)
|
||||||
|
|
|
||||||
184
src/app.py
184
src/app.py
|
|
@ -186,30 +186,35 @@ def strategy_execution_loop():
|
||||||
# This is the only place where brokers are polled for order fills
|
# This is the only place where brokers are polled for order fills
|
||||||
if brighter_trades.manual_broker_manager:
|
if brighter_trades.manual_broker_manager:
|
||||||
try:
|
try:
|
||||||
# Collect prices for broker updates
|
# Collect prices for broker updates from positions/orders (not Trade.exchange)
|
||||||
# Paper trades use symbol-only keys (single synthetic market)
|
|
||||||
# Live trades use exchange:symbol keys
|
|
||||||
broker_price_updates = {}
|
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:
|
# Get required price feeds from paper broker positions/orders
|
||||||
# Paper trades: single synthetic market, use first available exchange
|
paper_user_ids = brighter_trades.manual_broker_manager.get_active_paper_user_ids()
|
||||||
price = brighter_trades.exchanges.get_price(trade.symbol)
|
for user_id in paper_user_ids:
|
||||||
if price:
|
feeds = brighter_trades.manual_broker_manager.get_required_price_feeds(user_id)
|
||||||
# Paper uses symbol-only key
|
for exchange, symbol in feeds:
|
||||||
broker_price_updates[trade.symbol] = price
|
if exchange:
|
||||||
else:
|
# Exchange-qualified: fetch from specific exchange
|
||||||
# Live trades: use specific exchange
|
price = brighter_trades.exchanges.get_price(symbol, exchange)
|
||||||
|
if price and price > 0:
|
||||||
|
broker_price_updates[f"{exchange.lower()}:{symbol}"] = price
|
||||||
|
else:
|
||||||
|
# No exchange: use default price source
|
||||||
|
price = brighter_trades.exchanges.get_price(symbol)
|
||||||
|
if price and price > 0:
|
||||||
|
broker_price_updates[symbol] = price
|
||||||
|
|
||||||
|
# Also collect for live trades (unchanged logic)
|
||||||
|
for trade in brighter_trades.trades.active_trades.values():
|
||||||
|
if trade.broker_order_id and trade.broker_kind == 'live':
|
||||||
|
try:
|
||||||
|
exchange = getattr(trade, 'exchange', None) or trade.target
|
||||||
|
if exchange:
|
||||||
price = brighter_trades.exchanges.get_price(trade.symbol, exchange)
|
price = brighter_trades.exchanges.get_price(trade.symbol, exchange)
|
||||||
if price:
|
if price and price > 0:
|
||||||
# Live uses exchange:symbol key
|
|
||||||
price_key = f"{exchange.lower()}:{trade.symbol}"
|
price_key = f"{exchange.lower()}:{trade.symbol}"
|
||||||
broker_price_updates[price_key] = price
|
broker_price_updates[price_key] = price
|
||||||
# Also add symbol-only as fallback
|
|
||||||
broker_price_updates[trade.symbol] = price
|
broker_price_updates[trade.symbol] = price
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -221,34 +226,26 @@ def strategy_execution_loop():
|
||||||
event_type = event.get('type', 'fill')
|
event_type = event.get('type', 'fill')
|
||||||
|
|
||||||
if event_type == 'sltp_triggered':
|
if event_type == 'sltp_triggered':
|
||||||
# SL/TP triggered - find and settle related trades
|
# SL/TP triggered - reconcile matching paper trades only.
|
||||||
symbol = event.get('symbol')
|
symbol = event.get('symbol')
|
||||||
trigger_price = event.get('trigger_price', 0)
|
trigger_price = event.get('trigger_price', 0)
|
||||||
user_id = event.get('user_id')
|
user_id = event.get('user_id')
|
||||||
|
trade_ids = []
|
||||||
# Find ALL matching paper trades for this symbol and settle them
|
if user_id and trigger_price:
|
||||||
trades_to_settle = []
|
trade_ids = brighter_trades.trades.settle_broker_closed_position(
|
||||||
for trade in list(brighter_trades.trades.active_trades.values()):
|
user_id=user_id,
|
||||||
if trade.symbol == symbol and (trade.is_paper or trade.broker_kind == 'paper'):
|
symbol=symbol,
|
||||||
trades_to_settle.append(trade)
|
broker_key='paper',
|
||||||
user_id = user_id or trade.creator
|
close_price=trigger_price
|
||||||
|
)
|
||||||
# Settle each matching trade
|
_loop_debug.debug(
|
||||||
for trade in trades_to_settle:
|
f"Reconciled SL/TP close for user={user_id} symbol={symbol}: {trade_ids}"
|
||||||
# Settle the trade at the trigger price
|
)
|
||||||
trade.settle(qty=trade.stats.get('qty_filled', trade.base_order_qty), price=trigger_price)
|
|
||||||
# Move from active to settled
|
|
||||||
if trade.unique_id in brighter_trades.trades.active_trades:
|
|
||||||
del brighter_trades.trades.active_trades[trade.unique_id]
|
|
||||||
brighter_trades.trades.settled_trades[trade.unique_id] = trade
|
|
||||||
brighter_trades.trades._save_trade(trade)
|
|
||||||
_loop_debug.debug(f"Settled trade {trade.unique_id} via SL/TP at {trigger_price}")
|
|
||||||
|
|
||||||
# Notify user
|
# Notify user
|
||||||
if user_id:
|
if user_id:
|
||||||
user_name = brighter_trades.users.get_username(user_id=user_id)
|
user_name = brighter_trades.users.get_username(user_id=user_id)
|
||||||
if user_name:
|
if user_name:
|
||||||
trade_ids = [t.unique_id for t in trades_to_settle]
|
|
||||||
socketio.emit('message', {
|
socketio.emit('message', {
|
||||||
'reply': 'sltp_triggered',
|
'reply': 'sltp_triggered',
|
||||||
'data': sanitize_for_json({
|
'data': sanitize_for_json({
|
||||||
|
|
@ -295,6 +292,52 @@ def strategy_execution_loop():
|
||||||
})
|
})
|
||||||
}, room=user_name)
|
}, room=user_name)
|
||||||
_loop_debug.debug(f"Emitted order_filled to room={user_name}")
|
_loop_debug.debug(f"Emitted order_filled to room={user_name}")
|
||||||
|
else:
|
||||||
|
user_id = event.get('user_id')
|
||||||
|
broker_key = event.get('broker_key')
|
||||||
|
symbol = event.get('symbol')
|
||||||
|
side = str(event.get('side') or '').lower()
|
||||||
|
filled_price = event.get('filled_price', event.get('price', 0))
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
user_name = brighter_trades.users.get_username(user_id=user_id)
|
||||||
|
|
||||||
|
# Always emit order_filled so broker-backed panels refresh even when
|
||||||
|
# the fill belongs to a close order that has no local opening trade ID.
|
||||||
|
if user_name:
|
||||||
|
socketio.emit('message', {
|
||||||
|
'reply': 'order_filled',
|
||||||
|
'data': sanitize_for_json({
|
||||||
|
'order_id': event.get('order_id'),
|
||||||
|
'trade_id': None,
|
||||||
|
'symbol': symbol,
|
||||||
|
'side': event.get('side'),
|
||||||
|
'filled_qty': event.get('filled_qty', event.get('size', 0)),
|
||||||
|
'filled_price': filled_price,
|
||||||
|
'status': 'filled',
|
||||||
|
'broker_kind': event.get('broker_kind'),
|
||||||
|
'broker_key': broker_key
|
||||||
|
})
|
||||||
|
}, room=user_name)
|
||||||
|
|
||||||
|
# A live sell fill without a matching opening trade is typically a
|
||||||
|
# broker-initiated close order from the position-close flow.
|
||||||
|
if side == 'sell' and broker_key and symbol and filled_price:
|
||||||
|
settled_ids = brighter_trades.trades.settle_broker_closed_position(
|
||||||
|
user_id=user_id,
|
||||||
|
symbol=symbol,
|
||||||
|
broker_key=broker_key,
|
||||||
|
close_price=filled_price
|
||||||
|
)
|
||||||
|
if settled_ids and user_name:
|
||||||
|
socketio.emit('message', {
|
||||||
|
'reply': 'position_closed',
|
||||||
|
'data': sanitize_for_json({
|
||||||
|
'symbol': symbol,
|
||||||
|
'broker_key': broker_key,
|
||||||
|
'closed_trades': settled_ids
|
||||||
|
})
|
||||||
|
}, room=user_name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_loop_debug.debug(f"Exception in broker update: {e}")
|
_loop_debug.debug(f"Exception in broker update: {e}")
|
||||||
|
|
@ -317,6 +360,9 @@ def strategy_execution_loop():
|
||||||
exchange_symbols.add((None, trade.symbol))
|
exchange_symbols.add((None, trade.symbol))
|
||||||
|
|
||||||
_loop_debug.debug(f"Exchange+symbols to fetch: {exchange_symbols}")
|
_loop_debug.debug(f"Exchange+symbols to fetch: {exchange_symbols}")
|
||||||
|
# Log at INFO level for live trades debugging
|
||||||
|
if any(ex and ex.lower() not in ['paper', 'test_exchange'] for ex, _ in exchange_symbols):
|
||||||
|
logger.info(f"[PRICE FETCH] exchange_symbols to fetch: {exchange_symbols}")
|
||||||
price_updates = {}
|
price_updates = {}
|
||||||
for exchange, symbol in exchange_symbols:
|
for exchange, symbol in exchange_symbols:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1251,6 +1297,20 @@ def close_manual_position(symbol):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = brighter_trades.trades.close_position(user_id, symbol, broker_key)
|
result = brighter_trades.trades.close_position(user_id, symbol, broker_key)
|
||||||
|
|
||||||
|
# Emit position_closed event to refresh UI
|
||||||
|
if result.get('success'):
|
||||||
|
user_name = brighter_trades.users.get_username(user_id=user_id)
|
||||||
|
if user_name:
|
||||||
|
socketio.emit('message', {
|
||||||
|
'reply': 'position_closed',
|
||||||
|
'data': {
|
||||||
|
'symbol': symbol,
|
||||||
|
'broker_key': broker_key,
|
||||||
|
'closed_trades': result.get('closed_trades', [])
|
||||||
|
}
|
||||||
|
}, room=user_name)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing position {symbol}: {e}", exc_info=True)
|
logger.error(f"Error closing position {symbol}: {e}", exc_info=True)
|
||||||
|
|
@ -1265,15 +1325,44 @@ def get_manual_balance():
|
||||||
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
|
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
|
||||||
|
|
||||||
broker_key = request.args.get('broker_key', 'paper')
|
broker_key = request.args.get('broker_key', 'paper')
|
||||||
|
chart_exchange = (request.args.get('exchange') or '').strip().lower() or None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total = brighter_trades.manual_broker_manager.get_broker_balance(user_id, broker_key)
|
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)
|
available = brighter_trades.manual_broker_manager.get_available_balance(user_id, broker_key)
|
||||||
|
|
||||||
|
# Fallback for live mode: if manual broker balance is unavailable/stale, use the
|
||||||
|
# cached direct exchange balances for the exchange currently shown in the chart.
|
||||||
|
fallback_source = None
|
||||||
|
if broker_key != 'paper' and chart_exchange and total == 0.0 and available == 0.0:
|
||||||
|
user_name = brighter_trades.users.get_username(user_id=user_id)
|
||||||
|
exchange_balances = brighter_trades.exchanges.get_exchange_balances(user_name, chart_exchange)
|
||||||
|
|
||||||
|
quote_balance = 0.0
|
||||||
|
if exchange_balances is not None:
|
||||||
|
for asset in ('USDT', 'USD', 'BUSD', 'USDC'):
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
bal for bal in exchange_balances
|
||||||
|
if str(bal.get('asset', '')).upper() == asset
|
||||||
|
),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
quote_balance = float(match.get('balance', 0.0) or 0.0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if quote_balance > 0:
|
||||||
|
total = quote_balance
|
||||||
|
available = quote_balance
|
||||||
|
fallback_source = 'exchange'
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'total': total,
|
'total': total,
|
||||||
'available': available,
|
'available': available,
|
||||||
'broker_key': broker_key
|
'broker_key': broker_key,
|
||||||
|
'source': fallback_source or 'broker'
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting balance: {e}", exc_info=True)
|
logger.error(f"Error getting balance: {e}", exc_info=True)
|
||||||
|
|
@ -1315,6 +1404,21 @@ def get_trade_history():
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/manual/paper/reset', methods=['POST'])
|
||||||
|
def reset_paper_balance():
|
||||||
|
"""Reset the paper trading broker to initial state."""
|
||||||
|
user_id = _get_current_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = brighter_trades.manual_broker_manager.reset_paper_broker(user_id)
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resetting paper broker: {e}", exc_info=True)
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# External Sources API Routes
|
# External Sources API Routes
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ class Position:
|
||||||
current_price: float
|
current_price: float
|
||||||
unrealized_pnl: float
|
unrealized_pnl: float
|
||||||
realized_pnl: float = 0.0
|
realized_pnl: float = 0.0
|
||||||
|
entry_commission: float = 0.0 # Fee paid on entry, included in P&L
|
||||||
|
price_source: Optional[str] = None # Exchange to use for price lookups (e.g., 'kucoin')
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert position to dictionary for persistence."""
|
"""Convert position to dictionary for persistence."""
|
||||||
|
|
@ -72,6 +74,8 @@ class Position:
|
||||||
'current_price': self.current_price,
|
'current_price': self.current_price,
|
||||||
'unrealized_pnl': self.unrealized_pnl,
|
'unrealized_pnl': self.unrealized_pnl,
|
||||||
'realized_pnl': self.realized_pnl,
|
'realized_pnl': self.realized_pnl,
|
||||||
|
'entry_commission': self.entry_commission,
|
||||||
|
'price_source': self.price_source,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -84,6 +88,8 @@ class Position:
|
||||||
current_price=data['current_price'],
|
current_price=data['current_price'],
|
||||||
unrealized_pnl=data['unrealized_pnl'],
|
unrealized_pnl=data['unrealized_pnl'],
|
||||||
realized_pnl=data.get('realized_pnl', 0.0),
|
realized_pnl=data.get('realized_pnl', 0.0),
|
||||||
|
entry_commission=data.get('entry_commission', 0.0),
|
||||||
|
price_source=data.get('price_source'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -425,8 +425,8 @@ class LiveBroker(BaseBroker):
|
||||||
|
|
||||||
# Create local order tracking
|
# Create local order tracking
|
||||||
symbol = ex_order['symbol']
|
symbol = ex_order['symbol']
|
||||||
side = OrderSide.BUY if ex_order['side'].lower() == 'buy' else OrderSide.SELL
|
side = OrderSide.BUY if (ex_order.get('side') or 'buy').lower() == 'buy' else OrderSide.SELL
|
||||||
order_type = OrderType.LIMIT if ex_order.get('type', 'limit').lower() == 'limit' else OrderType.MARKET
|
order_type = OrderType.LIMIT if (ex_order.get('type') or 'limit').lower() == 'limit' else OrderType.MARKET
|
||||||
size = float(ex_order['quantity'])
|
size = float(ex_order['quantity'])
|
||||||
price = float(ex_order.get('price', 0) or 0)
|
price = float(ex_order.get('price', 0) or 0)
|
||||||
|
|
||||||
|
|
@ -640,13 +640,20 @@ class LiveBroker(BaseBroker):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse order status from exchange response
|
# Parse order status from exchange response
|
||||||
ex_status = exchange_order.get('status', 'open').lower()
|
# Use 'or' to handle case where status key exists but value is None
|
||||||
|
ex_status = (exchange_order.get('status') or 'open').lower()
|
||||||
if ex_status == 'closed' or ex_status == 'filled':
|
if ex_status == 'closed' or ex_status == 'filled':
|
||||||
order.status = OrderStatus.FILLED
|
order.status = OrderStatus.FILLED
|
||||||
order.filled_qty = float(exchange_order.get('filled', size))
|
order.filled_qty = float(exchange_order.get('filled', size))
|
||||||
order.filled_price = float(exchange_order.get('average', price or 0))
|
raw_avg = exchange_order.get('average')
|
||||||
|
order.filled_price = float(raw_avg if raw_avg is not None else (price or 0))
|
||||||
order.filled_at = datetime.now(timezone.utc)
|
order.filled_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# DEBUG: Log exchange response for price validation
|
||||||
|
logger.info(f"[FILL DEBUG] Exchange response: average={raw_avg}, price={price}, "
|
||||||
|
f"filled={order.filled_qty}, cost={exchange_order.get('cost')}, "
|
||||||
|
f"final filled_price={order.filled_price}")
|
||||||
|
|
||||||
# Calculate commission
|
# Calculate commission
|
||||||
fee = exchange_order.get('fee', {})
|
fee = exchange_order.get('fee', {})
|
||||||
if fee:
|
if fee:
|
||||||
|
|
@ -752,7 +759,8 @@ class LiveBroker(BaseBroker):
|
||||||
|
|
||||||
def _update_order_from_exchange(self, order: LiveOrder, ex_order: Dict[str, Any]):
|
def _update_order_from_exchange(self, order: LiveOrder, ex_order: Dict[str, Any]):
|
||||||
"""Update local order with exchange data."""
|
"""Update local order with exchange data."""
|
||||||
ex_status = ex_order.get('status', 'open').lower()
|
# Use 'or' to handle case where status key exists but value is None
|
||||||
|
ex_status = (ex_order.get('status') or 'open').lower()
|
||||||
|
|
||||||
if ex_status == 'closed' or ex_status == 'filled':
|
if ex_status == 'closed' or ex_status == 'filled':
|
||||||
order.status = OrderStatus.FILLED
|
order.status = OrderStatus.FILLED
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,8 @@ class PaperOrder:
|
||||||
price: Optional[float] = None,
|
price: Optional[float] = None,
|
||||||
stop_loss: Optional[float] = None,
|
stop_loss: Optional[float] = None,
|
||||||
take_profit: Optional[float] = None,
|
take_profit: Optional[float] = None,
|
||||||
time_in_force: str = 'GTC'
|
time_in_force: str = 'GTC',
|
||||||
|
exchange: Optional[str] = None
|
||||||
):
|
):
|
||||||
self.order_id = order_id
|
self.order_id = order_id
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
|
|
@ -43,6 +44,7 @@ class PaperOrder:
|
||||||
self.stop_loss = stop_loss
|
self.stop_loss = stop_loss
|
||||||
self.take_profit = take_profit
|
self.take_profit = take_profit
|
||||||
self.time_in_force = time_in_force
|
self.time_in_force = time_in_force
|
||||||
|
self.exchange = exchange # Exchange for price feed (e.g., 'kucoin')
|
||||||
self.status = OrderStatus.PENDING
|
self.status = OrderStatus.PENDING
|
||||||
self.filled_qty = 0.0
|
self.filled_qty = 0.0
|
||||||
self.filled_price = 0.0
|
self.filled_price = 0.0
|
||||||
|
|
@ -68,7 +70,8 @@ class PaperOrder:
|
||||||
'commission': self.commission,
|
'commission': self.commission,
|
||||||
'locked_funds': self.locked_funds,
|
'locked_funds': self.locked_funds,
|
||||||
'created_at': self.created_at.isoformat(),
|
'created_at': self.created_at.isoformat(),
|
||||||
'filled_at': self.filled_at.isoformat() if self.filled_at else None
|
'filled_at': self.filled_at.isoformat() if self.filled_at else None,
|
||||||
|
'exchange': self.exchange,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,10 +126,21 @@ class PaperBroker(BaseBroker):
|
||||||
"""Set the price provider callable."""
|
"""Set the price provider callable."""
|
||||||
self._price_provider = provider
|
self._price_provider = provider
|
||||||
|
|
||||||
def update_price(self, symbol: str, price: float):
|
def update_price(self, symbol: str, price: float, exchange: Optional[str] = None):
|
||||||
"""Update the current price for a symbol."""
|
"""Update price, optionally qualified by exchange."""
|
||||||
|
if exchange:
|
||||||
|
# Store exchange-qualified price
|
||||||
|
self._current_prices[f"{exchange}:{symbol}"] = price
|
||||||
|
# Always store symbol-only for backward compat
|
||||||
self._current_prices[symbol] = price
|
self._current_prices[symbol] = price
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_fill_limit_order(side: OrderSide, current_price: float, limit_price: float) -> bool:
|
||||||
|
"""Return True when the current price crosses a limit order's fill threshold."""
|
||||||
|
if side == OrderSide.BUY:
|
||||||
|
return current_price <= limit_price
|
||||||
|
return current_price >= limit_price
|
||||||
|
|
||||||
def place_order(
|
def place_order(
|
||||||
self,
|
self,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
|
@ -136,13 +150,14 @@ class PaperBroker(BaseBroker):
|
||||||
price: Optional[float] = None,
|
price: Optional[float] = None,
|
||||||
stop_loss: Optional[float] = None,
|
stop_loss: Optional[float] = None,
|
||||||
take_profit: Optional[float] = None,
|
take_profit: Optional[float] = None,
|
||||||
time_in_force: str = 'GTC'
|
time_in_force: str = 'GTC',
|
||||||
|
exchange: Optional[str] = None
|
||||||
) -> OrderResult:
|
) -> OrderResult:
|
||||||
"""Place a paper trading order."""
|
"""Place a paper trading order."""
|
||||||
order_id = str(uuid.uuid4())[:8]
|
order_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
# Validate order
|
# Validate order - use exchange-aware price lookup
|
||||||
current_price = self.get_current_price(symbol)
|
current_price = self.get_current_price(symbol, exchange)
|
||||||
if current_price <= 0:
|
if current_price <= 0:
|
||||||
return OrderResult(
|
return OrderResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -189,7 +204,8 @@ class PaperBroker(BaseBroker):
|
||||||
price=price,
|
price=price,
|
||||||
stop_loss=stop_loss,
|
stop_loss=stop_loss,
|
||||||
take_profit=take_profit,
|
take_profit=take_profit,
|
||||||
time_in_force=time_in_force
|
time_in_force=time_in_force,
|
||||||
|
exchange=exchange
|
||||||
)
|
)
|
||||||
|
|
||||||
# For market orders, fill immediately
|
# For market orders, fill immediately
|
||||||
|
|
@ -198,6 +214,39 @@ class PaperBroker(BaseBroker):
|
||||||
self._fill_order(order, fill_price)
|
self._fill_order(order, fill_price)
|
||||||
logger.info(f"PaperBroker: Market order filled: {side.value} {size} {symbol} @ {fill_price:.4f}")
|
logger.info(f"PaperBroker: Market order filled: {side.value} {size} {symbol} @ {fill_price:.4f}")
|
||||||
else:
|
else:
|
||||||
|
is_marketable = self._should_fill_limit_order(side, current_price, execution_price)
|
||||||
|
|
||||||
|
# IOC/FOK must fill immediately or fail immediately.
|
||||||
|
if time_in_force in ['IOC', 'FOK']:
|
||||||
|
if is_marketable:
|
||||||
|
self._fill_order(order, execution_price)
|
||||||
|
logger.info(
|
||||||
|
f"PaperBroker: {time_in_force} limit order filled immediately: "
|
||||||
|
f"{side.value} {size} {symbol} @ {execution_price}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
order.status = OrderStatus.CANCELLED if time_in_force == 'IOC' else OrderStatus.EXPIRED
|
||||||
|
self._orders[order_id] = order
|
||||||
|
logger.info(
|
||||||
|
f"PaperBroker: {time_in_force} limit order not fillable immediately: "
|
||||||
|
f"{side.value} {size} {symbol} @ {price}"
|
||||||
|
)
|
||||||
|
return OrderResult(
|
||||||
|
success=False,
|
||||||
|
order_id=order_id,
|
||||||
|
status=order.status,
|
||||||
|
message=f"{time_in_force} limit order could not be filled immediately"
|
||||||
|
)
|
||||||
|
return OrderResult(
|
||||||
|
success=True,
|
||||||
|
order_id=order_id,
|
||||||
|
status=order.status,
|
||||||
|
filled_qty=order.filled_qty,
|
||||||
|
filled_price=order.filled_price,
|
||||||
|
commission=order.commission,
|
||||||
|
message=f"Order {order_id} filled"
|
||||||
|
)
|
||||||
|
|
||||||
# Store pending order
|
# Store pending order
|
||||||
order.status = OrderStatus.OPEN
|
order.status = OrderStatus.OPEN
|
||||||
self._orders[order_id] = order
|
self._orders[order_id] = order
|
||||||
|
|
@ -253,13 +302,19 @@ class PaperBroker(BaseBroker):
|
||||||
new_entry = (existing.entry_price * existing.size + fill_price * order.size) / new_size
|
new_entry = (existing.entry_price * existing.size + fill_price * order.size) / new_size
|
||||||
existing.size = new_size
|
existing.size = new_size
|
||||||
existing.entry_price = new_entry
|
existing.entry_price = new_entry
|
||||||
|
existing.entry_commission += order.commission # Accumulate entry fees
|
||||||
|
# Most recent fill wins: update price source
|
||||||
|
if order.exchange:
|
||||||
|
existing.price_source = order.exchange
|
||||||
else:
|
else:
|
||||||
self._positions[order.symbol] = Position(
|
self._positions[order.symbol] = Position(
|
||||||
symbol=order.symbol,
|
symbol=order.symbol,
|
||||||
size=order.size,
|
size=order.size,
|
||||||
entry_price=fill_price,
|
entry_price=fill_price,
|
||||||
current_price=fill_price,
|
current_price=fill_price,
|
||||||
unrealized_pnl=0.0
|
unrealized_pnl=-order.commission, # Start with entry fee as loss
|
||||||
|
entry_commission=order.commission,
|
||||||
|
price_source=order.exchange
|
||||||
)
|
)
|
||||||
|
|
||||||
# Record SL/TP for this position (if set on order)
|
# Record SL/TP for this position (if set on order)
|
||||||
|
|
@ -280,10 +335,22 @@ class PaperBroker(BaseBroker):
|
||||||
if order.symbol in self._positions:
|
if order.symbol in self._positions:
|
||||||
position = self._positions[order.symbol]
|
position = self._positions[order.symbol]
|
||||||
order.entry_price = position.entry_price # Store for fee calculation
|
order.entry_price = position.entry_price # Store for fee calculation
|
||||||
realized_pnl = (fill_price - position.entry_price) * order.size - order.commission
|
|
||||||
|
# Calculate proportional entry commission for partial closes
|
||||||
|
if position.size > 0:
|
||||||
|
proportion = order.size / position.size
|
||||||
|
proportional_entry_commission = position.entry_commission * proportion
|
||||||
|
else:
|
||||||
|
proportional_entry_commission = position.entry_commission
|
||||||
|
|
||||||
|
# Realized P&L includes both entry and exit fees
|
||||||
|
realized_pnl = (fill_price - position.entry_price) * order.size - proportional_entry_commission - order.commission
|
||||||
position.realized_pnl += realized_pnl
|
position.realized_pnl += realized_pnl
|
||||||
position.size -= order.size
|
position.size -= order.size
|
||||||
|
|
||||||
|
# Reduce remaining entry commission proportionally
|
||||||
|
position.entry_commission -= proportional_entry_commission
|
||||||
|
|
||||||
# Track profitability for fee calculation
|
# Track profitability for fee calculation
|
||||||
order.realized_pnl = realized_pnl
|
order.realized_pnl = realized_pnl
|
||||||
order.is_profitable = realized_pnl > 0
|
order.is_profitable = realized_pnl > 0
|
||||||
|
|
@ -363,15 +430,30 @@ class PaperBroker(BaseBroker):
|
||||||
"""Get all open positions."""
|
"""Get all open positions."""
|
||||||
return list(self._positions.values())
|
return list(self._positions.values())
|
||||||
|
|
||||||
def get_current_price(self, symbol: str) -> float:
|
def get_current_price(self, symbol: str, exchange: Optional[str] = None) -> float:
|
||||||
"""Get current price for a symbol."""
|
"""Get price, preferring exchange-qualified if available."""
|
||||||
# First check cache
|
# First try exchange-qualified lookup
|
||||||
|
if exchange:
|
||||||
|
key = f"{exchange}:{symbol}"
|
||||||
|
if key in self._current_prices:
|
||||||
|
return self._current_prices[key]
|
||||||
|
|
||||||
|
# Fall back to symbol-only lookup
|
||||||
if symbol in self._current_prices:
|
if symbol in self._current_prices:
|
||||||
return self._current_prices[symbol]
|
return self._current_prices[symbol]
|
||||||
|
|
||||||
# Then try price provider
|
# Then try price provider
|
||||||
if self._price_provider:
|
if self._price_provider:
|
||||||
try:
|
try:
|
||||||
|
# Try exchange-qualified first, then symbol-only
|
||||||
|
if exchange:
|
||||||
|
try:
|
||||||
|
price = self._price_provider(f"{exchange}:{symbol}")
|
||||||
|
if price > 0:
|
||||||
|
self._current_prices[f"{exchange}:{symbol}"] = price
|
||||||
|
return price
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
price = self._price_provider(symbol)
|
price = self._price_provider(symbol)
|
||||||
self._current_prices[symbol] = price
|
self._current_prices[symbol] = price
|
||||||
return price
|
return price
|
||||||
|
|
@ -388,12 +470,14 @@ class PaperBroker(BaseBroker):
|
||||||
"""
|
"""
|
||||||
events = []
|
events = []
|
||||||
|
|
||||||
# Update position P&L
|
# Update position P&L (includes entry commission for accurate fee reflection)
|
||||||
for symbol, position in self._positions.items():
|
for symbol, position in self._positions.items():
|
||||||
current_price = self.get_current_price(symbol)
|
# Use position's price_source for exchange-aware price lookup
|
||||||
|
current_price = self.get_current_price(symbol, position.price_source)
|
||||||
if current_price > 0:
|
if current_price > 0:
|
||||||
position.current_price = current_price
|
position.current_price = current_price
|
||||||
position.unrealized_pnl = (current_price - position.entry_price) * position.size
|
# P&L = price movement - entry fees already paid
|
||||||
|
position.unrealized_pnl = (current_price - position.entry_price) * position.size - position.entry_commission
|
||||||
|
|
||||||
# Evaluate SL/TP for all tracked positions
|
# Evaluate SL/TP for all tracked positions
|
||||||
for symbol, sltp in list(self._position_sltp.items()):
|
for symbol, sltp in list(self._position_sltp.items()):
|
||||||
|
|
@ -402,7 +486,8 @@ class PaperBroker(BaseBroker):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
position = self._positions[symbol]
|
position = self._positions[symbol]
|
||||||
current_price = self.get_current_price(symbol)
|
# Use position's price_source for exchange-aware price lookup
|
||||||
|
current_price = self.get_current_price(symbol, position.price_source)
|
||||||
|
|
||||||
if position.size <= 0 or current_price <= 0:
|
if position.size <= 0 or current_price <= 0:
|
||||||
del self._position_sltp[symbol]
|
del self._position_sltp[symbol]
|
||||||
|
|
@ -444,17 +529,15 @@ class PaperBroker(BaseBroker):
|
||||||
if order.status != OrderStatus.OPEN:
|
if order.status != OrderStatus.OPEN:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
current_price = self.get_current_price(order.symbol)
|
# Use order's exchange for price lookup
|
||||||
|
current_price = self.get_current_price(order.symbol, order.exchange)
|
||||||
if current_price <= 0:
|
if current_price <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
should_fill = False
|
|
||||||
|
|
||||||
if order.order_type == OrderType.LIMIT:
|
if order.order_type == OrderType.LIMIT:
|
||||||
if order.side == OrderSide.BUY and current_price <= order.price:
|
should_fill = self._should_fill_limit_order(order.side, current_price, order.price)
|
||||||
should_fill = True
|
else:
|
||||||
elif order.side == OrderSide.SELL and current_price >= order.price:
|
should_fill = False
|
||||||
should_fill = True
|
|
||||||
|
|
||||||
if should_fill:
|
if should_fill:
|
||||||
# Release locked funds first (for buy orders)
|
# Release locked funds first (for buy orders)
|
||||||
|
|
@ -497,6 +580,7 @@ class PaperBroker(BaseBroker):
|
||||||
self._positions.clear()
|
self._positions.clear()
|
||||||
self._trade_history.clear()
|
self._trade_history.clear()
|
||||||
self._current_prices.clear()
|
self._current_prices.clear()
|
||||||
|
self._position_sltp.clear() # Clear SL/TP state to prevent stale triggers
|
||||||
logger.info(f"PaperBroker: Reset with balance {self.initial_balance}")
|
logger.info(f"PaperBroker: Reset with balance {self.initial_balance}")
|
||||||
|
|
||||||
# ==================== State Persistence Methods ====================
|
# ==================== State Persistence Methods ====================
|
||||||
|
|
@ -609,6 +693,8 @@ class PaperBroker(BaseBroker):
|
||||||
price=order_dict.get('price'),
|
price=order_dict.get('price'),
|
||||||
stop_loss=order_dict.get('stop_loss'),
|
stop_loss=order_dict.get('stop_loss'),
|
||||||
take_profit=order_dict.get('take_profit'),
|
take_profit=order_dict.get('take_profit'),
|
||||||
|
time_in_force=order_dict.get('time_in_force', 'GTC'),
|
||||||
|
exchange=order_dict.get('exchange'),
|
||||||
)
|
)
|
||||||
order.status = OrderStatus(order_dict['status'])
|
order.status = OrderStatus(order_dict['status'])
|
||||||
order.filled_qty = order_dict.get('filled_qty', 0.0)
|
order.filled_qty = order_dict.get('filled_qty', 0.0)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging as log
|
import logging
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from shared_utilities import timeframe_to_minutes, ts_of_n_minutes_ago
|
from shared_utilities import timeframe_to_minutes, ts_of_n_minutes_ago
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
# log.basicConfig(level=log.ERROR)
|
|
||||||
|
|
||||||
class Candles:
|
class Candles:
|
||||||
def __init__(self, exchanges, users, datacache, config, edm_client=None):
|
def __init__(self, exchanges, users, datacache, config, edm_client=None):
|
||||||
|
|
@ -24,6 +23,8 @@ class Candles:
|
||||||
|
|
||||||
# Cache the last received candle to detect duplicates
|
# Cache the last received candle to detect duplicates
|
||||||
self.cached_last_candle = None
|
self.cached_last_candle = None
|
||||||
|
# Avoid repeating the same expected EDM cap warning on every refresh.
|
||||||
|
self._edm_cap_warned_scopes: set[tuple[str, str, str]] = set()
|
||||||
|
|
||||||
# size_limit is the max number of lists of candle(ohlc) data allowed.
|
# size_limit is the max number of lists of candle(ohlc) data allowed.
|
||||||
self.data.create_cache(name='candles', cache_type='row', default_expiration=dt.timedelta(days=5),
|
self.data.create_cache(name='candles', cache_type='row', default_expiration=dt.timedelta(days=5),
|
||||||
|
|
@ -57,10 +58,21 @@ class Candles:
|
||||||
# EDM API has a maximum limit of 1000 candles
|
# EDM API has a maximum limit of 1000 candles
|
||||||
EDM_MAX_CANDLES = 1000
|
EDM_MAX_CANDLES = 1000
|
||||||
if num_candles > EDM_MAX_CANDLES:
|
if num_candles > EDM_MAX_CANDLES:
|
||||||
log.warning(f'Requested {num_candles} candles, capping to EDM limit of {EDM_MAX_CANDLES}')
|
warning_scope = (asset, exchange, timeframe)
|
||||||
|
if warning_scope not in self._edm_cap_warned_scopes:
|
||||||
|
logger.warning(
|
||||||
|
"Requested %s candles for %s/%s/%s, capping to EDM limit of %s",
|
||||||
|
num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES
|
||||||
|
)
|
||||||
|
self._edm_cap_warned_scopes.add(warning_scope)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Requested %s candles for %s/%s/%s, capped to %s",
|
||||||
|
num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES
|
||||||
|
)
|
||||||
num_candles = EDM_MAX_CANDLES
|
num_candles = EDM_MAX_CANDLES
|
||||||
|
|
||||||
log.info(f'[GET CANDLES] {asset} {exchange} {timeframe} limit={num_candles}')
|
logger.debug("Fetching candles from EDM: %s %s %s limit=%s", asset, exchange, timeframe, num_candles)
|
||||||
|
|
||||||
if self.edm is None:
|
if self.edm is None:
|
||||||
raise RuntimeError("EDM client not initialized. Cannot fetch candle data.")
|
raise RuntimeError("EDM client not initialized. Cannot fetch candle data.")
|
||||||
|
|
@ -76,10 +88,10 @@ class Candles:
|
||||||
)
|
)
|
||||||
|
|
||||||
if candles.empty:
|
if candles.empty:
|
||||||
log.warning(f"No candles returned from EDM for {asset}/{timeframe}/{exchange}")
|
logger.warning("No candles returned from EDM for %s/%s/%s", asset, timeframe, exchange)
|
||||||
return self.convert_candles(candles)
|
return self.convert_candles(candles)
|
||||||
|
|
||||||
log.info(f"Fetched {len(candles)} candles from EDM for {asset}/{timeframe}/{exchange}")
|
logger.debug("Fetched %s candles from EDM for %s/%s/%s", len(candles), asset, timeframe, exchange)
|
||||||
return self.convert_candles(candles[-num_candles:])
|
return self.convert_candles(candles[-num_candles:])
|
||||||
|
|
||||||
def set_new_candle(self, cdata: dict) -> bool:
|
def set_new_candle(self, cdata: dict) -> bool:
|
||||||
|
|
@ -93,7 +105,7 @@ class Candles:
|
||||||
"""
|
"""
|
||||||
# Update the cached last candle
|
# Update the cached last candle
|
||||||
self.cached_last_candle = cdata
|
self.cached_last_candle = cdata
|
||||||
log.debug(f"Candle updated: {cdata.get('symbol', 'unknown')} @ {cdata.get('close', 0)}")
|
logger.debug("Candle updated: %s @ %s", cdata.get('symbol', 'unknown'), cdata.get('close', 0))
|
||||||
return True
|
return True
|
||||||
def set_cache(self, symbol=None, interval=None, exchange_name=None, user_name=None):
|
def set_cache(self, symbol=None, interval=None, exchange_name=None, user_name=None):
|
||||||
"""
|
"""
|
||||||
|
|
@ -110,24 +122,24 @@ class Candles:
|
||||||
if not symbol:
|
if not symbol:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
|
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
|
||||||
log.info(f'set_candle_history(): No symbol provided. Using{symbol}')
|
logger.info('set_candle_history(): No symbol provided. Using %s', symbol)
|
||||||
if not interval:
|
if not interval:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
|
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
|
||||||
log.info(f'set_candle_history(): No timeframe provided. Using{interval}')
|
logger.info('set_candle_history(): No timeframe provided. Using %s', interval)
|
||||||
if not exchange_name:
|
if not exchange_name:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
|
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
|
||||||
|
|
||||||
# Log the completion to the console.
|
# Log the completion to the console.
|
||||||
log.info('set_candle_history(): Loading candle data...')
|
logger.info('set_candle_history(): Loading candle data...')
|
||||||
|
|
||||||
# Load candles from database
|
# Load candles from database
|
||||||
_cdata = self.get_last_n_candles(num_candles=self.max_records,
|
_cdata = self.get_last_n_candles(num_candles=self.max_records,
|
||||||
asset=symbol, timeframe=interval, exchange=exchange_name, user_name=user_name)
|
asset=symbol, timeframe=interval, exchange=exchange_name, user_name=user_name)
|
||||||
|
|
||||||
# Log the completion to the console.
|
# Log the completion to the console.
|
||||||
log.info('set_candle_history(): Candle data Loaded.')
|
logger.info('set_candle_history(): Candle data Loaded.')
|
||||||
return
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -213,17 +225,17 @@ class Candles:
|
||||||
if not symbol:
|
if not symbol:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
|
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
|
||||||
log.info(f'get_candle_history(): No symbol provided. Using {symbol}')
|
logger.info('get_candle_history(): No symbol provided. Using %s', symbol)
|
||||||
|
|
||||||
if not interval:
|
if not interval:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
|
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
|
||||||
log.info(f'get_candle_history(): No timeframe provided. Using {interval}')
|
logger.info('get_candle_history(): No timeframe provided. Using %s', interval)
|
||||||
|
|
||||||
if not exchange_name:
|
if not exchange_name:
|
||||||
assert user_name is not None
|
assert user_name is not None
|
||||||
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
|
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
|
||||||
log.info(f'get_candle_history(): No exchange name provided. Using {exchange_name}')
|
logger.info('get_candle_history(): No exchange name provided. Using %s', exchange_name)
|
||||||
|
|
||||||
candlesticks = self.get_last_n_candles(num_candles=num_records, asset=symbol, timeframe=interval,
|
candlesticks = self.get_last_n_candles(num_candles=num_records, asset=symbol, timeframe=interval,
|
||||||
exchange=exchange_name, user_name=user_name)
|
exchange=exchange_name, user_name=user_name)
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ class ManualTradingBrokerManager:
|
||||||
:param user_name: Username for exchange lookup.
|
:param user_name: Username for exchange lookup.
|
||||||
:return: LiveBroker instance or None if not configured or mode conflict.
|
:return: LiveBroker instance or None if not configured or mode conflict.
|
||||||
"""
|
"""
|
||||||
|
if not exchange_name:
|
||||||
|
logger.error("Cannot create live broker: exchange_name is required")
|
||||||
|
return None
|
||||||
|
|
||||||
# Use 'testnet'/'production' to match what's stored in trade.broker_mode
|
# Use 'testnet'/'production' to match what's stored in trade.broker_mode
|
||||||
requested_mode = 'testnet' if testnet else 'production'
|
requested_mode = 'testnet' if testnet else 'production'
|
||||||
broker_key = f"{exchange_name}_{requested_mode}"
|
broker_key = f"{exchange_name}_{requested_mode}"
|
||||||
|
|
@ -213,16 +217,17 @@ class ManualTradingBrokerManager:
|
||||||
"""
|
"""
|
||||||
Update current prices for all paper brokers.
|
Update current prices for all paper brokers.
|
||||||
|
|
||||||
:param price_updates: Dict mapping symbol to price.
|
:param price_updates: Dict mapping symbol or exchange:symbol to price.
|
||||||
"""
|
"""
|
||||||
for broker in self._paper_brokers.values():
|
for broker in self._paper_brokers.values():
|
||||||
for symbol, price in price_updates.items():
|
for key, price in price_updates.items():
|
||||||
# Handle exchange:symbol format
|
if ':' in key:
|
||||||
if ':' in symbol:
|
# Exchange-qualified key (e.g., 'kucoin:BTC/USDT')
|
||||||
_, sym = symbol.split(':', 1)
|
exchange, symbol = key.split(':', 1)
|
||||||
|
broker.update_price(symbol, price, exchange)
|
||||||
else:
|
else:
|
||||||
sym = symbol
|
# Symbol-only key
|
||||||
broker.update_price(sym, price)
|
broker.update_price(key, price)
|
||||||
|
|
||||||
def update_all_brokers(self, price_updates: Dict[str, float]) -> List[Dict]:
|
def update_all_brokers(self, price_updates: Dict[str, float]) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -394,6 +399,7 @@ class ManualTradingBrokerManager:
|
||||||
"success": result.success,
|
"success": result.success,
|
||||||
"message": result.message,
|
"message": result.message,
|
||||||
"order_id": result.order_id,
|
"order_id": result.order_id,
|
||||||
|
"status": getattr(result.status, 'value', result.status),
|
||||||
"filled_qty": result.filled_qty,
|
"filled_qty": result.filled_qty,
|
||||||
"filled_price": result.filled_price
|
"filled_price": result.filled_price
|
||||||
}
|
}
|
||||||
|
|
@ -466,16 +472,22 @@ class ManualTradingBrokerManager:
|
||||||
logger.error(f"Error placing order: {e}")
|
logger.error(f"Error placing order: {e}")
|
||||||
return OrderResult(success=False, message=str(e))
|
return OrderResult(success=False, message=str(e))
|
||||||
|
|
||||||
def _get_broker(self, user_id: int, broker_key: str):
|
def _get_broker(self, user_id: int, broker_key: str, create_paper: bool = True):
|
||||||
"""
|
"""
|
||||||
Get a broker by user_id and broker_key.
|
Get a broker by user_id and broker_key.
|
||||||
|
|
||||||
:param user_id: The user ID.
|
:param user_id: The user ID.
|
||||||
:param broker_key: 'paper' or 'exchange_mode' format.
|
:param broker_key: 'paper' or 'exchange_mode' format.
|
||||||
|
:param create_paper: If True, create paper broker on-demand (loads saved state).
|
||||||
:return: Broker instance or None.
|
:return: Broker instance or None.
|
||||||
"""
|
"""
|
||||||
if broker_key == 'paper':
|
if broker_key == 'paper':
|
||||||
return self._paper_brokers.get(user_id)
|
if user_id in self._paper_brokers:
|
||||||
|
return self._paper_brokers[user_id]
|
||||||
|
# Create paper broker on-demand (this loads saved state)
|
||||||
|
if create_paper:
|
||||||
|
return self.get_paper_broker(user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
if user_id in self._live_brokers:
|
if user_id in self._live_brokers:
|
||||||
return self._live_brokers[user_id].get(broker_key)
|
return self._live_brokers[user_id].get(broker_key)
|
||||||
|
|
@ -495,6 +507,37 @@ class ManualTradingBrokerManager:
|
||||||
return 0.0
|
return 0.0
|
||||||
return broker.get_balance()
|
return broker.get_balance()
|
||||||
|
|
||||||
|
def reset_paper_broker(self, user_id: int) -> Dict:
|
||||||
|
"""
|
||||||
|
Reset the paper broker for a user to initial state.
|
||||||
|
|
||||||
|
Clears all positions, orders, and restores the initial balance.
|
||||||
|
|
||||||
|
:param user_id: The user ID.
|
||||||
|
:return: Dict with success status and new balance.
|
||||||
|
"""
|
||||||
|
broker = self._paper_brokers.get(user_id)
|
||||||
|
if not broker:
|
||||||
|
# Create a fresh broker if one doesn't exist
|
||||||
|
broker = self.get_paper_broker(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
broker.reset()
|
||||||
|
|
||||||
|
# Save the reset state
|
||||||
|
state_id = f"manual_paper_{user_id}"
|
||||||
|
broker.save_state(state_id)
|
||||||
|
|
||||||
|
logger.info(f"Reset paper broker for user {user_id}, balance: {broker.initial_balance}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Paper trading balance reset successfully",
|
||||||
|
"balance": broker.initial_balance
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resetting paper broker for user {user_id}: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def get_available_balance(self, user_id: int, broker_key: str) -> float:
|
def get_available_balance(self, user_id: int, broker_key: str) -> float:
|
||||||
"""
|
"""
|
||||||
Get the available balance (not locked in orders).
|
Get the available balance (not locked in orders).
|
||||||
|
|
@ -574,3 +617,36 @@ class ManualTradingBrokerManager:
|
||||||
logger.error(f"Error recovering broker {broker_key} for user {user_id}: {e}")
|
logger.error(f"Error recovering broker {broker_key} for user {user_id}: {e}")
|
||||||
|
|
||||||
return recovered
|
return recovered
|
||||||
|
|
||||||
|
def get_active_paper_user_ids(self) -> List[int]:
|
||||||
|
"""Return user IDs with active paper brokers."""
|
||||||
|
return list(self._paper_brokers.keys())
|
||||||
|
|
||||||
|
def get_required_price_feeds(self, user_id: int) -> List[tuple]:
|
||||||
|
"""
|
||||||
|
Get (exchange, symbol) pairs needed for P&L updates.
|
||||||
|
|
||||||
|
Derived from positions and open orders, NOT from Trade.exchange.
|
||||||
|
Returns list of (exchange, symbol) tuples where exchange may be None
|
||||||
|
for positions/orders without a specified price source.
|
||||||
|
|
||||||
|
:param user_id: The user ID.
|
||||||
|
:return: List of (exchange, symbol) tuples.
|
||||||
|
"""
|
||||||
|
feeds = set()
|
||||||
|
broker = self._paper_brokers.get(user_id)
|
||||||
|
if broker:
|
||||||
|
# From positions
|
||||||
|
for pos in broker.get_all_positions():
|
||||||
|
if pos.price_source:
|
||||||
|
feeds.add((pos.price_source, pos.symbol))
|
||||||
|
else:
|
||||||
|
# Fallback: no exchange specified, use symbol only
|
||||||
|
feeds.add((None, pos.symbol))
|
||||||
|
# From open orders
|
||||||
|
for order in broker.get_open_orders():
|
||||||
|
exchange = order.get('exchange')
|
||||||
|
symbol = order.get('symbol')
|
||||||
|
if symbol:
|
||||||
|
feeds.add((exchange, symbol))
|
||||||
|
return list(feeds)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ class Charts {
|
||||||
this.candleSeries = this.chart_1.addSeries(LightweightCharts.CandlestickSeries);
|
this.candleSeries = this.chart_1.addSeries(LightweightCharts.CandlestickSeries);
|
||||||
|
|
||||||
// Initialize the candlestick series if price_history is available
|
// Initialize the candlestick series if price_history is available
|
||||||
|
this.price_history = this._normalizeCandles(this.price_history);
|
||||||
if (this.price_history && this.price_history.length > 0) {
|
if (this.price_history && this.price_history.length > 0) {
|
||||||
this.candleSeries.setData(this.price_history);
|
this.candleSeries.setData(this.price_history);
|
||||||
console.log(`Candle series initialized with ${this.price_history.length} candles`);
|
console.log(`Candle series initialized with ${this.price_history.length} candles`);
|
||||||
|
|
@ -48,8 +49,110 @@ class Charts {
|
||||||
}
|
}
|
||||||
|
|
||||||
update_main_chart(new_candle){
|
update_main_chart(new_candle){
|
||||||
|
const normalizedCandle = this._normalizeCandle(new_candle);
|
||||||
|
if (!normalizedCandle) {
|
||||||
|
console.warn('Skipping invalid candle update:', new_candle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCandle = Array.isArray(this.price_history) && this.price_history.length > 0
|
||||||
|
? this.price_history[this.price_history.length - 1]
|
||||||
|
: null;
|
||||||
|
if (lastCandle && normalizedCandle.time < lastCandle.time) {
|
||||||
|
console.warn('Skipping stale candle update:', normalizedCandle, 'last:', lastCandle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update candlestick series
|
// Update candlestick series
|
||||||
this.candleSeries.update(new_candle);
|
this.candleSeries.update(normalizedCandle);
|
||||||
|
|
||||||
|
// Keep local price history aligned with the live chart series.
|
||||||
|
if (!Array.isArray(this.price_history)) {
|
||||||
|
this.price_history = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = this.price_history.length - 1;
|
||||||
|
if (lastIndex >= 0 && this.price_history[lastIndex].time === normalizedCandle.time) {
|
||||||
|
this.price_history[lastIndex] = normalizedCandle;
|
||||||
|
} else if (lastIndex < 0 || this.price_history[lastIndex].time < normalizedCandle.time) {
|
||||||
|
this.price_history.push(normalizedCandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeCandleTime(rawTime) {
|
||||||
|
if (rawTime === null || rawTime === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'number' && Number.isFinite(rawTime)) {
|
||||||
|
return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'string') {
|
||||||
|
const numericValue = Number(rawTime);
|
||||||
|
if (Number.isFinite(numericValue)) {
|
||||||
|
return this._normalizeCandleTime(numericValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedTime = Date.parse(rawTime);
|
||||||
|
if (!Number.isNaN(parsedTime)) {
|
||||||
|
return Math.floor(parsedTime / 1000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawTime instanceof Date) {
|
||||||
|
return Math.floor(rawTime.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'object') {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'year') &&
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'month') &&
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'day')
|
||||||
|
) {
|
||||||
|
return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ['timestamp', 'time', 'value', '$date']) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawTime, key)) {
|
||||||
|
return this._normalizeCandleTime(rawTime[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeCandle(candle) {
|
||||||
|
if (!candle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = this._normalizeCandleTime(candle.time);
|
||||||
|
if (time === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...candle,
|
||||||
|
time,
|
||||||
|
open: parseFloat(candle.open),
|
||||||
|
high: parseFloat(candle.high),
|
||||||
|
low: parseFloat(candle.low),
|
||||||
|
close: parseFloat(candle.close)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeCandles(candles) {
|
||||||
|
if (!Array.isArray(candles)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return candles
|
||||||
|
.map(candle => this._normalizeCandle(candle))
|
||||||
|
.filter(candle => candle !== null)
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
}
|
}
|
||||||
|
|
||||||
create_RSI_chart(){
|
create_RSI_chart(){
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,57 @@ class Comms {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize incoming candle times to UTC seconds for lightweight-charts.
|
||||||
|
* EDM data occasionally arrives nested or as ISO strings.
|
||||||
|
* @param {*} rawTime
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
_normalizeCandleTime(rawTime) {
|
||||||
|
if (rawTime === null || rawTime === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'number' && Number.isFinite(rawTime)) {
|
||||||
|
return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'string') {
|
||||||
|
const numericValue = Number(rawTime);
|
||||||
|
if (Number.isFinite(numericValue)) {
|
||||||
|
return this._normalizeCandleTime(numericValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedTime = Date.parse(rawTime);
|
||||||
|
if (!Number.isNaN(parsedTime)) {
|
||||||
|
return Math.floor(parsedTime / 1000);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawTime instanceof Date) {
|
||||||
|
return Math.floor(rawTime.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawTime === 'object') {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'year') &&
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'month') &&
|
||||||
|
Object.prototype.hasOwnProperty.call(rawTime, 'day')
|
||||||
|
) {
|
||||||
|
return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of ['timestamp', 'time', 'value', '$date']) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(rawTime, key)) {
|
||||||
|
return this._normalizeCandleTime(rawTime[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/* Callback declarations */
|
/* Callback declarations */
|
||||||
|
|
||||||
candleUpdate(newCandle) {
|
candleUpdate(newCandle) {
|
||||||
|
|
@ -345,14 +396,17 @@ class Comms {
|
||||||
const candles = data.candles || [];
|
const candles = data.candles || [];
|
||||||
|
|
||||||
// EDM already sends time in seconds, no conversion needed
|
// EDM already sends time in seconds, no conversion needed
|
||||||
return candles.map(c => ({
|
return candles
|
||||||
time: c.time,
|
.map(c => ({
|
||||||
open: c.open,
|
time: this._normalizeCandleTime(c.time),
|
||||||
high: c.high,
|
open: parseFloat(c.open),
|
||||||
low: c.low,
|
high: parseFloat(c.high),
|
||||||
close: c.close,
|
low: parseFloat(c.low),
|
||||||
volume: c.volume
|
close: parseFloat(c.close),
|
||||||
}));
|
volume: parseFloat(c.volume)
|
||||||
|
}))
|
||||||
|
.filter(c => c.time !== null)
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -571,8 +625,13 @@ class Comms {
|
||||||
|
|
||||||
if (messageType === 'candle') {
|
if (messageType === 'candle') {
|
||||||
const candle = message.data;
|
const candle = message.data;
|
||||||
|
const candleTime = this._normalizeCandleTime(candle.time);
|
||||||
|
if (candleTime === null) {
|
||||||
|
console.warn('Skipping candle with invalid time payload:', candle.time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newCandle = {
|
const newCandle = {
|
||||||
time: candle.time, // EDM sends time in seconds
|
time: candleTime,
|
||||||
open: parseFloat(candle.open),
|
open: parseFloat(candle.open),
|
||||||
high: parseFloat(candle.high),
|
high: parseFloat(candle.high),
|
||||||
low: parseFloat(candle.low),
|
low: parseFloat(candle.low),
|
||||||
|
|
|
||||||
|
|
@ -23,17 +23,18 @@ class TradeUIManager {
|
||||||
this.sltpRow = null;
|
this.sltpRow = null;
|
||||||
this.onCloseTrade = null;
|
this.onCloseTrade = null;
|
||||||
|
|
||||||
// Exchanges known to support testnet/sandbox mode
|
// Exchanges known to support testnet/sandbox mode in ccxt
|
||||||
|
// IMPORTANT: Only include exchanges with verified working sandbox URLs
|
||||||
|
// KuCoin does NOT have sandbox support - removed to prevent real trades!
|
||||||
this.testnetSupportedExchanges = [
|
this.testnetSupportedExchanges = [
|
||||||
'binance', 'binanceus', 'binanceusdm', 'binancecoinm',
|
'binance', 'binanceus', 'binanceusdm', 'binancecoinm',
|
||||||
'kucoin', 'kucoinfutures',
|
|
||||||
'bybit',
|
'bybit',
|
||||||
'okx', 'okex',
|
'okx', 'okex',
|
||||||
'bitget',
|
'bitget',
|
||||||
'bitmex',
|
'bitmex',
|
||||||
'deribit',
|
'deribit',
|
||||||
'phemex',
|
'phemex'
|
||||||
'mexc'
|
// Removed: 'kucoin', 'kucoinfutures', 'mexc' - no sandbox support
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +142,7 @@ class TradeUIManager {
|
||||||
this._updateTestnetVisibility();
|
this._updateTestnetVisibility();
|
||||||
this._updateExchangeRowVisibility();
|
this._updateExchangeRowVisibility();
|
||||||
this._updateSellAvailability();
|
this._updateSellAvailability();
|
||||||
|
this._updateSltpVisibility();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,23 +334,23 @@ class TradeUIManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates SL/TP row visibility based on order side.
|
* Updates SL/TP row visibility based on supported mode and side.
|
||||||
* SL/TP only applies to BUY orders (opening positions).
|
* Manual SL/TP is currently supported for paper BUY orders only.
|
||||||
* SELL orders close existing positions, so SL/TP is not applicable.
|
|
||||||
*/
|
*/
|
||||||
_updateSltpVisibility() {
|
_updateSltpVisibility() {
|
||||||
if (!this.sltpRow || !this.sideSelect) return;
|
if (!this.sltpRow || !this.sideSelect || !this.targetSelect) return;
|
||||||
|
|
||||||
const side = this.sideSelect.value.toLowerCase();
|
const side = this.sideSelect.value.toLowerCase();
|
||||||
|
const isPaperTrade = this.targetSelect.value === 'test_exchange';
|
||||||
|
|
||||||
if (side === 'sell') {
|
if (!isPaperTrade || side === 'sell') {
|
||||||
// Hide SL/TP for SELL (closing positions)
|
// Hide SL/TP when unsupported or not applicable.
|
||||||
this.sltpRow.style.display = 'none';
|
this.sltpRow.style.display = 'none';
|
||||||
// Clear any values
|
// Clear values to avoid submitting stale unsupported inputs.
|
||||||
if (this.stopLossInput) this.stopLossInput.value = '';
|
if (this.stopLossInput) this.stopLossInput.value = '';
|
||||||
if (this.takeProfitInput) this.takeProfitInput.value = '';
|
if (this.takeProfitInput) this.takeProfitInput.value = '';
|
||||||
} else {
|
} else {
|
||||||
// Show SL/TP for BUY (opening positions)
|
// Show SL/TP for paper BUY orders.
|
||||||
this.sltpRow.style.display = 'contents';
|
this.sltpRow.style.display = 'contents';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -473,18 +475,16 @@ class TradeUIManager {
|
||||||
if (this.testnetCheckbox) {
|
if (this.testnetCheckbox) {
|
||||||
this.testnetCheckbox.checked = true;
|
this.testnetCheckbox.checked = true;
|
||||||
}
|
}
|
||||||
// Reset side to BUY and show SL/TP row
|
// Reset side to BUY
|
||||||
if (this.sideSelect) {
|
if (this.sideSelect) {
|
||||||
this.sideSelect.value = 'buy';
|
this.sideSelect.value = 'buy';
|
||||||
}
|
}
|
||||||
if (this.sltpRow) {
|
|
||||||
this.sltpRow.style.display = 'contents';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formElement.style.display = 'grid';
|
this.formElement.style.display = 'grid';
|
||||||
|
|
||||||
// Update SELL availability based on current broker/symbol
|
// Update SELL availability based on current broker/symbol
|
||||||
await this._updateSellAvailability();
|
await this._updateSellAvailability();
|
||||||
|
this._updateSltpVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -737,6 +737,22 @@ class TradeUIManager {
|
||||||
this.onCloseTrade = callback;
|
this.onCloseTrade = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the callback function for full refresh (trades + statistics).
|
||||||
|
* @param {Function} callback - The callback function.
|
||||||
|
*/
|
||||||
|
registerRefreshCallback(callback) {
|
||||||
|
this.onRefresh = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the callback function for position-close updates.
|
||||||
|
* @param {Function} callback - The callback function.
|
||||||
|
*/
|
||||||
|
registerPositionClosedCallback(callback) {
|
||||||
|
this.onPositionClosed = callback;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Broker Event Listeners ============
|
// ============ Broker Event Listeners ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -759,7 +775,11 @@ class TradeUIManager {
|
||||||
|
|
||||||
comms.on('position_closed', (data) => {
|
comms.on('position_closed', (data) => {
|
||||||
console.log('Position closed:', data);
|
console.log('Position closed:', data);
|
||||||
this.refreshAll();
|
if (this.onPositionClosed) {
|
||||||
|
this.onPositionClosed(data);
|
||||||
|
} else {
|
||||||
|
this.refreshAll();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
comms.on('sltp_triggered', (data) => {
|
comms.on('sltp_triggered', (data) => {
|
||||||
|
|
@ -834,12 +854,15 @@ class TradeUIManager {
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
if (!positions || positions.length === 0) {
|
// Filter out closed positions (size <= 0)
|
||||||
|
const openPositions = (positions || []).filter(pos => pos.size && Math.abs(pos.size) > 0);
|
||||||
|
|
||||||
|
if (openPositions.length === 0) {
|
||||||
container.innerHTML = '<p class="no-data-msg">No open positions</p>';
|
container.innerHTML = '<p class="no-data-msg">No open positions</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pos of positions) {
|
for (const pos of openPositions) {
|
||||||
const card = this._createPositionCard(pos);
|
const card = this._createPositionCard(pos);
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
}
|
}
|
||||||
|
|
@ -853,6 +876,10 @@ class TradeUIManager {
|
||||||
const plClass = pl >= 0 ? 'positive' : 'negative';
|
const plClass = pl >= 0 ? 'positive' : 'negative';
|
||||||
const plSign = pl >= 0 ? '+' : '';
|
const plSign = pl >= 0 ? '+' : '';
|
||||||
|
|
||||||
|
// Price source for tooltip (shows which exchange's prices are used for P&L)
|
||||||
|
const priceSource = position.price_source || 'default';
|
||||||
|
card.title = `P&L uses ${priceSource} prices`;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="position-header">
|
<div class="position-header">
|
||||||
<span class="position-symbol">${position.symbol || 'N/A'}</span>
|
<span class="position-symbol">${position.symbol || 'N/A'}</span>
|
||||||
|
|
@ -1033,6 +1060,10 @@ class TradeUIManager {
|
||||||
this.refreshPositions();
|
this.refreshPositions();
|
||||||
this.refreshHistory();
|
this.refreshHistory();
|
||||||
this.updateBrokerStatus();
|
this.updateBrokerStatus();
|
||||||
|
// Call refresh callback to update trades and statistics
|
||||||
|
if (this.onRefresh) {
|
||||||
|
this.onRefresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Broker Actions ============
|
// ============ Broker Actions ============
|
||||||
|
|
@ -1121,9 +1152,14 @@ class TradeUIManager {
|
||||||
*/
|
*/
|
||||||
async updateBrokerStatus() {
|
async updateBrokerStatus() {
|
||||||
const brokerKey = this._getCurrentBrokerKey();
|
const brokerKey = this._getCurrentBrokerKey();
|
||||||
|
const chartExchange = this.data?.exchange || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/manual/balance?broker_key=${encodeURIComponent(brokerKey)}`);
|
const params = new URLSearchParams({
|
||||||
|
broker_key: brokerKey,
|
||||||
|
exchange: chartExchange
|
||||||
|
});
|
||||||
|
const response = await fetch(`/api/manual/balance?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const balanceEl = document.getElementById('brokerBalance');
|
const balanceEl = document.getElementById('brokerBalance');
|
||||||
|
|
@ -1131,7 +1167,24 @@ class TradeUIManager {
|
||||||
|
|
||||||
if (balanceEl) {
|
if (balanceEl) {
|
||||||
const balance = data.available ?? data.total ?? 0;
|
const balance = data.available ?? data.total ?? 0;
|
||||||
balanceEl.textContent = `Available: $${balance.toFixed(2)}`;
|
// Get currency preference from localStorage (default: USD for paper, USDT for live)
|
||||||
|
const defaultCurrency = brokerKey === 'paper' ? 'USD' : 'USDT';
|
||||||
|
const currency = localStorage.getItem('balanceCurrency') || defaultCurrency;
|
||||||
|
balanceEl.textContent = `Available: $${balance.toFixed(2)} ${currency}`;
|
||||||
|
balanceEl.style.cursor = 'pointer';
|
||||||
|
balanceEl.title = data.source === 'exchange'
|
||||||
|
? `Using ${chartExchange || 'exchange'} chart balance. Click to toggle USD/USDT`
|
||||||
|
: 'Click to toggle USD/USDT';
|
||||||
|
// Add click handler if not already added
|
||||||
|
if (!balanceEl.dataset.clickHandler) {
|
||||||
|
balanceEl.dataset.clickHandler = 'true';
|
||||||
|
balanceEl.addEventListener('click', () => {
|
||||||
|
const current = localStorage.getItem('balanceCurrency') || defaultCurrency;
|
||||||
|
const newCurrency = current === 'USD' ? 'USDT' : 'USD';
|
||||||
|
localStorage.setItem('balanceCurrency', newCurrency);
|
||||||
|
this.updateBrokerStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (modeEl) {
|
if (modeEl) {
|
||||||
if (brokerKey === 'paper') {
|
if (brokerKey === 'paper') {
|
||||||
|
|
@ -1149,6 +1202,40 @@ class TradeUIManager {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not fetch balance:', e);
|
console.warn('Could not fetch balance:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update status bar class for reset button visibility
|
||||||
|
const statusBar = document.getElementById('brokerStatusBar');
|
||||||
|
if (statusBar) {
|
||||||
|
statusBar.className = `broker-status-bar mode-${brokerKey === 'paper' ? 'paper' : brokerKey.includes('testnet') ? 'testnet' : 'live'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset paper trading balance to initial state ($10,000).
|
||||||
|
*/
|
||||||
|
async resetPaperBalance() {
|
||||||
|
if (!confirm('Reset paper trading? This will clear all positions, orders, and restore your balance to $10,000 USD.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/manual/paper/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Paper balance reset:', data);
|
||||||
|
alert(`Paper trading reset! New balance: $${data.balance.toFixed(2)} USD`);
|
||||||
|
this.refreshAll();
|
||||||
|
} else {
|
||||||
|
alert(`Reset failed: ${data.message}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error resetting paper balance:', e);
|
||||||
|
alert('Failed to reset paper balance');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Broker-Aware SELL Disable ============
|
// ============ Broker-Aware SELL Disable ============
|
||||||
|
|
@ -1362,6 +1449,13 @@ class Trade {
|
||||||
// Set up close callback
|
// Set up close callback
|
||||||
this.uiManager.registerCloseTradeCallback(this.closeTrade.bind(this));
|
this.uiManager.registerCloseTradeCallback(this.closeTrade.bind(this));
|
||||||
|
|
||||||
|
// Set up refresh callback for trades and statistics
|
||||||
|
this.uiManager.registerRefreshCallback(() => {
|
||||||
|
this.fetchTrades();
|
||||||
|
this._updateStatistics();
|
||||||
|
});
|
||||||
|
this.uiManager.registerPositionClosedCallback(this.handlePositionClosed.bind(this));
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.submitNewTrade = this.submitNewTrade.bind(this);
|
this.submitNewTrade = this.submitNewTrade.bind(this);
|
||||||
|
|
||||||
|
|
@ -1410,7 +1504,7 @@ class Trade {
|
||||||
this.dataManager.fetchTrades(this.comms, this.data);
|
this.dataManager.fetchTrades(this.comms, this.data);
|
||||||
|
|
||||||
// Initialize broker event listeners and refresh broker UI
|
// Initialize broker event listeners and refresh broker UI
|
||||||
this.initBrokerListeners(this.comms);
|
this.uiManager.initBrokerListeners(this.comms);
|
||||||
this.refreshAll();
|
this.refreshAll();
|
||||||
|
|
||||||
this._initialized = true;
|
this._initialized = true;
|
||||||
|
|
@ -1420,6 +1514,55 @@ class Trade {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh all broker-backed panels through the UI manager.
|
||||||
|
*/
|
||||||
|
refreshAll() {
|
||||||
|
this.uiManager.refreshAll();
|
||||||
|
// Also refresh trades and statistics
|
||||||
|
this.fetchTrades();
|
||||||
|
this._updateStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate broker balance refresh to the UI manager.
|
||||||
|
*/
|
||||||
|
updateBrokerStatus() {
|
||||||
|
return this.uiManager.updateBrokerStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate order cancellation to the UI manager.
|
||||||
|
* Kept here because DOM actions call UI.trade.* methods.
|
||||||
|
*/
|
||||||
|
cancelOrder(orderId, brokerKey) {
|
||||||
|
return this.uiManager.cancelOrder(orderId, brokerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate position close to the UI manager.
|
||||||
|
* Kept here because DOM actions call UI.trade.* methods.
|
||||||
|
*/
|
||||||
|
closePosition(symbol, brokerKey) {
|
||||||
|
return this.uiManager.closePosition(symbol, brokerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate symbol-wide order cancellation to the UI manager.
|
||||||
|
* Kept here because DOM actions call UI.trade.* methods.
|
||||||
|
*/
|
||||||
|
cancelOrdersForSymbol(symbol, brokerKey) {
|
||||||
|
return this.uiManager.cancelOrdersForSymbol(symbol, brokerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate paper balance reset to the UI manager.
|
||||||
|
* Kept here because DOM actions call UI.trade.* methods.
|
||||||
|
*/
|
||||||
|
resetPaperBalance() {
|
||||||
|
return this.uiManager.resetPaperBalance();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the trading pair display in the form.
|
* Updates the trading pair display in the form.
|
||||||
* @private
|
* @private
|
||||||
|
|
@ -1485,6 +1628,23 @@ class Trade {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle position-closed event from the REST close-position flow.
|
||||||
|
* @param {Object} data - Position close payload with affected trade IDs.
|
||||||
|
*/
|
||||||
|
handlePositionClosed(data) {
|
||||||
|
console.log("Position closed event received:", data);
|
||||||
|
|
||||||
|
const closedTrades = Array.isArray(data?.closed_trades) ? data.closed_trades : [];
|
||||||
|
for (const tradeId of closedTrades) {
|
||||||
|
this.dataManager.removeTrade(tradeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uiManager.updateTradesHtml(this.dataManager.getAllTrades());
|
||||||
|
this._updateStatistics();
|
||||||
|
this.uiManager.refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle trade error.
|
* Handle trade error.
|
||||||
* @param {Object} data - Error data.
|
* @param {Object} data - Error data.
|
||||||
|
|
@ -1673,8 +1833,8 @@ class Trade {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SL/TP validation (only for BUY orders - SELL closes existing positions)
|
// SL/TP validation (paper BUY only)
|
||||||
if (side.toUpperCase() === 'BUY') {
|
if (isPaperTrade && side.toUpperCase() === 'BUY') {
|
||||||
if (stopLoss && stopLoss >= price) {
|
if (stopLoss && stopLoss >= price) {
|
||||||
alert('Stop Loss must be below entry price for BUY orders.');
|
alert('Stop Loss must be below entry price for BUY orders.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -1683,8 +1843,10 @@ class Trade {
|
||||||
alert('Take Profit must be above entry price for BUY orders.');
|
alert('Take Profit must be above entry price for BUY orders.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (!isPaperTrade && (stopLoss || takeProfit)) {
|
||||||
|
alert('Manual live Stop Loss / Take Profit is not supported yet.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Note: SL/TP fields are hidden for SELL orders (inventory-only model)
|
|
||||||
|
|
||||||
// Show confirmation for production live trades
|
// Show confirmation for production live trades
|
||||||
if (!isPaperTrade && !testnet) {
|
if (!isPaperTrade && !testnet) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div class="broker-status-bar" id="brokerStatusBar">
|
<div class="broker-status-bar" id="brokerStatusBar">
|
||||||
<span id="brokerModeIndicator" class="mode-badge mode-paper">PAPER</span>
|
<span id="brokerModeIndicator" class="mode-badge mode-paper">PAPER</span>
|
||||||
<span id="brokerBalance">Available: --</span>
|
<span id="brokerBalance">Available: --</span>
|
||||||
|
<button id="resetPaperBalanceBtn" class="btn-reset-paper" onclick="UI.trade.resetPaperBalance()" title="Reset paper trading balance to $10,000">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Order</button>
|
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Order</button>
|
||||||
|
|
@ -276,6 +277,26 @@
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-reset-paper {
|
||||||
|
display: none; /* Hidden by default, shown only for paper mode */
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset-paper:hover {
|
||||||
|
background: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broker-status-bar.mode-paper .btn-reset-paper {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Trade sections */
|
/* Trade sections */
|
||||||
.trade-section {
|
.trade-section {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
|
|
||||||
242
src/trade.py
242
src/trade.py
|
|
@ -76,7 +76,9 @@ class Trade:
|
||||||
'qty_settled': 0.0,
|
'qty_settled': 0.0,
|
||||||
'profit': 0.0,
|
'profit': 0.0,
|
||||||
'profit_pct': 0.0,
|
'profit_pct': 0.0,
|
||||||
'fee_paid': 0.0
|
'fee_paid': 0.0,
|
||||||
|
'realized_profit': 0.0,
|
||||||
|
'unrealized_profit': 0.0
|
||||||
}
|
}
|
||||||
self.order = None
|
self.order = None
|
||||||
else:
|
else:
|
||||||
|
|
@ -140,30 +142,66 @@ class Trade:
|
||||||
"""
|
"""
|
||||||
return self.status
|
return self.status
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _percent(part: float, whole: float) -> float:
|
||||||
|
if whole == 0:
|
||||||
|
return 0.0
|
||||||
|
return 100.0 * float(part) / float(whole)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_pl(entry_price: float, exit_price: float, qty: float, side: str, fee: float) -> float:
|
||||||
|
entry_value = qty * entry_price
|
||||||
|
exit_value = qty * exit_price
|
||||||
|
profit = exit_value - entry_value
|
||||||
|
if side == 'SELL':
|
||||||
|
profit *= -1
|
||||||
|
fees = (entry_value * fee) + (exit_value * fee)
|
||||||
|
return profit - fees
|
||||||
|
|
||||||
|
def _filled_qty(self) -> float:
|
||||||
|
return float(self.stats.get('qty_filled', 0.0) or 0.0)
|
||||||
|
|
||||||
|
def _settled_qty(self) -> float:
|
||||||
|
return float(self.stats.get('qty_settled', 0.0) or 0.0)
|
||||||
|
|
||||||
|
def _open_qty(self) -> float:
|
||||||
|
return max(self._filled_qty() - self._settled_qty(), 0.0)
|
||||||
|
|
||||||
def update_values(self, current_price: float) -> None:
|
def update_values(self, current_price: float) -> None:
|
||||||
"""
|
"""
|
||||||
Updates the P/L values and percentages based on the current price.
|
Updates the P/L values and percentages based on the current price.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def percent(part: float, whole: float) -> float:
|
|
||||||
if whole == 0:
|
|
||||||
return 0.0
|
|
||||||
return 100.0 * float(part) / float(whole)
|
|
||||||
|
|
||||||
self.stats['current_price'] = current_price
|
self.stats['current_price'] = current_price
|
||||||
initial_value = self.stats['opening_value']
|
opening_price = float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0)
|
||||||
self.stats['current_value'] = self.base_order_qty * current_price
|
filled_qty = self._filled_qty()
|
||||||
|
open_qty = self._open_qty()
|
||||||
|
realized_profit = float(self.stats.get('realized_profit', 0.0) or 0.0)
|
||||||
|
|
||||||
|
if open_qty > 0:
|
||||||
|
self.stats['current_value'] = open_qty * current_price
|
||||||
|
unrealized_profit = self._calculate_pl(
|
||||||
|
entry_price=opening_price,
|
||||||
|
exit_price=current_price,
|
||||||
|
qty=open_qty,
|
||||||
|
side=self.side,
|
||||||
|
fee=self.fee
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Keep legacy order-notional display for resting orders, but they should not show P/L.
|
||||||
|
if filled_qty <= 0 and self.status in ['inactive', 'pending', 'open', 'unfilled']:
|
||||||
|
self.stats['current_value'] = self.base_order_qty * current_price
|
||||||
|
else:
|
||||||
|
self.stats['current_value'] = 0.0
|
||||||
|
unrealized_profit = 0.0
|
||||||
|
|
||||||
logger.debug(f"Trade {self.unique_id}: Updated current value to {self.stats['current_value']}")
|
logger.debug(f"Trade {self.unique_id}: Updated current value to {self.stats['current_value']}")
|
||||||
|
|
||||||
self.stats['profit'] = self.stats['current_value'] - initial_value
|
self.stats['unrealized_profit'] = unrealized_profit
|
||||||
|
self.stats['profit'] = realized_profit + unrealized_profit
|
||||||
|
|
||||||
if self.side == 'SELL':
|
basis_qty = filled_qty if filled_qty > 0 else 0.0
|
||||||
self.stats['profit'] *= -1
|
basis_value = basis_qty * opening_price
|
||||||
|
self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value)
|
||||||
projected_fees = (self.stats['current_value'] * self.fee) + (self.stats['opening_value'] * self.fee)
|
|
||||||
self.stats['profit'] -= projected_fees
|
|
||||||
|
|
||||||
self.stats['profit_pct'] = percent(self.stats['profit'], initial_value)
|
|
||||||
logger.debug(f"Trade {self.unique_id}: Profit updated to {self.stats['profit']} ({self.stats['profit_pct']}%)")
|
logger.debug(f"Trade {self.unique_id}: Profit updated to {self.stats['profit']} ({self.stats['profit_pct']}%)")
|
||||||
|
|
||||||
def update(self, current_price: float) -> str:
|
def update(self, current_price: float) -> str:
|
||||||
|
|
@ -195,28 +233,45 @@ class Trade:
|
||||||
if self.status == 'inactive':
|
if self.status == 'inactive':
|
||||||
self.status = 'unfilled'
|
self.status = 'unfilled'
|
||||||
|
|
||||||
if self.status == 'unfilled':
|
current_filled = self._filled_qty()
|
||||||
|
if current_filled <= 0:
|
||||||
self.stats['qty_filled'] = qty
|
self.stats['qty_filled'] = qty
|
||||||
self.stats['opening_price'] = price
|
self.stats['opening_price'] = price
|
||||||
else:
|
else:
|
||||||
sum_of_values = (qty * price) + self.stats['opening_value']
|
sum_of_values = (qty * price) + (current_filled * self.stats['opening_price'])
|
||||||
t_qty = self.stats['qty_filled'] + qty
|
t_qty = current_filled + qty
|
||||||
weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0
|
weighted_average = sum_of_values / t_qty if t_qty != 0 else 0.0
|
||||||
self.stats['opening_price'] = weighted_average
|
self.stats['opening_price'] = weighted_average
|
||||||
self.stats['qty_filled'] += qty
|
self.stats['qty_filled'] = t_qty
|
||||||
|
|
||||||
self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price']
|
self.stats['opening_value'] = self.stats['qty_filled'] * self.stats['opening_price']
|
||||||
self.stats['current_value'] = self.stats['qty_filled'] * self.stats['current_price']
|
|
||||||
|
|
||||||
if self.stats['qty_filled'] >= self.base_order_qty:
|
if self.stats['qty_filled'] >= self.base_order_qty:
|
||||||
self.status = 'filled'
|
self.status = 'filled'
|
||||||
else:
|
else:
|
||||||
self.status = 'part-filled'
|
self.status = 'part-filled'
|
||||||
|
|
||||||
|
current_price = float(self.stats.get('current_price', 0.0) or 0.0)
|
||||||
|
if current_price <= 0:
|
||||||
|
current_price = price
|
||||||
|
self.update_values(current_price)
|
||||||
|
|
||||||
def settle(self, qty: float, price: float) -> None:
|
def settle(self, qty: float, price: float) -> None:
|
||||||
"""
|
"""
|
||||||
Settles all or part of the trade based on the provided quantity and price.
|
Settles all or part of the trade based on the provided quantity and price.
|
||||||
"""
|
"""
|
||||||
|
qty = float(qty or 0.0)
|
||||||
|
if qty <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
filled_qty = self._filled_qty()
|
||||||
|
open_qty = self._open_qty()
|
||||||
|
if filled_qty > 0 and open_qty > 0:
|
||||||
|
qty = min(qty, open_qty)
|
||||||
|
|
||||||
|
if qty <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
if self.stats['qty_settled'] == 0:
|
if self.stats['qty_settled'] == 0:
|
||||||
self.stats['settled_price'] = price
|
self.stats['settled_price'] = price
|
||||||
self.stats['settled_value'] = qty * price
|
self.stats['settled_value'] = qty * price
|
||||||
|
|
@ -230,7 +285,28 @@ class Trade:
|
||||||
|
|
||||||
self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price']
|
self.stats['settled_value'] = self.stats['qty_settled'] * self.stats['settled_price']
|
||||||
|
|
||||||
if self.stats['qty_settled'] >= self.base_order_qty:
|
realized_increment = self._calculate_pl(
|
||||||
|
entry_price=float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0),
|
||||||
|
exit_price=price,
|
||||||
|
qty=qty,
|
||||||
|
side=self.side,
|
||||||
|
fee=self.fee
|
||||||
|
)
|
||||||
|
self.stats['realized_profit'] = float(self.stats.get('realized_profit', 0.0) or 0.0) + realized_increment
|
||||||
|
|
||||||
|
if self._open_qty() <= 0:
|
||||||
|
self.stats['current_price'] = price
|
||||||
|
self.stats['current_value'] = 0.0
|
||||||
|
self.stats['unrealized_profit'] = 0.0
|
||||||
|
self.stats['profit'] = self.stats['realized_profit']
|
||||||
|
basis_qty = self._filled_qty() if self._filled_qty() > 0 else self.base_order_qty
|
||||||
|
basis_value = basis_qty * float(self.stats.get('opening_price', self.order_price) or self.order_price or 0.0)
|
||||||
|
self.stats['profit_pct'] = self._percent(self.stats['profit'], basis_value)
|
||||||
|
else:
|
||||||
|
self.update_values(float(self.stats.get('current_price', price) or price))
|
||||||
|
|
||||||
|
close_qty = filled_qty if filled_qty > 0 else self.base_order_qty
|
||||||
|
if self.stats['qty_settled'] >= close_qty:
|
||||||
self.status = 'closed'
|
self.status = 'closed'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -380,7 +456,9 @@ class Trades:
|
||||||
"broker_mode",
|
"broker_mode",
|
||||||
"broker_exchange",
|
"broker_exchange",
|
||||||
"broker_order_id",
|
"broker_order_id",
|
||||||
"exchange_order_id"
|
"exchange_order_id",
|
||||||
|
"stop_loss",
|
||||||
|
"take_profit"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -577,6 +655,7 @@ class Trades:
|
||||||
|
|
||||||
# Determine if this is a paper trade
|
# Determine if this is a paper trade
|
||||||
is_paper = target in ['test_exchange', 'paper', 'Paper Trade']
|
is_paper = target in ['test_exchange', 'paper', 'Paper Trade']
|
||||||
|
time_in_force = (time_in_force or 'GTC').upper()
|
||||||
|
|
||||||
# === PRODUCTION SAFETY GATE (BEFORE any broker/exchange creation) ===
|
# === PRODUCTION SAFETY GATE (BEFORE any broker/exchange creation) ===
|
||||||
if not is_paper and not testnet:
|
if not is_paper and not testnet:
|
||||||
|
|
@ -605,14 +684,20 @@ class Trades:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.'
|
return 'Error', f'Exchange "{target}" is not connected. Please add it in the Exchanges panel first.'
|
||||||
|
|
||||||
|
if not is_paper and (stop_loss is not None or take_profit is not None):
|
||||||
|
return 'Error', 'Manual live Stop Loss / Take Profit is not supported yet. Use paper trading for SL/TP for now.'
|
||||||
|
|
||||||
# For market orders, fetch the current price from exchange
|
# For market orders, fetch the current price from exchange
|
||||||
|
# For paper trades, use the specified exchange for consistent pricing
|
||||||
effective_price = float(price) if price else 0.0
|
effective_price = float(price) if price else 0.0
|
||||||
if order_type and order_type.upper() == 'MARKET' and self.exchange_interface:
|
if order_type and order_type.upper() == 'MARKET' and self.exchange_interface:
|
||||||
try:
|
try:
|
||||||
current_price = self.exchange_interface.get_price(symbol)
|
# Use exchange-aware price lookup for paper trades
|
||||||
|
price_exchange = exchange if is_paper and exchange else (target if not is_paper else None)
|
||||||
|
current_price = self.exchange_interface.get_price(symbol, price_exchange)
|
||||||
if current_price:
|
if current_price:
|
||||||
effective_price = float(current_price)
|
effective_price = float(current_price)
|
||||||
logger.debug(f"Market order: using current price {effective_price} for {symbol}")
|
logger.debug(f"Market order: using current price {effective_price} for {symbol} from {price_exchange or 'default'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not fetch current price for {symbol}: {e}, using provided price {price}")
|
logger.warning(f"Could not fetch current price for {symbol}: {e}, using provided price {price}")
|
||||||
|
|
||||||
|
|
@ -667,16 +752,22 @@ class Trades:
|
||||||
order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL
|
order_side = OrderSide.BUY if side.upper() == 'BUY' else OrderSide.SELL
|
||||||
order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT
|
order_type_enum = OrderType.MARKET if order_type.upper() == 'MARKET' else OrderType.LIMIT
|
||||||
|
|
||||||
result = broker.place_order(
|
# Build order kwargs - paper trades get exchange for price source tracking
|
||||||
symbol=symbol,
|
order_kwargs = {
|
||||||
side=order_side,
|
'symbol': symbol,
|
||||||
order_type=order_type_enum,
|
'side': order_side,
|
||||||
size=float(qty),
|
'order_type': order_type_enum,
|
||||||
price=effective_price if order_type.upper() == 'LIMIT' else None,
|
'size': float(qty),
|
||||||
stop_loss=stop_loss,
|
'price': effective_price if order_type.upper() == 'LIMIT' else None,
|
||||||
take_profit=take_profit,
|
'stop_loss': stop_loss,
|
||||||
time_in_force=time_in_force
|
'take_profit': take_profit,
|
||||||
)
|
'time_in_force': time_in_force,
|
||||||
|
}
|
||||||
|
if is_paper and exchange:
|
||||||
|
# Paper trades track exchange for price source
|
||||||
|
order_kwargs['exchange'] = exchange
|
||||||
|
|
||||||
|
result = broker.place_order(**order_kwargs)
|
||||||
|
|
||||||
if not result.success:
|
if not result.success:
|
||||||
return 'Error', result.message or 'Order placement failed'
|
return 'Error', result.message or 'Order placement failed'
|
||||||
|
|
@ -703,6 +794,7 @@ class Trades:
|
||||||
order_price=effective_price,
|
order_price=effective_price,
|
||||||
base_order_qty=float(qty),
|
base_order_qty=float(qty),
|
||||||
order_type=order_type.upper() if order_type else 'MARKET',
|
order_type=order_type.upper() if order_type else 'MARKET',
|
||||||
|
time_in_force=time_in_force,
|
||||||
strategy_id=strategy_id,
|
strategy_id=strategy_id,
|
||||||
is_paper=is_paper,
|
is_paper=is_paper,
|
||||||
testnet=testnet,
|
testnet=testnet,
|
||||||
|
|
@ -721,10 +813,29 @@ class Trades:
|
||||||
# Update stats if order was filled immediately (market orders)
|
# Update stats if order was filled immediately (market orders)
|
||||||
if result.status == OrderStatus.FILLED:
|
if result.status == OrderStatus.FILLED:
|
||||||
trade.stats['qty_filled'] = result.filled_qty or float(qty)
|
trade.stats['qty_filled'] = result.filled_qty or float(qty)
|
||||||
trade.stats['opening_price'] = result.filled_price or effective_price
|
|
||||||
|
# Validate filled_price - detect unreasonable deviations from effective_price
|
||||||
|
filled_price = result.filled_price or effective_price
|
||||||
|
if effective_price > 0 and filled_price > 0:
|
||||||
|
price_ratio = filled_price / effective_price
|
||||||
|
# Price shouldn't deviate by more than 10% from market price for market orders
|
||||||
|
if price_ratio > 1.1 or price_ratio < 0.9:
|
||||||
|
logger.warning(
|
||||||
|
f"[PRICE VALIDATION] Suspicious filled_price detected! "
|
||||||
|
f"filled_price={filled_price}, effective_price={effective_price}, "
|
||||||
|
f"ratio={price_ratio:.2f}. Using effective_price instead."
|
||||||
|
)
|
||||||
|
filled_price = effective_price
|
||||||
|
|
||||||
|
trade.stats['opening_price'] = filled_price
|
||||||
trade.stats['opening_value'] = trade.stats['qty_filled'] * trade.stats['opening_price']
|
trade.stats['opening_value'] = trade.stats['qty_filled'] * trade.stats['opening_price']
|
||||||
trade.stats['current_value'] = trade.stats['opening_value']
|
trade.stats['current_value'] = trade.stats['opening_value']
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[FILL STATS] trade={trade.unique_id[:8]}, qty_filled={trade.stats['qty_filled']}, "
|
||||||
|
f"opening_price={trade.stats['opening_price']}, result.filled_price={result.filled_price}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Broker trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price} "
|
logger.info(f"Broker trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price} "
|
||||||
f"(broker_kind={broker_kind}, status={trade_status})")
|
f"(broker_kind={broker_kind}, status={trade_status})")
|
||||||
|
|
||||||
|
|
@ -738,6 +849,7 @@ class Trades:
|
||||||
order_price=effective_price,
|
order_price=effective_price,
|
||||||
base_order_qty=float(qty),
|
base_order_qty=float(qty),
|
||||||
order_type=order_type.upper() if order_type else 'MARKET',
|
order_type=order_type.upper() if order_type else 'MARKET',
|
||||||
|
time_in_force=time_in_force,
|
||||||
strategy_id=strategy_id,
|
strategy_id=strategy_id,
|
||||||
is_paper=is_paper,
|
is_paper=is_paper,
|
||||||
testnet=testnet,
|
testnet=testnet,
|
||||||
|
|
@ -781,6 +893,44 @@ class Trades:
|
||||||
logger.error(f"Error creating new trade: {e}", exc_info=True)
|
logger.error(f"Error creating new trade: {e}", exc_info=True)
|
||||||
return 'Error', str(e)
|
return 'Error', str(e)
|
||||||
|
|
||||||
|
def settle_broker_closed_position(self, user_id: int, symbol: str, broker_key: str,
|
||||||
|
close_price: float) -> list[str]:
|
||||||
|
"""
|
||||||
|
Reconcile local trades after a broker-side position close.
|
||||||
|
|
||||||
|
This is used when the broker closes the position outside the normal
|
||||||
|
close_position() API flow, such as paper SL/TP triggers.
|
||||||
|
"""
|
||||||
|
settled_ids = []
|
||||||
|
|
||||||
|
for trade_id, trade in list(self.active_trades.items()):
|
||||||
|
if broker_key == 'paper':
|
||||||
|
matches_broker = trade.broker_kind == 'paper'
|
||||||
|
else:
|
||||||
|
trade_broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
|
||||||
|
matches_broker = (trade.broker_kind == 'live' and trade_broker_key == broker_key)
|
||||||
|
|
||||||
|
if not (trade.creator == user_id and trade.symbol == symbol and matches_broker):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if trade.status not in ['filled', 'part-filled']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
qty_to_settle = trade.stats.get('qty_filled', trade.base_order_qty)
|
||||||
|
if qty_to_settle <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trade.settle(qty=qty_to_settle, price=close_price)
|
||||||
|
trade.status = 'closed'
|
||||||
|
|
||||||
|
self._save_trade(trade)
|
||||||
|
del self.active_trades[trade_id]
|
||||||
|
self.settled_trades[trade_id] = trade
|
||||||
|
self.stats['num_trades'] -= 1
|
||||||
|
settled_ids.append(trade.unique_id)
|
||||||
|
|
||||||
|
return settled_ids
|
||||||
|
|
||||||
def get_trades_for_user(self, user_id: int, form: str = 'json') -> list:
|
def get_trades_for_user(self, user_id: int, form: str = 'json') -> list:
|
||||||
"""
|
"""
|
||||||
Returns trades visible to a specific user.
|
Returns trades visible to a specific user.
|
||||||
|
|
@ -1272,6 +1422,12 @@ class Trades:
|
||||||
exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None
|
exchange_key = f"{exchange.lower()}:{symbol}" if exchange else None
|
||||||
current_price = price_updates.get(exchange_key) if exchange_key else None
|
current_price = price_updates.get(exchange_key) if exchange_key else None
|
||||||
|
|
||||||
|
# DEBUG: Log price resolution for live trades
|
||||||
|
if trade.broker_kind == 'live':
|
||||||
|
logger.info(f"[PRICE DEBUG] trade={trade_id[:8]}, exchange={exchange}, "
|
||||||
|
f"exchange_key={exchange_key}, price_updates_keys={list(price_updates.keys())}, "
|
||||||
|
f"found_price={current_price}")
|
||||||
|
|
||||||
if current_price is None:
|
if current_price is None:
|
||||||
current_price = price_updates.get(symbol)
|
current_price = price_updates.get(symbol)
|
||||||
|
|
||||||
|
|
@ -1404,8 +1560,18 @@ class Trades:
|
||||||
result = self.manual_broker_manager.close_position(user_id, symbol, broker_key)
|
result = self.manual_broker_manager.close_position(user_id, symbol, broker_key)
|
||||||
|
|
||||||
if result.get('success'):
|
if result.get('success'):
|
||||||
|
close_status = str(result.get('status') or '').lower()
|
||||||
close_price = result.get('filled_price', 0.0)
|
close_price = result.get('filled_price', 0.0)
|
||||||
trades_closed = 0
|
trades_closed = 0
|
||||||
|
closed_trade_ids = []
|
||||||
|
|
||||||
|
# Live close-position requests may place a market order that is still pending/open.
|
||||||
|
# Only settle/remove the local trade immediately if the broker reports it filled.
|
||||||
|
if close_status not in ['', 'filled', 'partially_filled']:
|
||||||
|
result['trades_closed'] = 0
|
||||||
|
result['closed_trades'] = []
|
||||||
|
result['message'] = result.get('message') or 'Close order submitted.'
|
||||||
|
return result
|
||||||
|
|
||||||
for trade_id, trade in list(self.active_trades.items()):
|
for trade_id, trade in list(self.active_trades.items()):
|
||||||
# Check if this trade belongs to the same broker
|
# Check if this trade belongs to the same broker
|
||||||
|
|
@ -1461,11 +1627,13 @@ class Trades:
|
||||||
del self.active_trades[trade_id]
|
del self.active_trades[trade_id]
|
||||||
self.settled_trades[trade_id] = trade
|
self.settled_trades[trade_id] = trade
|
||||||
self.stats['num_trades'] -= 1
|
self.stats['num_trades'] -= 1
|
||||||
|
closed_trade_ids.append(trade_id)
|
||||||
|
|
||||||
final_pl = trade.stats.get('profit', 0.0)
|
final_pl = trade.stats.get('profit', 0.0)
|
||||||
logger.info(f"Trade {trade_id} closed via position close. P/L: {final_pl:.2f}")
|
logger.info(f"Trade {trade_id} closed via position close. P/L: {final_pl:.2f}")
|
||||||
|
|
||||||
result['trades_closed'] = trades_closed
|
result['trades_closed'] = trades_closed
|
||||||
|
result['closed_trades'] = closed_trade_ids
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,46 @@ class TestPaperBroker:
|
||||||
assert position is not None
|
assert position is not None
|
||||||
assert position.size == 0.1
|
assert position.size == 0.1
|
||||||
|
|
||||||
|
def test_paper_broker_ioc_limit_cancels_if_not_marketable(self):
|
||||||
|
"""IOC limit orders should fail immediately if not marketable."""
|
||||||
|
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||||
|
broker.update_price('BTC/USDT', 50000)
|
||||||
|
|
||||||
|
result = broker.place_order(
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side=OrderSide.BUY,
|
||||||
|
order_type=OrderType.LIMIT,
|
||||||
|
size=0.1,
|
||||||
|
price=49000,
|
||||||
|
time_in_force='IOC'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not result.success
|
||||||
|
assert result.status == OrderStatus.CANCELLED
|
||||||
|
assert broker.get_open_orders() == []
|
||||||
|
assert broker.get_position('BTC/USDT') is None
|
||||||
|
|
||||||
|
def test_paper_broker_fok_limit_fills_immediately_if_marketable(self):
|
||||||
|
"""FOK limit orders should fill immediately when already marketable."""
|
||||||
|
broker = PaperBroker(initial_balance=10000, commission=0.001)
|
||||||
|
broker.update_price('BTC/USDT', 50000)
|
||||||
|
|
||||||
|
result = broker.place_order(
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side=OrderSide.BUY,
|
||||||
|
order_type=OrderType.LIMIT,
|
||||||
|
size=0.1,
|
||||||
|
price=51000,
|
||||||
|
time_in_force='FOK'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success
|
||||||
|
assert result.status == OrderStatus.FILLED
|
||||||
|
assert broker.get_open_orders() == []
|
||||||
|
position = broker.get_position('BTC/USDT')
|
||||||
|
assert position is not None
|
||||||
|
assert position.size == 0.1
|
||||||
|
|
||||||
def test_paper_broker_cancel_order(self):
|
def test_paper_broker_cancel_order(self):
|
||||||
"""Test order cancellation."""
|
"""Test order cancellation."""
|
||||||
broker = PaperBroker(initial_balance=10000, commission=0, slippage=0)
|
broker = PaperBroker(initial_balance=10000, commission=0, slippage=0)
|
||||||
|
|
@ -196,6 +236,47 @@ class TestPaperBroker:
|
||||||
assert broker.get_available_balance() == 9900
|
assert broker.get_available_balance() == 9900
|
||||||
assert broker.get_balance() == 10100
|
assert broker.get_balance() == 10100
|
||||||
|
|
||||||
|
def test_paper_broker_pnl_includes_fees(self):
|
||||||
|
"""Test that P&L accurately reflects both entry and exit fees."""
|
||||||
|
broker = PaperBroker(initial_balance=10000, commission=0.001, slippage=0)
|
||||||
|
broker.update_price('BTC/USDT', 1000)
|
||||||
|
|
||||||
|
# Buy 1 unit at $1000, entry fee = $1
|
||||||
|
broker.place_order(
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side=OrderSide.BUY,
|
||||||
|
order_type=OrderType.MARKET,
|
||||||
|
size=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Immediately after buy, unrealized P&L should show -$1 (entry fee)
|
||||||
|
position = broker.get_position('BTC/USDT')
|
||||||
|
assert position is not None
|
||||||
|
assert position.entry_commission == 1.0 # 0.1% of $1000
|
||||||
|
assert position.unrealized_pnl == -1.0 # Entry fee already reflected
|
||||||
|
|
||||||
|
# Price hasn't moved, but we're down by entry fee
|
||||||
|
broker.update()
|
||||||
|
position = broker.get_position('BTC/USDT')
|
||||||
|
assert position.unrealized_pnl == -1.0
|
||||||
|
|
||||||
|
# Now sell at same price, exit fee = $1
|
||||||
|
result = broker.place_order(
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side=OrderSide.SELL,
|
||||||
|
order_type=OrderType.MARKET,
|
||||||
|
size=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Realized P&L should be -$2 (entry + exit fee)
|
||||||
|
assert result.success
|
||||||
|
# The realized_pnl on the order reflects both fees
|
||||||
|
# (price movement 0) - entry_fee ($1) - exit_fee ($1) = -$2
|
||||||
|
|
||||||
|
# Cash balance should reflect the loss
|
||||||
|
# Started with $10000, bought for $1001, sold for $999 = $9998
|
||||||
|
assert broker.get_available_balance() == 9998.0
|
||||||
|
|
||||||
def test_paper_broker_reset(self):
|
def test_paper_broker_reset(self):
|
||||||
"""Test broker reset."""
|
"""Test broker reset."""
|
||||||
broker = PaperBroker(initial_balance=10000)
|
broker = PaperBroker(initial_balance=10000)
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,42 @@ class TestTrade:
|
||||||
assert trade.status == 'part-filled'
|
assert trade.status == 'part-filled'
|
||||||
assert trade.stats['qty_filled'] == 0.05
|
assert trade.stats['qty_filled'] == 0.05
|
||||||
|
|
||||||
|
def test_trade_first_fill_from_open_order_uses_fill_price(self):
|
||||||
|
"""First broker fill should not average against the original unfilled order notional."""
|
||||||
|
trade = Trade(
|
||||||
|
target='kucoin',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=69394.3,
|
||||||
|
base_order_qty=0.0001
|
||||||
|
)
|
||||||
|
trade.status = 'open'
|
||||||
|
|
||||||
|
trade.trade_filled(qty=5.28e-06, price=69340.8)
|
||||||
|
|
||||||
|
assert trade.status == 'part-filled'
|
||||||
|
assert trade.stats['qty_filled'] == pytest.approx(5.28e-06)
|
||||||
|
assert trade.stats['opening_price'] == pytest.approx(69340.8)
|
||||||
|
assert trade.stats['opening_value'] == pytest.approx(5.28e-06 * 69340.8)
|
||||||
|
|
||||||
|
def test_trade_update_values_uses_filled_quantity_for_pl(self):
|
||||||
|
"""Unrealized P/L should be based on filled exposure, not the original order size."""
|
||||||
|
trade = Trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=50000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
fee=0.001
|
||||||
|
)
|
||||||
|
|
||||||
|
trade.trade_filled(qty=0.05, price=50000.0)
|
||||||
|
trade.update_values(55000.0)
|
||||||
|
|
||||||
|
assert trade.stats['current_value'] == pytest.approx(2750.0)
|
||||||
|
assert trade.stats['profit'] == pytest.approx(244.75)
|
||||||
|
assert trade.stats['profit_pct'] == pytest.approx(9.79)
|
||||||
|
|
||||||
def test_trade_settle(self):
|
def test_trade_settle(self):
|
||||||
"""Test trade settlement."""
|
"""Test trade settlement."""
|
||||||
trade = Trade(
|
trade = Trade(
|
||||||
|
|
@ -181,6 +217,8 @@ class TestTrade:
|
||||||
|
|
||||||
assert trade.status == 'closed'
|
assert trade.status == 'closed'
|
||||||
assert trade.stats['settled_price'] == 55000.0
|
assert trade.stats['settled_price'] == 55000.0
|
||||||
|
assert trade.stats['profit'] == pytest.approx(489.5)
|
||||||
|
assert trade.stats['profit_pct'] == pytest.approx(9.79)
|
||||||
|
|
||||||
|
|
||||||
class TestTrades:
|
class TestTrades:
|
||||||
|
|
@ -250,6 +288,25 @@ class TestTrades:
|
||||||
assert trade.status == 'filled'
|
assert trade.status == 'filled'
|
||||||
assert trade.creator == 1
|
assert trade.creator == 1
|
||||||
|
|
||||||
|
def test_new_paper_trade_persists_time_in_force(self, mock_users):
|
||||||
|
"""Manual trades should keep the selected time-in-force on the Trade object."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
|
||||||
|
status, trade_id = trades.new_trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
price=50000.0,
|
||||||
|
side='buy',
|
||||||
|
order_type='LIMIT',
|
||||||
|
qty=0.1,
|
||||||
|
user_id=1,
|
||||||
|
time_in_force='IOC'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 'Success'
|
||||||
|
trade = trades.get_trade_by_id(trade_id)
|
||||||
|
assert trade.time_in_force == 'IOC'
|
||||||
|
|
||||||
def test_new_live_trade_no_exchange(self, mock_users):
|
def test_new_live_trade_no_exchange(self, mock_users):
|
||||||
"""Test creating a live trade without exchange connected."""
|
"""Test creating a live trade without exchange connected."""
|
||||||
trades = Trades(mock_users)
|
trades = Trades(mock_users)
|
||||||
|
|
@ -269,6 +326,28 @@ class TestTrades:
|
||||||
assert status == 'Error'
|
assert status == 'Error'
|
||||||
assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower()
|
assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower()
|
||||||
|
|
||||||
|
def test_new_live_trade_rejects_manual_sltp(self, mock_users):
|
||||||
|
"""Manual live SL/TP should fail fast until exchange-native support exists."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
mock_exchange_interface = MagicMock()
|
||||||
|
mock_exchange_interface.get_exchange.return_value = MagicMock(configured=True)
|
||||||
|
trades.connect_exchanges(mock_exchange_interface)
|
||||||
|
|
||||||
|
status, msg = trades.new_trade(
|
||||||
|
target='binance',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
price=50000.0,
|
||||||
|
side='buy',
|
||||||
|
order_type='MARKET',
|
||||||
|
qty=0.1,
|
||||||
|
user_id=1,
|
||||||
|
testnet=True,
|
||||||
|
stop_loss=45000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 'Error'
|
||||||
|
assert 'not supported yet' in msg.lower()
|
||||||
|
|
||||||
def test_new_production_trade_blocked_without_env_var(self, mock_users):
|
def test_new_production_trade_blocked_without_env_var(self, mock_users):
|
||||||
"""Test that production trades are blocked without ALLOW_LIVE_PRODUCTION."""
|
"""Test that production trades are blocked without ALLOW_LIVE_PRODUCTION."""
|
||||||
import config
|
import config
|
||||||
|
|
@ -368,6 +447,31 @@ class TestTrades:
|
||||||
assert trade_id not in trades.active_trades
|
assert trade_id not in trades.active_trades
|
||||||
assert trade_id in trades.settled_trades
|
assert trade_id in trades.settled_trades
|
||||||
|
|
||||||
|
def test_close_trade_recomputes_final_pl_from_close_price(self, mock_users):
|
||||||
|
"""Closing should use the settlement price, not the last cached unrealized P/L."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
status, trade_id = trades.new_trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
price=50000.0,
|
||||||
|
side='buy',
|
||||||
|
order_type='MARKET',
|
||||||
|
qty=0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 'Success'
|
||||||
|
trade = trades.get_trade_by_id(trade_id)
|
||||||
|
trade.fee = 0.001
|
||||||
|
|
||||||
|
trade.update_values(45000.0)
|
||||||
|
assert trade.stats['profit'] < 0
|
||||||
|
|
||||||
|
result = trades.close_trade(trade_id, current_price=55000.0)
|
||||||
|
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['final_pl'] == pytest.approx(489.5)
|
||||||
|
assert result['final_pl_pct'] == pytest.approx(9.79)
|
||||||
|
|
||||||
def test_close_nonexistent_trade(self, mock_users):
|
def test_close_nonexistent_trade(self, mock_users):
|
||||||
"""Test closing a trade that doesn't exist."""
|
"""Test closing a trade that doesn't exist."""
|
||||||
trades = Trades(mock_users)
|
trades = Trades(mock_users)
|
||||||
|
|
@ -377,6 +481,136 @@ class TestTrades:
|
||||||
assert result['success'] is False
|
assert result['success'] is False
|
||||||
assert 'not found' in result['message']
|
assert 'not found' in result['message']
|
||||||
|
|
||||||
|
def test_settle_broker_closed_position_filters_by_user_and_status(self, mock_users):
|
||||||
|
"""Broker-side closes should only settle filled trades for the matching user/broker."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
|
||||||
|
filled_trade = Trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=50000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
is_paper=True,
|
||||||
|
creator=1,
|
||||||
|
broker_kind='paper',
|
||||||
|
broker_mode='paper'
|
||||||
|
)
|
||||||
|
filled_trade.trade_filled(qty=0.1, price=50000.0)
|
||||||
|
|
||||||
|
other_user_trade = Trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=50000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
is_paper=True,
|
||||||
|
creator=2,
|
||||||
|
broker_kind='paper',
|
||||||
|
broker_mode='paper'
|
||||||
|
)
|
||||||
|
other_user_trade.trade_filled(qty=0.1, price=50000.0)
|
||||||
|
|
||||||
|
open_trade = Trade(
|
||||||
|
target='test_exchange',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=49000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
is_paper=True,
|
||||||
|
creator=1,
|
||||||
|
broker_kind='paper',
|
||||||
|
broker_mode='paper'
|
||||||
|
)
|
||||||
|
open_trade.status = 'open'
|
||||||
|
|
||||||
|
trades.active_trades[filled_trade.unique_id] = filled_trade
|
||||||
|
trades.active_trades[other_user_trade.unique_id] = other_user_trade
|
||||||
|
trades.active_trades[open_trade.unique_id] = open_trade
|
||||||
|
trades.stats['num_trades'] = 3
|
||||||
|
|
||||||
|
settled_ids = trades.settle_broker_closed_position(
|
||||||
|
user_id=1,
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
broker_key='paper',
|
||||||
|
close_price=44000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert settled_ids == [filled_trade.unique_id]
|
||||||
|
assert filled_trade.unique_id not in trades.active_trades
|
||||||
|
assert filled_trade.unique_id in trades.settled_trades
|
||||||
|
assert trades.settled_trades[filled_trade.unique_id].status == 'closed'
|
||||||
|
assert other_user_trade.unique_id in trades.active_trades
|
||||||
|
assert open_trade.unique_id in trades.active_trades
|
||||||
|
|
||||||
|
def test_close_position_returns_closed_trade_ids(self, mock_users):
|
||||||
|
"""Close-position API flow should report which trade IDs were removed locally."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
trades.manual_broker_manager = MagicMock()
|
||||||
|
trades.manual_broker_manager.close_position.return_value = {
|
||||||
|
'success': True,
|
||||||
|
'status': 'filled',
|
||||||
|
'filled_price': 51000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
trade = Trade(
|
||||||
|
target='kucoin',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=50000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
creator=1,
|
||||||
|
broker_kind='live',
|
||||||
|
broker_mode='production',
|
||||||
|
broker_exchange='kucoin'
|
||||||
|
)
|
||||||
|
trade.trade_filled(qty=0.1, price=50000.0)
|
||||||
|
trades.active_trades[trade.unique_id] = trade
|
||||||
|
trades.stats['num_trades'] = 1
|
||||||
|
|
||||||
|
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
|
||||||
|
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['trades_closed'] == 1
|
||||||
|
assert result['closed_trades'] == [trade.unique_id]
|
||||||
|
assert trade.unique_id not in trades.active_trades
|
||||||
|
assert trade.unique_id in trades.settled_trades
|
||||||
|
|
||||||
|
def test_close_position_leaves_trade_active_when_close_order_is_still_open(self, mock_users):
|
||||||
|
"""Live close-position should not settle/remove the local trade until the close order fills."""
|
||||||
|
trades = Trades(mock_users)
|
||||||
|
trades.manual_broker_manager = MagicMock()
|
||||||
|
trades.manual_broker_manager.close_position.return_value = {
|
||||||
|
'success': True,
|
||||||
|
'status': 'open',
|
||||||
|
'order_id': 'close123',
|
||||||
|
'filled_price': 0.0,
|
||||||
|
'message': 'Close order submitted'
|
||||||
|
}
|
||||||
|
|
||||||
|
trade = Trade(
|
||||||
|
target='kucoin',
|
||||||
|
symbol='BTC/USDT',
|
||||||
|
side='BUY',
|
||||||
|
order_price=50000.0,
|
||||||
|
base_order_qty=0.1,
|
||||||
|
creator=1,
|
||||||
|
broker_kind='live',
|
||||||
|
broker_mode='production',
|
||||||
|
broker_exchange='kucoin'
|
||||||
|
)
|
||||||
|
trade.trade_filled(qty=0.1, price=50000.0)
|
||||||
|
trades.active_trades[trade.unique_id] = trade
|
||||||
|
trades.stats['num_trades'] = 1
|
||||||
|
|
||||||
|
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
|
||||||
|
|
||||||
|
assert result['success'] is True
|
||||||
|
assert result['trades_closed'] == 0
|
||||||
|
assert result['closed_trades'] == []
|
||||||
|
assert trade.unique_id in trades.active_trades
|
||||||
|
assert trade.unique_id not in trades.settled_trades
|
||||||
|
|
||||||
def test_is_valid_trade_id(self, mock_users):
|
def test_is_valid_trade_id(self, mock_users):
|
||||||
"""Test trade ID validation."""
|
"""Test trade ID validation."""
|
||||||
trades = Trades(mock_users)
|
trades = Trades(mock_users)
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,13 @@ def test_update_values():
|
||||||
assert position_size == 10
|
assert position_size == 10
|
||||||
pl = trade_obj.get_pl()
|
pl = trade_obj.get_pl()
|
||||||
print(f'PL reported: {pl}')
|
print(f'PL reported: {pl}')
|
||||||
assert pl == 0
|
# With 0.1% fee (0.001): gross P/L 0, fees = (10*0.001) + (10*0.001) = 0.02
|
||||||
|
# Net PL: -0.02
|
||||||
|
assert pl == pytest.approx(-0.02)
|
||||||
pl_pct = trade_obj.get_pl_pct()
|
pl_pct = trade_obj.get_pl_pct()
|
||||||
print(f'PL% reported: {pl_pct}')
|
print(f'PL% reported: {pl_pct}')
|
||||||
assert pl_pct == 0
|
# Should be -0.02/10 * 100 = -0.2%
|
||||||
|
assert pl_pct == pytest.approx(-0.2)
|
||||||
|
|
||||||
# Divide the price of the quote symbol by 2.
|
# Divide the price of the quote symbol by 2.
|
||||||
current_price = 50
|
current_price = 50
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue