From f60372808037fb13f966fd40ff7514705fcd46a5 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 2 Mar 2026 16:05:53 -0400 Subject: [PATCH] Fix paper trade P/L updates and fee calculations - Fix default fee from 0.1 (10%) to 0.001 (0.1%) in Trade class - Add real-time trade updates via WebSocket trade_update events - Fetch current market price for market orders instead of using cached price - Add trade persistence to database with proper schema - Add execution loop to update trades even without active strategies - Add frontend handler for trade_update events in communication.js - Add handleTradeUpdate method in trade.js for live P/L updates - Add debug file logging for trade update debugging - Update statistics dashboard and trading HUD templates Co-Authored-By: Claude Opus 4.5 --- src/BrighterTrades.py | 144 ++-- src/app.py | 45 ++ src/static/communication.js | 9 +- src/static/trade.js | 1030 +++++++++++++++++++++++----- src/templates/new_trade_popup.html | 69 +- src/templates/statistics_hud.html | 71 ++ src/templates/trading_hud.html | 209 +++++- src/trade.py | 425 +++++++++++- tests/test_trade.py | 686 +++++++++++------- 9 files changed, 2160 insertions(+), 528 deletions(-) diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index e5d3bcd..6360c12 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -44,7 +44,7 @@ class BrighterTrades: self.indicators = Indicators(self.candles, self.users, self.data) # Object that maintains the trades data - self.trades = Trades(self.users) + self.trades = Trades(self.users, data_cache=self.data) # The Trades object needs to connect to an exchange_interface. self.trades.connect_exchanges(exchanges=self.exchanges) @@ -349,6 +349,12 @@ class BrighterTrades: price_updates = {symbol: float(cdata['close'])} trade_updates = self.trades.update(price_updates) + # Debug: log trade updates + if self.trades.active_trades: + logger.debug(f"Active trades: {list(self.trades.active_trades.keys())}") + logger.debug(f"Price updates for symbol '{symbol}': {price_updates}") + logger.debug(f"Trade updates returned: {trade_updates}") + # Update all active strategy instances with new candle data stg_updates = self.strategies.update(candle_data=cdata) @@ -1088,69 +1094,105 @@ class BrighterTrades: return result - def close_trade(self, trade_id): + def close_trade(self, trade_id: str, current_price: float = None) -> dict: """ Closes a trade identified by the given trade ID. :param trade_id: The ID of the trade to be closed. + :param current_price: Optional current price for settlement. + :return: Dict with success status and trade info. """ - if self.trades.is_valid_trade_id(trade_id): - pass - # self.trades.close_trade(trade_id)TODO - # self.config.remove('trades', trade_id) - print(f"Trade {trade_id} has been closed.") - else: - print(f"Invalid trade ID: {trade_id}. Unable to close the trade.") + if not self.trades.is_valid_trade_id(trade_id): + logger.warning(f"Invalid trade ID: {trade_id}. Unable to close the trade.") + return {"success": False, "message": f"Invalid trade ID: {trade_id}"} - def received_new_trade(self, data: dict) -> dict | None: + result = self.trades.close_trade(trade_id, current_price=current_price) + if result.get('success'): + logger.info(f"Trade {trade_id} has been closed.") + else: + logger.warning(f"Failed to close trade {trade_id}: {result.get('message')}") + + return result + + def received_new_trade(self, data: dict, user_id: int = None) -> dict: """ Called when a new trade has been defined and created in the UI. :param data: A dictionary containing the attributes of the trade. - :return: The details of the trade as a dictionary, or None on failure. + :param user_id: The ID of the user creating the trade. + :return: Dict with success status and trade data. """ - def vld(attr): + def get_value(attr, default=None): """ - Casts numeric strings to float before returning the attribute. - Returns None if the attribute is absent in the data. + Gets a value from data, casting numeric strings to float where appropriate. """ - if attr in data and data[attr] != '': + val = data.get(attr, default) + if val is None or val == '': + return default + # Try to cast to float for numeric fields + if attr in ['price', 'quantity']: try: - return float(data[attr]) - except ValueError: - return data[attr] - else: - return None + return float(val) + except (ValueError, TypeError): + return val + return val + + # Get trade parameters + target = get_value('target') or get_value('exchange_name', 'test_exchange') + symbol = get_value('symbol') or get_value('trading_pair') + price = get_value('price', 0.0) + side = get_value('side', 'buy') + order_type = get_value('orderType') or get_value('order_type', 'MARKET') + quantity = get_value('quantity', 0.0) + strategy_id = get_value('strategy_id') + + # Validate required fields + if not symbol: + return {"success": False, "message": "Symbol is required."} + if not quantity or float(quantity) <= 0: + return {"success": False, "message": "Quantity must be greater than 0."} + + # Forward the request to trades + status, result = self.trades.new_trade( + target=target, + symbol=symbol, + price=price, + side=side, + order_type=order_type, + qty=quantity, + user_id=user_id, + strategy_id=strategy_id + ) - # Forward the request to trades. - status, result = self.trades.new_trade(target=vld('exchange_name'), symbol=vld('symbol'), price=vld('price'), - side=vld('side'), order_type=vld('orderType'), - qty=vld('quantity')) if status == 'Error': - print(f'Error placing the trade: {result}') - return None + logger.warning(f'Error placing the trade: {result}') + return {"success": False, "message": result} - print(f'Trade order received: exchange_name={vld("exchange_name")}, ' - f'symbol={vld("symbol")}, ' - f'side={vld("side")}, ' - f'type={vld("orderType")}, ' - f'quantity={vld("quantity")}, ' - f'price={vld("price")}') - - # Update config's list of trades and save to file.TODO - # self.config.update_data('trades', self.trades.get_trades('dict')) + logger.info(f'Trade order received: target={target}, symbol={symbol}, ' + f'side={side}, type={order_type}, quantity={quantity}, price={price}') + # Get the created trade trade_obj = self.trades.get_trade_by_id(result) if trade_obj: - # Return the trade object that was created in a form that can be converted to json. - return trade_obj.__dict__ + return { + "success": True, + "message": "Trade created successfully.", + "trade": trade_obj.to_json() + } else: - return None + return {"success": False, "message": "Trade created but could not be retrieved."} - def get_trades(self): - """ Return a JSON object of all the trades in the trades instance.""" - return self.trades.get_trades('dict') + def get_trades(self, user_id: int = None): + """ + Return a JSON object of all the trades in the trades instance. + + :param user_id: Optional user ID to filter trades. + :return: List of trade dictionaries. + """ + if user_id is not None: + return self.trades.get_trades_for_user(user_id, 'json') + return self.trades.get_trades('json') def delete_backtest(self, msg_data): """ Delete an existing backtest by interacting with the Backtester. """ @@ -1296,8 +1338,8 @@ class BrighterTrades: return standard_reply("strategies", strategies) elif request_for == 'trades': - if trades := self.get_trades(): - return standard_reply("trades", trades) + trades = self.get_trades(user_id) + return standard_reply("trades", trades if trades else []) else: print('Warning: Unhandled request!') print(msg_data) @@ -1331,7 +1373,14 @@ class BrighterTrades: }) if msg_type == 'close_trade': - self.close_trade(msg_data) + 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) + else: + return standard_reply("trade_error", result) if msg_type == 'new_signal': result = self.received_new_signal(msg_data, user_id) @@ -1365,8 +1414,11 @@ class BrighterTrades: return standard_reply("strategy_error", {"message": "Failed to edit strategy."}) if msg_type == 'new_trade': - if r_data := self.received_new_trade(msg_data): - return standard_reply("trade_created", r_data) + result = self.received_new_trade(msg_data, user_id=user_id) + if result.get('success'): + return standard_reply("trade_created", result) + else: + return standard_reply("trade_error", result) if msg_type == 'config_exchange': user = msg_data.get('user') or user_name diff --git a/src/app.py b/src/app.py index 35fd9d8..c1bef3f 100644 --- a/src/app.py +++ b/src/app.py @@ -23,6 +23,13 @@ from utils import sanitize_for_json # noqa: E402 # Set up logging log_level_name = os.getenv('BRIGHTER_LOG_LEVEL', 'INFO').upper() log_level = getattr(logging, log_level_name, logging.INFO) + +# Debug file logger for execution loop +_loop_debug = logging.getLogger('loop_debug') +_loop_debug.setLevel(logging.DEBUG) +_loop_handler = logging.FileHandler('/home/rob/PycharmProjects/BrighterTrading/trade_debug.log', mode='a') +_loop_handler.setFormatter(logging.Formatter('%(asctime)s - [LOOP] %(message)s')) +_loop_debug.addHandler(_loop_handler) logging.basicConfig(level=log_level) logging.getLogger('ccxt.base.exchange').setLevel(logging.WARNING) logging.getLogger('urllib3').setLevel(logging.WARNING) @@ -145,6 +152,44 @@ def strategy_execution_loop(): except Exception as e: logger.error(f"Error executing strategy {instance_key}: {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: + _loop_debug.debug(f"Has active trades, getting prices...") + try: + symbols = set(trade.symbol for trade in brighter_trades.trades.active_trades.values()) + _loop_debug.debug(f"Symbols to fetch: {symbols}") + price_updates = {} + for symbol in symbols: + try: + price = brighter_trades.exchanges.get_price(symbol) + _loop_debug.debug(f"Got price for {symbol}: {price}") + if price: + price_updates[symbol] = price + except Exception as e: + _loop_debug.debug(f"Failed to get price for {symbol}: {e}") + logger.warning(f"Could not get price for {symbol}: {e}") + + _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"trade_updates returned: {trade_updates}") + if trade_updates: + logger.debug(f"Trade updates (no active strategies): {trade_updates}") + for update in trade_updates: + trade_id = update.get('id') + trade = brighter_trades.trades.active_trades.get(trade_id) + _loop_debug.debug(f"Emitting update for trade_id={trade_id}, creator={trade.creator if trade else None}") + if trade and trade.creator: + user_name = brighter_trades.users.get_username(user_id=trade.creator) + if user_name: + socketio.emit('trade_update', sanitize_for_json(update), room=user_name) + _loop_debug.debug(f"Emitted trade_update to room={user_name}") + except Exception as e: + _loop_debug.debug(f"Exception in trade update: {e}") + logger.error(f"Error updating trades (no strategies): {e}", exc_info=True) + except Exception as e: logger.error(f"Strategy execution loop error: {e}") diff --git a/src/static/communication.js b/src/static/communication.js index ea05de5..bfa73f0 100644 --- a/src/static/communication.js +++ b/src/static/communication.js @@ -78,6 +78,12 @@ class Comms { console.log('Strategy events received:', data); this.emit('strategy_events', data); }); + + // Handle trade update events from execution loop + this.socket.on('trade_update', (data) => { + console.log('Trade update received:', data); + this.emit('trade_update', data); + }); } /** @@ -333,7 +339,8 @@ class Comms { high: candlestick.h, low: candlestick.l, close: candlestick.c, - vol: candlestick.v + vol: candlestick.v, + symbol: tradingPair // Include trading pair for trade matching }; this.candleUpdate(newCandle); diff --git a/src/static/trade.js b/src/static/trade.js index 194b034..acc993c 100644 --- a/src/static/trade.js +++ b/src/static/trade.js @@ -1,195 +1,869 @@ -class Trade { +/** + * TradeUIManager - Handles DOM updates and trade card rendering + */ +class TradeUIManager { constructor() { - // A list of all the open trades. - this.trades = []; - /* HTML elements that interact with this class. */ - // The html element that displays the quote value to execute the trade at. - this.priceInput_el = null; - // The html element that displays the actual quote value of the base asset. - this.currentPrice_el = null; - // The html element that displays the quantity to executed the trade at. - this.qtyInput_el = null; - // The html element that displays the value of the trade. (quantity * price) - this.tradeValue_el = null; - // The html element that displays the orderType of the trade. (Market|Limit) - this.orderType_el = null; - // The html element that displays the target for the trade ['binance'|'test_exchange']. - this.target_el = null; - // The html element that displays the side to trade. [buy|sell] - this.side_el = null; - // The html element that displays the form dialog for a new trade. - this.formDialog_el = null; - // The html element that displays the active trades. - this.activeTLst_el = null; + this.targetEl = null; + this.formElement = null; + this.priceInput = null; + this.currentPriceDisplay = null; + this.qtyInput = null; + this.tradeValueDisplay = null; + this.orderTypeSelect = null; + this.targetSelect = null; + this.sideSelect = null; + this.onCloseTrade = null; } - connectElements({price='price', currentPrice='currentPrice', quantity='quantity', - tradeValue='tradeValue', orderType='orderType', target='tradeTarget', - side='side', ntf='new_trade_form', atl='activeTradesLst'}={}){ - /* - Sets a reference to the dom elements that interact with this class. - Overwriting the default id's allows for instance specific binding. - */ - this.priceInput_el = document.getElementById(price); - this.currentPrice_el = document.getElementById(currentPrice); - this.qtyInput_el = document.getElementById(quantity); - this.tradeValue_el = document.getElementById(tradeValue); - this.orderType_el = document.getElementById(orderType); - this.target_el = document.getElementById(target); - this.side_el = document.getElementById(side); - this.formDialog_el = document.getElementById(ntf); - this.activeTLst_el = document.getElementById(atl); - } - initialize(){ - /* Set the values and behavior of elements this class interacts with. */ - // Bind this instance with the html element ment to interact with it. - this.connectElements(); - // Store this object pointer for referencing inside callbacks and event handlers. - var that = this; - // Assign the quote value of the asset to the current price display element. - if (window.UI.data.price_history && window.UI.data.price_history.length > 0) { - let ph = window.UI.data.price_history; - // Assign the last closing price in the price history to the price input element. - that.priceInput_el.value = ph[ph.length - 1].close; - // Set the current price display to the same value. - that.currentPrice_el.value = that.priceInput_el.value; + /** + * Initializes the UI elements with provided IDs. + * @param {Object} config - Configuration object with element IDs. + */ + initUI(config = {}) { + const { + targetId = 'tradesContainer', + formElId = 'new_trade_form', + priceId = 'price', + currentPriceId = 'currentPrice', + qtyId = 'quantity', + tradeValueId = 'tradeValue', + orderTypeId = 'orderType', + tradeTargetId = 'tradeTarget', + sideId = 'side' + } = config; + + this.targetEl = document.getElementById(targetId); + if (!this.targetEl) { + console.warn(`Element for displaying trades "${targetId}" not found.`); + } + + this.formElement = document.getElementById(formElId); + if (!this.formElement) { + console.warn(`Trade form element "${formElId}" not found.`); + } + + this.priceInput = document.getElementById(priceId); + this.currentPriceDisplay = document.getElementById(currentPriceId); + this.qtyInput = document.getElementById(qtyId); + this.tradeValueDisplay = document.getElementById(tradeValueId); + this.orderTypeSelect = document.getElementById(orderTypeId); + this.targetSelect = document.getElementById(tradeTargetId); + this.sideSelect = document.getElementById(sideId); + + // Set up event listeners + this._setupFormListeners(); + } + + /** + * Set up form event listeners for price calculation. + */ + _setupFormListeners() { + const updateTradeValue = () => { + if (!this.qtyInput || !this.tradeValueDisplay) return; + + let price; + if (this.orderTypeSelect?.value === 'MARKET') { + price = parseFloat(this.currentPriceDisplay?.value || 0); + } else { + price = parseFloat(this.priceInput?.value || 0); + } + const qty = parseFloat(this.qtyInput.value || 0); + this.tradeValueDisplay.value = (qty * price).toFixed(2); + }; + + // Order type changes affect which price to use + if (this.orderTypeSelect) { + this.orderTypeSelect.addEventListener('change', () => { + if (this.orderTypeSelect.value === 'MARKET') { + if (this.currentPriceDisplay) this.currentPriceDisplay.style.display = 'inline-block'; + if (this.priceInput) this.priceInput.style.display = 'none'; + } else { + if (this.currentPriceDisplay) this.currentPriceDisplay.style.display = 'none'; + if (this.priceInput) this.priceInput.style.display = 'inline-block'; + } + updateTradeValue(); + }); + } + + // Update trade value on price/qty changes + if (this.priceInput) { + this.priceInput.addEventListener('change', updateTradeValue); + this.priceInput.addEventListener('input', updateTradeValue); + } + if (this.qtyInput) { + this.qtyInput.addEventListener('change', updateTradeValue); + this.qtyInput.addEventListener('input', updateTradeValue); + } + } + + /** + * Displays the trade creation form. + * @param {number} currentPrice - Optional current price to prefill. + */ + displayForm(currentPrice = null) { + if (!this.formElement) { + console.error("Form element not initialized."); + return; + } + + // Reset form values + if (this.qtyInput) this.qtyInput.value = ''; + if (this.tradeValueDisplay) this.tradeValueDisplay.value = '0'; + + // Set current price if available + if (currentPrice !== null) { + if (this.priceInput) this.priceInput.value = currentPrice; + if (this.currentPriceDisplay) this.currentPriceDisplay.value = currentPrice; + } + + this.formElement.style.display = 'grid'; + } + + /** + * Hides the trade form. + */ + hideForm() { + if (this.formElement) { + this.formElement.style.display = 'none'; + } + } + + /** + * Updates the current price display (for market orders). + * @param {number} price - The current price. + */ + updateCurrentPrice(price) { + if (this.currentPriceDisplay) { + this.currentPriceDisplay.value = price; + } + } + + /** + * Updates the HTML representation of the trades as cards. + * @param {Object[]} trades - The list of trades to display. + */ + updateTradesHtml(trades) { + if (!this.targetEl) { + console.error("Target element for displaying trades is not set."); + return; + } + + // Clear existing content + while (this.targetEl.firstChild) { + this.targetEl.removeChild(this.targetEl.firstChild); + } + + if (!trades || trades.length === 0) { + const emptyMsg = document.createElement('p'); + emptyMsg.className = 'no-data-msg'; + emptyMsg.textContent = 'No active trades'; + this.targetEl.appendChild(emptyMsg); + return; + } + + // Create and append cards for all trades + for (const trade of trades) { + try { + const tradeCard = this._createTradeCard(trade); + this.targetEl.appendChild(tradeCard); + } catch (error) { + console.error(`Error processing trade:`, error, trade); + } + } + } + + /** + * Creates a trade card HTML element. + * @param {Object} trade - The trade data. + * @returns {HTMLElement} - The card element. + */ + _createTradeCard(trade) { + const tradeItem = document.createElement('div'); + tradeItem.className = 'trade-card'; + tradeItem.setAttribute('data-trade-id', trade.unique_id || trade.tbl_key); + + // Add paper/live class + if (trade.is_paper) { + tradeItem.classList.add('trade-paper'); } else { - console.error('Price history data is not available or empty.'); + tradeItem.classList.add('trade-live'); } - // Set the trade value to zero. This will update when price and quantity inputs are received. - this.tradeValue_el.value = 0; - // Toggle current price or input-field for value updates depending on orderType. - function update_tradeValue(){ - if ( that.orderType_el.value == 'MARKET') - {that.tradeValue_el.value = that.qtyInput_el.value * that.currentPrice_el.value;} - else - {that.tradeValue_el.value = that.qtyInput_el.value * that.priceInput_el.value;} - } - // Market orders are executed at the current price. To avoid confusion. - // Grey out this price input when orderType is set to Market. - this.orderType_el.addEventListener('change', function(){ - if(this.value == 'MARKET') - { - that.currentPrice_el.style.display = "inline-block"; - that.priceInput_el.style.display = "none"; + + // Add side class + const side = (trade.side || 'BUY').toUpperCase(); + tradeItem.classList.add(side === 'BUY' ? 'trade-buy' : 'trade-sell'); + + // Close/Delete button + const closeButton = document.createElement('button'); + closeButton.className = 'trade-close-btn'; + closeButton.innerHTML = '✘'; + closeButton.title = 'Close Trade'; + closeButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.onCloseTrade) { + this.onCloseTrade(trade.unique_id || trade.tbl_key); } - else if(this.value == 'LIMIT') - { - that.currentPrice_el.style.display = "none"; - that.priceInput_el.style.display = "inline-block"; - } - update_tradeValue(); }); - // Update the trade value display everytime the price input changes. - this.priceInput_el.addEventListener('change', update_tradeValue); - // Update the trade value display everytime the quantity input changes. - this.qtyInput_el.addEventListener('change', update_tradeValue); - // Send a request to the server for any loaded data. - this.fetchTrades(); + tradeItem.appendChild(closeButton); + + // Paper badge + if (trade.is_paper) { + const paperBadge = document.createElement('span'); + paperBadge.className = 'trade-paper-badge'; + paperBadge.textContent = 'PAPER'; + tradeItem.appendChild(paperBadge); + } + + // Trade info container + const tradeInfo = document.createElement('div'); + tradeInfo.className = 'trade-info'; + + // Symbol and side + const symbolRow = document.createElement('div'); + symbolRow.className = 'trade-symbol-row'; + + const sideSpan = document.createElement('span'); + sideSpan.className = `trade-side ${side.toLowerCase()}`; + sideSpan.textContent = side; + symbolRow.appendChild(sideSpan); + + const symbolSpan = document.createElement('span'); + symbolSpan.className = 'trade-symbol'; + symbolSpan.textContent = trade.symbol || 'N/A'; + symbolRow.appendChild(symbolSpan); + + tradeInfo.appendChild(symbolRow); + + // Quantity + const qtyRow = document.createElement('div'); + qtyRow.className = 'trade-row'; + qtyRow.innerHTML = `Qty:${this._formatNumber(trade.base_order_qty)}`; + tradeInfo.appendChild(qtyRow); + + // Entry price + const entryRow = document.createElement('div'); + entryRow.className = 'trade-row'; + const stats = trade.stats || {}; + const entryPrice = stats.opening_price || trade.order_price || 0; + entryRow.innerHTML = `Entry:${this._formatPrice(entryPrice)}`; + tradeInfo.appendChild(entryRow); + + // P/L + const pl = stats.profit || 0; + const plPct = stats.profit_pct || 0; + const plRow = document.createElement('div'); + plRow.className = 'trade-row trade-pl-row'; + plRow.id = `${trade.unique_id}_pl`; + + const plClass = pl >= 0 ? 'positive' : 'negative'; + plRow.innerHTML = `P/L:${this._formatPL(pl)} (${this._formatPct(plPct)})`; + tradeInfo.appendChild(plRow); + + tradeItem.appendChild(tradeInfo); + + // Hover details panel + const hoverPanel = document.createElement('div'); + hoverPanel.className = 'trade-hover'; + + let hoverHtml = `${trade.symbol}`; + hoverHtml += `
`; + hoverHtml += `Side: ${side}`; + hoverHtml += `Type: ${trade.order_type || 'MARKET'}`; + hoverHtml += `Quantity: ${this._formatNumber(trade.base_order_qty)}`; + hoverHtml += `Entry Price: ${this._formatPrice(entryPrice)}`; + hoverHtml += `Current Value: ${this._formatPrice(stats.current_value || 0)}`; + hoverHtml += `P/L: ${this._formatPL(pl)} (${this._formatPct(plPct)})`; + hoverHtml += `Status: ${trade.status || 'unknown'}`; + hoverHtml += `Target: ${trade.target || 'N/A'}`; + if (trade.is_paper) { + hoverHtml += `Paper Trade`; + } + hoverHtml += `
`; + + hoverPanel.innerHTML = hoverHtml; + tradeItem.appendChild(hoverPanel); + + return tradeItem; } - // Call to display the 'Create new trade' dialog. - open_tradeForm() { this.formDialog_el.style.display = "grid"; } - // Call to hide the 'Create trade' dialog. - close_tradeForm() { this.formDialog_el.style.display = "none"; } + /** + * Updates a single trade's P/L display. + * @param {string} tradeId - The trade ID. + * @param {number} pl - The P/L value. + * @param {number} plPct - The P/L percentage. + */ + updateTradePL(tradeId, pl, plPct) { + const plEl = document.getElementById(`${tradeId}_pl_value`); + if (plEl) { + const plClass = pl >= 0 ? 'positive' : 'negative'; + plEl.className = `trade-value trade-pl ${plClass}`; + plEl.textContent = `${this._formatPL(pl)} (${this._formatPct(plPct)})`; - fetchTrades(){ - if (window.UI.data.comms) { - window.UI.data.comms.sendToApp('request', { request: 'trades', user_name: window.UI.data.user_name }); - } else { - console.error('Comms instance not available.'); + // Add flash animation + plEl.classList.add('trade-pl-flash'); + setTimeout(() => plEl.classList.remove('trade-pl-flash'), 300); } } - // Call to display the 'Trade details' dialog. - open_tradeDetails_Form() { document.getElementById("trade_details_form").style.display = "grid"; } - // Call to hide the 'Create new signal' dialog. - close_tradeDetails_Form() { document.getElementById("trade_details_form").style.display = "none"; } - - submitNewTrade(){ - // Collect all the input fields. - var target = this.target_el.value; // The target to trade ['binance'|'test_exchange']. - var symbol = window.UI.data.trading_pair; // The symbol to trade at. - var price = this.priceInput_el.value; // The price to trade at this is ignored in a market trade. - var side = this.side_el.value; // The side to trade at. - var orderType = this.orderType_el.value; // The orderType for the trade. - var quantity = this.qtyInput_el.value; // The base quantity to trade. - var data = { - target, symbol, price, side, orderType, quantity - }; - window.UI.data.comms.sendToApp( "new_trade", data); - this.close_tradeForm(); - } - set_data(trades){ - console.log('incoming trades!!!'); - console.log(trades); - // Create a list item for every trade and add it to a UL element. - var ul = this.activeTLst_el; - - // loop through a provided list of trades and attributes. - for (let trade in trades){ - // Create a Json object from each trade. - if (typeof(trades[trade]) == 'string') - // During initialization the object received from the server is in string form. - var obj = JSON.parse(trades[trade]); - else - // When trades are created this function receives a javascript object. - var obj=trades[trade]; - // Keep a local record of the trade. - this.trades.push(obj); - // Define the function that is called when closing an individual trade. - let click_func = "window.UI.trade.closeTrade('" + obj.unique_id + "')"; - - // Create a close button for every individual trade. - let close_btn = ''; - // Format some markup to display the attributes of the trade. - //let trade_id = " " + obj.unique_id + ": "; - let trade_qty = "" + obj.stats.qty_filled + ""; - let trade_profit = "" + obj.stats.profit + ""; - let trade_pct = "" + obj.stats.profit_pct + ""; - let trade_icon = ""; - // Stick all the html together. - let html = trade_icon; - html += "QTY:" + trade_qty + ""; - html += "P/L:" + trade_profit + ""; - html += "P/L %:" + trade_pct + ""; - html += close_btn; - // Put it in a div. - html = "
" + html + "
" - // Create the list item. - let li = document.createElement("li"); - // Give it an id. - li.id = obj.unique_id + '_item'; - // Inject the html. - li.innerHTML= html; - // And add it the the UL we created earlier. - ul.appendChild(li); - } - console.log('pushed trades!!!'); - console.log(this.trades); - - } - closeTrade(tradeId){ - // Requests that the server close a specific trade. - window.UI.data.comms.sendToApp('close_trade', tradeId); - // Get the trade element from the UI - let child = document.getElementById(tradeId + '_item'); - // Ask the parent of the trade element to remove its child(trade) from the document. - child.parentNode.removeChild(child); - } - update_received(trade_updts){ -// if ( 'cmd' in trade_updts) { -// let alert = -// window.UI.alerts.publish_alerts('trade', trade_updts); -// this.executeCmd(trade_updts.cmd); -// } - console.log('Received trade update.'); - console.log('Updating trade updating local list.'); - this.trades = trade_updts; - for (let trade in this.trades){ - console.log('dont forget to update the html.'); - console.log(trade); + /** + * Removes a trade card from the display. + * @param {string} tradeId - The trade ID. + */ + removeTradeCard(tradeId) { + const card = document.querySelector(`[data-trade-id="${tradeId}"]`); + if (card) { + card.classList.add('trade-card-removing'); + setTimeout(() => card.remove(), 300); } } + // ============ Formatting helpers ============ + + _formatNumber(num) { + if (num === null || num === undefined) return 'N/A'; + return parseFloat(num).toLocaleString(undefined, { maximumFractionDigits: 6 }); + } + + _formatPrice(price) { + if (price === null || price === undefined) return 'N/A'; + return parseFloat(price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 }); + } + + _formatPL(pl) { + if (pl === null || pl === undefined) return '$0.00'; + const formatted = Math.abs(pl).toFixed(2); + return pl >= 0 ? `+$${formatted}` : `-$${formatted}`; + } + + _formatPct(pct) { + if (pct === null || pct === undefined) return '0.00%'; + const formatted = Math.abs(pct).toFixed(2); + return pct >= 0 ? `+${formatted}%` : `-${formatted}%`; + } + + /** + * Sets the callback function for closing a trade. + * @param {Function} callback - The callback function. + */ + registerCloseTradeCallback(callback) { + this.onCloseTrade = callback; + } +} + + +/** + * TradeDataManager - Manages in-memory trade data store + */ +class TradeDataManager { + constructor() { + this.trades = []; + } + + /** + * Fetches trades from the server. + * @param {Object} comms - The communications instance. + * @param {Object} data - An object containing user data. + */ + fetchTrades(comms, data) { + if (comms) { + try { + const requestData = { + request: 'trades', + user_name: data?.user_name + }; + comms.sendToApp('request', requestData); + } catch (error) { + console.error("Error fetching trades:", error.message); + } + } else { + throw new Error('Communications instance not available.'); + } + } + + /** + * Adds a new trade to the local store. + * @param {Object} data - The trade data. + */ + addTrade(data) { + const tradeData = data.trade || data; + console.log("Adding new trade:", tradeData); + + if (!tradeData.unique_id && !tradeData.tbl_key) { + console.error("Trade data missing identifier:", tradeData); + return; + } + + // Check for duplicates + const id = tradeData.unique_id || tradeData.tbl_key; + const exists = this.trades.find(t => t.unique_id === id || t.tbl_key === id); + if (!exists) { + this.trades.push(tradeData); + } + } + + /** + * Retrieves a trade by its ID. + * @param {string} tradeId - The trade ID. + * @returns {Object|null} - The trade object or null. + */ + getTradeById(tradeId) { + return this.trades.find(t => t.unique_id === tradeId || t.tbl_key === tradeId) || null; + } + + /** + * Updates trade data (including P/L updates). + * @param {Object} updateData - The updated trade data. + */ + updateTrade(updateData) { + const tradeId = updateData.id || updateData.unique_id || updateData.tbl_key; + if (!tradeId) return; + + const trade = this.getTradeById(tradeId); + if (trade) { + // Update stats + if (updateData.pl !== undefined) { + trade.stats = trade.stats || {}; + trade.stats.profit = updateData.pl; + } + if (updateData.pl_pct !== undefined) { + trade.stats = trade.stats || {}; + trade.stats.profit_pct = updateData.pl_pct; + } + if (updateData.status) { + trade.status = updateData.status; + } + } + } + + /** + * Removes a trade from the store. + * @param {string} tradeId - The trade ID. + */ + removeTrade(tradeId) { + console.log(`Removing trade: ${tradeId}`); + this.trades = this.trades.filter( + t => t.unique_id !== tradeId && t.tbl_key !== tradeId + ); + } + + /** + * Returns all trades. + * @returns {Object[]} - The list of trades. + */ + getAllTrades() { + return this.trades; + } + + /** + * Sets all trades (used when loading from server). + * @param {Object[]} trades - The list of trades. + */ + setTrades(trades) { + this.trades = Array.isArray(trades) ? trades : []; + } + + /** + * Applies batch P/L updates from server. + * @param {Object[]} updates - Array of trade update objects. + */ + applyUpdates(updates) { + if (!Array.isArray(updates)) return; + + for (const update of updates) { + this.updateTrade(update); + } + } + + /** + * Gets trade statistics. + * @returns {Object} - Statistics object. + */ + getStatistics() { + const totalTrades = this.trades.length; + const paperTrades = this.trades.filter(t => t.is_paper).length; + const liveTrades = totalTrades - paperTrades; + + let totalPL = 0; + for (const trade of this.trades) { + totalPL += (trade.stats?.profit || 0); + } + + return { + totalTrades, + paperTrades, + liveTrades, + totalPL + }; + } +} + + +/** + * Trade - Main coordinator class that manages TradeUIManager, TradeDataManager, and SocketIO communication + */ +class Trade { + constructor(ui = null) { + this.ui = ui; + this.comms = null; + this.data = null; + + this.dataManager = new TradeDataManager(); + this.uiManager = new TradeUIManager(); + + // Set up close callback + this.uiManager.registerCloseTradeCallback(this.closeTrade.bind(this)); + + // Bind methods + this.submitNewTrade = this.submitNewTrade.bind(this); + + this._initialized = false; + } + + /** + * Initializes the Trade instance. + * Called after page load when UI.data is available. + * @param {Object} config - Configuration object. + */ + initialize(config = {}) { + try { + // Get references from global UI if not provided in constructor + if (!this.ui && window.UI) { + this.ui = window.UI; + } + this.data = this.ui?.data || window.UI?.data; + this.comms = this.data?.comms; + + this.uiManager.initUI(config); + + if (!this.comms) { + console.error("Communications instance not available."); + return; + } + + // Register handlers with Comms + this.comms.on('trades', this.handleTradesResponse.bind(this)); + this.comms.on('trade_created', this.handleTradeCreated.bind(this)); + this.comms.on('trade_closed', this.handleTradeClosed.bind(this)); + this.comms.on('trade_error', this.handleTradeError.bind(this)); + this.comms.on('updates', this.handleUpdates.bind(this)); + this.comms.on('trade_update', this.handleTradeUpdate.bind(this)); + + // Set initial price if available + if (this.data?.price_history && this.data.price_history.length > 0) { + const lastPrice = this.data.price_history[this.data.price_history.length - 1].close; + this.uiManager.updateCurrentPrice(lastPrice); + } + + // Update trading pair display + this._updateTradingPairDisplay(); + + // Fetch existing trades + this.dataManager.fetchTrades(this.comms, this.data); + + this._initialized = true; + console.log("Trade module initialized successfully"); + } catch (error) { + console.error("Error initializing Trade:", error); + } + } + + /** + * Updates the trading pair display in the form. + * @private + */ + _updateTradingPairDisplay() { + const symbolDisplay = document.getElementById('trade_symbol_display'); + if (symbolDisplay && this.data?.trading_pair) { + symbolDisplay.textContent = this.data.trading_pair; + } + } + + /** + * Handle initial trades list response from server. + * @param {Array} data - List of trade objects. + */ + handleTradesResponse(data) { + console.log("Received trades list:", data); + if (Array.isArray(data)) { + this.dataManager.setTrades(data); + this.uiManager.updateTradesHtml(this.dataManager.getAllTrades()); + this._updateStatistics(); + } + } + + /** + * Handle new trade created event. + * @param {Object} data - Server response with trade data. + */ + handleTradeCreated(data) { + console.log("Trade created:", data); + if (data.success) { + this.dataManager.addTrade(data); + this.uiManager.updateTradesHtml(this.dataManager.getAllTrades()); + this._updateStatistics(); + } else { + alert(`Failed to create trade: ${data.message}`); + } + } + + /** + * Handle trade closed event. + * @param {Object} data - Server response with closed trade info. + */ + handleTradeClosed(data) { + console.log("Trade closed:", data); + if (data.success) { + const tradeId = data.trade_id; + this.dataManager.removeTrade(tradeId); + this.uiManager.removeTradeCard(tradeId); + this._updateStatistics(); + + // Show P/L notification + if (data.final_pl !== undefined) { + const plText = data.final_pl >= 0 + ? `+$${data.final_pl.toFixed(2)}` + : `-$${Math.abs(data.final_pl).toFixed(2)}`; + console.log(`Trade closed with P/L: ${plText}`); + } + } + } + + /** + * Handle trade error. + * @param {Object} data - Error data. + */ + handleTradeError(data) { + console.error("Trade error:", data.message); + alert(`Trade error: ${data.message}`); + } + + /** + * Handle updates (including trade P/L updates). + * @param {Object} data - Update data from server. + */ + handleUpdates(data) { + const { trade_updts } = data; + if (trade_updts && Array.isArray(trade_updts)) { + this.dataManager.applyUpdates(trade_updts); + + // Update UI for each trade + for (const update of trade_updts) { + const tradeId = update.id; + if (update.pl !== undefined && update.pl_pct !== undefined) { + this.uiManager.updateTradePL(tradeId, update.pl, update.pl_pct); + } + } + + this._updateStatistics(); + } + } + + /** + * Handle individual trade update from execution loop. + * @param {Object} data - Single trade update data from server. + */ + handleTradeUpdate(data) { + console.log('Trade update event received:', data); + if (data && data.id) { + // Update the trade in data manager + this.dataManager.applyUpdates([data]); + + // Update the UI + if (data.pl !== undefined && data.pl_pct !== undefined) { + this.uiManager.updateTradePL(data.id, data.pl, data.pl_pct); + } + + this._updateStatistics(); + } + } + + // ================ Form Methods ================ + + /** + * Opens the trade creation form. + */ + open_tradeForm() { + // Get current price if available + let currentPrice = null; + if (this.data?.price_history && this.data.price_history.length > 0) { + currentPrice = this.data.price_history[this.data.price_history.length - 1].close; + } + this.uiManager.displayForm(currentPrice); + } + + /** + * Closes the trade form. + */ + close_tradeForm() { + this.uiManager.hideForm(); + } + + /** + * Submits a new trade. + */ + submitNewTrade() { + if (!this.comms) { + console.error("Comms instance not available."); + return; + } + + const formElement = this.uiManager.formElement; + if (!formElement) { + console.error("Form element not available."); + return; + } + + const target = this.uiManager.targetSelect?.value || 'test_exchange'; + const symbol = this.data?.trading_pair || ''; + const orderType = this.uiManager.orderTypeSelect?.value || 'MARKET'; + const side = this.uiManager.sideSelect?.value || 'buy'; + + let price; + if (orderType === 'MARKET') { + price = parseFloat(this.uiManager.currentPriceDisplay?.value || 0); + } else { + price = parseFloat(this.uiManager.priceInput?.value || 0); + } + + const quantity = parseFloat(this.uiManager.qtyInput?.value || 0); + + // Validation + if (!symbol) { + alert('Please select a trading pair first.'); + return; + } + if (quantity <= 0) { + alert('Please enter a valid quantity.'); + return; + } + if (orderType === 'LIMIT' && price <= 0) { + alert('Please enter a valid price for limit orders.'); + return; + } + + const tradeData = { + target, + symbol, + price, + side, + orderType, + quantity, + user_name: this.data?.user_name + }; + + console.log("Submitting trade:", tradeData); + this.comms.sendToApp('new_trade', tradeData); + this.close_tradeForm(); + } + + /** + * Closes a trade. + * @param {string} tradeId - The trade ID. + */ + closeTrade(tradeId) { + if (!this.comms) { + console.error("Comms instance not available."); + return; + } + + console.log(`Closing trade: ${tradeId}`); + this.comms.sendToApp('close_trade', { trade_id: tradeId }); + } + + /** + * Fetches trades from server. + */ + fetchTrades() { + this.dataManager.fetchTrades(this.comms, this.data); + } + + /** + * Updates the statistics panel with trade data. + * @private + */ + _updateStatistics() { + const stats = this.dataManager.getStatistics(); + + // Update statistics panel if available + const activeTradesEl = document.getElementById('stat_active_trades'); + const totalPLEl = document.getElementById('stat_trades_pl'); + const paperCountEl = document.getElementById('stat_paper_trades'); + const liveCountEl = document.getElementById('stat_live_trades'); + + if (activeTradesEl) { + activeTradesEl.textContent = stats.totalTrades; + } + if (totalPLEl) { + const plClass = stats.totalPL >= 0 ? 'positive' : 'negative'; + totalPLEl.className = `stat-value ${plClass}`; + totalPLEl.textContent = stats.totalPL >= 0 + ? `+$${stats.totalPL.toFixed(2)}` + : `-$${Math.abs(stats.totalPL).toFixed(2)}`; + } + if (paperCountEl) { + paperCountEl.textContent = stats.paperTrades; + } + if (liveCountEl) { + liveCountEl.textContent = stats.liveTrades; + } + } + + // ================ Legacy Methods (for backwards compatibility) ================ + + /** + * Legacy method: Connect elements (calls initUI). + */ + connectElements(config = {}) { + this.uiManager.initUI({ + priceId: config.price || 'price', + currentPriceId: config.currentPrice || 'currentPrice', + qtyId: config.quantity || 'quantity', + tradeValueId: config.tradeValue || 'tradeValue', + orderTypeId: config.orderType || 'orderType', + tradeTargetId: config.target || 'tradeTarget', + sideId: config.side || 'side', + formElId: config.ntf || 'new_trade_form', + targetId: config.atl || 'tradesContainer' + }); + } + + /** + * Legacy method: Set data (handles trades response). + * @param {Array} trades - List of trades. + */ + set_data(trades) { + this.handleTradesResponse(trades); + } + + /** + * Legacy method: Update received (handles updates). + * @param {Array} trade_updts - Trade updates. + */ + update_received(trade_updts) { + this.handleUpdates({ trade_updts }); + } + + /** + * Legacy: Open trade details form. + */ + open_tradeDetails_Form() { + const el = document.getElementById("trade_details_form"); + if (el) el.style.display = "grid"; + } + + /** + * Legacy: Close trade details form. + */ + close_tradeDetails_Form() { + const el = document.getElementById("trade_details_form"); + if (el) el.style.display = "none"; + } + + /** + * Gets all trades. + * @returns {Object[]} - The list of trades. + */ + get trades() { + return this.dataManager.getAllTrades(); + } } diff --git a/src/templates/new_trade_popup.html b/src/templates/new_trade_popup.html index 1cf4294..7b795e2 100644 --- a/src/templates/new_trade_popup.html +++ b/src/templates/new_trade_popup.html @@ -1,47 +1,50 @@
- -
- + +
+

Create New Trade

- -
- - -
- + + + + + + - + + - - - + + - + + - - - - + + + + - - - - - - -
+ + + + + 0 + + +
-
+
diff --git a/src/templates/statistics_hud.html b/src/templates/statistics_hud.html index b8faefd..196d87b 100644 --- a/src/templates/statistics_hud.html +++ b/src/templates/statistics_hud.html @@ -1,4 +1,29 @@
+ +
+

Active Trades

+
+
+ Total + 0 +
+
+ Paper + 0 +
+
+ Live + 0 +
+
+ Total P/L + $0.00 +
+
+
+ +
+

Running Strategies

@@ -94,6 +119,52 @@ margin: 5px 0; } + /* Trades summary grid */ + .trades-summary-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + margin: 8px 0; + } + + .trades-summary-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 6px; + background: #f5f5f5; + border-radius: 4px; + } + + .trades-summary-label { + font-size: 10px; + color: #666; + text-transform: uppercase; + } + + .trades-summary-value { + font-size: 14px; + font-weight: bold; + font-family: monospace; + color: #333; + } + + .trades-summary-value.paper { + color: #9e9e9e; + } + + .trades-summary-value.live { + color: #2196f3; + } + + .trades-summary-value.positive { + color: #2e7d32; + } + + .trades-summary-value.negative { + color: #c62828; + } + /* Running strategies list */ .running-strategy-item { display: flex; diff --git a/src/templates/trading_hud.html b/src/templates/trading_hud.html index a1d2733..c8012b7 100644 --- a/src/templates/trading_hud.html +++ b/src/templates/trading_hud.html @@ -1,6 +1,211 @@

-

Trades

-
    +

    Active Trades

    +
    +

    No active trades

    +
    + + diff --git a/src/trade.py b/src/trade.py index d20a418..32a78d5 100644 --- a/src/trade.py +++ b/src/trade.py @@ -1,5 +1,7 @@ +import json import logging import uuid +import datetime as dt from typing import Any from Users import Users @@ -7,16 +9,26 @@ from Users import Users # Configure logging logger = logging.getLogger(__name__) +# Debug file logger for trade updates +_debug_logger = logging.getLogger('trade_debug') +_debug_logger.setLevel(logging.DEBUG) +_debug_handler = logging.FileHandler('/home/rob/PycharmProjects/BrighterTrading/trade_debug.log', mode='w') +_debug_handler.setFormatter(logging.Formatter('%(asctime)s - %(message)s')) +_debug_logger.addHandler(_debug_handler) + class Trade: def __init__(self, target: str, symbol: str, side: str, order_price: float, base_order_qty: float, order_type: str = 'MARKET', time_in_force: str = 'GTC', unique_id: str | None = None, status: str | None = None, stats: dict[str, Any] | None = None, - order: Any | None = None, fee: float = 0.1, strategy_id: str | None = None): + order: Any | None = None, fee: float = 0.001, strategy_id: str | None = None, + is_paper: bool = False, creator: int | None = None, created_at: str | None = None, + tbl_key: str | None = None): """ Initializes a Trade instance with all necessary attributes. """ self.unique_id = unique_id or uuid.uuid4().hex + self.tbl_key = tbl_key or self.unique_id self.target = target self.symbol = symbol self.side = side.upper() @@ -26,6 +38,9 @@ class Trade: self.base_order_qty = base_order_qty self.fee = fee self.strategy_id = strategy_id + self.is_paper = is_paper + self.creator = creator + self.created_at = created_at or dt.datetime.now(dt.timezone.utc).isoformat() if status is None: self.status = 'inactive' @@ -54,6 +69,7 @@ class Trade: """ return { 'unique_id': self.unique_id, + 'tbl_key': self.tbl_key, 'strategy_id': self.strategy_id, 'target': self.target, 'symbol': self.symbol, @@ -64,7 +80,10 @@ class Trade: 'time_in_force': self.time_in_force, 'status': self.status, 'stats': self.stats, - 'order': self.order + 'order': self.order, + 'is_paper': self.is_paper, + 'creator': self.creator, + 'created_at': self.created_at } def get_position_size(self) -> float: @@ -186,13 +205,17 @@ class Trade: class Trades: - def __init__(self, users: Users): + def __init__(self, users: Users, data_cache: Any = None): """ Initializes the Trades class with necessary attributes. + + :param users: Users instance for user lookups. + :param data_cache: DataCache instance for persistence. """ self.users = users + self.data_cache = data_cache self.exchange_interface: Any | None = None # Define the type based on your exchange interface - self.exchange_fees = {'maker': 0.01, 'taker': 0.05} + self.exchange_fees = {'maker': 0.001, 'taker': 0.001} self.hedge_mode = False self.side: str | None = None self.active_trades: dict[str, Trade] = {} # Keyed by trade.unique_id @@ -201,6 +224,308 @@ class Trades: self.balances: dict[str, float] = {} # Track balances per strategy self.locked_funds: dict[str, float] = {} # Track locked funds per strategy + # Initialize database persistence if data_cache is available + if self.data_cache: + self._ensure_table_exists() + self._create_cache() + self._load_trades_from_db() + + def _ensure_table_exists(self) -> None: + """Create the trades table in the database if it doesn't exist.""" + try: + if not self.data_cache.db.table_exists('trades'): + create_sql = """ + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + creator INTEGER, + unique_id TEXT UNIQUE, + target TEXT NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + order_type TEXT NOT NULL, + order_price REAL, + base_order_qty REAL NOT NULL, + time_in_force TEXT DEFAULT 'GTC', + fee REAL DEFAULT 0.1, + status TEXT DEFAULT 'inactive', + stats_json TEXT, + strategy_id TEXT, + is_paper INTEGER DEFAULT 0, + created_at TEXT, + tbl_key TEXT UNIQUE + ) + """ + self.data_cache.db.execute_sql(create_sql, params=[]) + logger.info("Created trades table in database") + except Exception as e: + logger.error(f"Error ensuring trades table exists: {e}", exc_info=True) + + def _create_cache(self) -> None: + """Create the trades cache in DataCache.""" + try: + self.data_cache.create_cache( + name='trades', + cache_type='table', + size_limit=1000, + eviction_policy='deny', + default_expiration=dt.timedelta(hours=24), + columns=[ + "creator", + "unique_id", + "target", + "symbol", + "side", + "order_type", + "order_price", + "base_order_qty", + "time_in_force", + "fee", + "status", + "stats_json", + "strategy_id", + "is_paper", + "created_at", + "tbl_key" + ] + ) + except Exception as e: + logger.debug(f"Cache 'trades' may already exist: {e}") + + def _load_trades_from_db(self) -> None: + """Load all active trades from database into memory.""" + try: + trades_df = self.data_cache.get_all_rows_from_datacache(cache_name='trades') + if trades_df is not None and not trades_df.empty: + for _, row in trades_df.iterrows(): + # Only load non-closed trades + status = row.get('status', 'inactive') + if status == 'closed': + continue + + # Parse stats JSON + stats_json = row.get('stats_json', '{}') + try: + stats = json.loads(stats_json) if stats_json else {} + except (json.JSONDecodeError, TypeError): + stats = {} + + trade = Trade( + target=row.get('target', ''), + symbol=row.get('symbol', ''), + side=row.get('side', 'BUY'), + order_price=float(row.get('order_price', 0)), + base_order_qty=float(row.get('base_order_qty', 0)), + order_type=row.get('order_type', 'MARKET'), + time_in_force=row.get('time_in_force', 'GTC'), + unique_id=row.get('unique_id'), + status=status, + stats=stats if stats else None, + fee=float(row.get('fee', 0.001)), + strategy_id=row.get('strategy_id'), + is_paper=bool(row.get('is_paper', 0)), + creator=row.get('creator'), + created_at=row.get('created_at'), + tbl_key=row.get('tbl_key') + ) + self.active_trades[trade.unique_id] = trade + self.stats['num_trades'] += 1 + logger.info(f"Loaded {len(self.active_trades)} active trades from database") + except Exception as e: + logger.error(f"Error loading trades from database: {e}", exc_info=True) + + def _save_trade(self, trade: Trade) -> bool: + """ + Save a trade to the database. + + :param trade: Trade object to save. + :return: True if successful, False otherwise. + """ + if not self.data_cache: + return True # No persistence, just return success + + try: + columns = ( + "creator", "unique_id", "target", "symbol", "side", "order_type", + "order_price", "base_order_qty", "time_in_force", "fee", "status", + "stats_json", "strategy_id", "is_paper", "created_at", "tbl_key" + ) + + stats_json = json.dumps(trade.stats) if trade.stats else '{}' + + values = ( + trade.creator, + trade.unique_id, + trade.target, + trade.symbol, + trade.side, + trade.order_type, + trade.order_price, + trade.base_order_qty, + trade.time_in_force, + trade.fee, + trade.status, + stats_json, + trade.strategy_id, + int(trade.is_paper), + trade.created_at, + trade.tbl_key + ) + + # Check if trade already exists + existing = self.data_cache.get_rows_from_datacache( + cache_name='trades', + filter_vals=[('tbl_key', trade.tbl_key)], + include_tbl_key=True + ) + + if existing.empty: + # Insert new trade + self.data_cache.insert_row_into_datacache( + cache_name='trades', + columns=columns, + values=values + ) + else: + # Update existing trade + self.data_cache.modify_datacache_item( + cache_name='trades', + filter_vals=[('tbl_key', trade.tbl_key)], + field_names=columns, + new_values=values, + key=trade.tbl_key, + overwrite='tbl_key' + ) + + return True + except Exception as e: + logger.error(f"Failed to save trade {trade.unique_id}: {e}", exc_info=True) + return False + + def _update_trade_in_db(self, trade: Trade) -> bool: + """ + Update a trade's stats in the database. + + :param trade: Trade object to update. + :return: True if successful, False otherwise. + """ + return self._save_trade(trade) + + def _delete_trade_from_db(self, trade_id: str) -> bool: + """ + Delete a trade from the database. + + :param trade_id: The unique ID of the trade to delete. + :return: True if successful, False otherwise. + """ + if not self.data_cache: + return True + + try: + self.data_cache.remove_row_from_datacache( + cache_name='trades', + filter_vals=[('unique_id', trade_id)] + ) + return True + except Exception as e: + logger.error(f"Failed to delete trade {trade_id}: {e}", exc_info=True) + return False + + def new_trade(self, target: str, symbol: str, price: float, side: str, + order_type: str, qty: float, user_id: int = None, + strategy_id: str = None) -> tuple[str, str | None]: + """ + Creates a new trade (paper or live). + + :param target: The exchange target ('test_exchange' for paper, exchange name for live). + :param symbol: The trading pair symbol. + :param price: The price to trade at (ignored for market orders). + :param side: 'BUY' or 'SELL'. + :param order_type: 'MARKET' or 'LIMIT'. + :param qty: The quantity to trade. + :param user_id: The user creating the trade. + :param strategy_id: Optional strategy ID if from a strategy. + :return: Tuple of (status, trade_id or error message). + """ + # Determine if this is a paper trade + is_paper = target in ['test_exchange', 'paper', 'Paper Trade'] + + # For market orders, fetch the current price from exchange + effective_price = float(price) if price else 0.0 + if order_type and order_type.upper() == 'MARKET' and self.exchange_interface: + try: + current_price = self.exchange_interface.get_price(symbol) + if current_price: + effective_price = float(current_price) + logger.debug(f"Market order: using current price {effective_price} for {symbol}") + except Exception as e: + logger.warning(f"Could not fetch current price for {symbol}: {e}, using provided price {price}") + + try: + trade = Trade( + target=target, + symbol=symbol, + side=side.upper(), + order_price=effective_price, + base_order_qty=float(qty), + order_type=order_type.upper() if order_type else 'MARKET', + strategy_id=strategy_id, + is_paper=is_paper, + creator=user_id + ) + + if is_paper: + # Paper trade: simulate immediate fill + trade.status = 'filled' + trade.stats['qty_filled'] = trade.base_order_qty + trade.stats['opening_price'] = trade.order_price + trade.stats['opening_value'] = trade.base_order_qty * trade.order_price + trade.stats['current_value'] = trade.stats['opening_value'] + logger.info(f"Paper trade created: {trade.unique_id} {side} {qty} {symbol} @ {effective_price}") + else: + # Live trade: place order on exchange + if not self.exchange_connected(): + return 'Error', 'No exchange connected' + + user_name = self._get_user_name(user_id) if user_id else 'unknown' + status, msg = self.place_order(trade, user_name=user_name) + if status != 'success': + return 'Error', msg + + # Add to active trades + self.active_trades[trade.unique_id] = trade + self.stats['num_trades'] += 1 + + # Persist to database + self._save_trade(trade) + + return 'Success', trade.unique_id + + except Exception as e: + logger.error(f"Error creating new trade: {e}", exc_info=True) + return 'Error', str(e) + + def get_trades_for_user(self, user_id: int, form: str = 'json') -> list: + """ + Returns trades visible to a specific user. + + :param user_id: The user ID to filter trades for. + :param form: Output format ('json', 'obj', 'dict'). + :return: List of trades. + """ + user_trades = [ + trade for trade in self.active_trades.values() + if trade.creator == user_id or trade.creator is None + ] + + if form == 'obj': + return user_trades + elif form == 'json': + return [trade.to_json() for trade in user_trades] + elif form == 'dict': + return [trade.__dict__ for trade in user_trades] + else: + return [trade.to_json() for trade in user_trades] + def buy(self, order_data: dict[str, Any], user_id: int) -> tuple[str, str | None]: """ Executes a buy order. @@ -520,16 +845,37 @@ class Trades: :param price_updates: Dictionary mapping symbols to their current prices. :return: List of dictionaries containing updated trade data. """ + _debug_logger.debug(f"=== Trades.update() called ===") + _debug_logger.debug(f"price_updates: {price_updates}") + _debug_logger.debug(f"active_trades count: {len(self.active_trades)}") + _debug_logger.debug(f"active_trades keys: {list(self.active_trades.keys())}") + r_update = [] for trade_id, trade in list(self.active_trades.items()): symbol = trade.symbol + _debug_logger.debug(f"Processing trade_id={trade_id}, symbol={symbol}, status={trade.status}") current_price = price_updates.get(symbol) + _debug_logger.debug(f"current_price from get(): {current_price}") if current_price is None: - logger.warning(f"No price update provided for symbol '{symbol}'. Skipping trade {trade_id}.") + # Try to find a matching symbol (handle format differences like BTC/USD vs BTC/USDT) + for price_symbol, price in price_updates.items(): + # Normalize both symbols for comparison + norm_trade = symbol.upper().replace('/', '') + norm_price = price_symbol.upper().replace('/', '') + if norm_trade == norm_price or norm_trade.rstrip('T') == norm_price.rstrip('T'): + current_price = price + logger.debug(f"Matched trade symbol '{symbol}' to price symbol '{price_symbol}'") + break + + if current_price is None: + _debug_logger.debug(f"current_price is None after matching, skipping trade {trade_id}") + logger.warning(f"No price update for symbol '{symbol}'. Available: {list(price_updates.keys())}. Skipping trade {trade_id}.") continue + _debug_logger.debug(f"current_price resolved to: {current_price}") + _debug_logger.debug(f"Checking trade.status: '{trade.status}' in ['unfilled', 'part-filled']") if trade.status in ['unfilled', 'part-filled']: status = self.exchange_interface.get_trade_status(trade) if status in ['FILLED', 'PARTIALLY_FILLED']: @@ -559,57 +905,102 @@ class Trades: trade.status = status.lower() continue # Skip further processing for this trade + _debug_logger.debug(f"Checking if trade.status == 'inactive': {trade.status == 'inactive'}") if trade.status == 'inactive': + _debug_logger.debug(f"Trade {trade_id} is inactive, skipping") logger.error(f"Trades:update() - inactive trade encountered: {trade_id}") continue # Skip processing for inactive trades + _debug_logger.debug(f"Calling trade.update({current_price})") trade.update(current_price) trade_status = trade.status + _debug_logger.debug(f"After trade.update(), trade_status={trade_status}, trade.stats={trade.stats}") if trade_status in ['updated', 'filled', 'part-filled']: - r_update.append({ + update_data = { 'status': trade_status, 'id': trade.unique_id, 'pl': trade.stats.get('profit', 0.0), 'pl_pct': trade.stats.get('profit_pct', 0.0) - }) + } + r_update.append(update_data) + _debug_logger.debug(f"Appended update_data: {update_data}") + logger.info(f"Trade {trade_id} updated: price={current_price}, P/L={update_data['pl']:.2f} ({update_data['pl_pct']:.2f}%)") else: + _debug_logger.debug(f"trade_status '{trade_status}' not in update list, appending minimal data") r_update.append({'id': trade.unique_id, 'status': trade_status}) + _debug_logger.debug(f"=== Trades.update() returning: {r_update} ===") return r_update - def close_trade(self, trade_id: str) -> bool: + def close_trade(self, trade_id: str, current_price: float = None) -> dict: """ Closes a specific trade by settling it. + :param trade_id: The unique ID of the trade. - :return: True if successful, False otherwise. + :param current_price: Optional current price (used for paper trades). + :return: Dict with success status and trade info. """ trade = self.get_trade_by_id(trade_id) if not trade: logger.error(f"close_trade(): Trade ID {trade_id} not found.") - return False + return {"success": False, "message": f"Trade {trade_id} not found."} if trade.status == 'closed': logger.warning(f"close_trade(): Trade ID {trade_id} is already closed.") - return False + return {"success": False, "message": f"Trade {trade_id} is already closed."} try: - # Fetch the current price from the exchange - current_price = self.exchange_interface.get_price(trade.symbol) + # Get current price + if current_price is None: + if trade.is_paper: + # For paper trades without a price, use the last known current price + current_price = trade.stats.get('current_price', trade.order_price) + elif self.exchange_interface: + current_price = self.exchange_interface.get_price(trade.symbol) + else: + current_price = trade.stats.get('current_price', trade.order_price) + + # Settle the trade trade.settle(qty=trade.base_order_qty, price=current_price) + # Calculate final P/L + final_pl = trade.stats.get('profit', 0.0) + final_pl_pct = trade.stats.get('profit_pct', 0.0) + + # Move from active to settled if trade.status == 'closed': del self.active_trades[trade_id] self.settled_trades[trade_id] = trade self.stats['num_trades'] -= 1 - logger.info(f"Trade {trade_id} has been closed.") - return True + + # Update database - either delete or mark as closed + if self.data_cache: + self._save_trade(trade) + + logger.info(f"Trade {trade_id} closed. P/L: {final_pl:.2f} ({final_pl_pct:.2f}%)") + + return { + "success": True, + "message": "Trade closed successfully.", + "trade_id": trade_id, + "final_pl": final_pl, + "final_pl_pct": final_pl_pct, + "settled_price": current_price + } else: + # Partial settlement + self._save_trade(trade) logger.info(f"Trade {trade_id} partially settled.") - return True + return { + "success": True, + "message": "Trade partially settled.", + "trade_id": trade_id + } + except Exception as e: logger.error(f"Error closing trade '{trade_id}': {e}", exc_info=True) - return False + return {"success": False, "message": f"Error closing trade: {str(e)}"} def reduce_trade(self, user_id: int, trade_id: str, qty: float) -> float | None: """ diff --git a/tests/test_trade.py b/tests/test_trade.py index 48245c8..e2bf3ab 100644 --- a/tests/test_trade.py +++ b/tests/test_trade.py @@ -1,276 +1,460 @@ -from ExchangeInterface import ExchangeInterface -from trade import Trades +"""Tests for the Trade and Trades classes.""" +import pytest +from unittest.mock import MagicMock, patch + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from trade import Trade, Trades -def test_connect_exchange(): - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - account_info = exchange.get_precision(symbol='ETHUSDT') - print(account_info) - assert test_trades_obj.exchange_connected() +class TestTrade: + """Tests for the Trade class.""" + + def test_trade_creation(self): + """Test basic trade creation.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1 + ) + + assert trade.symbol == 'BTC/USDT' + assert trade.side == 'BUY' + assert trade.order_price == 50000.0 + assert trade.base_order_qty == 0.1 + assert trade.status == 'inactive' + assert trade.is_paper is False + assert trade.unique_id is not None + + def test_trade_paper_flag(self): + """Test is_paper flag.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + is_paper=True + ) + + assert trade.is_paper is True + + def test_trade_to_json(self): + """Test trade serialization.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + is_paper=True, + creator=1 + ) + + json_data = trade.to_json() + + assert json_data['symbol'] == 'BTC/USDT' + assert json_data['side'] == 'BUY' + assert json_data['is_paper'] is True + assert json_data['creator'] == 1 + assert 'stats' in json_data + + def test_trade_update_values(self): + """Test P/L calculation.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1, + fee=0.001 # 0.1% fee + ) + trade.status = 'filled' + trade.stats['qty_filled'] = 0.1 + + # Price goes up + trade.update_values(55000.0) + + assert trade.stats['current_price'] == 55000.0 + assert trade.stats['current_value'] == 5500.0 + # Profit should be positive (minus fees) + assert trade.stats['profit'] > 0 + + def test_trade_sell_side_pl(self): + """Test P/L calculation for sell side.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='SELL', + order_price=50000.0, + base_order_qty=0.1, + fee=0.001 + ) + trade.status = 'filled' + trade.stats['qty_filled'] = 0.1 + + # Price goes down - should be profit for sell + trade.update_values(45000.0) + + assert trade.stats['profit'] > 0 + + def test_trade_filled(self): + """Test trade fill logic.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1 + ) + + trade.trade_filled(qty=0.1, price=50000.0) + + assert trade.status == 'filled' + assert trade.stats['qty_filled'] == 0.1 + assert trade.stats['opening_price'] == 50000.0 + + def test_trade_partial_fill(self): + """Test partial fill logic.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1 + ) + + trade.trade_filled(qty=0.05, price=50000.0) + + assert trade.status == 'part-filled' + assert trade.stats['qty_filled'] == 0.05 + + def test_trade_settle(self): + """Test trade settlement.""" + trade = Trade( + target='test_exchange', + symbol='BTC/USDT', + side='BUY', + order_price=50000.0, + base_order_qty=0.1 + ) + trade.trade_filled(qty=0.1, price=50000.0) + + trade.settle(qty=0.1, price=55000.0) + + assert trade.status == 'closed' + assert trade.stats['settled_price'] == 55000.0 -def test_get_trades_by_status(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - assert test_trades_obj.exchange_connected() +class TestTrades: + """Tests for the Trades class.""" - # create a trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 1.5, price=100, offset=None) - print('trade 0 created.') - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' + @pytest.fixture + def mock_users(self): + """Create a mock Users object.""" + users = MagicMock() + users.get_username.return_value = 'test_user' + return users - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print('trade 1 created.') - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' + @pytest.fixture + def mock_data_cache(self): + """Create a mock DataCache object.""" + dc = MagicMock() + dc.db.table_exists.return_value = True + dc.get_all_rows_from_datacache.return_value = None + dc.get_rows_from_datacache.return_value = MagicMock(empty=True) + return dc - # create a 3rd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print('trade 2 created.') - print(test_trades_obj.active_trades[2].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' + def test_trades_creation_no_cache(self, mock_users): + """Test Trades creation without data cache.""" + trades = Trades(mock_users) - # should be three trades in this list - print(f'Expecting 3 trades in list: Actual:{len(test_trades_obj.active_trades)}') - assert len(test_trades_obj.active_trades) is 3 - print(test_trades_obj.active_trades[0].status) - print(test_trades_obj.active_trades[1].status) - print(test_trades_obj.active_trades[2].status) + assert trades.users == mock_users + assert trades.data_cache is None + assert len(trades.active_trades) == 0 - # fill trade one - test_trades_obj.active_trades[1].trade_filled(0.4, 2100) - print(f'trade 1 filled. status:') - print(test_trades_obj.active_trades[1].status) + def test_trades_creation_with_cache(self, mock_users, mock_data_cache): + """Test Trades creation with data cache.""" + trades = Trades(mock_users, data_cache=mock_data_cache) - # Search for all inactive trades - result = test_trades_obj.get_trades_by_status('inactive') - print(f'search for all inactive trades. The result: {result}') - assert len(result) is 2 + assert trades.data_cache == mock_data_cache - # Search for all filled trades - result = test_trades_obj.get_trades_by_status('filled') - print(f'search for all filled trades. The result: {result}') - assert len(result) is 1 + def test_connect_exchanges(self, mock_users): + """Test exchange connection.""" + trades = Trades(mock_users) + mock_exchange = MagicMock() + + trades.connect_exchanges(mock_exchange) + + assert trades.exchange_interface == mock_exchange + assert trades.exchange_connected() is True + + def test_new_paper_trade(self, mock_users): + """Test creating a paper trade.""" + 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, + user_id=1 + ) + + assert status == 'Success' + assert trade_id is not None + assert trade_id in trades.active_trades + + # Check trade properties + trade = trades.get_trade_by_id(trade_id) + assert trade.is_paper is True + assert trade.status == 'filled' + assert trade.creator == 1 + + def test_new_live_trade_no_exchange(self, mock_users): + """Test creating a live trade without exchange connected.""" + 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 + ) + + assert status == 'Error' + assert 'No exchange connected' in msg + + def test_get_trades_json(self, mock_users): + """Test getting trades in JSON format.""" + trades = Trades(mock_users) + + # Create a paper trade + trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1 + ) + + result = trades.get_trades('json') + + assert len(result) == 1 + assert result[0]['symbol'] == 'BTC/USDT' + + def test_get_trades_for_user(self, mock_users): + """Test filtering trades by user.""" + trades = Trades(mock_users) + + # Create trades for different users + trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1, + user_id=1 + ) + trades.new_trade( + target='test_exchange', + symbol='ETH/USDT', + price=3000.0, + side='buy', + order_type='MARKET', + qty=1.0, + user_id=2 + ) + + # Filter for user 1 + user1_trades = trades.get_trades_for_user(1, 'json') + assert len(user1_trades) == 1 + assert user1_trades[0]['symbol'] == 'BTC/USDT' + + def test_close_paper_trade(self, mock_users): + """Test closing a paper trade.""" + trades = Trades(mock_users) + + # Create a paper trade + status, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1 + ) + + assert trade_id in trades.active_trades + + # Close the trade + result = trades.close_trade(trade_id, current_price=55000.0) + + assert result['success'] is True + assert trade_id not in trades.active_trades + assert trade_id in trades.settled_trades + + def test_close_nonexistent_trade(self, mock_users): + """Test closing a trade that doesn't exist.""" + trades = Trades(mock_users) + + result = trades.close_trade('nonexistent_id') + + assert result['success'] is False + assert 'not found' in result['message'] + + def test_is_valid_trade_id(self, mock_users): + """Test trade ID validation.""" + trades = Trades(mock_users) + + _, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1 + ) + + assert trades.is_valid_trade_id(trade_id) is True + assert trades.is_valid_trade_id('invalid_id') is False + + def test_update_trades(self, mock_users): + """Test updating trade P/L with price changes.""" + trades = Trades(mock_users) + + # Create and fill a trade + _, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='buy', + order_type='MARKET', + qty=0.1 + ) + + # Update with new price + price_updates = {'BTC/USDT': 55000.0} + updates = trades.update(price_updates) + + assert len(updates) > 0 + # Find our trade in updates + trade_update = next((u for u in updates if u['id'] == trade_id), None) + assert trade_update is not None + assert trade_update['pl'] != 0 # Should have some P/L + + def test_buy_method_paper(self, mock_users): + """Test buy method creates a BUY paper trade using new_trade.""" + trades = Trades(mock_users) + + # Use new_trade for paper trades (buy/sell methods are for live trading) + 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 + ) + + assert status == 'Success' + trade = trades.get_trade_by_id(trade_id) + assert trade.side == 'BUY' + + def test_sell_method_paper(self, mock_users): + """Test sell method creates a SELL paper trade using new_trade.""" + trades = Trades(mock_users) + + # Use new_trade for paper trades (buy/sell methods are for live trading) + status, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0, + side='sell', + order_type='MARKET', + qty=0.1, + user_id=1 + ) + + assert status == 'Success' + trade = trades.get_trade_by_id(trade_id) + assert trade.side == 'SELL' -def test_get_trade_by_id(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - assert test_trades_obj.exchange_connected() +class TestTradeIntegration: + """Integration tests for Trade system.""" - # create a trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 1.5, price=100, offset=None) - print('trade 0 created.') - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' + @pytest.fixture + def mock_users(self): + users = MagicMock() + users.get_username.return_value = 'test_user' + return users - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print('trade 1 created.') - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' + def test_full_trade_lifecycle(self, mock_users): + """Test complete lifecycle: create -> update -> close.""" + trades = Trades(mock_users) - id = test_trades_obj.active_trades[0].unique_id - print(f'the id of trade 0 is{id}') - result = test_trades_obj.get_trade_by_id(id) - print(f'here is the result after searching for the id:{result}') - assert result.unique_id is id + # Create trade + 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 + ) + assert status == 'Success' + trade = trades.get_trade_by_id(trade_id) + # Set a more realistic fee (0.1% instead of default 10%) + trade.fee = 0.001 + trade.stats['fee_paid'] = trade.stats['opening_value'] * trade.fee -def test_load_trades(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - assert test_trades_obj.exchange_connected() - print(f'Active trades: {test_trades_obj.active_trades}') - trades = [{ - 'order_price': 24595.4, - 'exchange_name': 'backtester', - 'base_order_qty': 0.05, - 'order': None, - 'fee': 0.1, - 'order_type': 'MARKET', - 'side': 'buy', - 'stats': { - 'current_price': 24595.4, - 'current_value': 1229.7700000000002, - 'fee_paid': 0, - 'opening_price': 24595.4, - 'opening_value': 1229.7700000000002, - 'profit': 0, - 'profit_pct': 0, - 'qty_filled': 0, - 'qty_settled': 0, - 'settled_price': 0, - 'settled_value': 0 - }, - 'status': 'inactive', - 'symbol': 'BTCUSDT', - 'time_in_force': 'GTC', - 'unique_id': '9330afd188474d83b06e19d1916c0474' - }] - test_trades_obj.load_trades(trades) - print(f'Active trades: {test_trades_obj.active_trades[0].__dict__}') - assert len(test_trades_obj.active_trades) > 0 + # Update with higher price (20% increase should exceed fees) + trades.update({'BTC/USDT': 60000.0}) + trade = trades.get_trade_by_id(trade_id) + assert trade.stats['profit'] > 0 # Should be in profit -def test_place_order(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') + # Close trade + result = trades.close_trade(trade_id, current_price=60000.0) + assert result['success'] is True + assert result['final_pl'] > 0 - # Create a new treade on the exchange_interface. - test_trades_obj.new_trade('exchange_interface', 'BTCUSDT', 'BUY', 'MARKET', 0.1, price=100, offset=None) - print(test_trades_obj.active_trades[0].__dict__) + def test_multiple_trades(self, mock_users): + """Test managing multiple trades.""" + trades = Trades(mock_users) - # If the status of the trade is unfilled the order is placed. - assert test_trades_obj.active_trades[0].status is 'unfilled' + # Create multiple trades + trade_ids = [] + for i in range(3): + _, trade_id = trades.new_trade( + target='test_exchange', + symbol='BTC/USDT', + price=50000.0 + (i * 100), + side='buy', + order_type='MARKET', + qty=0.1 + ) + trade_ids.append(trade_id) + assert len(trades.active_trades) == 3 -def test_update(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') + # Close one trade + trades.close_trade(trade_ids[1]) - # Create a trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=100, offset=None) - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' - - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.2, price=100, offset=None) - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' - - test_trades_obj.active_trades[0].trade_filled(0.4, 100) - test_trades_obj.active_trades[1].trade_filled(0.2, 100) - - test_trades_obj.update(200) - print(test_trades_obj.active_trades[0].__dict__) - print(test_trades_obj.active_trades[1].__dict__) - - -def test_new_trade(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - - # create an trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.1, price=100, offset=None) - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' - - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' - - -def test_close_trade(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - - # create a trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.1, price=100, offset=None) - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' - - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' - - # should be two trades in this list - print(len(test_trades_obj.active_trades)) - assert len(test_trades_obj.active_trades) > 1 - - trade_id = test_trades_obj.active_trades[0].unique_id - test_trades_obj.close_trade(trade_id) - - # should be 1 trade in this list - print(len(test_trades_obj.active_trades)) - assert len(test_trades_obj.active_trades) == 1 - - -def test_reduce_trade(): - # Connect to the exchange_interface - exchange = ExchangeInterface() - test_trades_obj = Trades() - test_trades_obj.connect_exchange(exchange) - print(f'\nConnected to exchange_interface: {test_trades_obj.exchange_connected()}') - assert test_trades_obj.exchange_connected() - - # create a trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 1.5, price=100, offset=None) - print('trade 0 created.') - print(test_trades_obj.active_trades[0].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[0].status is 'inactive' - - # create a 2nd trade but not on the exchange_interface - test_trades_obj.new_trade('backtestor', 'BTCUSDT', 'BUY', 'MARKET', 0.4, price=2100, offset=None) - print('trade 1 created.') - print(test_trades_obj.active_trades[1].__dict__) - # If the status of the trade is inactive the trade is created but the order isn't placed. - assert test_trades_obj.active_trades[1].status is 'inactive' - - # should be two trades in this list - print(f'Expecting 2 trades in list: Actual:{len(test_trades_obj.active_trades)}') - assert len(test_trades_obj.active_trades) > 1 - - # Grab inactive trade 0 - trade_id = test_trades_obj.active_trades[0].unique_id - # reduce the trade by 0.5 - remaining_qty = test_trades_obj.reduce_trade(trade_id, 0.5) - # The trade should be 1 (1.5 - 0.5) - print(f'The remaining quantity of the trade should be 1: Actual: {remaining_qty}') - assert remaining_qty == 1 - - print('trade 0:') - print(test_trades_obj.active_trades[0].__dict__) - - test_trades_obj.active_trades[1].trade_filled(0.4, 2100) - # Grab filled trade 1 - trade_id = test_trades_obj.active_trades[1].unique_id - - # reduce the trade by 0.1 - remaining_qty = float(test_trades_obj.reduce_trade(trade_id, 0.1)) - # The trade should be 0.3 (0.4 - 0.1) - print(f'\nThe remaining quantity of trade 1 should be 0.3: Actual: {remaining_qty}') - assert remaining_qty == 0.3 - - print('trade 1:') - print(test_trades_obj.active_trades[1].__dict__) + assert len(trades.active_trades) == 2 + assert trade_ids[1] not in trades.active_trades