Compare commits

...

4 Commits

Author SHA1 Message Date
rob d3bbb36dc2 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>
2026-03-12 12:15:56 -03:00
rob 5866319b5e Fix critical Phase 4 issues: wire SL/TP to broker, reconcile trades, fix schema
High priority fixes:
- Wire time_in_force, stop_loss, take_profit to broker.place_order()
  * Pass time_in_force from received_new_trade() to new_trade()
  * Pass SL/TP/TIF from new_trade() to broker.place_order()

- Reconcile Trade ledger when SL/TP triggers
  * Find all matching paper trades for symbol
  * Settle trades at trigger price
  * Move from active_trades to settled_trades
  * Save to database

- Fix fresh schema missing SL/TP columns
  * Add stop_loss and take_profit to CREATE TABLE statement
  * Ensures first-run trade persistence works

Medium priority fixes:
- Hide SL/TP fields for SELL orders (inventory-only model)
  * SL/TP only makes sense for BUY (opening positions)
  * SELL closes existing positions, no SL/TP needed
  * Added _updateSltpVisibility() method
  * Side change listener shows/hides SL/TP row
  * Removed SELL-side SL/TP validation

Tests:
- Added 2 integration tests for manual trading SL/TP path
- 353 tests pass (4 pre-existing failures unrelated)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 19:49:39 -03:00
rob 4bfa51f0b1 Fix UX mismatch and dead code in paper trading
- Hide exchange dropdown for paper trades (uses single synthetic market)
- Add _updateExchangeRowVisibility() to toggle exchange row visibility
- Call visibility update on target change and form open
- Remove stale 'binance' fallback in Trade constructor call
  (Trade.__init__ now handles exchange normalization for paper trades)

Addresses codex feedback:
- Medium: UX mismatch where exchange choice appeared relevant for paper
- Low: Dead code passing 'binance' for paper trades

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 19:40:03 -03:00
rob 2ae087a099 Add stop loss, take profit, and time-in-force support for manual trading
Phase 4: Advanced Order Management

- Add stop_loss and take_profit fields to Trade class with DB persistence
- Add SL/TP form fields in new trade popup with validation
- Implement SL/TP evaluation in PaperBroker.update()
- Auto-close positions when SL/TP thresholds are crossed
- Emit sltp_triggered events to frontend with notifications
- Add Time-in-Force dropdown (GTC, IOC, FOK) to trade form
- Add persistence for position_sltp tracking across state save/load
- Add unit tests for SL/TP functionality (5 new tests)
- Add Trade class SL/TP field tests (2 new tests)

Also includes Phase 3 manual trading broker hardening:
- Fix broker UI not wired on startup
- Fix cancelled orders vanishing from history
- Fix positions not live-updating P/L
- Fix paper pricing exchange-ambiguity
- Simplify paper trades to single synthetic market

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-11 19:35:52 -03:00
20 changed files with 4043 additions and 179 deletions

View File

@ -18,6 +18,7 @@ from trade import Trades
from edm_client import EdmClient, EdmWebSocketClient
from wallet import WalletManager
from utils import sanitize_for_json
from manual_trading_broker import ManualTradingBrokerManager
# Configure logging
logger = logging.getLogger(__name__)
@ -76,6 +77,25 @@ class BrighterTrades:
# The Trades object needs to connect to an exchange_interface.
self.trades.connect_exchanges(exchanges=self.exchanges)
# Manual trading broker manager for broker-based order lifecycle
self.manual_broker_manager = ManualTradingBrokerManager(
data_cache=self.data,
exchange_interface=self.exchanges,
users=self.users
)
# Wire up broker manager to trades
self.trades.manual_broker_manager = self.manual_broker_manager
logger.info("Manual trading broker manager initialized")
# Recover brokers for any persisted broker-managed trades
try:
recovered = self.trades.recover_brokers()
if recovered and recovered > 0:
logger.info(f"Recovered {recovered} brokers for persisted trades")
except (TypeError, AttributeError):
# Handle case where trades or recover_brokers is mocked in tests
pass
# Object that maintains the strategies data
self.strategies = Strategies(self.data, self.trades, self.indicators, edm_client=self.edm_client)
@ -1360,7 +1380,7 @@ class BrighterTrades:
if val is None or val == '':
return default
# Try to cast to float for numeric fields
if attr in ['price', 'quantity']:
if attr in ['price', 'quantity', 'stopLoss', 'takeProfit']:
try:
return float(val)
except (ValueError, TypeError):
@ -1377,6 +1397,9 @@ class BrighterTrades:
quantity = get_value('quantity', 0.0)
strategy_id = get_value('strategy_id')
testnet = data.get('testnet', False)
stop_loss = get_value('stopLoss')
take_profit = get_value('takeProfit')
time_in_force = get_value('timeInForce', 'GTC')
# Validate required fields
if not symbol:
@ -1395,7 +1418,10 @@ class BrighterTrades:
qty=quantity,
user_id=user_id,
strategy_id=strategy_id,
testnet=testnet
testnet=testnet,
stop_loss=stop_loss,
take_profit=take_profit,
time_in_force=time_in_force
)
if status == 'Error':
@ -1655,11 +1681,25 @@ class BrighterTrades:
trade_id = msg_data.get('trade_id') or msg_data.get('unique_id') or msg_data
if isinstance(trade_id, dict):
trade_id = trade_id.get('trade_id') or trade_id.get('unique_id')
result = self.close_trade(str(trade_id))
if result.get('success'):
return standard_reply("trade_closed", result)
# Route based on trade status: cancel if unfilled, close position if filled
trade = self.trades.get_trade_by_id(str(trade_id))
if not trade:
return standard_reply("trade_error", {"success": False, "message": "Trade not found"})
if trade.status in ['pending', 'open', 'unfilled']:
# Cancel the unfilled order
result = self.trades.cancel_order(str(trade_id))
else:
return standard_reply("trade_error", result)
# Close the position for this trade's symbol
broker_key = 'paper' if trade.broker_kind == 'paper' else f"{trade.broker_exchange}_{trade.broker_mode}"
result = self.trades.close_position(trade.creator, trade.symbol, broker_key)
# 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)
if msg_type == 'new_signal':
result = self.received_new_signal(msg_data, user_id)

View File

@ -367,3 +367,58 @@ class Database:
# records = records.drop('id', axis=1) Todo: Reminder I may need to put this back later.
return records
def migrate_trades_broker_fields(self) -> bool:
"""
Add broker tracking columns to trades table.
This migration adds columns required for broker integration:
- broker_kind: 'paper' or 'live'
- broker_mode: 'testnet', 'production', or 'paper'
- broker_exchange: Exchange name (for live trades)
- broker_order_id: Local broker order ID
- exchange_order_id: Live exchange order ID
:return: True if migration was successful.
"""
columns = [
('broker_kind', 'TEXT'),
('broker_mode', 'TEXT'),
('broker_exchange', 'TEXT'),
('broker_order_id', 'TEXT'),
('exchange_order_id', 'TEXT'),
]
try:
with SQLite(self.db_file) as conn:
cursor = conn.cursor()
# Check if table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='trades'"
)
if not cursor.fetchone():
# Table doesn't exist yet, columns will be added when table is created
return True
# Get existing columns
cursor.execute("PRAGMA table_info(trades)")
existing_columns = {row[1] for row in cursor.fetchall()}
# Add missing columns
for col_name, col_type in columns:
if col_name not in existing_columns:
try:
cursor.execute(
f'ALTER TABLE trades ADD COLUMN {col_name} {col_type}'
)
print(f"Added column {col_name} to trades table")
except Exception as e:
print(f"Note: Column {col_name} may already exist: {e}")
conn.commit()
return True
except Exception as e:
print(f"Error migrating trades table: {e}")
return False

View File

@ -85,7 +85,13 @@ class Exchange:
client.set_sandbox_mode(True)
logger.info(f"Sandbox mode enabled for {self.exchange_id}")
except Exception as e:
logger.warning(f"Could not enable sandbox mode for {self.exchange_id}: {e}")
# CRITICAL: Do NOT continue with production if testnet was requested
# This prevents users from accidentally trading real money
logger.error(f"TESTNET UNAVAILABLE: {self.exchange_id} does not support sandbox mode: {e}")
raise ValueError(
f"Testnet/sandbox mode is not available for {self.exchange_id}. "
f"Please use paper trading mode instead, or trade on production with caution."
)
return client

View File

@ -126,6 +126,9 @@ class ExchangeInterface:
pass # No existing entry, that's fine
# Create new exchange with explicit testnet setting
if not exchange_name:
logger.error("Cannot create exchange: exchange_name is required")
return False
logger.info(f"Creating {exchange_name} for {user_name} with testnet={testnet}")
exchange = Exchange(name=exchange_name, api_keys=api_keys, exchange_id=exchange_name.lower(),
testnet=testnet)

View File

@ -35,6 +35,7 @@ _loop_debug.addHandler(_loop_handler)
logging.basicConfig(level=log_level)
logging.getLogger('ccxt.base.exchange').setLevel(logging.WARNING)
logging.getLogger('urllib3').setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# Create a Flask object named app that serves the html.
app = Flask(__name__)
@ -181,6 +182,167 @@ def strategy_execution_loop():
except Exception as e:
logger.error(f"Error executing strategy {instance_key}: {e}", exc_info=True)
# === BROKER UPDATES (single owner - happens here only) ===
# This is the only place where brokers are polled for order fills
if brighter_trades.manual_broker_manager:
try:
# Collect prices for broker updates from positions/orders (not Trade.exchange)
broker_price_updates = {}
# Get required price feeds from paper broker positions/orders
paper_user_ids = brighter_trades.manual_broker_manager.get_active_paper_user_ids()
for user_id in paper_user_ids:
feeds = brighter_trades.manual_broker_manager.get_required_price_feeds(user_id)
for exchange, symbol in feeds:
if exchange:
# Exchange-qualified: fetch from specific exchange
price = brighter_trades.exchanges.get_price(symbol, exchange)
if price and price > 0:
broker_price_updates[f"{exchange.lower()}:{symbol}"] = price
else:
# No exchange: use default price source
price = brighter_trades.exchanges.get_price(symbol)
if price and price > 0:
broker_price_updates[symbol] = price
# Also collect for live trades (unchanged logic)
for trade in brighter_trades.trades.active_trades.values():
if trade.broker_order_id and trade.broker_kind == 'live':
try:
exchange = getattr(trade, 'exchange', None) or trade.target
if exchange:
price = brighter_trades.exchanges.get_price(trade.symbol, exchange)
if price and price > 0:
price_key = f"{exchange.lower()}:{trade.symbol}"
broker_price_updates[price_key] = price
broker_price_updates[trade.symbol] = price
except Exception:
pass
# Update all brokers and get fill events
fill_events = brighter_trades.manual_broker_manager.update_all_brokers(broker_price_updates)
for event in fill_events:
event_type = event.get('type', 'fill')
if event_type == 'sltp_triggered':
# SL/TP triggered - reconcile matching paper trades only.
symbol = event.get('symbol')
trigger_price = event.get('trigger_price', 0)
user_id = event.get('user_id')
trade_ids = []
if user_id and trigger_price:
trade_ids = brighter_trades.trades.settle_broker_closed_position(
user_id=user_id,
symbol=symbol,
broker_key='paper',
close_price=trigger_price
)
_loop_debug.debug(
f"Reconciled SL/TP close for user={user_id} symbol={symbol}: {trade_ids}"
)
# Notify user
if user_id:
user_name = brighter_trades.users.get_username(user_id=user_id)
if user_name:
socketio.emit('message', {
'reply': 'sltp_triggered',
'data': sanitize_for_json({
'trigger': event.get('trigger'),
'symbol': symbol,
'trigger_price': trigger_price,
'size': event.get('size'),
'pnl': event.get('pnl'),
'trade_ids': trade_ids
})
}, room=user_name)
_loop_debug.debug(f"Emitted sltp_triggered to room={user_name}")
continue
# Find trade by broker_order_id and update
trade = brighter_trades.trades.find_trade_by_broker_order_id(event.get('order_id'))
if trade:
trade.trade_filled(
qty=event.get('filled_qty', event.get('size', 0)),
price=event.get('filled_price', event.get('price', 0))
)
# Note: trade_filled() sets status to 'filled' or 'part-filled' appropriately
# Do NOT override it here - that would lose partial-fill state
brighter_trades.trades._save_trade(trade)
# Emit fill event to user through existing message pattern
# Uses the 'message' event with 'reply' field that Comms understands
user_id = event.get('user_id') or trade.creator
if user_id:
user_name = brighter_trades.users.get_username(user_id=user_id)
if user_name:
socketio.emit('message', {
'reply': 'order_filled',
'data': sanitize_for_json({
'order_id': event.get('order_id'),
'trade_id': trade.unique_id,
'symbol': trade.symbol,
'side': trade.side,
'filled_qty': event.get('filled_qty', event.get('size', 0)),
'filled_price': event.get('filled_price', event.get('price', 0)),
'status': trade.status, # 'filled' or 'part-filled'
'broker_kind': event.get('broker_kind'),
'broker_key': event.get('broker_key')
})
}, room=user_name)
_loop_debug.debug(f"Emitted order_filled to room={user_name}")
else:
user_id = event.get('user_id')
broker_key = event.get('broker_key')
symbol = event.get('symbol')
side = str(event.get('side') or '').lower()
filled_price = event.get('filled_price', event.get('price', 0))
if user_id:
user_name = brighter_trades.users.get_username(user_id=user_id)
# Always emit order_filled so broker-backed panels refresh even when
# the fill belongs to a close order that has no local opening trade ID.
if user_name:
socketio.emit('message', {
'reply': 'order_filled',
'data': sanitize_for_json({
'order_id': event.get('order_id'),
'trade_id': None,
'symbol': symbol,
'side': event.get('side'),
'filled_qty': event.get('filled_qty', event.get('size', 0)),
'filled_price': filled_price,
'status': 'filled',
'broker_kind': event.get('broker_kind'),
'broker_key': broker_key
})
}, room=user_name)
# A live sell fill without a matching opening trade is typically a
# broker-initiated close order from the position-close flow.
if side == 'sell' and broker_key and symbol and filled_price:
settled_ids = brighter_trades.trades.settle_broker_closed_position(
user_id=user_id,
symbol=symbol,
broker_key=broker_key,
close_price=filled_price
)
if settled_ids and user_name:
socketio.emit('message', {
'reply': 'position_closed',
'data': sanitize_for_json({
'symbol': symbol,
'broker_key': broker_key,
'closed_trades': settled_ids
})
}, room=user_name)
except Exception as e:
_loop_debug.debug(f"Exception in broker update: {e}")
logger.error(f"Error updating brokers: {e}", exc_info=True)
# Update active trades (runs every iteration, regardless of active strategies)
_loop_debug.debug(f"Checking active_trades: {len(brighter_trades.trades.active_trades)} trades")
if brighter_trades.trades.active_trades:
@ -198,6 +360,9 @@ def strategy_execution_loop():
exchange_symbols.add((None, trade.symbol))
_loop_debug.debug(f"Exchange+symbols to fetch: {exchange_symbols}")
# Log at INFO level for live trades debugging
if any(ex and ex.lower() not in ['paper', 'test_exchange'] for ex, _ in exchange_symbols):
logger.info(f"[PRICE FETCH] exchange_symbols to fetch: {exchange_symbols}")
price_updates = {}
for exchange, symbol in exchange_symbols:
try:
@ -215,8 +380,9 @@ def strategy_execution_loop():
_loop_debug.debug(f"price_updates: {price_updates}")
if price_updates:
_loop_debug.debug(f"Calling brighter_trades.trades.update()")
trade_updates = brighter_trades.trades.update(price_updates)
_loop_debug.debug(f"Calling brighter_trades.trades.update_prices_only()")
# Use update_prices_only to avoid duplicate broker polling
trade_updates = brighter_trades.trades.update_prices_only(price_updates)
_loop_debug.debug(f"trade_updates returned: {trade_updates}")
if trade_updates:
logger.debug(f"Trade updates (no active strategies): {trade_updates}")
@ -242,12 +408,34 @@ def strategy_execution_loop():
logger.info("Strategy execution loop stopped")
_strategy_loop_started = False
def start_strategy_loop():
"""Start the strategy execution loop in a background greenlet."""
"""
Start the strategy execution loop in a background greenlet.
This supports both `python src/app.py` and WSGI imports.
The loop is only started once per process.
"""
global _strategy_loop_started
if _strategy_loop_started:
return
if app.config.get('TESTING') or os.getenv('PYTEST_CURRENT_TEST'):
return
if os.getenv('BRIGHTER_DISABLE_STRATEGY_LOOP', '').lower() in ('1', 'true', 'yes'):
logger.info("Strategy execution loop disabled by BRIGHTER_DISABLE_STRATEGY_LOOP")
return
eventlet.spawn(strategy_execution_loop)
_strategy_loop_started = True
logger.info("Strategy execution loop started")
# Start the loop when the app starts (will be called from main block)
# Start the loop when the app starts (will be called from main block or before_request)
def start_wallet_background_jobs():
@ -281,7 +469,8 @@ def start_wallet_background_jobs():
@app.before_request
def ensure_background_jobs_started():
"""Ensure wallet background jobs are running in non-`__main__` deployments."""
"""Ensure background jobs are running in non-`__main__` deployments."""
start_strategy_loop()
start_wallet_background_jobs()
@ -1032,6 +1221,204 @@ def admin_credit_user():
return jsonify(result)
# =============================================================================
# Manual Trading API Routes (Position-First)
# =============================================================================
@app.route('/api/manual/orders', methods=['GET'])
def get_manual_open_orders():
"""Get all open orders for the current user across all brokers."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
try:
orders = brighter_trades.manual_broker_manager.get_all_open_orders(user_id)
return jsonify({'success': True, 'orders': orders})
except Exception as e:
logger.error(f"Error getting open orders: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/orders/<order_id>/cancel', methods=['POST'])
def cancel_manual_order(order_id):
"""Cancel a specific open order."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
data = request.get_json() or {}
broker_key = data.get('broker_key', 'paper')
try:
# First try to find the trade by broker_order_id
trade = brighter_trades.trades.find_trade_by_broker_order_id(order_id)
if trade:
result = brighter_trades.trades.cancel_order(trade.unique_id)
else:
# Fall back to broker manager direct cancel
result = brighter_trades.manual_broker_manager.cancel_order(user_id, order_id, broker_key)
return jsonify(result)
except Exception as e:
logger.error(f"Error cancelling order {order_id}: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/positions', methods=['GET'])
def get_manual_positions():
"""Get all positions for the current user across all brokers."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
try:
positions = brighter_trades.manual_broker_manager.get_all_positions(user_id)
return jsonify({'success': True, 'positions': positions})
except Exception as e:
logger.error(f"Error getting positions: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/positions/<path:symbol>/close', methods=['POST'])
def close_manual_position(symbol):
"""
Close entire position for a symbol (position-first operation).
This is the preferred close method - it closes the entire position
rather than individual trades.
"""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
data = request.get_json() or {}
broker_key = data.get('broker_key', 'paper')
try:
result = brighter_trades.trades.close_position(user_id, symbol, broker_key)
# Emit position_closed event to refresh UI
if result.get('success'):
user_name = brighter_trades.users.get_username(user_id=user_id)
if user_name:
socketio.emit('message', {
'reply': 'position_closed',
'data': {
'symbol': symbol,
'broker_key': broker_key,
'closed_trades': result.get('closed_trades', [])
}
}, room=user_name)
return jsonify(result)
except Exception as e:
logger.error(f"Error closing position {symbol}: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/balance', methods=['GET'])
def get_manual_balance():
"""Get balance for a specific broker."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
broker_key = request.args.get('broker_key', 'paper')
chart_exchange = (request.args.get('exchange') or '').strip().lower() or None
try:
total = brighter_trades.manual_broker_manager.get_broker_balance(user_id, broker_key)
available = brighter_trades.manual_broker_manager.get_available_balance(user_id, broker_key)
# Fallback for live mode: if manual broker balance is unavailable/stale, use the
# cached direct exchange balances for the exchange currently shown in the chart.
fallback_source = None
if broker_key != 'paper' and chart_exchange and total == 0.0 and available == 0.0:
user_name = brighter_trades.users.get_username(user_id=user_id)
exchange_balances = brighter_trades.exchanges.get_exchange_balances(user_name, chart_exchange)
quote_balance = 0.0
if exchange_balances is not None:
for asset in ('USDT', 'USD', 'BUSD', 'USDC'):
match = next(
(
bal for bal in exchange_balances
if str(bal.get('asset', '')).upper() == asset
),
None
)
if match:
quote_balance = float(match.get('balance', 0.0) or 0.0)
break
if quote_balance > 0:
total = quote_balance
available = quote_balance
fallback_source = 'exchange'
return jsonify({
'success': True,
'total': total,
'available': available,
'broker_key': broker_key,
'source': fallback_source or 'broker'
})
except Exception as e:
logger.error(f"Error getting balance: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/orders/symbol/<path:symbol>/cancel', methods=['POST'])
def cancel_orders_for_symbol(symbol):
"""Cancel all resting orders for a symbol."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
data = request.get_json() or {}
broker_key = data.get('broker_key', 'paper')
try:
result = brighter_trades.trades.cancel_orders_for_symbol(user_id, symbol, broker_key)
return jsonify(result)
except Exception as e:
logger.error(f"Error cancelling orders for {symbol}: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/manual/history', methods=['GET'])
def get_trade_history():
"""Get trade history for current user."""
user_id = _get_current_user_id()
if not user_id:
return jsonify({'success': False, 'message': 'Not authenticated'}), 401
limit = request.args.get('limit', 50, type=int)
try:
history = brighter_trades.trades.get_trade_history(user_id, limit)
return jsonify({'success': True, 'history': history})
except Exception as e:
logger.error(f"Error getting trade history: {e}", exc_info=True)
return jsonify({'success': False, 'message': str(e)}), 500
@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
# =============================================================================

View File

@ -45,6 +45,7 @@ class OrderResult:
"""Result of an order placement."""
success: bool
order_id: Optional[str] = None
exchange_order_id: Optional[str] = None # Live exchange order ID from CCXT
message: Optional[str] = None
status: OrderStatus = OrderStatus.PENDING
filled_qty: float = 0.0
@ -61,6 +62,8 @@ class Position:
current_price: float
unrealized_pnl: float
realized_pnl: float = 0.0
entry_commission: float = 0.0 # Fee paid on entry, included in P&L
price_source: Optional[str] = None # Exchange to use for price lookups (e.g., 'kucoin')
def to_dict(self) -> Dict[str, Any]:
"""Convert position to dictionary for persistence."""
@ -71,6 +74,8 @@ class Position:
'current_price': self.current_price,
'unrealized_pnl': self.unrealized_pnl,
'realized_pnl': self.realized_pnl,
'entry_commission': self.entry_commission,
'price_source': self.price_source,
}
@classmethod
@ -83,6 +88,8 @@ class Position:
current_price=data['current_price'],
unrealized_pnl=data['unrealized_pnl'],
realized_pnl=data.get('realized_pnl', 0.0),
entry_commission=data.get('entry_commission', 0.0),
price_source=data.get('price_source'),
)

View File

@ -425,8 +425,8 @@ class LiveBroker(BaseBroker):
# Create local order tracking
symbol = ex_order['symbol']
side = OrderSide.BUY if ex_order['side'].lower() == 'buy' else OrderSide.SELL
order_type = OrderType.LIMIT if ex_order.get('type', 'limit').lower() == 'limit' else OrderType.MARKET
side = OrderSide.BUY if (ex_order.get('side') or 'buy').lower() == 'buy' else OrderSide.SELL
order_type = OrderType.LIMIT if (ex_order.get('type') or 'limit').lower() == 'limit' else OrderType.MARKET
size = float(ex_order['quantity'])
price = float(ex_order.get('price', 0) or 0)
@ -575,6 +575,7 @@ class LiveBroker(BaseBroker):
return OrderResult(
success=True,
order_id=existing_order.order_id,
exchange_order_id=existing_order.exchange_order_id,
status=existing_order.status,
filled_qty=existing_order.filled_qty,
filled_price=existing_order.filled_price,
@ -639,13 +640,20 @@ class LiveBroker(BaseBroker):
)
# Parse order status from exchange response
ex_status = exchange_order.get('status', 'open').lower()
# Use 'or' to handle case where status key exists but value is None
ex_status = (exchange_order.get('status') or 'open').lower()
if ex_status == 'closed' or ex_status == 'filled':
order.status = OrderStatus.FILLED
order.filled_qty = float(exchange_order.get('filled', size))
order.filled_price = float(exchange_order.get('average', price or 0))
raw_avg = exchange_order.get('average')
order.filled_price = float(raw_avg if raw_avg is not None else (price or 0))
order.filled_at = datetime.now(timezone.utc)
# DEBUG: Log exchange response for price validation
logger.info(f"[FILL DEBUG] Exchange response: average={raw_avg}, price={price}, "
f"filled={order.filled_qty}, cost={exchange_order.get('cost')}, "
f"final filled_price={order.filled_price}")
# Calculate commission
fee = exchange_order.get('fee', {})
if fee:
@ -662,6 +670,7 @@ class LiveBroker(BaseBroker):
return OrderResult(
success=True,
order_id=order_id,
exchange_order_id=order.exchange_order_id,
status=order.status,
filled_qty=order.filled_qty,
filled_price=order.filled_price,
@ -750,7 +759,8 @@ class LiveBroker(BaseBroker):
def _update_order_from_exchange(self, order: LiveOrder, ex_order: Dict[str, Any]):
"""Update local order with exchange data."""
ex_status = ex_order.get('status', 'open').lower()
# Use 'or' to handle case where status key exists but value is None
ex_status = (ex_order.get('status') or 'open').lower()
if ex_status == 'closed' or ex_status == 'filled':
order.status = OrderStatus.FILLED

View File

@ -32,7 +32,8 @@ class PaperOrder:
price: Optional[float] = None,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
time_in_force: str = 'GTC'
time_in_force: str = 'GTC',
exchange: Optional[str] = None
):
self.order_id = order_id
self.symbol = symbol
@ -43,6 +44,7 @@ class PaperOrder:
self.stop_loss = stop_loss
self.take_profit = take_profit
self.time_in_force = time_in_force
self.exchange = exchange # Exchange for price feed (e.g., 'kucoin')
self.status = OrderStatus.PENDING
self.filled_qty = 0.0
self.filled_price = 0.0
@ -68,7 +70,8 @@ class PaperOrder:
'commission': self.commission,
'locked_funds': self.locked_funds,
'created_at': self.created_at.isoformat(),
'filled_at': self.filled_at.isoformat() if self.filled_at else None
'filled_at': self.filled_at.isoformat() if self.filled_at else None,
'exchange': self.exchange,
}
@ -113,6 +116,9 @@ class PaperBroker(BaseBroker):
self._positions: Dict[str, Position] = {}
self._trade_history: List[Dict[str, Any]] = []
# SL/TP tracking per symbol: {symbol: {stop_loss, take_profit, side, entry_price}}
self._position_sltp: Dict[str, Dict[str, Any]] = {}
# Current prices cache
self._current_prices: Dict[str, float] = {}
@ -120,10 +126,21 @@ class PaperBroker(BaseBroker):
"""Set the price provider callable."""
self._price_provider = provider
def update_price(self, symbol: str, price: float):
"""Update the current price for a symbol."""
def update_price(self, symbol: str, price: float, exchange: Optional[str] = None):
"""Update price, optionally qualified by exchange."""
if exchange:
# Store exchange-qualified price
self._current_prices[f"{exchange}:{symbol}"] = price
# Always store symbol-only for backward compat
self._current_prices[symbol] = price
@staticmethod
def _should_fill_limit_order(side: OrderSide, current_price: float, limit_price: float) -> bool:
"""Return True when the current price crosses a limit order's fill threshold."""
if side == OrderSide.BUY:
return current_price <= limit_price
return current_price >= limit_price
def place_order(
self,
symbol: str,
@ -133,13 +150,14 @@ class PaperBroker(BaseBroker):
price: Optional[float] = None,
stop_loss: Optional[float] = None,
take_profit: Optional[float] = None,
time_in_force: str = 'GTC'
time_in_force: str = 'GTC',
exchange: Optional[str] = None
) -> OrderResult:
"""Place a paper trading order."""
order_id = str(uuid.uuid4())[:8]
# Validate order
current_price = self.get_current_price(symbol)
# Validate order - use exchange-aware price lookup
current_price = self.get_current_price(symbol, exchange)
if current_price <= 0:
return OrderResult(
success=False,
@ -186,7 +204,8 @@ class PaperBroker(BaseBroker):
price=price,
stop_loss=stop_loss,
take_profit=take_profit,
time_in_force=time_in_force
time_in_force=time_in_force,
exchange=exchange
)
# For market orders, fill immediately
@ -195,6 +214,39 @@ class PaperBroker(BaseBroker):
self._fill_order(order, fill_price)
logger.info(f"PaperBroker: Market order filled: {side.value} {size} {symbol} @ {fill_price:.4f}")
else:
is_marketable = self._should_fill_limit_order(side, current_price, execution_price)
# IOC/FOK must fill immediately or fail immediately.
if time_in_force in ['IOC', 'FOK']:
if is_marketable:
self._fill_order(order, execution_price)
logger.info(
f"PaperBroker: {time_in_force} limit order filled immediately: "
f"{side.value} {size} {symbol} @ {execution_price}"
)
else:
order.status = OrderStatus.CANCELLED if time_in_force == 'IOC' else OrderStatus.EXPIRED
self._orders[order_id] = order
logger.info(
f"PaperBroker: {time_in_force} limit order not fillable immediately: "
f"{side.value} {size} {symbol} @ {price}"
)
return OrderResult(
success=False,
order_id=order_id,
status=order.status,
message=f"{time_in_force} limit order could not be filled immediately"
)
return OrderResult(
success=True,
order_id=order_id,
status=order.status,
filled_qty=order.filled_qty,
filled_price=order.filled_price,
commission=order.commission,
message=f"Order {order_id} filled"
)
# Store pending order
order.status = OrderStatus.OPEN
self._orders[order_id] = order
@ -250,14 +302,30 @@ class PaperBroker(BaseBroker):
new_entry = (existing.entry_price * existing.size + fill_price * order.size) / new_size
existing.size = new_size
existing.entry_price = new_entry
existing.entry_commission += order.commission # Accumulate entry fees
# Most recent fill wins: update price source
if order.exchange:
existing.price_source = order.exchange
else:
self._positions[order.symbol] = Position(
symbol=order.symbol,
size=order.size,
entry_price=fill_price,
current_price=fill_price,
unrealized_pnl=0.0
unrealized_pnl=-order.commission, # Start with entry fee as loss
entry_commission=order.commission,
price_source=order.exchange
)
# Record SL/TP for this position (if set on order)
if order.stop_loss or order.take_profit:
self._position_sltp[order.symbol] = {
'stop_loss': order.stop_loss,
'take_profit': order.take_profit,
'side': 'long', # BUY opens a long position
'entry_price': fill_price
}
logger.info(f"SL/TP set for {order.symbol}: SL={order.stop_loss}, TP={order.take_profit}")
else:
# Add proceeds to cash
total_proceeds = order_value - order.commission
@ -267,10 +335,22 @@ class PaperBroker(BaseBroker):
if order.symbol in self._positions:
position = self._positions[order.symbol]
order.entry_price = position.entry_price # Store for fee calculation
realized_pnl = (fill_price - position.entry_price) * order.size - order.commission
# Calculate proportional entry commission for partial closes
if position.size > 0:
proportion = order.size / position.size
proportional_entry_commission = position.entry_commission * proportion
else:
proportional_entry_commission = position.entry_commission
# Realized P&L includes both entry and exit fees
realized_pnl = (fill_price - position.entry_price) * order.size - proportional_entry_commission - order.commission
position.realized_pnl += realized_pnl
position.size -= order.size
# Reduce remaining entry commission proportionally
position.entry_commission -= proportional_entry_commission
# Track profitability for fee calculation
order.realized_pnl = realized_pnl
order.is_profitable = realized_pnl > 0
@ -278,6 +358,9 @@ class PaperBroker(BaseBroker):
# Remove position if fully closed
if position.size <= 0:
del self._positions[order.symbol]
# Clear SL/TP tracking for this symbol
if order.symbol in self._position_sltp:
del self._position_sltp[order.symbol]
# Record trade
self._trade_history.append({
@ -347,15 +430,30 @@ class PaperBroker(BaseBroker):
"""Get all open positions."""
return list(self._positions.values())
def get_current_price(self, symbol: str) -> float:
"""Get current price for a symbol."""
# First check cache
def get_current_price(self, symbol: str, exchange: Optional[str] = None) -> float:
"""Get price, preferring exchange-qualified if available."""
# First try exchange-qualified lookup
if exchange:
key = f"{exchange}:{symbol}"
if key in self._current_prices:
return self._current_prices[key]
# Fall back to symbol-only lookup
if symbol in self._current_prices:
return self._current_prices[symbol]
# Then try price provider
if self._price_provider:
try:
# Try exchange-qualified first, then symbol-only
if exchange:
try:
price = self._price_provider(f"{exchange}:{symbol}")
if price > 0:
self._current_prices[f"{exchange}:{symbol}"] = price
return price
except Exception:
pass
price = self._price_provider(symbol)
self._current_prices[symbol] = price
return price
@ -372,29 +470,74 @@ class PaperBroker(BaseBroker):
"""
events = []
# Update position P&L
# Update position P&L (includes entry commission for accurate fee reflection)
for symbol, position in self._positions.items():
current_price = self.get_current_price(symbol)
# Use position's price_source for exchange-aware price lookup
current_price = self.get_current_price(symbol, position.price_source)
if current_price > 0:
position.current_price = current_price
position.unrealized_pnl = (current_price - position.entry_price) * position.size
# P&L = price movement - entry fees already paid
position.unrealized_pnl = (current_price - position.entry_price) * position.size - position.entry_commission
# Evaluate SL/TP for all tracked positions
for symbol, sltp in list(self._position_sltp.items()):
if symbol not in self._positions:
del self._position_sltp[symbol]
continue
position = self._positions[symbol]
# Use position's price_source for exchange-aware price lookup
current_price = self.get_current_price(symbol, position.price_source)
if position.size <= 0 or current_price <= 0:
del self._position_sltp[symbol]
continue
triggered = None
trigger_price = current_price
# Long position: SL triggers when price drops, TP when price rises
if sltp['side'] == 'long':
if sltp.get('stop_loss') and current_price <= sltp['stop_loss']:
triggered = 'stop_loss'
elif sltp.get('take_profit') and current_price >= sltp['take_profit']:
triggered = 'take_profit'
# Short position: SL triggers when price rises, TP when price drops
else:
if sltp.get('stop_loss') and current_price >= sltp['stop_loss']:
triggered = 'stop_loss'
elif sltp.get('take_profit') and current_price <= sltp['take_profit']:
triggered = 'take_profit'
if triggered:
# Auto-close position
close_result = self.close_position(symbol)
if close_result.success:
events.append({
'type': 'sltp_triggered',
'trigger': triggered,
'symbol': symbol,
'trigger_price': trigger_price,
'size': close_result.filled_qty,
'pnl': position.unrealized_pnl
})
logger.info(f"SL/TP triggered: {triggered} for {symbol} at {trigger_price}")
# SL/TP tracking cleared in _fill_order when position closes
# Check pending limit orders
for order_id, order in list(self._orders.items()):
if order.status != OrderStatus.OPEN:
continue
current_price = self.get_current_price(order.symbol)
# Use order's exchange for price lookup
current_price = self.get_current_price(order.symbol, order.exchange)
if current_price <= 0:
continue
should_fill = False
if order.order_type == OrderType.LIMIT:
if order.side == OrderSide.BUY and current_price <= order.price:
should_fill = True
elif order.side == OrderSide.SELL and current_price >= order.price:
should_fill = True
should_fill = self._should_fill_limit_order(order.side, current_price, order.price)
else:
should_fill = False
if should_fill:
# Release locked funds first (for buy orders)
@ -437,6 +580,7 @@ class PaperBroker(BaseBroker):
self._positions.clear()
self._trade_history.clear()
self._current_prices.clear()
self._position_sltp.clear() # Clear SL/TP state to prevent stale triggers
logger.info(f"PaperBroker: Reset with balance {self.initial_balance}")
# ==================== State Persistence Methods ====================
@ -520,6 +664,7 @@ class PaperBroker(BaseBroker):
'positions': positions_data,
'trade_history': self._trade_history,
'current_prices': self._current_prices,
'position_sltp': self._position_sltp,
}
def from_state_dict(self, state: Dict[str, Any]):
@ -548,6 +693,8 @@ class PaperBroker(BaseBroker):
price=order_dict.get('price'),
stop_loss=order_dict.get('stop_loss'),
take_profit=order_dict.get('take_profit'),
time_in_force=order_dict.get('time_in_force', 'GTC'),
exchange=order_dict.get('exchange'),
)
order.status = OrderStatus(order_dict['status'])
order.filled_qty = order_dict.get('filled_qty', 0.0)
@ -574,6 +721,9 @@ class PaperBroker(BaseBroker):
# Restore price cache
self._current_prices = state.get('current_prices', {})
# Restore SL/TP tracking
self._position_sltp = state.get('position_sltp', {})
logger.info(f"PaperBroker: State restored - cash: {self._cash:.2f}, "
f"positions: {len(self._positions)}, orders: {len(self._orders)}")

View File

@ -1,12 +1,11 @@
import datetime as dt
import logging as log
import logging
import pytz
from shared_utilities import timeframe_to_minutes, ts_of_n_minutes_ago
# log.basicConfig(level=log.ERROR)
logger = logging.getLogger(__name__)
class Candles:
def __init__(self, exchanges, users, datacache, config, edm_client=None):
@ -24,6 +23,8 @@ class Candles:
# Cache the last received candle to detect duplicates
self.cached_last_candle = None
# Avoid repeating the same expected EDM cap warning on every refresh.
self._edm_cap_warned_scopes: set[tuple[str, str, str]] = set()
# size_limit is the max number of lists of candle(ohlc) data allowed.
self.data.create_cache(name='candles', cache_type='row', default_expiration=dt.timedelta(days=5),
@ -57,10 +58,21 @@ class Candles:
# EDM API has a maximum limit of 1000 candles
EDM_MAX_CANDLES = 1000
if num_candles > EDM_MAX_CANDLES:
log.warning(f'Requested {num_candles} candles, capping to EDM limit of {EDM_MAX_CANDLES}')
warning_scope = (asset, exchange, timeframe)
if warning_scope not in self._edm_cap_warned_scopes:
logger.warning(
"Requested %s candles for %s/%s/%s, capping to EDM limit of %s",
num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES
)
self._edm_cap_warned_scopes.add(warning_scope)
else:
logger.debug(
"Requested %s candles for %s/%s/%s, capped to %s",
num_candles, asset, timeframe, exchange, EDM_MAX_CANDLES
)
num_candles = EDM_MAX_CANDLES
log.info(f'[GET CANDLES] {asset} {exchange} {timeframe} limit={num_candles}')
logger.debug("Fetching candles from EDM: %s %s %s limit=%s", asset, exchange, timeframe, num_candles)
if self.edm is None:
raise RuntimeError("EDM client not initialized. Cannot fetch candle data.")
@ -76,10 +88,10 @@ class Candles:
)
if candles.empty:
log.warning(f"No candles returned from EDM for {asset}/{timeframe}/{exchange}")
logger.warning("No candles returned from EDM for %s/%s/%s", asset, timeframe, exchange)
return self.convert_candles(candles)
log.info(f"Fetched {len(candles)} candles from EDM for {asset}/{timeframe}/{exchange}")
logger.debug("Fetched %s candles from EDM for %s/%s/%s", len(candles), asset, timeframe, exchange)
return self.convert_candles(candles[-num_candles:])
def set_new_candle(self, cdata: dict) -> bool:
@ -93,7 +105,7 @@ class Candles:
"""
# Update the cached last candle
self.cached_last_candle = cdata
log.debug(f"Candle updated: {cdata.get('symbol', 'unknown')} @ {cdata.get('close', 0)}")
logger.debug("Candle updated: %s @ %s", cdata.get('symbol', 'unknown'), cdata.get('close', 0))
return True
def set_cache(self, symbol=None, interval=None, exchange_name=None, user_name=None):
"""
@ -110,24 +122,24 @@ class Candles:
if not symbol:
assert user_name is not None
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
log.info(f'set_candle_history(): No symbol provided. Using{symbol}')
logger.info('set_candle_history(): No symbol provided. Using %s', symbol)
if not interval:
assert user_name is not None
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
log.info(f'set_candle_history(): No timeframe provided. Using{interval}')
logger.info('set_candle_history(): No timeframe provided. Using %s', interval)
if not exchange_name:
assert user_name is not None
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
# Log the completion to the console.
log.info('set_candle_history(): Loading candle data...')
logger.info('set_candle_history(): Loading candle data...')
# Load candles from database
_cdata = self.get_last_n_candles(num_candles=self.max_records,
asset=symbol, timeframe=interval, exchange=exchange_name, user_name=user_name)
# Log the completion to the console.
log.info('set_candle_history(): Candle data Loaded.')
logger.info('set_candle_history(): Candle data Loaded.')
return
@staticmethod
@ -213,17 +225,17 @@ class Candles:
if not symbol:
assert user_name is not None
symbol = self.users.get_chart_view(user_name=user_name, prop='market')
log.info(f'get_candle_history(): No symbol provided. Using {symbol}')
logger.info('get_candle_history(): No symbol provided. Using %s', symbol)
if not interval:
assert user_name is not None
interval = self.users.get_chart_view(user_name=user_name, prop='timeframe')
log.info(f'get_candle_history(): No timeframe provided. Using {interval}')
logger.info('get_candle_history(): No timeframe provided. Using %s', interval)
if not exchange_name:
assert user_name is not None
exchange_name = self.users.get_chart_view(user_name=user_name, prop='exchange_name')
log.info(f'get_candle_history(): No exchange name provided. Using {exchange_name}')
logger.info('get_candle_history(): No exchange name provided. Using %s', exchange_name)
candlesticks = self.get_last_n_candles(num_candles=num_records, asset=symbol, timeframe=interval,
exchange=exchange_name, user_name=user_name)

View File

@ -0,0 +1,652 @@
"""
Manual Trading Broker Manager for BrighterTrading.
Manages broker instances (PaperBroker/LiveBroker) for manual (non-strategy) trading.
This provides a unified interface for placing orders, tracking positions, and
managing order lifecycle for manual trades.
"""
import logging
from typing import Any, Callable, Dict, List, Optional
from brokers.base_broker import OrderSide, OrderType, OrderStatus, Position, OrderResult
from brokers.paper_broker import PaperBroker
from brokers.live_broker import LiveBroker
logger = logging.getLogger(__name__)
class ManualTradingBrokerManager:
"""
Manages broker instances for manual (non-strategy) trading.
Key design principles:
- Reuses mode-aware exchange connections from ExchangeInterface
- Separate orders (unfilled) from positions (filled/aggregated)
- Single owner for broker polling (strategy_execution_loop only)
"""
def __init__(self, data_cache: Any = None, exchange_interface: Any = None, users: Any = None):
"""
Initialize the ManualTradingBrokerManager.
:param data_cache: DataCache instance for persistence.
:param exchange_interface: ExchangeInterface instance for exchange connections.
:param users: Users instance for API key lookup.
"""
self._paper_brokers: Dict[int, PaperBroker] = {} # user_id -> broker
self._live_brokers: Dict[int, Dict[str, LiveBroker]] = {} # user_id -> {exchange_mode_key: broker}
self.data_cache = data_cache
self.exchange_interface = exchange_interface
self.users = users
def get_paper_broker(self, user_id: int, initial_balance: float = 10000.0) -> PaperBroker:
"""
Get or create a PaperBroker for a user.
:param user_id: The user ID.
:param initial_balance: Initial balance for new brokers.
:return: PaperBroker instance.
"""
if user_id not in self._paper_brokers:
# Create price provider that uses exchange_interface
# Accepts either 'symbol' or 'exchange:symbol' format
def price_provider(symbol_key: str) -> float:
if self.exchange_interface:
try:
# Parse exchange:symbol format if present
if ':' in symbol_key:
exchange, symbol = symbol_key.split(':', 1)
return self.exchange_interface.get_price(symbol, exchange)
else:
return self.exchange_interface.get_price(symbol_key)
except Exception as e:
logger.warning(f"Failed to get price for {symbol_key}: {e}")
return 0.0
broker = PaperBroker(
price_provider=price_provider,
data_cache=self.data_cache,
initial_balance=initial_balance
)
# Try to load saved state
state_id = f"manual_paper_{user_id}"
broker.load_state(state_id)
self._paper_brokers[user_id] = broker
logger.info(f"Created paper broker for user {user_id}")
return self._paper_brokers[user_id]
def get_live_broker(
self,
user_id: int,
exchange_name: str,
testnet: bool,
user_name: str
) -> Optional[LiveBroker]:
"""
Get or create a LiveBroker for a user+exchange+mode.
Uses mode-aware exchange connection from exchange_interface.
Validates that the exchange's sandbox mode matches the requested testnet flag.
Policy: One mode per exchange per user. Cannot have both testnet and production
simultaneously for the same exchange. Returns None if mode conflict.
:param user_id: The user ID.
:param exchange_name: Exchange name (e.g., 'binance').
:param testnet: Whether to use testnet mode.
:param user_name: Username for exchange lookup.
:return: LiveBroker instance or None if not configured or mode conflict.
"""
if not exchange_name:
logger.error("Cannot create live broker: exchange_name is required")
return None
# Use 'testnet'/'production' to match what's stored in trade.broker_mode
requested_mode = 'testnet' if testnet else 'production'
broker_key = f"{exchange_name}_{requested_mode}"
opposite_mode = 'production' if testnet else 'testnet'
opposite_key = f"{exchange_name}_{opposite_mode}"
if user_id not in self._live_brokers:
self._live_brokers[user_id] = {}
# Check for mode conflict - reject if opposite mode already active
if opposite_key in self._live_brokers[user_id]:
logger.error(
f"Mode conflict: {exchange_name} already active in {opposite_mode} mode "
f"for user {user_id}. Disconnect first before switching to {requested_mode}."
)
return None
if broker_key not in self._live_brokers[user_id]:
if not self.exchange_interface:
logger.error("No exchange interface configured")
return None
try:
# Get exchange connection from exchange_interface
try:
exchange = self.exchange_interface.get_exchange(
ename=exchange_name,
uname=user_name
)
except ValueError:
exchange = None # Exchange doesn't exist yet
# CRITICAL: Verify exchange testnet mode matches requested mode
if exchange:
exchange_is_testnet = bool(getattr(exchange, 'testnet', False))
if exchange_is_testnet != testnet:
# Exchange mode mismatch - need to reconnect with correct mode
logger.warning(
f"Exchange '{exchange_name}' is in "
f"{'testnet' if exchange_is_testnet else 'production'} mode, "
f"but requested {'testnet' if testnet else 'production'}. "
f"Reconnecting with correct mode."
)
# Get API keys and reconnect with correct mode
if self.users:
api_keys = self.users.get_api_keys(user_name, exchange_name)
if api_keys:
self.exchange_interface.connect_exchange(
exchange_name=exchange_name,
user_name=user_name,
api_keys=api_keys,
testnet=testnet
)
exchange = self.exchange_interface.get_exchange(
ename=exchange_name, uname=user_name
)
# If exchange doesn't exist or isn't configured, try to load API keys
if not exchange or not exchange.configured:
if self.users:
logger.info(f"Exchange '{exchange_name}' not configured, loading API keys...")
api_keys = self.users.get_api_keys(user_name, exchange_name)
if api_keys:
success = self.exchange_interface.connect_exchange(
exchange_name=exchange_name,
user_name=user_name,
api_keys=api_keys,
testnet=testnet
)
if success:
exchange = self.exchange_interface.get_exchange(
ename=exchange_name, uname=user_name
)
if not exchange or not exchange.configured:
logger.error(f"Exchange {exchange_name} not configured for user {user_name}")
return None
# Final verification: exchange mode MUST match requested mode
exchange_is_testnet = bool(getattr(exchange, 'testnet', False))
if exchange_is_testnet != testnet:
logger.error(
f"Exchange mode mismatch after reconnect: exchange is "
f"{'testnet' if exchange_is_testnet else 'production'}, "
f"but requested {'testnet' if testnet else 'production'}"
)
return None
broker = LiveBroker(
exchange=exchange,
testnet=testnet,
data_cache=self.data_cache
)
broker.connect()
# Try to load saved state
state_id = f"manual_live_{user_id}_{broker_key}"
broker.load_state(state_id)
self._live_brokers[user_id][broker_key] = broker
logger.info(f"Created live broker for user {user_id}, {broker_key}")
except Exception as e:
logger.error(f"Failed to create live broker: {e}", exc_info=True)
return None
return self._live_brokers[user_id].get(broker_key)
def set_prices(self, price_updates: Dict[str, float]) -> None:
"""
Update current prices for all paper brokers.
:param price_updates: Dict mapping symbol or exchange:symbol to price.
"""
for broker in self._paper_brokers.values():
for key, price in price_updates.items():
if ':' in key:
# Exchange-qualified key (e.g., 'kucoin:BTC/USDT')
exchange, symbol = key.split(':', 1)
broker.update_price(symbol, price, exchange)
else:
# Symbol-only key
broker.update_price(key, price)
def update_all_brokers(self, price_updates: Dict[str, float]) -> List[Dict]:
"""
Update all brokers and return fill events.
Called from strategy_execution_loop for single-owner polling.
:param price_updates: Dict mapping symbol to price.
:return: List of fill events with user_id and broker_kind added.
"""
events = []
# Update prices for paper brokers
self.set_prices(price_updates)
# Process paper brokers
for user_id, broker in self._paper_brokers.items():
try:
fill_events = broker.update()
for e in fill_events:
e['user_id'] = user_id
e['broker_kind'] = 'paper'
e['broker_key'] = 'paper'
events.extend(fill_events)
# Save state after updates
state_id = f"manual_paper_{user_id}"
broker.save_state(state_id)
except Exception as ex:
logger.error(f"Error updating paper broker for user {user_id}: {ex}")
# Process live brokers
for user_id, brokers in self._live_brokers.items():
for broker_key, broker in brokers.items():
try:
fill_events = broker.update()
for e in fill_events:
e['user_id'] = user_id
e['broker_kind'] = 'live'
e['broker_key'] = broker_key
events.extend(fill_events)
# Save state after updates
state_id = f"manual_live_{user_id}_{broker_key}"
broker.save_state(state_id)
except Exception as ex:
logger.error(f"Error updating live broker {broker_key} for user {user_id}: {ex}")
return events
def get_all_open_orders(self, user_id: int) -> List[Dict]:
"""
Get all open orders for a user across all brokers.
:param user_id: The user ID.
:return: List of open order dicts with broker_key added.
"""
orders = []
# Paper broker orders
if user_id in self._paper_brokers:
broker = self._paper_brokers[user_id]
for order in broker.get_open_orders():
order['broker_key'] = 'paper'
order['broker_kind'] = 'paper'
orders.append(order)
# Live broker orders
if user_id in self._live_brokers:
for broker_key, broker in self._live_brokers[user_id].items():
try:
for order in broker.get_open_orders():
order['broker_key'] = broker_key
order['broker_kind'] = 'live'
orders.append(order)
except Exception as e:
logger.warning(f"Could not get orders from {broker_key}: {e}")
return orders
def get_all_positions(self, user_id: int) -> List[Dict]:
"""
Get all positions for a user across all brokers.
:param user_id: The user ID.
:return: List of position dicts with broker_key added.
"""
positions = []
# Paper broker positions
if user_id in self._paper_brokers:
broker = self._paper_brokers[user_id]
for pos in broker.get_all_positions():
pos_dict = pos.to_dict()
pos_dict['broker_key'] = 'paper'
pos_dict['broker_kind'] = 'paper'
positions.append(pos_dict)
# Live broker positions
if user_id in self._live_brokers:
for broker_key, broker in self._live_brokers[user_id].items():
try:
for pos in broker.get_all_positions():
pos_dict = pos.to_dict()
pos_dict['broker_key'] = broker_key
pos_dict['broker_kind'] = 'live'
positions.append(pos_dict)
except Exception as e:
logger.warning(f"Could not get positions from {broker_key}: {e}")
return positions
def cancel_order(self, user_id: int, order_id: str, broker_key: str) -> Dict:
"""
Cancel an open order.
:param user_id: The user ID.
:param order_id: The order ID to cancel.
:param broker_key: The broker key ('paper' or 'exchange_mode').
:return: Dict with success status and message.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return {"success": False, "message": f"Broker not found: {broker_key}"}
try:
result = broker.cancel_order(order_id)
if result:
# Save state after cancel
if broker_key == 'paper':
state_id = f"manual_paper_{user_id}"
else:
state_id = f"manual_live_{user_id}_{broker_key}"
broker.save_state(state_id)
return {
"success": result,
"message": "Order cancelled" if result else "Failed to cancel order"
}
except Exception as e:
logger.error(f"Error cancelling order {order_id}: {e}")
return {"success": False, "message": str(e)}
def close_position(self, user_id: int, symbol: str, broker_key: str) -> Dict:
"""
Close an entire position (position-first operation).
:param user_id: The user ID.
:param symbol: Trading symbol to close.
:param broker_key: The broker key ('paper' or 'exchange_mode').
:return: Dict with success status and OrderResult details.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return {"success": False, "message": f"Broker not found: {broker_key}"}
try:
result = broker.close_position(symbol)
if result.success:
# Save state after close
if broker_key == 'paper':
state_id = f"manual_paper_{user_id}"
else:
state_id = f"manual_live_{user_id}_{broker_key}"
broker.save_state(state_id)
return {
"success": result.success,
"message": result.message,
"order_id": result.order_id,
"status": getattr(result.status, 'value', result.status),
"filled_qty": result.filled_qty,
"filled_price": result.filled_price
}
except Exception as e:
logger.error(f"Error closing position {symbol}: {e}")
return {"success": False, "message": str(e)}
def get_position(self, user_id: int, symbol: str, broker_key: str) -> Optional[Position]:
"""
Get a specific position.
:param user_id: The user ID.
:param symbol: Trading symbol.
:param broker_key: The broker key.
:return: Position or None.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return None
return broker.get_position(symbol)
def place_order(
self,
user_id: int,
broker_key: str,
symbol: str,
side: OrderSide,
order_type: OrderType,
size: float,
price: Optional[float] = None
) -> OrderResult:
"""
Place an order through the specified broker.
:param user_id: The user ID.
:param broker_key: The broker key ('paper' or 'exchange_mode').
:param symbol: Trading symbol.
:param side: OrderSide.BUY or OrderSide.SELL.
:param order_type: OrderType.MARKET or OrderType.LIMIT.
:param size: Order size.
:param price: Limit price (required for limit orders).
:return: OrderResult.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return OrderResult(
success=False,
message=f"Broker not found: {broker_key}"
)
try:
result = broker.place_order(
symbol=symbol,
side=side,
order_type=order_type,
size=size,
price=price
)
if result.success:
# Save state after order
if broker_key == 'paper':
state_id = f"manual_paper_{user_id}"
else:
state_id = f"manual_live_{user_id}_{broker_key}"
broker.save_state(state_id)
return result
except Exception as e:
logger.error(f"Error placing order: {e}")
return OrderResult(success=False, message=str(e))
def _get_broker(self, user_id: int, broker_key: str, create_paper: bool = True):
"""
Get a broker by user_id and broker_key.
:param user_id: The user ID.
:param broker_key: 'paper' or 'exchange_mode' format.
:param create_paper: If True, create paper broker on-demand (loads saved state).
:return: Broker instance or None.
"""
if broker_key == 'paper':
if user_id in self._paper_brokers:
return self._paper_brokers[user_id]
# Create paper broker on-demand (this loads saved state)
if create_paper:
return self.get_paper_broker(user_id)
return None
if user_id in self._live_brokers:
return self._live_brokers[user_id].get(broker_key)
return None
def get_broker_balance(self, user_id: int, broker_key: str) -> float:
"""
Get the total balance/equity for a broker.
:param user_id: The user ID.
:param broker_key: The broker key.
:return: Total balance.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return 0.0
return broker.get_balance()
def reset_paper_broker(self, user_id: int) -> Dict:
"""
Reset the paper broker for a user to initial state.
Clears all positions, orders, and restores the initial balance.
:param user_id: The user ID.
:return: Dict with success status and new balance.
"""
broker = self._paper_brokers.get(user_id)
if not broker:
# Create a fresh broker if one doesn't exist
broker = self.get_paper_broker(user_id)
try:
broker.reset()
# Save the reset state
state_id = f"manual_paper_{user_id}"
broker.save_state(state_id)
logger.info(f"Reset paper broker for user {user_id}, balance: {broker.initial_balance}")
return {
"success": True,
"message": "Paper trading balance reset successfully",
"balance": broker.initial_balance
}
except Exception as e:
logger.error(f"Error resetting paper broker for user {user_id}: {e}")
return {"success": False, "message": str(e)}
def get_available_balance(self, user_id: int, broker_key: str) -> float:
"""
Get the available balance (not locked in orders).
:param user_id: The user ID.
:param broker_key: The broker key.
:return: Available balance.
"""
broker = self._get_broker(user_id, broker_key)
if not broker:
return 0.0
return broker.get_available_balance()
def recover_brokers_for_trades(self, trades: List[Any], get_username_func) -> int:
"""
Recover/recreate brokers for persisted trades after a restart.
This ensures that broker-managed trades are properly tracked after
the application restarts. Should be called after loading trades from DB.
:param trades: List of Trade objects with broker_order_id.
:param get_username_func: Function that takes user_id and returns username.
:return: Number of brokers recovered.
"""
recovered = 0
# Group trades by (user_id, broker_kind, broker_key)
broker_needed: Dict[tuple, Dict] = {}
for trade in trades:
if not trade.broker_order_id:
continue # Not a broker-managed trade
user_id = trade.creator
if not user_id:
continue
if trade.broker_kind == 'paper':
key = (user_id, 'paper', 'paper')
broker_needed[key] = {'kind': 'paper'}
elif trade.broker_kind == 'live' and trade.broker_exchange and trade.broker_mode:
broker_key = f"{trade.broker_exchange}_{trade.broker_mode}"
testnet = trade.broker_mode == 'testnet'
key = (user_id, 'live', broker_key)
broker_needed[key] = {
'kind': 'live',
'exchange': trade.broker_exchange,
'testnet': testnet
}
# Recreate each needed broker
for (user_id, kind, broker_key), info in broker_needed.items():
try:
if kind == 'paper':
# Check if already exists
if user_id not in self._paper_brokers:
self.get_paper_broker(user_id)
logger.info(f"Recovered paper broker for user {user_id}")
recovered += 1
else:
# Live broker
if user_id not in self._live_brokers or broker_key not in self._live_brokers.get(user_id, {}):
user_name = get_username_func(user_id)
if user_name:
broker = self.get_live_broker(
user_id=user_id,
exchange_name=info['exchange'],
testnet=info['testnet'],
user_name=user_name
)
if broker:
logger.info(f"Recovered live broker {broker_key} for user {user_id}")
recovered += 1
else:
logger.warning(f"Could not recover live broker {broker_key} for user {user_id}")
except Exception as e:
logger.error(f"Error recovering broker {broker_key} for user {user_id}: {e}")
return recovered
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);
// Initialize the candlestick series if price_history is available
this.price_history = this._normalizeCandles(this.price_history);
if (this.price_history && this.price_history.length > 0) {
this.candleSeries.setData(this.price_history);
console.log(`Candle series initialized with ${this.price_history.length} candles`);
@ -48,8 +49,110 @@ class Charts {
}
update_main_chart(new_candle){
const normalizedCandle = this._normalizeCandle(new_candle);
if (!normalizedCandle) {
console.warn('Skipping invalid candle update:', new_candle);
return;
}
const lastCandle = Array.isArray(this.price_history) && this.price_history.length > 0
? this.price_history[this.price_history.length - 1]
: null;
if (lastCandle && normalizedCandle.time < lastCandle.time) {
console.warn('Skipping stale candle update:', normalizedCandle, 'last:', lastCandle);
return;
}
// Update candlestick series
this.candleSeries.update(new_candle);
this.candleSeries.update(normalizedCandle);
// Keep local price history aligned with the live chart series.
if (!Array.isArray(this.price_history)) {
this.price_history = [];
}
const lastIndex = this.price_history.length - 1;
if (lastIndex >= 0 && this.price_history[lastIndex].time === normalizedCandle.time) {
this.price_history[lastIndex] = normalizedCandle;
} else if (lastIndex < 0 || this.price_history[lastIndex].time < normalizedCandle.time) {
this.price_history.push(normalizedCandle);
}
}
_normalizeCandleTime(rawTime) {
if (rawTime === null || rawTime === undefined) {
return null;
}
if (typeof rawTime === 'number' && Number.isFinite(rawTime)) {
return rawTime > 1e12 ? Math.floor(rawTime / 1000) : Math.floor(rawTime);
}
if (typeof rawTime === 'string') {
const numericValue = Number(rawTime);
if (Number.isFinite(numericValue)) {
return this._normalizeCandleTime(numericValue);
}
const parsedTime = Date.parse(rawTime);
if (!Number.isNaN(parsedTime)) {
return Math.floor(parsedTime / 1000);
}
return null;
}
if (rawTime instanceof Date) {
return Math.floor(rawTime.getTime() / 1000);
}
if (typeof rawTime === 'object') {
if (
Object.prototype.hasOwnProperty.call(rawTime, 'year') &&
Object.prototype.hasOwnProperty.call(rawTime, 'month') &&
Object.prototype.hasOwnProperty.call(rawTime, 'day')
) {
return Math.floor(Date.UTC(rawTime.year, rawTime.month - 1, rawTime.day) / 1000);
}
for (const key of ['timestamp', 'time', 'value', '$date']) {
if (Object.prototype.hasOwnProperty.call(rawTime, key)) {
return this._normalizeCandleTime(rawTime[key]);
}
}
}
return null;
}
_normalizeCandle(candle) {
if (!candle) {
return null;
}
const time = this._normalizeCandleTime(candle.time);
if (time === null) {
return null;
}
return {
...candle,
time,
open: parseFloat(candle.open),
high: parseFloat(candle.high),
low: parseFloat(candle.low),
close: parseFloat(candle.close)
};
}
_normalizeCandles(candles) {
if (!Array.isArray(candles)) {
return [];
}
return candles
.map(candle => this._normalizeCandle(candle))
.filter(candle => candle !== null)
.sort((a, b) => a.time - b.time);
}
create_RSI_chart(){

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 */
candleUpdate(newCandle) {
@ -345,14 +396,17 @@ class Comms {
const candles = data.candles || [];
// EDM already sends time in seconds, no conversion needed
return candles.map(c => ({
time: c.time,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume
}));
return candles
.map(c => ({
time: this._normalizeCandleTime(c.time),
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
volume: parseFloat(c.volume)
}))
.filter(c => c.time !== null)
.sort((a, b) => a.time - b.time);
}
/**
@ -571,8 +625,13 @@ class Comms {
if (messageType === 'candle') {
const candle = message.data;
const candleTime = this._normalizeCandleTime(candle.time);
if (candleTime === null) {
console.warn('Skipping candle with invalid time payload:', candle.time);
return;
}
const newCandle = {
time: candle.time, // EDM sends time in seconds
time: candleTime,
open: parseFloat(candle.open),
high: parseFloat(candle.high),
low: parseFloat(candle.low),

View File

@ -16,19 +16,25 @@ class TradeUIManager {
this.exchangeSelect = null;
this.testnetCheckbox = null;
this.testnetRow = null;
this.stopLossInput = null;
this.takeProfitInput = null;
this.timeInForceSelect = null;
this.exchangeRow = null;
this.sltpRow = null;
this.onCloseTrade = null;
// Exchanges known to support testnet/sandbox mode
// Exchanges known to support testnet/sandbox mode in ccxt
// IMPORTANT: Only include exchanges with verified working sandbox URLs
// KuCoin does NOT have sandbox support - removed to prevent real trades!
this.testnetSupportedExchanges = [
'binance', 'binanceus', 'binanceusdm', 'binancecoinm',
'kucoin', 'kucoinfutures',
'bybit',
'okx', 'okex',
'bitget',
'bitmex',
'deribit',
'phemex',
'mexc'
'phemex'
// Removed: 'kucoin', 'kucoinfutures', 'mexc' - no sandbox support
];
}
@ -50,7 +56,12 @@ class TradeUIManager {
symbolId = 'tradeSymbol',
exchangeId = 'tradeExchange',
testnetId = 'tradeTestnet',
testnetRowId = 'testnet-row'
testnetRowId = 'testnet-row',
stopLossId = 'stopLoss',
takeProfitId = 'takeProfit',
timeInForceId = 'timeInForce',
exchangeRowId = 'exchange-row',
sltpRowId = 'sltp-row'
} = config;
this.targetEl = document.getElementById(targetId);
@ -74,6 +85,11 @@ class TradeUIManager {
this.exchangeSelect = document.getElementById(exchangeId);
this.testnetCheckbox = document.getElementById(testnetId);
this.testnetRow = document.getElementById(testnetRowId);
this.stopLossInput = document.getElementById(stopLossId);
this.takeProfitInput = document.getElementById(takeProfitId);
this.timeInForceSelect = document.getElementById(timeInForceId);
this.exchangeRow = document.getElementById(exchangeRowId);
this.sltpRow = document.getElementById(sltpRowId);
// Set up event listeners
this._setupFormListeners();
@ -120,10 +136,13 @@ class TradeUIManager {
this.qtyInput.addEventListener('input', updateTradeValue);
}
// Trade target (exchange) changes affect testnet visibility
// Trade target (exchange) changes affect testnet visibility, exchange row, and SELL availability
if (this.targetSelect) {
this.targetSelect.addEventListener('change', () => {
this._updateTestnetVisibility();
this._updateExchangeRowVisibility();
this._updateSellAvailability();
this._updateSltpVisibility();
});
}
@ -134,6 +153,27 @@ class TradeUIManager {
await this._populateSymbolDropdown(selectedExchange, null);
});
}
// Symbol changes affect SELL availability
if (this.symbolInput) {
this.symbolInput.addEventListener('change', () => {
this._updateSellAvailability();
});
}
// Testnet checkbox changes affect broker key, thus SELL availability
if (this.testnetCheckbox) {
this.testnetCheckbox.addEventListener('change', () => {
this._updateSellAvailability();
});
}
// Side changes affect SL/TP visibility (not applicable for SELL/close)
if (this.sideSelect) {
this.sideSelect.addEventListener('change', () => {
this._updateSltpVisibility();
});
}
}
/**
@ -274,6 +314,47 @@ class TradeUIManager {
}
}
/**
* Updates exchange row visibility based on trade mode.
* Paper trades use a single synthetic market, so exchange selection is irrelevant.
*/
_updateExchangeRowVisibility() {
if (!this.exchangeRow || !this.targetSelect) return;
const selectedTarget = this.targetSelect.value;
const isPaperTrade = selectedTarget === 'test_exchange';
if (isPaperTrade) {
// Hide exchange row for paper trading (uses single synthetic market)
this.exchangeRow.style.display = 'none';
} else {
// Show exchange row for live exchanges
this.exchangeRow.style.display = 'contents';
}
}
/**
* Updates SL/TP row visibility based on supported mode and side.
* Manual SL/TP is currently supported for paper BUY orders only.
*/
_updateSltpVisibility() {
if (!this.sltpRow || !this.sideSelect || !this.targetSelect) return;
const side = this.sideSelect.value.toLowerCase();
const isPaperTrade = this.targetSelect.value === 'test_exchange';
if (!isPaperTrade || side === 'sell') {
// Hide SL/TP when unsupported or not applicable.
this.sltpRow.style.display = 'none';
// Clear values to avoid submitting stale unsupported inputs.
if (this.stopLossInput) this.stopLossInput.value = '';
if (this.takeProfitInput) this.takeProfitInput.value = '';
} else {
// Show SL/TP for paper BUY orders.
this.sltpRow.style.display = 'contents';
}
}
/**
* Populates the exchange selector with connected exchanges.
* @param {string[]} connectedExchanges - List of connected exchange names.
@ -358,6 +439,9 @@ class TradeUIManager {
// Reset form values
if (this.qtyInput) this.qtyInput.value = '';
if (this.tradeValueDisplay) this.tradeValueDisplay.value = '0';
if (this.stopLossInput) this.stopLossInput.value = '';
if (this.takeProfitInput) this.takeProfitInput.value = '';
if (this.timeInForceSelect) this.timeInForceSelect.value = 'GTC';
// Set current price if available
if (currentPrice !== null) {
@ -377,18 +461,30 @@ class TradeUIManager {
const exchangeToUse = chartExchange || 'binance';
await this._populateSymbolDropdown(exchangeToUse, symbol);
// Reset to paper trade and hide testnet row
// Reset to paper trade and hide testnet/exchange rows
if (this.targetSelect) {
this.targetSelect.value = 'test_exchange';
}
if (this.testnetRow) {
this.testnetRow.style.display = 'none';
}
if (this.exchangeRow) {
// Hide exchange row for paper trading (uses single synthetic market)
this.exchangeRow.style.display = 'none';
}
if (this.testnetCheckbox) {
this.testnetCheckbox.checked = true;
}
// Reset side to BUY
if (this.sideSelect) {
this.sideSelect.value = 'buy';
}
this.formElement.style.display = 'grid';
// Update SELL availability based on current broker/symbol
await this._updateSellAvailability();
this._updateSltpVisibility();
}
/**
@ -640,6 +736,554 @@ class TradeUIManager {
registerCloseTradeCallback(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 ============
/**
* Initialize broker event listeners through Comms.
* @param {Comms} comms - The communications instance.
*/
initBrokerListeners(comms) {
if (!comms) return;
// Listen for order fill events via existing message/reply pattern
comms.on('order_filled', (data) => {
console.log('Order filled:', data);
this.refreshAll();
});
comms.on('order_cancelled', (data) => {
console.log('Order cancelled:', data);
this.refreshAll();
});
comms.on('position_closed', (data) => {
console.log('Position closed:', data);
if (this.onPositionClosed) {
this.onPositionClosed(data);
} else {
this.refreshAll();
}
});
comms.on('sltp_triggered', (data) => {
console.log('SL/TP triggered:', data);
const triggerName = data.trigger === 'stop_loss' ? 'Stop Loss' : 'Take Profit';
const pnl = data.pnl != null ? data.pnl.toFixed(2) : 'N/A';
alert(`${triggerName} triggered for ${data.symbol}\nPrice: ${data.trigger_price}\nP/L: ${pnl}`);
this.refreshAll();
});
}
// ============ Open Orders Section ============
/**
* Render open orders section.
* @param {Object[]} orders - List of open order dicts.
*/
renderOrders(orders) {
const container = document.getElementById('openOrdersContainer');
if (!container) return;
container.innerHTML = '';
if (!orders || orders.length === 0) {
container.innerHTML = '<p class="no-data-msg">No open orders</p>';
return;
}
const table = document.createElement('table');
table.className = 'orders-table';
table.innerHTML = `
<tr>
<th>Symbol</th>
<th>Side</th>
<th>Size</th>
<th>Price</th>
<th>Broker</th>
<th>Action</th>
</tr>
`;
for (const order of orders) {
const row = document.createElement('tr');
const sideClass = (order.side || '').toLowerCase() === 'buy' ? 'order-buy' : 'order-sell';
row.className = `order-row ${sideClass}`;
row.innerHTML = `
<td>${order.symbol || 'N/A'}</td>
<td>${(order.side || '').toUpperCase()}</td>
<td>${this._formatNumber(order.size)}</td>
<td>${order.price ? this._formatPrice(order.price) : 'MARKET'}</td>
<td>${order.broker_key || 'paper'}</td>
<td>
<button class="btn-cancel" onclick="UI.trade.cancelOrder('${order.order_id}', '${order.broker_key || 'paper'}')">
Cancel
</button>
</td>
`;
table.appendChild(row);
}
container.appendChild(table);
}
// ============ Positions Section ============
/**
* Render positions section.
* @param {Object[]} positions - List of position dicts.
*/
renderPositions(positions) {
const container = document.getElementById('positionsContainer');
if (!container) return;
container.innerHTML = '';
// 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>';
return;
}
for (const pos of openPositions) {
const card = this._createPositionCard(pos);
container.appendChild(card);
}
}
_createPositionCard(position) {
const card = document.createElement('div');
card.className = 'position-card';
const pl = position.unrealized_pnl || 0;
const plClass = pl >= 0 ? 'positive' : 'negative';
const plSign = pl >= 0 ? '+' : '';
// 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 = `
<div class="position-header">
<span class="position-symbol">${position.symbol || 'N/A'}</span>
<span class="position-broker">${position.broker_key || 'paper'}</span>
</div>
<div class="position-details">
<div class="position-row">
<span>Size:</span>
<span>${this._formatNumber(position.size)}</span>
</div>
<div class="position-row">
<span>Entry:</span>
<span>${position.entry_price ? this._formatPrice(position.entry_price) : '-'}</span>
</div>
<div class="position-row">
<span>P/L:</span>
<span class="position-pl ${plClass}">${plSign}${pl.toFixed(2)}</span>
</div>
</div>
<div class="position-actions">
<button class="btn-close-position" onclick="UI.trade.closePosition('${position.symbol}', '${position.broker_key || 'paper'}')">
Close Position
</button>
<button class="btn-cancel-orders" onclick="UI.trade.cancelOrdersForSymbol('${position.symbol}', '${position.broker_key || 'paper'}')" title="Cancel resting orders">
Cancel
</button>
</div>
`;
return card;
}
// ============ History Section ============
/**
* Render trade history section.
* @param {Object[]} history - List of trade history dicts.
*/
renderHistory(history) {
const container = document.getElementById('historyContainer');
if (!container) return;
container.innerHTML = '';
if (!history || history.length === 0) {
container.innerHTML = '<p class="no-data-msg">No trade history</p>';
return;
}
// Use history-specific card (no close button)
for (const trade of history) {
try {
const card = this._createHistoryCard(trade);
container.appendChild(card);
} catch (error) {
console.error('Error rendering history trade:', error, trade);
}
}
}
/**
* Create a history card for settled/cancelled trades.
* Unlike active trade cards, history cards have no close button.
* @param {Object} trade - The trade data.
* @returns {HTMLElement} - The history card element.
*/
_createHistoryCard(trade) {
const card = document.createElement('div');
card.className = 'trade-card trade-history';
card.setAttribute('data-trade-id', trade.unique_id || trade.tbl_key);
// Add paper/live class
if (trade.is_paper) {
card.classList.add('trade-paper');
}
// Add side class
const side = (trade.side || 'BUY').toUpperCase();
card.classList.add(side === 'BUY' ? 'trade-buy' : 'trade-sell');
// Status badge (closed/cancelled)
const statusBadge = document.createElement('span');
statusBadge.className = 'trade-status-badge';
statusBadge.textContent = (trade.status || 'closed').toUpperCase();
statusBadge.style.cssText = `
position: absolute; top: 4px; right: 4px;
background: ${trade.status === 'cancelled' ? '#ff9800' : '#9e9e9e'};
color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px;
`;
card.appendChild(statusBadge);
// Paper badge
if (trade.is_paper) {
const paperBadge = document.createElement('span');
paperBadge.className = 'trade-paper-badge';
paperBadge.textContent = 'PAPER';
card.appendChild(paperBadge);
}
// Trade info
const info = document.createElement('div');
info.className = 'trade-info';
const symbolRow = document.createElement('div');
symbolRow.className = 'trade-symbol-row';
symbolRow.innerHTML = `
<span class="trade-side ${side.toLowerCase()}">${side}</span>
<span class="trade-symbol">${trade.symbol || 'N/A'}</span>
`;
info.appendChild(symbolRow);
// Stats
const stats = trade.stats || {};
const qty = stats.qty_filled || trade.base_order_qty || 0;
const settledPrice = stats.settled_price || stats.opening_price || trade.order_price || 0;
const profit = stats.profit || 0;
const profitClass = profit >= 0 ? 'positive' : 'negative';
const profitSign = profit >= 0 ? '+' : '';
info.innerHTML += `
<div class="trade-row">
<span class="trade-label">Qty:</span>
<span class="trade-value">${this._formatNumber(qty)}</span>
</div>
<div class="trade-row">
<span class="trade-label">Price:</span>
<span class="trade-value">${this._formatPrice(settledPrice)}</span>
</div>
<div class="trade-row">
<span class="trade-label">P/L:</span>
<span class="trade-pl ${profitClass}">${profitSign}${profit.toFixed(2)}</span>
</div>
`;
card.appendChild(info);
return card;
}
// ============ Refresh Methods ============
async refreshOrders() {
try {
const response = await fetch('/api/manual/orders');
const data = await response.json();
if (data.success) {
this.renderOrders(data.orders);
}
} catch (e) {
console.error('Failed to refresh orders:', e);
}
}
async refreshPositions() {
try {
const response = await fetch('/api/manual/positions');
const data = await response.json();
if (data.success) {
this.renderPositions(data.positions);
}
} catch (e) {
console.error('Failed to refresh positions:', e);
}
}
async refreshHistory() {
try {
const response = await fetch('/api/manual/history?limit=20');
const data = await response.json();
if (data.success) {
this.renderHistory(data.history);
}
} catch (e) {
console.error('Failed to refresh history:', e);
}
}
refreshAll() {
this.refreshOrders();
this.refreshPositions();
this.refreshHistory();
this.updateBrokerStatus();
// Call refresh callback to update trades and statistics
if (this.onRefresh) {
this.onRefresh();
}
}
// ============ Broker Actions ============
/**
* Cancel a specific open order via REST API.
*/
async cancelOrder(orderId, brokerKey) {
try {
const response = await fetch(`/api/manual/orders/${encodeURIComponent(orderId)}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ broker_key: brokerKey })
});
const result = await response.json();
if (result.success) {
this.refreshAll();
} else {
console.error('Cancel failed:', result.message);
alert('Failed to cancel order: ' + result.message);
}
} catch (error) {
console.error('Cancel order error:', error);
}
}
/**
* Close a position (filled exposure only) via REST API.
*/
async closePosition(symbol, brokerKey) {
try {
const response = await fetch(`/api/manual/positions/${encodeURIComponent(symbol)}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ broker_key: brokerKey })
});
const result = await response.json();
if (result.success) {
this.refreshAll();
} else {
console.error('Close position failed:', result.message);
alert('Failed to close position: ' + result.message);
}
} catch (error) {
console.error('Close position error:', error);
}
}
/**
* Cancel all resting orders for a symbol (explicit user action).
*/
async cancelOrdersForSymbol(symbol, brokerKey) {
try {
const response = await fetch(`/api/manual/orders/symbol/${encodeURIComponent(symbol)}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ broker_key: brokerKey })
});
const result = await response.json();
if (result.success) {
this.refreshAll();
} else {
console.error('Cancel orders failed:', result.message);
}
} catch (error) {
console.error('Cancel orders error:', error);
}
}
// ============ Broker Status ============
/**
* Get current broker key based on form selection.
*/
_getCurrentBrokerKey() {
if (!this.targetSelect) return 'paper';
const target = this.targetSelect.value;
if (target === 'test_exchange' || target === 'paper') return 'paper';
const testnet = this.testnetCheckbox?.checked ?? true;
return `${target}_${testnet ? 'testnet' : 'production'}`;
}
/**
* Update broker status bar display.
*/
async updateBrokerStatus() {
const brokerKey = this._getCurrentBrokerKey();
const chartExchange = this.data?.exchange || '';
try {
const params = new URLSearchParams({
broker_key: brokerKey,
exchange: chartExchange
});
const response = await fetch(`/api/manual/balance?${params.toString()}`);
const data = await response.json();
if (data.success) {
const balanceEl = document.getElementById('brokerBalance');
const modeEl = document.getElementById('brokerModeIndicator');
if (balanceEl) {
const balance = data.available ?? data.total ?? 0;
// Get currency preference from localStorage (default: USD for paper, USDT for live)
const defaultCurrency = brokerKey === 'paper' ? 'USD' : 'USDT';
const currency = localStorage.getItem('balanceCurrency') || defaultCurrency;
balanceEl.textContent = `Available: $${balance.toFixed(2)} ${currency}`;
balanceEl.style.cursor = 'pointer';
balanceEl.title = data.source === 'exchange'
? `Using ${chartExchange || 'exchange'} chart balance. Click to toggle USD/USDT`
: 'Click to toggle USD/USDT';
// Add click handler if not already added
if (!balanceEl.dataset.clickHandler) {
balanceEl.dataset.clickHandler = 'true';
balanceEl.addEventListener('click', () => {
const current = localStorage.getItem('balanceCurrency') || defaultCurrency;
const newCurrency = current === 'USD' ? 'USDT' : 'USD';
localStorage.setItem('balanceCurrency', newCurrency);
this.updateBrokerStatus();
});
}
}
if (modeEl) {
if (brokerKey === 'paper') {
modeEl.textContent = 'PAPER';
modeEl.className = 'mode-badge mode-paper';
} else if (brokerKey.includes('testnet')) {
modeEl.textContent = 'TESTNET';
modeEl.className = 'mode-badge mode-testnet';
} else {
modeEl.textContent = 'LIVE';
modeEl.className = 'mode-badge mode-live';
}
}
}
} catch (e) {
console.warn('Could not fetch balance:', e);
}
// 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 ============
/**
* Check if position exists for symbol and broker.
*/
async _checkPositionExists(symbol, brokerKey) {
try {
const response = await fetch('/api/manual/positions');
const data = await response.json();
if (data.success && data.positions) {
return data.positions.some(p =>
p.symbol === symbol &&
p.broker_key === brokerKey &&
(p.size || 0) > 0
);
}
} catch (e) {
console.warn('Could not check position:', e);
}
return false;
}
/**
* Update SELL option availability based on position.
* If SELL is currently selected but becomes invalid, reset to BUY.
*/
async _updateSellAvailability() {
if (!this.sideSelect || !this.symbolInput) return;
const symbol = this.symbolInput.value;
const brokerKey = this._getCurrentBrokerKey();
const hasPosition = await this._checkPositionExists(symbol, brokerKey);
const sellOption = this.sideSelect.querySelector('option[value="SELL"]');
if (sellOption) {
sellOption.disabled = !hasPosition;
sellOption.title = hasPosition ? '' : 'No position to sell. Buy first.';
// If SELL is currently selected but no longer valid, reset to BUY
if (!hasPosition && this.sideSelect.value === 'SELL') {
this.sideSelect.value = 'BUY';
this._updateSltpVisibility();
}
}
}
}
@ -805,6 +1449,13 @@ class Trade {
// Set up close callback
this.uiManager.registerCloseTradeCallback(this.closeTrade.bind(this));
// Set up refresh callback for trades and statistics
this.uiManager.registerRefreshCallback(() => {
this.fetchTrades();
this._updateStatistics();
});
this.uiManager.registerPositionClosedCallback(this.handlePositionClosed.bind(this));
// Bind methods
this.submitNewTrade = this.submitNewTrade.bind(this);
@ -849,9 +1500,13 @@ class Trade {
// Update trading pair display
this._updateTradingPairDisplay();
// Fetch existing trades
// Fetch existing trades (legacy path)
this.dataManager.fetchTrades(this.comms, this.data);
// Initialize broker event listeners and refresh broker UI
this.uiManager.initBrokerListeners(this.comms);
this.refreshAll();
this._initialized = true;
console.log("Trade module initialized successfully");
} catch (error) {
@ -859,6 +1514,55 @@ class Trade {
}
}
/**
* Refresh all broker-backed panels through the UI manager.
*/
refreshAll() {
this.uiManager.refreshAll();
// Also refresh trades and statistics
this.fetchTrades();
this._updateStatistics();
}
/**
* Delegate broker balance refresh to the UI manager.
*/
updateBrokerStatus() {
return this.uiManager.updateBrokerStatus();
}
/**
* Delegate order cancellation to the UI manager.
* Kept here because DOM actions call UI.trade.* methods.
*/
cancelOrder(orderId, brokerKey) {
return this.uiManager.cancelOrder(orderId, brokerKey);
}
/**
* Delegate position close to the UI manager.
* Kept here because DOM actions call UI.trade.* methods.
*/
closePosition(symbol, brokerKey) {
return this.uiManager.closePosition(symbol, brokerKey);
}
/**
* Delegate symbol-wide order cancellation to the UI manager.
* Kept here because DOM actions call UI.trade.* methods.
*/
cancelOrdersForSymbol(symbol, brokerKey) {
return this.uiManager.cancelOrdersForSymbol(symbol, brokerKey);
}
/**
* Delegate paper balance reset to the UI manager.
* Kept here because DOM actions call UI.trade.* methods.
*/
resetPaperBalance() {
return this.uiManager.resetPaperBalance();
}
/**
* Updates the trading pair display in the form.
* @private
@ -893,6 +1597,8 @@ class Trade {
this.dataManager.addTrade(data);
this.uiManager.updateTradesHtml(this.dataManager.getAllTrades());
this._updateStatistics();
// Also refresh the new broker UI panels (Orders, Positions, History)
this.refreshAll();
} else {
alert(`Failed to create trade: ${data.message}`);
}
@ -909,6 +1615,8 @@ class Trade {
this.dataManager.removeTrade(tradeId);
this.uiManager.removeTradeCard(tradeId);
this._updateStatistics();
// Also refresh the new broker UI panels
this.refreshAll();
// Show P/L notification
if (data.final_pl !== undefined) {
@ -920,6 +1628,23 @@ class Trade {
}
}
/**
* Handle position-closed event from the REST close-position flow.
* @param {Object} data - Position close payload with affected trade IDs.
*/
handlePositionClosed(data) {
console.log("Position closed event received:", data);
const closedTrades = Array.isArray(data?.closed_trades) ? data.closed_trades : [];
for (const tradeId of closedTrades) {
this.dataManager.removeTrade(tradeId);
}
this.uiManager.updateTradesHtml(this.dataManager.getAllTrades());
this._updateStatistics();
this.uiManager.refreshAll();
}
/**
* Handle trade error.
* @param {Object} data - Error data.
@ -960,15 +1685,57 @@ class Trade {
// Update the trade in data manager
this.dataManager.applyUpdates([data]);
// Update the UI
// Update legacy trade card UI
if (data.pl !== undefined && data.pl_pct !== undefined) {
this.uiManager.updateTradePL(data.id, data.pl, data.pl_pct);
}
// Also update position cards (for filled positions showing unrealized P/L)
// Match by symbol + broker_key to avoid cross-broker contamination
if (data.symbol && (data.pl !== undefined || data.current_price !== undefined)) {
this._updatePositionPL(data.symbol, data.broker_key, data.pl, data.current_price);
}
this._updateStatistics();
}
}
/**
* Update P/L display in position cards for a given symbol + broker.
* @param {string} symbol - The trading symbol.
* @param {string} brokerKey - The broker key ('paper' or 'exchange_mode').
* @param {number} pl - The unrealized P/L.
* @param {number} currentPrice - The current price.
*/
_updatePositionPL(symbol, brokerKey, pl, currentPrice) {
const container = document.getElementById('positionsContainer');
if (!container) return;
// Find position card matching this symbol AND broker_key
const cards = container.querySelectorAll('.position-card');
for (const card of cards) {
const symbolEl = card.querySelector('.position-symbol');
const brokerEl = card.querySelector('.position-broker');
// Match both symbol and broker_key to avoid cross-broker contamination
const symbolMatch = symbolEl && symbolEl.textContent === symbol;
const brokerMatch = !brokerKey || (brokerEl && brokerEl.textContent === brokerKey);
if (symbolMatch && brokerMatch) {
const plEl = card.querySelector('.position-pl');
if (plEl && pl !== undefined) {
const plClass = pl >= 0 ? 'positive' : 'negative';
const plSign = pl >= 0 ? '+' : '';
plEl.textContent = `${plSign}${pl.toFixed(2)}`;
plEl.className = `position-pl ${plClass}`;
// Flash animation
plEl.classList.add('trade-pl-flash');
setTimeout(() => plEl.classList.remove('trade-pl-flash'), 300);
}
}
}
}
// ================ Form Methods ================
/**
@ -1045,6 +1812,13 @@ class Trade {
const quantity = parseFloat(this.uiManager.qtyInput?.value || 0);
// Get SL/TP and TIF
const stopLossVal = this.uiManager.stopLossInput?.value;
const takeProfitVal = this.uiManager.takeProfitInput?.value;
const stopLoss = stopLossVal ? parseFloat(stopLossVal) : null;
const takeProfit = takeProfitVal ? parseFloat(takeProfitVal) : null;
const timeInForce = this.uiManager.timeInForceSelect?.value || 'GTC';
// Validation
if (!symbol) {
alert('Please enter a trading pair.');
@ -1059,6 +1833,21 @@ class Trade {
return;
}
// SL/TP validation (paper BUY only)
if (isPaperTrade && side.toUpperCase() === 'BUY') {
if (stopLoss && stopLoss >= price) {
alert('Stop Loss must be below entry price for BUY orders.');
return;
}
if (takeProfit && takeProfit <= price) {
alert('Take Profit must be above entry price for BUY orders.');
return;
}
} else if (!isPaperTrade && (stopLoss || takeProfit)) {
alert('Manual live Stop Loss / Take Profit is not supported yet.');
return;
}
// Show confirmation for production live trades
if (!isPaperTrade && !testnet) {
const proceed = confirm(
@ -1084,6 +1873,9 @@ class Trade {
orderType,
quantity,
testnet,
stopLoss,
takeProfit,
timeInForce,
user_name: this.data?.user_name
};

View File

@ -99,25 +99,25 @@
<tr>
<th>Symbol</th>
<th>Side</th>
<th>Quantity</th>
<th>Size</th>
<th>Price</th>
<th>Status</th>
</tr>
{% for symbol, orders in open_orders.items() %}
{% if orders %}
{% for order in orders %}
{% if open_orders %}
{% for order in open_orders %}
<tr>
<td>{{ symbol }}</td>
<td>{{ order[0] }}</td>
<td>{{ order[1] }}</td>
<td>{{ order[2] }}</td>
<td>{{ order.symbol }}</td>
<td>{{ order.side }}</td>
<td>{{ order.size }}</td>
<td>{{ order.price | default('-') }}</td>
<td>{{ order.status }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4">No active orders</td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr>
<td colspan="5">No open orders</td>
</tr>
{% endif %}
</table>
</div>

View File

@ -1,5 +1,5 @@
<!-- New Trade Form Popup -->
<div class="form-popup" id="new_trade_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 480px; border-radius: 10px;">
<div class="form-popup" id="new_trade_form" style="display: none; overflow: hidden; position: absolute; width: 400px; height: 580px; border-radius: 10px;">
<!-- Draggable Header Section -->
<div class="dialog-header" id="trade_draggable_header">
@ -31,11 +31,13 @@
</small>
</div>
<!-- Exchange Selection -->
<label for="tradeExchange"><b>Exchange:</b></label>
<select name="tradeExchange" id="tradeExchange">
<!-- Exchanges populated dynamically, defaults to chart view -->
</select>
<!-- Exchange Selection (for price data source - hidden for paper trades) -->
<div id="exchange-row" style="display: contents;">
<label for="tradeExchange"><b>Exchange:</b></label>
<select name="tradeExchange" id="tradeExchange">
<!-- Exchanges populated dynamically, defaults to chart view -->
</select>
</div>
<!-- Symbol Selection -->
<label for="tradeSymbol"><b>Symbol:</b></label>
@ -71,6 +73,25 @@
<!-- Value field -->
<label for="tradeValue"><b>Est. Value:</b></label>
<output name="tradeValue" id="tradeValue" for="quantity price">0</output>
<!-- Stop Loss / Take Profit (only for BUY orders - not applicable for closing positions) -->
<div id="sltp-row" style="display: contents;">
<label for="stopLoss"><b>Stop Loss:</b></label>
<input type="number" min="0" step="0.00000001" name="stopLoss" id="stopLoss"
placeholder="Optional - triggers auto-close" style="width: 100%;">
<label for="takeProfit"><b>Take Profit:</b></label>
<input type="number" min="0" step="0.00000001" name="takeProfit" id="takeProfit"
placeholder="Optional - triggers auto-close" style="width: 100%;">
</div>
<!-- Time in Force -->
<label for="timeInForce"><b>Time in Force:</b></label>
<select name="timeInForce" id="timeInForce" style="width: 100%;">
<option value="GTC">Good 'til Cancelled (GTC)</option>
<option value="IOC">Immediate or Cancel (IOC)</option>
<option value="FOK">Fill or Kill (FOK)</option>
</select>
</div>
<!-- Buttons -->

View File

@ -1,10 +1,39 @@
<div id="trade_content" class="content">
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Trade</button>
<hr>
<h3>Active Trades</h3>
<div id="tradesContainer" class="trades-container">
<p class="no-data-msg">No active trades</p>
<div class="broker-status-bar" id="brokerStatusBar">
<span id="brokerModeIndicator" class="mode-badge mode-paper">PAPER</span>
<span id="brokerBalance">Available: --</span>
<button id="resetPaperBalanceBtn" class="btn-reset-paper" onclick="UI.trade.resetPaperBalance()" title="Reset paper trading balance to $10,000">Reset</button>
</div>
<button class="btn" id="new_trade" onclick="UI.trade.open_tradeForm()">New Order</button>
<hr>
<!-- Open Orders Section -->
<section class="trade-section">
<h3>Open Orders</h3>
<div id="openOrdersContainer" class="orders-container">
<p class="no-data-msg">No open orders</p>
</div>
</section>
<!-- Positions Section -->
<section class="trade-section">
<h3>Positions</h3>
<div id="positionsContainer" class="positions-container">
<p class="no-data-msg">No open positions</p>
</div>
</section>
<!-- Trade History Section -->
<section class="trade-section">
<h3>Trade History</h3>
<div id="historyContainer" class="trades-container">
<p class="no-data-msg">No trade history</p>
</div>
</section>
<!-- Legacy container for backwards compatibility -->
<div id="tradesContainer" class="trades-container" style="display:none;"></div>
</div>
<style>
@ -208,4 +237,226 @@
font-size: 12px;
margin: 10px 0;
}
/* Broker status bar */
.broker-status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
background: #f5f5f5;
border-radius: 4px;
margin-bottom: 10px;
font-size: 12px;
}
.mode-badge {
font-weight: bold;
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
}
.mode-badge.mode-paper {
background: #9e9e9e;
color: white;
}
.mode-badge.mode-testnet {
background: #4caf50;
color: white;
}
.mode-badge.mode-live {
background: #f44336;
color: white;
}
#brokerBalance {
color: #333;
font-family: monospace;
}
.btn-reset-paper {
display: none; /* Hidden by default, shown only for paper mode */
background: #ff9800;
color: white;
border: none;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
margin-left: auto;
}
.btn-reset-paper:hover {
background: #f57c00;
}
.broker-status-bar.mode-paper .btn-reset-paper {
display: inline-block;
}
/* Trade sections */
.trade-section {
margin-bottom: 15px;
}
.trade-section h3 {
font-size: 13px;
margin: 8px 0 5px 0;
color: #555;
border-bottom: 1px solid #eee;
padding-bottom: 3px;
}
/* Orders container */
.orders-container {
min-height: 30px;
}
.orders-table {
width: 100%;
font-size: 11px;
border-collapse: collapse;
}
.orders-table th,
.orders-table td {
padding: 4px 6px;
text-align: left;
border-bottom: 1px solid #eee;
}
.orders-table th {
background: #f9f9f9;
font-weight: 600;
color: #666;
}
.order-row.order-buy {
border-left: 3px solid #4caf50;
}
.order-row.order-sell {
border-left: 3px solid #f44336;
}
.btn-cancel {
background: #ff5252;
color: white;
border: none;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
}
.btn-cancel:hover {
background: #ff1744;
}
/* Positions container */
.positions-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 30px;
}
.position-card {
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 8px 10px;
min-width: 150px;
font-size: 11px;
}
.position-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.position-symbol {
font-weight: bold;
color: #333;
}
.position-broker {
font-size: 9px;
color: #888;
background: #eee;
padding: 1px 4px;
border-radius: 2px;
}
.position-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.position-row {
display: flex;
justify-content: space-between;
}
.position-row span:first-child {
color: #666;
}
.position-row span:last-child {
font-family: monospace;
font-weight: 500;
}
.position-pl {
font-weight: bold;
}
.position-pl.positive {
color: #2e7d32;
}
.position-pl.negative {
color: #c62828;
}
.position-actions {
display: flex;
gap: 5px;
margin-top: 6px;
}
.btn-close-position {
background: #2196f3;
color: white;
border: none;
padding: 3px 8px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
flex: 1;
}
.btn-close-position:hover {
background: #1976d2;
}
.btn-cancel-orders {
background: #ff9800;
color: white;
border: none;
padding: 3px 8px;
border-radius: 3px;
font-size: 10px;
cursor: pointer;
}
.btn-cancel-orders:hover {
background: #f57c00;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,46 @@ class TestPaperBroker:
assert position is not None
assert position.size == 0.1
def test_paper_broker_ioc_limit_cancels_if_not_marketable(self):
"""IOC limit orders should fail immediately if not marketable."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.LIMIT,
size=0.1,
price=49000,
time_in_force='IOC'
)
assert not result.success
assert result.status == OrderStatus.CANCELLED
assert broker.get_open_orders() == []
assert broker.get_position('BTC/USDT') is None
def test_paper_broker_fok_limit_fills_immediately_if_marketable(self):
"""FOK limit orders should fill immediately when already marketable."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.LIMIT,
size=0.1,
price=51000,
time_in_force='FOK'
)
assert result.success
assert result.status == OrderStatus.FILLED
assert broker.get_open_orders() == []
position = broker.get_position('BTC/USDT')
assert position is not None
assert position.size == 0.1
def test_paper_broker_cancel_order(self):
"""Test order cancellation."""
broker = PaperBroker(initial_balance=10000, commission=0, slippage=0)
@ -196,6 +236,47 @@ class TestPaperBroker:
assert broker.get_available_balance() == 9900
assert broker.get_balance() == 10100
def test_paper_broker_pnl_includes_fees(self):
"""Test that P&L accurately reflects both entry and exit fees."""
broker = PaperBroker(initial_balance=10000, commission=0.001, slippage=0)
broker.update_price('BTC/USDT', 1000)
# Buy 1 unit at $1000, entry fee = $1
broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=1.0
)
# Immediately after buy, unrealized P&L should show -$1 (entry fee)
position = broker.get_position('BTC/USDT')
assert position is not None
assert position.entry_commission == 1.0 # 0.1% of $1000
assert position.unrealized_pnl == -1.0 # Entry fee already reflected
# Price hasn't moved, but we're down by entry fee
broker.update()
position = broker.get_position('BTC/USDT')
assert position.unrealized_pnl == -1.0
# Now sell at same price, exit fee = $1
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.SELL,
order_type=OrderType.MARKET,
size=1.0
)
# Realized P&L should be -$2 (entry + exit fee)
assert result.success
# The realized_pnl on the order reflects both fees
# (price movement 0) - entry_fee ($1) - exit_fee ($1) = -$2
# Cash balance should reflect the loss
# Started with $10000, bought for $1001, sold for $999 = $9998
assert broker.get_available_balance() == 9998.0
def test_paper_broker_reset(self):
"""Test broker reset."""
broker = PaperBroker(initial_balance=10000)
@ -351,3 +432,253 @@ class TestExecutionPriceFallbacks:
assert updates
assert trade.status == 'filled'
assert trade.stats['opening_price'] == pytest.approx(321.0)
class TestPaperBrokerSLTP:
"""Tests for Stop Loss / Take Profit functionality in PaperBroker."""
def test_sl_triggers_on_price_drop(self):
"""Test that stop loss triggers when price drops below threshold."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
# Buy with SL at 45000
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.1,
stop_loss=45000
)
assert result.success
# Position exists
position = broker.get_position('BTC/USDT')
assert position is not None
assert position.size == 0.1
# SL is tracked
assert 'BTC/USDT' in broker._position_sltp
assert broker._position_sltp['BTC/USDT']['stop_loss'] == 45000
# Price drops below SL
broker.update_price('BTC/USDT', 44000)
events = broker.update()
# SL should have triggered
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
assert len(sltp_events) == 1
assert sltp_events[0]['trigger'] == 'stop_loss'
assert sltp_events[0]['symbol'] == 'BTC/USDT'
# Position should be closed
position = broker.get_position('BTC/USDT')
assert position is None
# SL tracking cleared
assert 'BTC/USDT' not in broker._position_sltp
def test_tp_triggers_on_price_rise(self):
"""Test that take profit triggers when price rises above threshold."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
# Buy with TP at 55000
result = broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.1,
take_profit=55000
)
assert result.success
# TP is tracked
assert 'BTC/USDT' in broker._position_sltp
assert broker._position_sltp['BTC/USDT']['take_profit'] == 55000
# Price rises above TP
broker.update_price('BTC/USDT', 56000)
events = broker.update()
# TP should have triggered
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
assert len(sltp_events) == 1
assert sltp_events[0]['trigger'] == 'take_profit'
# Position should be closed
position = broker.get_position('BTC/USDT')
assert position is None
def test_sltp_cleared_on_manual_close(self):
"""Test that SL/TP tracking is cleared when position is manually closed."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
# Buy with SL and TP
broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.1,
stop_loss=45000,
take_profit=55000
)
assert 'BTC/USDT' in broker._position_sltp
# Manually close position
broker.close_position('BTC/USDT')
# SL/TP tracking should be cleared
assert 'BTC/USDT' not in broker._position_sltp
def test_sltp_persists_across_state_save_load(self):
"""Test that SL/TP tracking persists across state save/load."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
# Buy with SL and TP
broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.1,
stop_loss=45000,
take_profit=55000
)
# Save state
state = broker.to_state_dict()
assert 'position_sltp' in state
assert 'BTC/USDT' in state['position_sltp']
# Create new broker and restore state
broker2 = PaperBroker(initial_balance=10000)
broker2.from_state_dict(state)
# SL/TP should be restored
assert 'BTC/USDT' in broker2._position_sltp
assert broker2._position_sltp['BTC/USDT']['stop_loss'] == 45000
assert broker2._position_sltp['BTC/USDT']['take_profit'] == 55000
def test_no_sltp_trigger_when_price_within_range(self):
"""Test that no SL/TP triggers when price stays within range."""
broker = PaperBroker(initial_balance=10000, commission=0.001)
broker.update_price('BTC/USDT', 50000)
# Buy with SL at 45000 and TP at 55000
broker.place_order(
symbol='BTC/USDT',
side=OrderSide.BUY,
order_type=OrderType.MARKET,
size=0.1,
stop_loss=45000,
take_profit=55000
)
# Price moves but stays within range
broker.update_price('BTC/USDT', 48000)
events = broker.update()
# No SL/TP triggers
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
assert len(sltp_events) == 0
# Position still exists
position = broker.get_position('BTC/USDT')
assert position is not None
assert position.size == 0.1
class TestManualTradingSLTP:
"""Integration tests for SL/TP in manual trading path (Trades.new_trade -> broker)."""
@pytest.fixture
def mock_users(self):
"""Create a mock Users object."""
from unittest.mock import MagicMock
users = MagicMock()
users.get_username.return_value = 'test_user'
return users
def test_new_trade_passes_sltp_to_broker(self, mock_users):
"""Test that new_trade() passes SL/TP to broker.place_order()."""
from manual_trading_broker import ManualTradingBrokerManager
trades = Trades(mock_users)
# Set up manual broker manager
broker_manager = ManualTradingBrokerManager()
trades.manual_broker_manager = broker_manager
# Get the paper broker and set price
broker = broker_manager.get_paper_broker(user_id=1)
broker.update_price('BTC/USDT', 50000)
# Create trade with SL/TP
status, trade_id = trades.new_trade(
target='test_exchange',
symbol='BTC/USDT',
price=50000.0,
side='buy',
order_type='MARKET',
qty=0.1,
user_id=1,
stop_loss=45000.0,
take_profit=60000.0
)
assert status == 'Success'
# Verify trade has SL/TP
trade = trades.get_trade_by_id(trade_id)
assert trade.stop_loss == 45000.0
assert trade.take_profit == 60000.0
# Verify broker has SL/TP tracking
assert 'BTC/USDT' in broker._position_sltp
assert broker._position_sltp['BTC/USDT']['stop_loss'] == 45000.0
assert broker._position_sltp['BTC/USDT']['take_profit'] == 60000.0
def test_new_trade_sltp_triggers_on_price_drop(self, mock_users):
"""Test that SL/TP triggers work through the full manual trading path."""
from manual_trading_broker import ManualTradingBrokerManager
trades = Trades(mock_users)
# Set up manual broker manager
broker_manager = ManualTradingBrokerManager()
trades.manual_broker_manager = broker_manager
# Get the paper broker and set price
broker = broker_manager.get_paper_broker(user_id=1)
broker.update_price('BTC/USDT', 50000)
# Create trade with SL
status, trade_id = trades.new_trade(
target='test_exchange',
symbol='BTC/USDT',
price=50000.0,
side='buy',
order_type='MARKET',
qty=0.1,
user_id=1,
stop_loss=45000.0
)
assert status == 'Success'
assert trade_id in trades.active_trades
# Price drops below SL
broker.update_price('BTC/USDT', 44000)
events = broker.update()
# Verify SL triggered
sltp_events = [e for e in events if e.get('type') == 'sltp_triggered']
assert len(sltp_events) == 1
assert sltp_events[0]['trigger'] == 'stop_loss'
# Position should be closed at broker level
position = broker.get_position('BTC/USDT')
assert position is None

View File

@ -63,6 +63,39 @@ class TestTrade:
assert json_data['creator'] == 1
assert 'stats' in json_data
def test_trade_with_stop_loss_take_profit(self):
"""Test trade with SL/TP fields."""
trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
stop_loss=45000.0,
take_profit=60000.0
)
assert trade.stop_loss == 45000.0
assert trade.take_profit == 60000.0
# Verify serialization
json_data = trade.to_json()
assert json_data['stop_loss'] == 45000.0
assert json_data['take_profit'] == 60000.0
def test_trade_sltp_defaults_to_none(self):
"""Test that SL/TP default to None when not provided."""
trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1
)
assert trade.stop_loss is None
assert trade.take_profit is None
def test_trade_update_values(self):
"""Test P/L calculation."""
trade = Trade(
@ -133,6 +166,42 @@ class TestTrade:
assert trade.status == 'part-filled'
assert trade.stats['qty_filled'] == 0.05
def test_trade_first_fill_from_open_order_uses_fill_price(self):
"""First broker fill should not average against the original unfilled order notional."""
trade = Trade(
target='kucoin',
symbol='BTC/USDT',
side='BUY',
order_price=69394.3,
base_order_qty=0.0001
)
trade.status = 'open'
trade.trade_filled(qty=5.28e-06, price=69340.8)
assert trade.status == 'part-filled'
assert trade.stats['qty_filled'] == pytest.approx(5.28e-06)
assert trade.stats['opening_price'] == pytest.approx(69340.8)
assert trade.stats['opening_value'] == pytest.approx(5.28e-06 * 69340.8)
def test_trade_update_values_uses_filled_quantity_for_pl(self):
"""Unrealized P/L should be based on filled exposure, not the original order size."""
trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
fee=0.001
)
trade.trade_filled(qty=0.05, price=50000.0)
trade.update_values(55000.0)
assert trade.stats['current_value'] == pytest.approx(2750.0)
assert trade.stats['profit'] == pytest.approx(244.75)
assert trade.stats['profit_pct'] == pytest.approx(9.79)
def test_trade_settle(self):
"""Test trade settlement."""
trade = Trade(
@ -148,6 +217,8 @@ class TestTrade:
assert trade.status == 'closed'
assert trade.stats['settled_price'] == 55000.0
assert trade.stats['profit'] == pytest.approx(489.5)
assert trade.stats['profit_pct'] == pytest.approx(9.79)
class TestTrades:
@ -217,10 +288,51 @@ class TestTrades:
assert trade.status == 'filled'
assert trade.creator == 1
def test_new_paper_trade_persists_time_in_force(self, mock_users):
"""Manual trades should keep the selected time-in-force on the Trade object."""
trades = Trades(mock_users)
status, trade_id = trades.new_trade(
target='test_exchange',
symbol='BTC/USDT',
price=50000.0,
side='buy',
order_type='LIMIT',
qty=0.1,
user_id=1,
time_in_force='IOC'
)
assert status == 'Success'
trade = trades.get_trade_by_id(trade_id)
assert trade.time_in_force == 'IOC'
def test_new_live_trade_no_exchange(self, mock_users):
"""Test creating a live trade without exchange connected."""
trades = Trades(mock_users)
# Use testnet=True to bypass production safety gate and test exchange check
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
)
assert status == 'Error'
assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower()
def test_new_live_trade_rejects_manual_sltp(self, mock_users):
"""Manual live SL/TP should fail fast until exchange-native support exists."""
trades = Trades(mock_users)
mock_exchange_interface = MagicMock()
mock_exchange_interface.get_exchange.return_value = MagicMock(configured=True)
trades.connect_exchanges(mock_exchange_interface)
status, msg = trades.new_trade(
target='binance',
symbol='BTC/USDT',
@ -228,11 +340,41 @@ class TestTrades:
side='buy',
order_type='MARKET',
qty=0.1,
user_id=1
user_id=1,
testnet=True,
stop_loss=45000.0
)
assert status == 'Error'
assert 'No exchange' in msg.lower() or 'no exchange' in msg.lower()
assert 'not supported yet' in msg.lower()
def test_new_production_trade_blocked_without_env_var(self, mock_users):
"""Test that production trades are blocked without ALLOW_LIVE_PRODUCTION."""
import config
original_value = getattr(config, 'ALLOW_LIVE_PRODUCTION', False)
try:
# Ensure production is NOT allowed
config.ALLOW_LIVE_PRODUCTION = False
trades = Trades(mock_users)
status, msg = trades.new_trade(
target='binance',
symbol='BTC/USDT',
price=50000.0,
side='buy',
order_type='MARKET',
qty=0.1,
user_id=1,
testnet=False # Production mode
)
assert status == 'Error'
assert 'production trading is disabled' in msg.lower()
finally:
# Restore original value
config.ALLOW_LIVE_PRODUCTION = original_value
def test_get_trades_json(self, mock_users):
"""Test getting trades in JSON format."""
@ -305,6 +447,31 @@ class TestTrades:
assert trade_id not in trades.active_trades
assert trade_id in trades.settled_trades
def test_close_trade_recomputes_final_pl_from_close_price(self, mock_users):
"""Closing should use the settlement price, not the last cached unrealized P/L."""
trades = Trades(mock_users)
status, trade_id = trades.new_trade(
target='test_exchange',
symbol='BTC/USDT',
price=50000.0,
side='buy',
order_type='MARKET',
qty=0.1
)
assert status == 'Success'
trade = trades.get_trade_by_id(trade_id)
trade.fee = 0.001
trade.update_values(45000.0)
assert trade.stats['profit'] < 0
result = trades.close_trade(trade_id, current_price=55000.0)
assert result['success'] is True
assert result['final_pl'] == pytest.approx(489.5)
assert result['final_pl_pct'] == pytest.approx(9.79)
def test_close_nonexistent_trade(self, mock_users):
"""Test closing a trade that doesn't exist."""
trades = Trades(mock_users)
@ -314,6 +481,136 @@ class TestTrades:
assert result['success'] is False
assert 'not found' in result['message']
def test_settle_broker_closed_position_filters_by_user_and_status(self, mock_users):
"""Broker-side closes should only settle filled trades for the matching user/broker."""
trades = Trades(mock_users)
filled_trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
is_paper=True,
creator=1,
broker_kind='paper',
broker_mode='paper'
)
filled_trade.trade_filled(qty=0.1, price=50000.0)
other_user_trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
is_paper=True,
creator=2,
broker_kind='paper',
broker_mode='paper'
)
other_user_trade.trade_filled(qty=0.1, price=50000.0)
open_trade = Trade(
target='test_exchange',
symbol='BTC/USDT',
side='BUY',
order_price=49000.0,
base_order_qty=0.1,
is_paper=True,
creator=1,
broker_kind='paper',
broker_mode='paper'
)
open_trade.status = 'open'
trades.active_trades[filled_trade.unique_id] = filled_trade
trades.active_trades[other_user_trade.unique_id] = other_user_trade
trades.active_trades[open_trade.unique_id] = open_trade
trades.stats['num_trades'] = 3
settled_ids = trades.settle_broker_closed_position(
user_id=1,
symbol='BTC/USDT',
broker_key='paper',
close_price=44000.0
)
assert settled_ids == [filled_trade.unique_id]
assert filled_trade.unique_id not in trades.active_trades
assert filled_trade.unique_id in trades.settled_trades
assert trades.settled_trades[filled_trade.unique_id].status == 'closed'
assert other_user_trade.unique_id in trades.active_trades
assert open_trade.unique_id in trades.active_trades
def test_close_position_returns_closed_trade_ids(self, mock_users):
"""Close-position API flow should report which trade IDs were removed locally."""
trades = Trades(mock_users)
trades.manual_broker_manager = MagicMock()
trades.manual_broker_manager.close_position.return_value = {
'success': True,
'status': 'filled',
'filled_price': 51000.0
}
trade = Trade(
target='kucoin',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
creator=1,
broker_kind='live',
broker_mode='production',
broker_exchange='kucoin'
)
trade.trade_filled(qty=0.1, price=50000.0)
trades.active_trades[trade.unique_id] = trade
trades.stats['num_trades'] = 1
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
assert result['success'] is True
assert result['trades_closed'] == 1
assert result['closed_trades'] == [trade.unique_id]
assert trade.unique_id not in trades.active_trades
assert trade.unique_id in trades.settled_trades
def test_close_position_leaves_trade_active_when_close_order_is_still_open(self, mock_users):
"""Live close-position should not settle/remove the local trade until the close order fills."""
trades = Trades(mock_users)
trades.manual_broker_manager = MagicMock()
trades.manual_broker_manager.close_position.return_value = {
'success': True,
'status': 'open',
'order_id': 'close123',
'filled_price': 0.0,
'message': 'Close order submitted'
}
trade = Trade(
target='kucoin',
symbol='BTC/USDT',
side='BUY',
order_price=50000.0,
base_order_qty=0.1,
creator=1,
broker_kind='live',
broker_mode='production',
broker_exchange='kucoin'
)
trade.trade_filled(qty=0.1, price=50000.0)
trades.active_trades[trade.unique_id] = trade
trades.stats['num_trades'] = 1
result = trades.close_position(1, 'BTC/USDT', 'kucoin_production')
assert result['success'] is True
assert result['trades_closed'] == 0
assert result['closed_trades'] == []
assert trade.unique_id in trades.active_trades
assert trade.unique_id not in trades.settled_trades
def test_is_valid_trade_id(self, mock_users):
"""Test trade ID validation."""
trades = Trades(mock_users)

View File

@ -68,10 +68,13 @@ def test_update_values():
assert position_size == 10
pl = trade_obj.get_pl()
print(f'PL reported: {pl}')
assert pl == 0
# With 0.1% fee (0.001): gross P/L 0, fees = (10*0.001) + (10*0.001) = 0.02
# Net PL: -0.02
assert pl == pytest.approx(-0.02)
pl_pct = trade_obj.get_pl_pct()
print(f'PL% reported: {pl_pct}')
assert pl_pct == 0
# Should be -0.02/10 * 100 = -0.2%
assert pl_pct == pytest.approx(-0.2)
# Divide the price of the quote symbol by 2.
current_price = 50