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:
rob 2026-03-12 12:15:56 -03:00
parent 5866319b5e
commit d3bbb36dc2
17 changed files with 1304 additions and 170 deletions

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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:
# Exchange-qualified: fetch from specific exchange
price = brighter_trades.exchanges.get_price(symbol, exchange)
if price and price > 0:
broker_price_updates[f"{exchange.lower()}:{symbol}"] = price
else: else:
# Live trades: use specific exchange # 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
# ============================================================================= # =============================================================================

View File

@ -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'),
) )

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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(){

View File

@ -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),

View File

@ -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);
if (this.onPositionClosed) {
this.onPositionClosed(data);
} else {
this.refreshAll(); 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) {

View File

@ -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;

View File

@ -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
def update_values(self, current_price: float) -> None: @staticmethod
""" def _percent(part: float, whole: float) -> float:
Updates the P/L values and percentages based on the current price.
"""
def percent(part: float, whole: float) -> float:
if whole == 0: if whole == 0:
return 0.0 return 0.0
return 100.0 * float(part) / float(whole) return 100.0 * float(part) / float(whole)
@staticmethod
def _calculate_pl(entry_price: float, exit_price: float, qty: float, side: str, fee: float) -> float:
entry_value = qty * entry_price
exit_value = qty * exit_price
profit = exit_value - entry_value
if side == 'SELL':
profit *= -1
fees = (entry_value * fee) + (exit_value * fee)
return profit - fees
def _filled_qty(self) -> float:
return float(self.stats.get('qty_filled', 0.0) or 0.0)
def _settled_qty(self) -> float:
return float(self.stats.get('qty_settled', 0.0) or 0.0)
def _open_qty(self) -> float:
return max(self._filled_qty() - self._settled_qty(), 0.0)
def update_values(self, current_price: float) -> None:
"""
Updates the P/L values and percentages based on the current price.
"""
self.stats['current_price'] = current_price 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)
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 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

View File

@ -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)

View File

@ -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)

View File

@ -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