brighter-trading/src/static/trade.js

1998 lines
71 KiB
JavaScript

/**
* TradeUIManager - Handles DOM updates and trade card rendering
*/
class TradeUIManager {
constructor() {
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.symbolInput = null;
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 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',
'bybit',
'okx', 'okex',
'bitget',
'bitmex',
'deribit',
'phemex'
// Removed: 'kucoin', 'kucoinfutures', 'mexc' - no sandbox support
];
}
/**
* 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',
symbolId = 'tradeSymbol',
exchangeId = 'tradeExchange',
testnetId = 'tradeTestnet',
testnetRowId = 'testnet-row',
stopLossId = 'stopLoss',
takeProfitId = 'takeProfit',
timeInForceId = 'timeInForce',
exchangeRowId = 'exchange-row',
sltpRowId = 'sltp-row'
} = 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);
this.symbolInput = document.getElementById(symbolId);
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();
}
/**
* 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);
}
// 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();
});
}
// Exchange dropdown changes update symbol list
if (this.exchangeSelect) {
this.exchangeSelect.addEventListener('change', async () => {
const selectedExchange = this.exchangeSelect.value;
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();
});
}
}
/**
* Populates the symbol dropdown with trading pairs from EDM.
* @param {string} exchange - The exchange to fetch symbols for.
* @param {string|null} selectedSymbol - Symbol to select (or null to use first).
*/
async _populateSymbolDropdown(exchange, selectedSymbol) {
if (!this.symbolInput) return;
// Popular base currencies to prioritize (most traded first)
const popularBases = ['BTC', 'ETH', 'SOL', 'XRP', 'ADA', 'DOGE', 'AVAX', 'DOT', 'MATIC', 'LINK',
'LTC', 'UNI', 'ATOM', 'XLM', 'ALGO', 'FIL', 'NEAR', 'APT', 'ARB', 'OP'];
// Common symbols as fallback
const commonSymbols = ['BTC/USDT', 'ETH/USDT', 'BTC/USD', 'ETH/USD', 'SOL/USDT', 'XRP/USDT', 'ADA/USDT', 'DOGE/USDT'];
let symbols = [];
let allExchangeSymbols = [];
// Try to fetch symbols from EDM
try {
const edm_url = window.bt_data?.edm_url || 'http://localhost:8080';
const response = await fetch(`${edm_url}/exchanges/${exchange.toLowerCase()}/symbols`);
if (response.ok) {
const data = await response.json();
if (data.symbols && data.symbols.length > 0) {
allExchangeSymbols = data.symbols;
console.log(`Fetched ${allExchangeSymbols.length} symbols for ${exchange}`);
// Prioritize: First add popular USDT pairs in order of popularity
for (const base of popularBases) {
const usdtPair = `${base}/USDT`;
if (allExchangeSymbols.includes(usdtPair) && !symbols.includes(usdtPair)) {
symbols.push(usdtPair);
}
}
// Then add popular USD pairs
for (const base of popularBases) {
const usdPair = `${base}/USD`;
if (allExchangeSymbols.includes(usdPair) && !symbols.includes(usdPair)) {
symbols.push(usdPair);
}
}
// Then add remaining USDT pairs (sorted)
const remainingUsdtPairs = allExchangeSymbols
.filter(s => s.endsWith('/USDT') && !symbols.includes(s))
.sort();
symbols.push(...remainingUsdtPairs);
// Then add remaining USD pairs
const remainingUsdPairs = allExchangeSymbols
.filter(s => s.endsWith('/USD') && !symbols.includes(s))
.sort();
symbols.push(...remainingUsdPairs);
// Finally add other pairs (BTC pairs, etc.)
const otherPairs = allExchangeSymbols
.filter(s => !symbols.includes(s))
.sort();
symbols.push(...otherPairs);
}
}
} catch (error) {
console.warn(`Failed to fetch symbols for ${exchange}, using defaults:`, error);
}
// Fall back to common symbols if nothing fetched
if (symbols.length === 0) {
symbols = [...commonSymbols];
}
// Ensure selected symbol is at the top of the list
if (selectedSymbol) {
// Remove it if it exists elsewhere in the list
symbols = symbols.filter(s => s !== selectedSymbol);
// Add it to the front
symbols.unshift(selectedSymbol);
}
// Populate the dropdown
this.symbolInput.innerHTML = '';
for (const symbol of symbols) {
const option = document.createElement('option');
option.value = symbol;
option.textContent = symbol;
this.symbolInput.appendChild(option);
}
// Set the selected value
if (selectedSymbol) {
this.symbolInput.value = selectedSymbol;
}
}
/**
* Updates testnet checkbox visibility based on selected exchange.
*/
_updateTestnetVisibility() {
if (!this.testnetRow || !this.targetSelect) return;
const selectedTarget = this.targetSelect.value;
const isPaperTrade = selectedTarget === 'test_exchange';
if (isPaperTrade) {
// Hide testnet row for paper trading
this.testnetRow.style.display = 'none';
} else {
// Show testnet row for live exchanges
this.testnetRow.style.display = 'block';
// Check if this exchange supports testnet
const exchangeId = selectedTarget.toLowerCase();
const supportsTestnet = this.testnetSupportedExchanges.includes(exchangeId);
const warningEl = document.getElementById('testnet-warning');
const unavailableEl = document.getElementById('testnet-unavailable');
if (supportsTestnet) {
// Enable testnet checkbox
if (this.testnetCheckbox) {
this.testnetCheckbox.disabled = false;
this.testnetCheckbox.checked = true;
}
if (warningEl) warningEl.style.display = 'block';
if (unavailableEl) unavailableEl.style.display = 'none';
} else {
// Disable testnet checkbox - this exchange doesn't support it
if (this.testnetCheckbox) {
this.testnetCheckbox.disabled = true;
this.testnetCheckbox.checked = false;
}
if (warningEl) warningEl.style.display = 'none';
if (unavailableEl) unavailableEl.style.display = 'block';
}
}
}
/**
* 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.
*/
populateExchangeSelector(connectedExchanges) {
if (!this.targetSelect) return;
// Clear existing options except Paper Trade
while (this.targetSelect.options.length > 1) {
this.targetSelect.remove(1);
}
// Add connected exchanges
if (connectedExchanges && connectedExchanges.length > 0) {
for (const exchange of connectedExchanges) {
// Skip 'default' exchange used internally
if (exchange.toLowerCase() === 'default') continue;
const option = document.createElement('option');
option.value = exchange.toLowerCase();
option.textContent = `${exchange} (Live)`;
this.targetSelect.appendChild(option);
}
}
}
/**
* Populates the exchange dropdown for price data source.
* @param {string[]} availableExchanges - List of available exchange names.
* @param {string} defaultExchange - Default exchange to select (chart view).
*/
populateExchangeDropdown(availableExchanges, defaultExchange = null) {
if (!this.exchangeSelect) return;
// Clear existing options
this.exchangeSelect.innerHTML = '';
// Common exchanges list as fallback
const commonExchanges = ['binance', 'kucoin', 'coinbase', 'kraken', 'bybit', 'okx', 'gemini', 'bitstamp'];
let exchanges = (availableExchanges && availableExchanges.length > 0)
? [...availableExchanges]
: [...commonExchanges];
// Ensure chart view exchange is in the list and at the top
if (defaultExchange) {
const normalizedDefault = defaultExchange.toLowerCase();
// Remove if exists elsewhere in the list
exchanges = exchanges.filter(e => e.toLowerCase() !== normalizedDefault);
// Add at the beginning
exchanges.unshift(normalizedDefault);
}
// Add exchanges
for (const exchange of exchanges) {
if (exchange.toLowerCase() === 'default') continue;
const option = document.createElement('option');
option.value = exchange.toLowerCase();
option.textContent = exchange.charAt(0).toUpperCase() + exchange.slice(1).toLowerCase();
this.exchangeSelect.appendChild(option);
}
// Set default to chart view exchange (first option after our reordering)
if (defaultExchange) {
this.exchangeSelect.value = defaultExchange.toLowerCase();
}
}
/**
* Displays the trade creation form.
* @param {number} currentPrice - Optional current price to prefill.
* @param {string} symbol - Optional trading pair to prefill.
* @param {string[]} connectedExchanges - Optional list of connected exchanges.
* @param {string} chartExchange - Optional current chart view exchange.
*/
async displayForm(currentPrice = null, symbol = null, connectedExchanges = null, chartExchange = 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';
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) {
if (this.priceInput) this.priceInput.value = currentPrice;
if (this.currentPriceDisplay) this.currentPriceDisplay.value = currentPrice;
}
// Populate target selector (Paper vs Live exchanges)
if (connectedExchanges) {
this.populateExchangeSelector(connectedExchanges);
}
// Populate exchange dropdown for price data source
this.populateExchangeDropdown(connectedExchanges, chartExchange);
// Populate symbol dropdown with the chart exchange and symbol
const exchangeToUse = chartExchange || 'binance';
await this._populateSymbolDropdown(exchangeToUse, symbol);
// 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();
}
/**
* 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 {
tradeItem.classList.add('trade-live');
}
// 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);
}
});
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);
} else if (trade.testnet) {
// Testnet badge for live trades
const testnetBadge = document.createElement('span');
testnetBadge.className = 'trade-testnet-badge';
testnetBadge.textContent = 'TESTNET';
testnetBadge.style.cssText = 'background: #28a745; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; position: absolute; top: 4px; left: 4px;';
tradeItem.appendChild(testnetBadge);
} else if (!trade.is_paper) {
// Production badge for live trades (not paper, not testnet)
const prodBadge = document.createElement('span');
prodBadge.className = 'trade-prod-badge';
prodBadge.textContent = 'LIVE';
prodBadge.style.cssText = 'background: #dc3545; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; position: absolute; top: 4px; left: 4px;';
tradeItem.appendChild(prodBadge);
}
// 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 = `<span class="trade-label">Qty:</span><span class="trade-value">${this._formatNumber(trade.base_order_qty)}</span>`;
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 = `<span class="trade-label">Entry:</span><span class="trade-value">${this._formatPrice(entryPrice)}</span>`;
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 = `<span class="trade-label">P/L:</span><span class="trade-value trade-pl ${plClass}" id="${trade.unique_id}_pl_value">${this._formatPL(pl)} (${this._formatPct(plPct)})</span>`;
tradeInfo.appendChild(plRow);
tradeItem.appendChild(tradeInfo);
// Hover details panel
const hoverPanel = document.createElement('div');
hoverPanel.className = 'trade-hover';
let hoverHtml = `<strong>${trade.symbol}</strong>`;
hoverHtml += `<div class="trade-details">`;
hoverHtml += `<span>Side: ${side}</span>`;
hoverHtml += `<span>Type: ${trade.order_type || 'MARKET'}</span>`;
hoverHtml += `<span>Quantity: ${this._formatNumber(trade.base_order_qty)}</span>`;
hoverHtml += `<span>Entry Price: ${this._formatPrice(entryPrice)}</span>`;
hoverHtml += `<span>Current Value: ${this._formatPrice(stats.current_value || 0)}</span>`;
hoverHtml += `<span class="${plClass}">P/L: ${this._formatPL(pl)} (${this._formatPct(plPct)})</span>`;
hoverHtml += `<span>Status: ${trade.status || 'unknown'}</span>`;
// Show actual exchange name (use 'exchange' field for paper trades, otherwise 'target')
const displayExchange = trade.is_paper ? (trade.exchange || trade.target || 'N/A') : (trade.target || 'N/A');
hoverHtml += `<span>Exchange: ${displayExchange}</span>`;
if (trade.is_paper) {
hoverHtml += `<span class="trade-paper-indicator">Paper Trade</span>`;
} else if (trade.testnet) {
hoverHtml += `<span class="trade-testnet-indicator" style="color: #28a745;">Testnet</span>`;
} else {
hoverHtml += `<span class="trade-prod-indicator" style="color: #dc3545;">Production (Live)</span>`;
}
hoverHtml += `</div>`;
hoverPanel.innerHTML = hoverHtml;
tradeItem.appendChild(hoverPanel);
return tradeItem;
}
/**
* 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)})`;
// Add flash animation
plEl.classList.add('trade-pl-flash');
setTimeout(() => plEl.classList.remove('trade-pl-flash'), 300);
}
}
/**
* 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;
}
/**
* 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();
}
}
}
}
/**
* 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));
// 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);
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 (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) {
console.error("Error initializing Trade:", error);
}
}
/**
* 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
*/
_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();
// Also refresh the new broker UI panels (Orders, Positions, History)
this.refreshAll();
} 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();
// Also refresh the new broker UI panels
this.refreshAll();
// 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 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.
*/
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 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 ================
/**
* Opens the trade creation form.
*/
async 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;
}
// Get symbol from chart
const symbol = this.data?.trading_pair || '';
// Get current chart view exchange
const chartExchange = this.data?.exchange || 'binance';
// Get connected exchanges from UI.exchanges
const connectedExchanges = window.UI?.exchanges?.connected_exchanges || [];
await this.uiManager.displayForm(currentPrice, symbol, connectedExchanges, chartExchange);
}
/**
* Sets the symbol dropdown to the current chart symbol.
*/
async useChartSymbol() {
if (this.uiManager.symbolInput && this.data?.trading_pair) {
const chartExchange = this.data?.exchange || 'binance';
// Re-populate dropdown with chart symbol at top
await this.uiManager._populateSymbolDropdown(chartExchange, this.data.trading_pair);
}
}
/**
* 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.uiManager.symbolInput?.value || this.data?.trading_pair || '';
const orderType = this.uiManager.orderTypeSelect?.value || 'MARKET';
const side = this.uiManager.sideSelect?.value || 'buy';
const exchange = this.uiManager.exchangeSelect?.value || this.data?.exchange || 'binance';
// Get testnet setting (only relevant for live exchanges)
const isPaperTrade = target === 'test_exchange';
const testnet = isPaperTrade ? false : (this.uiManager.testnetCheckbox?.checked ?? true);
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);
// 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.');
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;
}
// 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(
"WARNING: PRODUCTION MODE\n\n" +
"You are about to execute a LIVE trade with REAL MONEY.\n\n" +
`Exchange: ${target}\n` +
`Symbol: ${symbol}\n` +
`Side: ${side.toUpperCase()}\n` +
`Quantity: ${quantity}\n\n` +
"Are you sure you want to proceed?"
);
if (!proceed) {
return;
}
}
const tradeData = {
target,
exchange,
symbol,
price,
side,
orderType,
quantity,
testnet,
stopLoss,
takeProfit,
timeInForce,
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();
}
}