/** * Escapes HTML special characters to prevent XSS attacks. * @param {string} str - The string to escape. * @returns {string} - The escaped string. */ function escapeHtml(str) { if (str == null) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Escapes a string for safe embedding inside a single-quoted JS string literal. * @param {string} str - Raw string value. * @returns {string} - JS-escaped string. */ function escapeJsString(str) { if (str == null) return ''; return String(str) .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/\r/g, '\\r') .replace(/\n/g, '\\n'); } class StratUIManager { constructor(workspaceManager) { this.workspaceManager = workspaceManager; this.targetEl = null; this.formElement = null; } /** * Initializes the UI elements with provided IDs. * @param {string} targetId - The ID of the HTML element where strategies will be displayed. * @param {string} formElId - The ID of the HTML element for the strategy creation form. */ initUI(targetId, formElId) { // Get the target element for displaying strategies this.targetEl = document.getElementById(targetId); if (!this.targetEl) { throw new Error(`Element for displaying strategies "${targetId}" not found.`); } // Get the form element for strategy creation this.formElement = document.getElementById(formElId); if (!this.formElement) { throw new Error(`Strategies form element "${formElId}" not found.`); } } /** * Displays the form for creating or editing a strategy. * @param {string} action - The action to perform ('new' or 'edit'). * @param {object|null} strategyData - The data of the strategy to edit (only applicable for 'edit' action). */ async displayForm(action, strategyData = null) { console.log(`Opening form for action: ${action}, strategy: ${strategyData?.name}`); if (this.formElement) { const headerTitle = this.formElement.querySelector("#draggable_header h1"); const submitCreateBtn = this.formElement.querySelector("#submit-create"); const submitEditBtn = this.formElement.querySelector("#submit-edit"); const nameBox = this.formElement.querySelector('#name_box'); const publicCheckbox = this.formElement.querySelector('#public_checkbox'); const feeBox = this.formElement.querySelector('#fee_box'); const exchangeSelect = this.formElement.querySelector('#strategy_exchange'); const symbolInput = this.formElement.querySelector('#strategy_symbol'); const timeframeSelect = this.formElement.querySelector('#strategy_timeframe'); if (!headerTitle || !submitCreateBtn || !submitEditBtn || !nameBox || !publicCheckbox || !feeBox) { console.error('One or more form elements were not found.'); return; } // Remove any existing warning banner const existingWarning = this.formElement.querySelector('.running-strategy-warning'); if (existingWarning) { existingWarning.remove(); } // Remove any existing source change warning const existingSourceWarning = this.formElement.querySelector('.source-change-warning'); if (existingSourceWarning) { existingSourceWarning.remove(); } // Track the strategy being edited (for restart prompt after save) this._editingStrategyId = null; // Get current chart view for default source const chartView = window.UI?.data?.getChartView?.() || { exchange: 'binance', market: 'BTC/USDT', timeframe: '5m' }; console.log('Current chart view for default source:', chartView); // Set up exchange change listener to update symbols if (exchangeSelect && symbolInput) { // Remove old listener if exists exchangeSelect.removeEventListener('change', this._onExchangeChange); this._onExchangeChange = async () => { const selectedExchange = exchangeSelect.value; await this._populateSymbolDropdown(symbolInput, selectedExchange, null); }; exchangeSelect.addEventListener('change', this._onExchangeChange); } // Update form based on action if (action === 'new') { headerTitle.textContent = "Create New Strategy"; submitCreateBtn.style.display = "inline-block"; submitEditBtn.style.display = "none"; nameBox.value = ''; publicCheckbox.checked = false; feeBox.value = 0; // Set default source from current chart view const defaultExchange = (chartView.exchange || 'binance').toLowerCase(); if (exchangeSelect) exchangeSelect.value = defaultExchange; if (timeframeSelect) timeframeSelect.value = chartView.timeframe || '5m'; // Populate symbols for the default exchange if (symbolInput) { await this._populateSymbolDropdown(symbolInput, defaultExchange, chartView.market || 'BTC/USDT'); } } else if (action === 'edit' && strategyData) { headerTitle.textContent = "Edit Strategy"; submitCreateBtn.style.display = "none"; submitEditBtn.style.display = "inline-block"; nameBox.value = strategyData.name; publicCheckbox.checked = strategyData.public === 1; feeBox.value = strategyData.fee || 0; // Store the strategy ID for later use this._editingStrategyId = strategyData.tbl_key; // Load saved source values (if available) or fall back to chart view const savedSource = strategyData.default_source || {}; const savedExchange = (savedSource.exchange || chartView.exchange || 'binance').toLowerCase(); const savedSymbol = savedSource.market || savedSource.symbol || chartView.market || 'BTC/USDT'; const savedTimeframe = savedSource.timeframe || chartView.timeframe || '5m'; if (exchangeSelect) exchangeSelect.value = savedExchange; if (timeframeSelect) timeframeSelect.value = savedTimeframe; // Populate symbols for the saved exchange and select the saved symbol if (symbolInput) { await this._populateSymbolDropdown(symbolInput, savedExchange, savedSymbol); } // Show warning if current chart view differs from saved source const currentExchange = (chartView.exchange || 'binance').toLowerCase(); const currentSymbol = chartView.market || 'BTC/USDT'; const currentTimeframe = chartView.timeframe || '5m'; if (savedSource.exchange && ( savedExchange !== currentExchange || savedSymbol !== currentSymbol || savedTimeframe !== currentTimeframe )) { const warningBanner = document.createElement('div'); warningBanner.className = 'source-change-warning'; warningBanner.innerHTML = ` 📊 Strategy was saved with ${savedExchange.toUpperCase()} ${savedSymbol} (${savedTimeframe}). You're viewing ${currentExchange.toUpperCase()} ${currentSymbol} (${currentTimeframe}). Update below if you want to change the trading source. `; warningBanner.style.cssText = ` background: #e7f3ff; border: 1px solid #007bff; border-radius: 5px; padding: 10px; margin: 10px; color: #004085; font-size: 12px; display: flex; align-items: center; `; // Insert after header const header = this.formElement.querySelector('#draggable_header'); if (header && header.nextSibling) { header.parentNode.insertBefore(warningBanner, header.nextSibling); } } // Check if strategy is currently running and show warning if (UI.strats && UI.strats.isStrategyRunning(strategyData.tbl_key)) { const runningInfo = UI.strats.getRunningInfo(strategyData.tbl_key); const modeText = runningInfo ? runningInfo.mode : 'unknown'; // Create warning banner const warningBanner = document.createElement('div'); warningBanner.className = 'running-strategy-warning'; warningBanner.innerHTML = ` ⚠️ This strategy is currently running in ${modeText} mode. Changes will not take effect until you restart it. `; warningBanner.style.cssText = ` background: #fff3cd; border: 1px solid #ffc107; border-radius: 5px; padding: 10px; margin: 10px; color: #856404; font-size: 13px; display: flex; align-items: center; `; // Insert after header const header = this.formElement.querySelector('#draggable_header'); if (header && header.nextSibling) { header.parentNode.insertBefore(warningBanner, header.nextSibling); } } } // Display the form this.formElement.style.display = "grid"; // Initialize Blockly workspace after the form becomes visible if (UI.strats && this.workspaceManager) { try { await this.workspaceManager.initWorkspace(); console.log("Blockly workspace initialized."); // Restore workspace from XML if editing if (action === 'edit' && strategyData && strategyData.workspace) { this.workspaceManager.loadWorkspaceFromXml(strategyData.workspace); console.log("Workspace restored from XML."); } } catch (error) { console.error("Failed to initialize Blockly workspace:", error); } } else { console.error("Workspace manager is not initialized or is unavailable."); } } else { console.error(`Form element "${this.formElement.id}" not found.`); } } /** * Hides the "Create New Strategy" form by adding a 'hidden' class. */ hideForm() { if (this.formElement) { this.formElement.style.display = 'none'; // Hide the form } } /** * Populates the symbol dropdown with symbols for the given exchange. * Fetches from EDM API, falls back to common symbols on error. * @param {HTMLSelectElement} selectElement - The symbol dropdown element. * @param {string} exchange - The exchange name. * @param {string|null} selectedSymbol - The symbol to select after populating. */ async _populateSymbolDropdown(selectElement, exchange, selectedSymbol) { if (!selectElement) 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 (no arbitrary limit - show all available) selectElement.innerHTML = ''; for (const symbol of symbols) { const option = document.createElement('option'); option.value = symbol; option.textContent = symbol; selectElement.appendChild(option); } // Set the selected value if (selectedSymbol) { selectElement.value = selectedSymbol; } } /** * Updates the HTML representation of the strategies. * @param {Object[]} strategies - The list of strategies to display. */ updateStrategiesHtml(strategies) { if (this.targetEl) { // Clear existing content while (this.targetEl.firstChild) { this.targetEl.removeChild(this.targetEl.firstChild); } // Create and append new elements for all strategies for (let i = 0; i < strategies.length; i++) { const strat = strategies[i]; console.log(`Processing strategy ${i + 1}/${strategies.length}:`, strat); try { const strategyItem = document.createElement('div'); strategyItem.className = 'strategy-item'; strategyItem.setAttribute('data-strategy-id', strat.tbl_key); // Check if this is a subscribed strategy (not owned) const isSubscribed = strat.is_subscribed && !strat.is_owner; const isOwner = strat.is_owner !== false; // Default to owner if not specified // Check if strategy is running const isRunning = UI.strats && UI.strats.isStrategyRunning(strat.tbl_key); const runningInfo = isRunning ? UI.strats.getRunningInfo(strat.tbl_key) : null; // Add subscribed class if applicable if (isSubscribed) { strategyItem.classList.add('subscribed'); } // Delete/Unsubscribe button if (isSubscribed) { // Show unsubscribe button for subscribed strategies const unsubscribeButton = document.createElement('button'); unsubscribeButton.className = 'unsubscribe-button'; unsubscribeButton.innerHTML = '−'; // Minus sign unsubscribeButton.title = 'Unsubscribe from strategy'; unsubscribeButton.addEventListener('click', (e) => { e.stopPropagation(); if (isRunning) { alert('Cannot unsubscribe while strategy is running. Stop it first.'); return; } if (UI.strats && UI.strats.unsubscribeFromStrategy) { UI.strats.unsubscribeFromStrategy(strat.tbl_key); } }); strategyItem.appendChild(unsubscribeButton); } else { // Delete button for owned strategies const deleteButton = document.createElement('button'); deleteButton.className = 'delete-button'; deleteButton.innerHTML = '✘'; deleteButton.addEventListener('click', (e) => { e.stopPropagation(); if (isRunning) { alert('Cannot delete a running strategy. Stop it first.'); return; } console.log(`Delete button clicked for strategy: ${strat.name}`); if (this.onDeleteStrategy) { this.onDeleteStrategy(strat.tbl_key); } else { console.error("Delete strategy callback is not set."); } }); strategyItem.appendChild(deleteButton); } // Run/Stop button const runButton = document.createElement('button'); runButton.className = isRunning ? 'run-button running' : 'run-button'; runButton.innerHTML = isRunning ? '■' : '▶'; // Stop or Play icon runButton.title = isRunning ? `Stop (${runningInfo.mode})` : 'Run strategy'; runButton.addEventListener('click', (e) => { e.stopPropagation(); if (isRunning) { UI.strats.stopStrategy(strat.tbl_key); } else { // Use runStrategyWithOptions to honor testnet checkbox and show warnings UI.strats.runStrategyWithOptions(strat.tbl_key); } }); strategyItem.appendChild(runButton); // Strategy icon const strategyIcon = document.createElement('div'); strategyIcon.className = isRunning ? 'strategy-icon running' : 'strategy-icon'; if (isSubscribed) { strategyIcon.classList.add('subscribed'); } strategyIcon.addEventListener('click', () => { console.log(`Strategy icon clicked for strategy: ${strat.name}`); if (isSubscribed) { // Show info modal for subscribed strategies (can't edit) this.showSubscribedStrategyInfo(strat); } else { // Normal edit behavior for owned strategies this.displayForm('edit', strat).catch(error => { console.error('Error displaying form:', error); }); } }); // Strategy name const strategyName = document.createElement('div'); strategyName.className = 'strategy-name'; strategyName.textContent = strat.name || 'Unnamed Strategy'; strategyIcon.appendChild(strategyName); // Creator badge for subscribed strategies if (isSubscribed && strat.creator_name) { const creatorBadge = document.createElement('div'); creatorBadge.className = 'creator-badge'; creatorBadge.textContent = `by @${strat.creator_name}`; strategyIcon.appendChild(creatorBadge); } strategyItem.appendChild(strategyIcon); // Strategy hover details with run controls const strategyHover = document.createElement('div'); strategyHover.className = 'strategy-hover'; const strategyKey = String(strat.tbl_key || ''); const strategyKeyHtml = escapeHtml(strategyKey); const strategyKeyJs = escapeHtml(escapeJsString(strategyKey)); // Build hover content (escape user-controlled values) let hoverHtml = `${escapeHtml(strat.name || 'Unnamed Strategy')}`; // Show running status if applicable if (isRunning) { let modeDisplay = runningInfo.mode; const safeModeDisplay = escapeHtml(modeDisplay); let modeBadge = ''; // Add testnet/production badge for live mode if (runningInfo.mode === 'live') { if (runningInfo.testnet) { modeBadge = 'TESTNET'; } else { modeBadge = 'PRODUCTION'; } } let statusHtml = `
Running in ${safeModeDisplay} mode ${modeBadge}`; // Show balance if available if (runningInfo.balance !== undefined) { statusHtml += `
Balance: $${runningInfo.balance.toFixed(2)}`; } if (runningInfo.trade_count !== undefined) { statusHtml += ` | Trades: ${runningInfo.trade_count}`; } // Show circuit breaker status for live mode if (runningInfo.circuit_breaker && runningInfo.circuit_breaker.tripped) { const safeCircuitReason = escapeHtml(runningInfo.circuit_breaker.reason || 'Unknown'); statusHtml += `
⚠️ Circuit Breaker TRIPPED: ${safeCircuitReason}`; } statusHtml += `
`; hoverHtml += statusHtml; } // Stats if (strat.stats && Object.keys(strat.stats).length > 0) { hoverHtml += `
Stats: ${escapeHtml(JSON.stringify(strat.stats, null, 2))}`; } // Run controls hoverHtml += `
`; strategyHover.innerHTML = hoverHtml; strategyItem.appendChild(strategyHover); // Append to target element this.targetEl.appendChild(strategyItem); } catch (error) { console.error(`Error processing strategy ${i + 1}:`, error); } } console.log('All strategies have been processed and appended.'); } else { console.error("Target element for updating strategies is not set."); } } /** * Toggles the fee input field based on the state of the public checkbox. */ toggleFeeInput() { /** @type {HTMLInputElement} */ const publicCheckbox = document.getElementById('public_checkbox'); const feeBox = document.getElementById('fee_box'); if (publicCheckbox && feeBox) { feeBox.disabled = !publicCheckbox.checked; } } /** * Sets the callback function for deleting a strategy. * @param {Function} callback - The callback function to call when deleting a strategy. */ registerDeleteStrategyCallback(callback) { this.onDeleteStrategy = callback; } // ========== AI Strategy Builder Methods ========== /** * Opens the AI strategy builder dialog. */ openAIDialog() { const dialog = document.getElementById('ai_strategy_form'); if (dialog) { // Reset state const descriptionEl = document.getElementById('ai_strategy_description'); const loadingEl = document.getElementById('ai_strategy_loading'); const errorEl = document.getElementById('ai_strategy_error'); const generateBtn = document.getElementById('ai_generate_btn'); if (descriptionEl) descriptionEl.value = ''; if (loadingEl) loadingEl.style.display = 'none'; if (errorEl) errorEl.style.display = 'none'; if (generateBtn) generateBtn.disabled = false; // Show and center the dialog dialog.style.display = 'block'; dialog.style.left = '50%'; dialog.style.top = '50%'; dialog.style.transform = 'translate(-50%, -50%)'; } } /** * Closes the AI strategy builder dialog. */ closeAIDialog() { const dialog = document.getElementById('ai_strategy_form'); if (dialog) { dialog.style.display = 'none'; } } /** * Calls the API to generate a strategy from the natural language description. */ async generateWithAI() { const descriptionEl = document.getElementById('ai_strategy_description'); const description = descriptionEl ? descriptionEl.value.trim() : ''; if (!description) { alert('Please enter a strategy description.'); return; } const loadingEl = document.getElementById('ai_strategy_loading'); const errorEl = document.getElementById('ai_strategy_error'); const generateBtn = document.getElementById('ai_generate_btn'); // Gather user's available indicators and signals const indicators = this._getAvailableIndicators(); const signals = this._getAvailableSignals(); const defaultSource = this._getDefaultSource(); // Check if description mentions indicators but none are configured const indicatorKeywords = ['ema', 'sma', 'rsi', 'macd', 'bollinger', 'bb', 'atr', 'adx', 'stochastic']; const descLower = description.toLowerCase(); const mentionsIndicators = indicatorKeywords.some(kw => descLower.includes(kw)); if (mentionsIndicators && indicators.length === 0) { const proceed = confirm( 'Your strategy mentions indicators (EMA, RSI, Bollinger Bands, etc.) but you haven\'t configured any indicators yet.\n\n' + 'Please add the required indicators in the Indicators panel on the right side of the screen first.\n\n' + 'Click OK to proceed anyway (the AI will use price-based logic only), or Cancel to add indicators first.' ); if (!proceed) { return; } } // Show loading state if (loadingEl) loadingEl.style.display = 'block'; if (errorEl) errorEl.style.display = 'none'; if (generateBtn) generateBtn.disabled = true; console.log('Generating strategy with:', { description, indicators, signals, defaultSource }); try { const response = await fetch('/api/generate-strategy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description, indicators, signals, default_source: defaultSource }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Strategy generation failed'); } // Load the generated Blockly XML into the workspace if (this.workspaceManager && data.workspace_xml) { this.workspaceManager.loadWorkspaceFromXml(data.workspace_xml); } // Close the AI dialog this.closeAIDialog(); console.log('Strategy generated successfully with AI'); } catch (error) { console.error('AI generation error:', error); if (errorEl) { errorEl.textContent = `Error: ${error.message}`; errorEl.style.display = 'block'; } } finally { if (loadingEl) loadingEl.style.display = 'none'; if (generateBtn) generateBtn.disabled = false; } } /** * Gets the user's available indicators for the AI prompt. * @returns {Array} Array of indicator objects with name and outputs. * @private */ _getAvailableIndicators() { // Use getIndicatorOutputs() which returns {name: outputs[]} from i_objs const indicatorOutputs = window.UI?.indicators?.getIndicatorOutputs?.() || {}; const indicatorObjs = window.UI?.indicators?.i_objs || {}; return Object.entries(indicatorOutputs).map(([name, outputs]) => ({ name: name, type: indicatorObjs[name]?.constructor?.name || 'unknown', outputs: outputs })); } /** * Gets the user's available signals for the AI prompt. * @returns {Array} Array of signal objects with name. * @private */ _getAvailableSignals() { // Get from UI.signals if available const signals = window.UI?.signals?.signals || []; return signals.map(sig => ({ name: sig.name || sig.id })); } /** * Gets the current default trading source from the strategy form. * @returns {Object} Object with exchange, market, and timeframe. * @private */ _getDefaultSource() { const exchangeEl = document.getElementById('strategy_exchange'); const symbolEl = document.getElementById('strategy_symbol'); const timeframeEl = document.getElementById('strategy_timeframe'); return { exchange: exchangeEl ? exchangeEl.value : 'binance', market: symbolEl ? symbolEl.value : 'BTC/USDT', timeframe: timeframeEl ? timeframeEl.value : '5m' }; } /** * Shows information modal for subscribed strategies (cannot edit). * @param {Object} strat - The subscribed strategy object. */ showSubscribedStrategyInfo(strat) { const message = `Strategy: ${strat.name}\nCreator: @${strat.creator_name || 'Unknown'}\n\nThis is a subscribed strategy and cannot be edited.\n\nYou can run this strategy but the workspace and code are not accessible.`; alert(message); } /** * Shows the public strategy browser modal. */ async showPublicStrategyBrowser() { // Request public strategies from server if (UI.strats && UI.strats.requestPublicStrategies) { UI.strats.requestPublicStrategies(); } } /** * Renders the public strategy browser modal with available strategies. * @param {Array} strategies - List of public strategies to display. */ renderPublicStrategyModal(strategies) { // Remove existing modal if any let existingModal = document.getElementById('public-strategy-modal'); if (existingModal) { existingModal.remove(); } // Create modal const modal = document.createElement('div'); modal.id = 'public-strategy-modal'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); // Close on overlay click modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); } } class StratDataManager { constructor() { this.strategies = []; } /** * Fetches the saved strategies from the server. * @param {Object} comms - The communications instance to interact with the server. * @param {Object} data - An object containing user data. */ fetchSavedStrategies(comms, data) { if (comms) { try { const requestData = { request: 'strategies', user_name: data?.user_name }; comms.sendToApp('request', requestData); } catch (error) { console.error("Error fetching saved strategies:", error.message); alert('Unable to connect to the server. Please check your connection or try reinitializing the application.'); } } else { throw new Error('Communications instance not available.'); } } /** * Handles the creation of a new strategy. * @param {Object} data - The data for the newly created strategy. */ addNewStrategy(data) { console.log("Adding new strategy. Data:", data); if (!data.name) { console.error("Strategy data missing 'name' field:", data); } this.strategies.push(data); } /** * Retrieves a strategy by its tbl_key. * @param {string} tbl_key - The tbl_key of the strategy to find. * @returns {Object|null} - The strategy object or null if not found. */ getStrategyById(tbl_key) { return this.strategies.find(strategy => strategy.tbl_key === tbl_key) || null; } /** * Handles updates to the strategy itself (e.g., configuration changes). * @param {Object} data - The updated strategy data. */ updateStrategyData(data) { // Ignore runtime execution events; only apply persisted strategy records. if (!data || typeof data !== 'object') { return; } const strategyKey = data.tbl_key || data.id; if (!strategyKey) { return; } console.log("Strategy updated:", data); const index = this.strategies.findIndex( strategy => (strategy.tbl_key || strategy.id) === strategyKey ); if (index !== -1) { this.strategies[index] = { ...this.strategies[index], ...data }; } else { this.strategies.push(data); // Add if not found } } /** * Handles the deletion of a strategy. * @param {string} tbl_key - The tbl_key for the deleted strategy. */ removeStrategy(tbl_key) { try { console.log(`Removing strategy with tbl_key: ${tbl_key}`); // Filter out the strategy with the matching tbl_key this.strategies = this.strategies.filter(strat => strat.tbl_key !== tbl_key); console.log("Remaining strategies:", this.strategies); } catch (error) { console.error("Error handling strategy deletion:", error.message); } } /** * Handles batch updates for strategies, such as multiple configuration or performance updates. * @param {Object} data - The data containing batch updates for strategies. */ applyBatchUpdates(data) { const { stg_updts } = data; if (Array.isArray(stg_updts)) { stg_updts.forEach(strategy => { if (strategy && (strategy.tbl_key || strategy.id)) { this.updateStrategyData(strategy); } }); } } /** * Returns all available strategies. * @returns {Object[]} - The list of available strategies. */ getAllStrategies() { return this.strategies; } } class StratWorkspaceManager { constructor() { this.workspace = null; this.blocksDefined = false; this.MAX_TOP_LEVEL_BLOCKS = 10; // Set your desired limit this.MAX_DEPTH = 10; // Set your desired limit } /** * Initializes the Blockly workspace with custom blocks and generators. * Ensures required elements are present in the DOM and initializes the workspace. * @async * @throws {Error} If required elements ('blocklyDiv' or 'toolbox_advanced') are not found. */ async initWorkspace() { if (!document.getElementById('blocklyDiv')) { console.error("blocklyDiv is not loaded."); return; } if (this.workspace) { this.workspace.dispose(); } // Initialize custom blocks and Blockly workspace await this._loadModulesAndInitWorkspace(); // Set the maximum allowed nesting depth Blockly.JSON.maxDepth = this.MAX_DEPTH; // or any desired value } async _loadModulesAndInitWorkspace() { if (!this.blocksDefined) { try { // Separate json_base_generator as it needs to be loaded first const jsonBaseModule = await import('./blocks/generators/json_base_generator.js'); jsonBaseModule.defineJsonBaseGenerator(); console.log('Defined defineJsonBaseGenerator from json_base_generator.js'); // Map generator and block files to the functions that define them. const generatorModules = [ { file: 'balances_generators.js', defineFunc: 'defineBalancesGenerators' }, { file: 'order_metrics_generators.js', defineFunc: 'defineOrderMetricsGenerators' }, { file: 'trade_metrics_generators.js', defineFunc: 'defineTradeMetricsGenerators' }, { file: 'time_metrics_generators.js', defineFunc: 'defineTimeMetricsGenerators' }, { file: 'market_data_generators.js', defineFunc: 'defineMarketDataGenerators' }, { file: 'logical_generators.js', defineFunc: 'defineLogicalGenerators' }, { file: 'trade_order_generators.js', defineFunc: 'defineTradeOrderGenerators' }, { file: 'control_generators.js', defineFunc: 'defineControlGenerators' }, { file: 'values_and_flags_generators.js', defineFunc: 'defineVAFGenerators' }, { file: 'risk_management_generators.js', defineFunc: 'defineRiskManagementGenerators' }, { file: 'advanced_math_generators.js', defineFunc: 'defineAdvancedMathGenerators' } ]; const blockModules = [ { file: 'balances_blocks.js', defineFunc: 'defineBalanceBlocks' }, { file: 'order_metrics_blocks.js', defineFunc: 'defineOrderMetricsBlocks' }, { file: 'trade_metrics_blocks.js', defineFunc: 'defineTradeMetricsBlocks' }, { file: 'time_metrics_blocks.js', defineFunc: 'defineTimeMetricsBlocks' }, { file: 'market_data_blocks.js', defineFunc: 'defineMarketDataBlocks' }, { file: 'logical_blocks.js', defineFunc: 'defineLogicalBlocks' }, { file: 'trade_order_blocks.js', defineFunc: 'defineTradeOrderBlocks' }, { file: 'control_blocks.js', defineFunc: 'defineControlBlocks' }, { file: 'values_and_flags.js', defineFunc: 'defineValuesAndFlags' }, { file: 'risk_management_blocks.js', defineFunc: 'defineRiskManagementBlocks' }, { file: 'advanced_math_blocks.js', defineFunc: 'defineAdvancedMathBlocks' } ]; // Function to import and define modules in parallel const importAndDefineParallel = async (modules, basePath) => { await Promise.all(modules.map(async moduleInfo => { try { const module = await import(`${basePath}/${moduleInfo.file}`); if (typeof module[moduleInfo.defineFunc] === 'function') { module[moduleInfo.defineFunc](); console.log(`Defined ${moduleInfo.defineFunc} from ${moduleInfo.file}`); } else { console.error(`Define function ${moduleInfo.defineFunc} not found in ${moduleInfo.file}`); } } catch (importError) { console.error(`Error importing ${moduleInfo.file}:`, importError); } })); }; // Import and define all generator modules await importAndDefineParallel(generatorModules, './blocks/generators'); // Import and define all block modules await importAndDefineParallel(blockModules, './blocks/blocks'); // Load and define indicator blocks const indicatorBlocksModule = await import('./blocks/indicator_blocks.js'); indicatorBlocksModule.defineIndicatorBlocks(); // Load and define signal blocks const signalBlocksModule = await import('./blocks/signal_blocks.js'); signalBlocksModule.defineSignalBlocks(); } catch (error) { console.error("Error loading Blockly modules: ", error); return; } this.blocksDefined = true; } const toolboxElement = document.getElementById('toolbox_advanced'); if (!toolboxElement) { console.error("toolbox is not loaded."); return; } /** * @override the toolbox zoom */ Blockly.VerticalFlyout.prototype.getFlyoutScale = function() { return 1; }; this.workspace = Blockly.inject('blocklyDiv', { toolbox: toolboxElement, scrollbars: true, trashcan: true, grid: { spacing: 20, length: 3, colour: '#ccc', snap: true }, zoom: { controls: true, wheel: true, startScale: 1.0, maxScale: 3, minScale: 0.3, scaleSpeed: 1.2 } }); // Add tooltips to toolbox categories this._setupCategoryTooltips(); // Note: Blockly has built-in copy/paste support (Ctrl+C, Ctrl+V, Ctrl+X, Delete) console.log('Blockly workspace initialized and modules loaded.'); } /** * Adjusts the Blockly workspace dimensions to fit within the container. */ adjustWorkspace() { const blocklyDiv = document.getElementById('blocklyDiv'); if (blocklyDiv && this.workspace) { Blockly.svgResize(this.workspace); } else { console.error("Cannot resize workspace: Blockly or blocklyDiv is not loaded."); } } /** * Generates the strategy data including, JSON representation, and workspace XML. * @returns {string|null} - A JSON string containing the strategy data or null if failed. */ compileStrategyJson() { if (!this.workspace) { console.error("Workspace is not available."); return null; } /** @type {HTMLInputElement} */ const nameElement = document.getElementById('name_box'); if (!nameElement) { console.error("Name input element (name_box) is not available."); return null; } const strategyName = nameElement.value; // Generate code and data representations const strategyJson = this._generateStrategyJsonFromWorkspace(); if (!strategyJson) { console.error("Failed to generate strategy JSON."); return null; } // Generate workspace XML for restoration when editing const workspaceXml = Blockly.Xml.workspaceToDom(this.workspace); const workspaceXmlText = Blockly.Xml.domToText(workspaceXml); return JSON.stringify({ name: strategyName, strategy_json: strategyJson, workspace: workspaceXmlText }); } /** * Generates a JSON representation of the strategy from the workspace. * @private * @returns {Object} - An array of JSON objects representing the top-level blocks. */ _generateStrategyJsonFromWorkspace() { const { initializationBlocks, actionBlocks } = this._categorizeOrphanedBlocks(this.workspace); const totalTopLevelBlocks = initializationBlocks.length + actionBlocks.length; if (totalTopLevelBlocks > this.MAX_TOP_LEVEL_BLOCKS) { console.error(`Too many top-level blocks. Maximum allowed is ${this.MAX_TOP_LEVEL_BLOCKS}.`); alert(`Your strategy has too many top-level blocks. Please reduce the number of top-level blocks to ${this.MAX_TOP_LEVEL_BLOCKS} or fewer.`); return null; } // Proceed with strategy JSON creation return this._createStrategyJson(initializationBlocks, actionBlocks); } /** * Identify all orphaned blocks on the workspace. Categorize them into initialization blocks and action blocks. * @private * @returns {Object} - an object containing an array of initializationBlocks and actionBlocks. */ _categorizeOrphanedBlocks(workspace) { const blocks = workspace.getTopBlocks(true); const orphanedBlocks = blocks.filter(block => !block.getParent()); const initializationBlocks = []; const actionBlocks = []; orphanedBlocks.forEach(block => { // Only consider specified block types switch (block.type) { // Initialization block types (run first) case 'set_available_strategy_balance': case 'set_variable': case 'set_flag': case 'set_leverage': case 'max_position_size': case 'pause_strategy': case 'strategy_resume': case 'strategy_exit': case 'notify_user': initializationBlocks.push(block); break; // Action block types (run last) case 'trade_action': case 'schedule_action': case 'execute_if': actionBlocks.push(block); break; // Ignore other blocks or log a warning default: console.warn(`Block type '${block.type}' is not allowed as a top-level block and will be ignored.`); break; } }); return { initializationBlocks, actionBlocks }; } _createStrategyJson(initializationBlocks, actionBlocks) { const statements = []; // Process initialization blocks (run first) initializationBlocks.forEach(block => { const blockJson = Blockly.JSON._blockToJson(block); if (blockJson) { statements.push(blockJson); } }); // Process action blocks (run last) actionBlocks.forEach(block => { const blockJson = Blockly.JSON._blockToJson(block); if (blockJson) { statements.push(blockJson); } }); // Create the root strategy block return { type: 'strategy', statements: statements }; } /** * Adds tooltips to toolbox category labels. * @private */ _setupCategoryTooltips() { // Category tooltips mapping const categoryTooltips = { 'Indicators': 'Use your configured technical indicators in strategy logic', 'Balances': 'Access strategy and account balance information', 'Order Metrics': 'Monitor order status, volume, and fill rates', 'Trade Metrics': 'Monitor active trades, P&L, and trade history', 'Time Metrics': 'Time-based conditions for your strategy', 'Market Data': 'Access real-time prices and candle data', 'Logical': 'Build conditions with comparisons and logic operators', 'Trade Order': 'Execute trades with stop-loss, take-profit, and limits', 'Control': 'Control strategy flow: pause, resume, exit, and schedule', 'Values and flags': 'Store values, set flags, and send notifications', 'Risk Management': 'Control leverage, margin, and position limits', 'Math': 'Arithmetic, statistics, and mathematical functions' }; // Wait a moment for Blockly to render the toolbox setTimeout(() => { // Find all category labels in the toolbox const toolboxDiv = document.querySelector('.blocklyToolboxDiv'); if (!toolboxDiv) return; const categoryRows = toolboxDiv.querySelectorAll('.blocklyTreeRow'); categoryRows.forEach(row => { const labelSpan = row.querySelector('.blocklyTreeLabel'); if (labelSpan) { const categoryName = labelSpan.textContent; if (categoryTooltips[categoryName]) { row.setAttribute('title', categoryTooltips[categoryName]); row.style.cursor = 'help'; } } }); console.log('Category tooltips added'); }, 100); } /** * Restores the Blockly workspace from an XML string. * @param {string} workspaceXmlText - The XML text representing the workspace. */ loadWorkspaceFromXml(workspaceXmlText) { try { if (!this.workspace) { console.error("Cannot restore workspace: Blockly workspace is not initialized."); return; } const workspaceXml = Blockly.utils.xml.textToDom(workspaceXmlText); if (!workspaceXml || !workspaceXml.hasChildNodes()) { console.error('Invalid workspace XML provided.'); alert('The provided workspace data is invalid and cannot be loaded.'); return; } this.workspace.clear(); Blockly.Xml.domToWorkspace(workspaceXml, this.workspace); } catch (error) { // Save the failed XML for debugging const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const debugKey = `failed_strategy_xml_${timestamp}`; try { localStorage.setItem(debugKey, workspaceXmlText); console.error(`Failed XML saved to localStorage as "${debugKey}"`); console.error('To retrieve: localStorage.getItem("' + debugKey + '")'); } catch (e) { // If localStorage fails, log to console console.error('Failed workspace XML (copy for debugging):', workspaceXmlText); } if (error instanceof SyntaxError) { console.error('Syntax error in workspace XML:', error.message); alert('There was a syntax error in the workspace data. Please check the data and try again.\n\nDebug XML saved to localStorage as: ' + debugKey); } else { console.error('Unexpected error restoring workspace:', error); alert('An unexpected error occurred while restoring the workspace.\n\nDebug XML saved to localStorage as: ' + debugKey); } } } } class Strategies { constructor() { this.dataManager = new StratDataManager(); this.workspaceManager = new StratWorkspaceManager(); this.uiManager = new StratUIManager(this.workspaceManager); this.comms = null; this.data = null; this._initialized = false; // Track running strategies: key = "strategy_id:mode", value = { mode, instance_id, ... } // Using composite key to support same strategy in different modes this.runningStrategies = new Map(); // Set the delete callback this.uiManager.registerDeleteStrategyCallback(this.deleteStrategy.bind(this)); // Bind methods to ensure correct 'this' context this.submitStrategy = this.submitStrategy.bind(this); this.runStrategy = this.runStrategy.bind(this); this.stopStrategy = this.stopStrategy.bind(this); } /** * Initializes the Strategies instance with necessary dependencies. * @param {string} targetId - The ID of the HTML element where strategies will be displayed. * @param {string} formElId - The ID of the HTML element for the strategy creation form. * @param {Object} data - An object containing user data and communication instances. */ initialize(targetId, formElId, data) { try { // Initialize UI Manager this.uiManager.initUI(targetId, formElId); if (!data || typeof data !== 'object') { console.error("Invalid data provided for initialization."); return; } this.data = data; if (!this.data.user_name || typeof this.data.user_name !== 'string') { console.error("Invalid user_name provided in data object."); return; } this.comms = this.data?.comms; if (!this.comms) { console.error("Communications instance not provided in data."); return; } // Register handlers with Comms for specific message types this.comms.on('strategy_created', this.handleStrategyCreated.bind(this)); this.comms.on('strategy_updated', this.handleStrategyUpdated.bind(this)); this.comms.on('strategy_deleted', this.handleStrategyDeleted.bind(this)); this.comms.on('updates', this.handleUpdates.bind(this)); this.comms.on('strategies', this.handleStrategies.bind(this)); // Register the handler for 'strategy_error' reply this.comms.on('strategy_error', this.handleStrategyError.bind(this)); // Register handlers for run/stop strategy this.comms.on('strategy_started', this.handleStrategyStarted.bind(this)); this.comms.on('strategy_stopped', this.handleStrategyStopped.bind(this)); this.comms.on('strategy_run_error', this.handleStrategyRunError.bind(this)); this.comms.on('strategy_stop_error', this.handleStrategyStopError.bind(this)); this.comms.on('strategy_status', this.handleStrategyStatus.bind(this)); this.comms.on('strategy_events', this.handleStrategyEvents.bind(this)); // Register handlers for subscription events this.comms.on('public_strategies', this.handlePublicStrategies.bind(this)); this.comms.on('strategy_subscribed', this.handleStrategySubscribed.bind(this)); this.comms.on('strategy_unsubscribed', this.handleStrategyUnsubscribed.bind(this)); this.comms.on('subscription_error', this.handleSubscriptionError.bind(this)); // Fetch saved strategies using DataManager this.dataManager.fetchSavedStrategies(this.comms, this.data); // Request status of any running strategies (handles page reload) this.requestStrategyStatus(); this._initialized = true; } catch (error) { console.error("Error initializing Strategies instance:", error.message); } } /** * Handles strategy-related errors sent from the server. * @param {Object} errorData - The error message and additional details. */ handleStrategyError(errorData) { console.error("Strategy Error:", errorData.message); // Display a user-friendly error message if (errorData.message) { alert(`Error: ${errorData.message}`); } else { alert("An unknown error occurred while processing the strategy."); } } /** * Handles the reception of existing strategies from the server. * @param {Array} data - The data containing the list of existing strategies. */ handleStrategies(data) { console.log("Received strategies data:", data); if (Array.isArray(data)) { console.log(`Number of strategies received: ${data.length}`); // Update the DataManager with the received strategies this.dataManager.strategies = data; // Update the UI to display the strategies this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); console.log("Successfully loaded strategies."); } else { console.error("Failed to load strategies: Invalid data format"); alert("Failed to load strategies: Invalid data format"); } } /** * Handles the creation of a new strategy. * @param {Object} data - The data for the newly created strategy. */ handleStrategyCreated(data) { console.log("handleStrategyCreated received data:", data); if (data.success && data.strategy) { this.dataManager.addNewStrategy(data.strategy); this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } else { console.error("Failed to create strategy:", data.message); alert(`Strategy creation failed: ${data.message}`); } } /** * Handles updates to the strategy itself (e.g., configuration changes). * @param {Object} data - The server response containing strategy update metadata. */ handleStrategyUpdated(data) { if (data.success) { console.log("Strategy updated successfully:", data); // Locate the strategy in the local state by its tbl_key const updatedStrategyKey = data.strategy.tbl_key; const updatedAt = data.updated_at; const strategy = this.dataManager.getStrategyById(updatedStrategyKey); if (strategy) { // Update the relevant strategy data Object.assign(strategy, data.strategy); // Update the `updated_at` field strategy.updated_at = updatedAt; // Refresh the UI to reflect the updated metadata this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } else { console.warn("Updated strategy not found in local records:", updatedStrategyKey); } // Check if the strategy was running and prompt for restart if (this.isStrategyRunning(updatedStrategyKey)) { const runningInfo = this.getRunningInfo(updatedStrategyKey); const strategyName = data.strategy.name || 'Strategy'; const modeText = runningInfo ? runningInfo.mode : 'current'; const shouldRestart = confirm( `"${strategyName}" was updated successfully!\n\n` + `This strategy is currently running in ${modeText} mode.\n` + `The changes will NOT take effect until you restart.\n\n` + `Would you like to restart the strategy now to apply changes?` ); if (shouldRestart) { // Stop and restart the strategy const mode = runningInfo.mode; const testnet = runningInfo.testnet !== undefined ? runningInfo.testnet : true; const initialBalance = runningInfo.initial_balance || 10000; // Stop first this.stopStrategy(updatedStrategyKey, mode); // Restart after a brief delay to allow stop to complete setTimeout(() => { this.runStrategy(updatedStrategyKey, mode, initialBalance, testnet); }, 1000); } } } else { console.error("Failed to update strategy:", data.message); alert(`Strategy update failed: ${data.message}`); } } /** * Handles the deletion of a strategy. * @param {Object} response - The full response object from the server. */ handleStrategyDeleted(response) { // Extract the message and tbl_key from the response const success = response.message === "Strategy deleted successfully."; // Use the message to confirm success const tbl_key = response.tbl_key; // Extract tbl_key directly if (success) { if (tbl_key) { console.log(`Successfully deleted strategy with tbl_key: ${tbl_key}`); // Remove the strategy using tbl_key this.dataManager.removeStrategy(tbl_key); // Update the UI to reflect the deletion this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } else { console.warn("tbl_key is missing in the server response, unable to remove strategy from UI."); } } else { // Handle failure console.error("Failed to delete strategy:", response.message || "Unknown error"); alert(`Failed to delete strategy: ${response.message || "Unknown error"}`); } } /** * Handles batch updates for strategies, such as multiple configuration or performance updates. * @param {Object} data - The data containing batch updates for strategies. */ handleUpdates(data) { const strategyEvents = Array.isArray(data?.stg_updts) ? data.stg_updts : []; for (const event of strategyEvents) { if (!event || typeof event !== 'object') { continue; } if (event.type === 'strategy_exited' && event.strategy_id && event.mode) { this.runningStrategies.delete(this._makeRunningKey(event.strategy_id, event.mode)); } if (event.type === 'tick_complete' && event.strategy_id && event.mode) { const key = this._makeRunningKey(event.strategy_id, event.mode); const running = this.runningStrategies.get(key); if (running) { if (typeof event.balance === 'number') { running.balance = event.balance; } if (typeof event.trades === 'number') { running.trade_count = event.trades; } this.runningStrategies.set(key, running); } } if (event.type === 'error') { console.warn("Strategy runtime error:", event.message, event); } } this.dataManager.applyBatchUpdates(data); this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } /** * Resizes the Blockly workspace using StratWorkspaceManager. */ resizeWorkspace() { this.workspaceManager.adjustWorkspace(); } /** * Generates the strategy data including Python code, JSON representation, and workspace XML. * @returns {string} - A JSON string containing the strategy data. */ generateStrategyJson() { return this.workspaceManager.compileStrategyJson(); } /** * Restores the Blockly workspace from an XML string using StratWorkspaceManager. * @param {string} workspaceXmlText - The XML text representing the workspace. */ restoreWorkspaceFromXml(workspaceXmlText) { this.workspaceManager.loadWorkspaceFromXml(workspaceXmlText); } /** * Submits or edits a strategy based on the provided action. * @param {string} action - Action type, either 'new' or 'edit'. */ submitStrategy(action) { /** @type {HTMLInputElement} */ const feeBox = document.getElementById('fee_box'); /** @type {HTMLInputElement} */ const nameBox = document.getElementById('name_box'); /** @type {HTMLInputElement} */ const publicCheckbox = document.getElementById('public_checkbox'); /** @type {HTMLSelectElement} */ const exchangeSelect = document.getElementById('strategy_exchange'); /** @type {HTMLInputElement} */ const symbolInput = document.getElementById('strategy_symbol'); /** @type {HTMLSelectElement} */ const timeframeSelect = document.getElementById('strategy_timeframe'); if (!feeBox || !nameBox || !publicCheckbox) { console.error("One or more form elements are missing."); alert("An error occurred: Required form elements are missing."); return; } let strategyData; try { // Compile the strategy JSON (conditions and actions) const compiledStrategy = this.generateStrategyJson(); // Returns JSON string const parsedStrategy = JSON.parse(compiledStrategy); // Object with 'name', 'strategy_json', 'workspace' // Check for an incomplete strategy (no root blocks) if (!parsedStrategy.strategy_json.statements || parsedStrategy.strategy_json.statements.length === 0) { alert("Your strategy is incomplete. Please add actions or conditions before submitting."); return; } // Get default source values const defaultSource = { exchange: exchangeSelect ? exchangeSelect.value : 'binance', market: symbolInput ? symbolInput.value.trim() : 'BTC/USDT', timeframe: timeframeSelect ? timeframeSelect.value : '5m' }; // Prepare the strategy data to send strategyData = { code: parsedStrategy.strategy_json, // The compiled strategy JSON string workspace: parsedStrategy.workspace, // Serialized workspace XML name: nameBox.value.trim(), fee: parseFloat(feeBox.value.trim()), public: publicCheckbox.checked ? 1 : 0, user_name: this.data.user_name, default_source: defaultSource // Include explicit default source // Add 'stats' if necessary }; console.log('Submitting strategy with default source:', defaultSource); } catch (error) { console.error('Failed to compile strategy JSON:', error); alert('An error occurred while processing the strategy data.'); return; } // Basic client-side validation if (isNaN(strategyData.fee) || strategyData.fee < 0) { alert("Please enter a valid, non-negative number for the fee."); return; } // Validate fee is 1-100 if public if (strategyData.public === 1 && (strategyData.fee < 1 || strategyData.fee > 100)) { alert("Fee must be between 1 and 100 (percentage of exchange commission)."); return; } if (!strategyData.name) { alert("Please provide a name for the strategy."); return; } // Send the strategy data to the server if (this.comms) { // Determine message type based on action const messageType = action === 'new' ? 'new_strategy' : 'edit_strategy'; this.comms.sendToApp(messageType, strategyData); this.uiManager.hideForm(); } else { console.error("Comms instance not available or invalid action type."); } } /** * Deletes a strategy by its name. * @param {string} tbl_key - The name of the strategy to be deleted. */ deleteStrategy(tbl_key) { console.log(`Deleting strategy: ${tbl_key}`); // Prepare data for server request const deleteData = { tbl_key: tbl_key }; // Send delete request to the server if (this.comms) { this.comms.sendToApp('delete_strategy', deleteData); } else { console.error("Comms instance not available."); } } /** * Creates a composite key for running strategies map. * @param {string} strategyId - Strategy tbl_key. * @param {string} mode - Trading mode. * @returns {string} - Composite key. */ _makeRunningKey(strategyId, mode) { return `${strategyId}:${mode}`; } /** * Requests current status of running strategies from server. * Called on init to sync state after page reload. */ requestStrategyStatus() { if (!this.comms) { console.warn("Comms not available, skipping status request."); return; } this.comms.sendToApp('get_strategy_status', { user_name: this.data.user_name }); } /** * Handles mode change in the dropdown to show/hide live options. * @param {string} strategyId - The strategy tbl_key. * @param {string} mode - Selected mode. */ onModeChange(strategyId, mode) { const liveOptions = document.getElementById(`live-options-${strategyId}`); if (liveOptions) { liveOptions.style.display = mode === 'live' ? 'block' : 'none'; } } /** * Runs a strategy with options from the UI. * @param {string} strategyId - The strategy tbl_key. */ runStrategyWithOptions(strategyId) { console.log(`runStrategyWithOptions called for strategy: ${strategyId}`); const modeSelect = document.getElementById(`mode-select-${strategyId}`); const testnetCheckbox = document.getElementById(`testnet-${strategyId}`); const mode = modeSelect ? modeSelect.value : 'paper'; const testnet = testnetCheckbox ? testnetCheckbox.checked : true; // Show immediate visual feedback on the button const btn = document.querySelector(`.strategy-item[data-strategy-id="${strategyId}"] .btn-run`); if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; btn.style.backgroundColor = '#6c757d'; } // For live mode with production (non-testnet), show extra warning if (mode === 'live' && !testnet) { const proceed = confirm( "⚠️ WARNING: PRODUCTION MODE ⚠️\n\n" + "You are about to start LIVE trading with REAL MONEY.\n\n" + "• Real trades will be executed on your exchange account\n" + "• Financial losses are possible\n" + "• The circuit breaker will halt at -10% drawdown\n\n" + "Are you absolutely sure you want to continue?" ); if (!proceed) { // Reset button state if (btn) { btn.disabled = false; btn.textContent = 'Run Strategy'; btn.style.backgroundColor = '#28a745'; } return; } } this.runStrategy(strategyId, mode, 10000, testnet); } /** * Runs a strategy in the specified mode. * @param {string} strategyId - The strategy tbl_key. * @param {string} mode - Trading mode ('paper' or 'live'). * @param {number} initialBalance - Starting balance (default 10000). * @param {boolean} testnet - Use testnet for live trading (default true). */ runStrategy(strategyId, mode = 'paper', initialBalance = 10000, testnet = true) { console.log(`Running strategy ${strategyId} in ${mode} mode (testnet: ${testnet})`); if (!this.comms) { console.error("Comms instance not available."); return; } // Check if already running in this mode const runKey = this._makeRunningKey(strategyId, mode); if (this.runningStrategies.has(runKey)) { alert(`Strategy is already running in ${mode} mode.`); return; } // Show loading state on the button const btnSelector = `.strategy-item[data-strategy-id="${strategyId}"] .btn-run`; const btn = document.querySelector(btnSelector); if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; btn.style.opacity = '0.7'; } const runData = { strategy_id: strategyId, mode: mode, initial_balance: initialBalance, commission: 0.001, testnet: testnet, max_position_pct: 0.5, circuit_breaker_pct: -0.10, user_name: this.data.user_name }; this.comms.sendToApp('run_strategy', runData); // Re-enable button after a short delay (server response will update state) setTimeout(() => { if (btn) { btn.disabled = false; btn.style.opacity = '1'; // If not yet marked as running, reset the text if (!this.runningStrategies.has(runKey)) { btn.textContent = 'Run Strategy'; } } }, 3000); } /** * Stops a running strategy. * @param {string} strategyId - The strategy tbl_key. * @param {string} mode - Trading mode (optional, will find first running mode). */ stopStrategy(strategyId, mode = null) { console.log(`Stopping strategy ${strategyId}`); if (!this.comms) { console.error("Comms instance not available."); return; } // If mode not specified, find any running instance of this strategy let runKey; let running; if (mode) { runKey = this._makeRunningKey(strategyId, mode); running = this.runningStrategies.get(runKey); } else { // Find first matching strategy regardless of mode for (const [key, value] of this.runningStrategies) { if (key.startsWith(strategyId + ':')) { runKey = key; running = value; break; } } } if (!running) { console.warn(`Strategy ${strategyId} is not running.`); return; } const stopData = { strategy_id: strategyId, mode: running.mode, user_name: this.data.user_name }; this.comms.sendToApp('stop_strategy', stopData); } /** * Handles successful strategy start. * @param {Object} data - Response data from server. */ handleStrategyStarted(data) { console.log("Strategy started:", data); if (data.strategy_id) { // Use actual_mode if provided (for live fallback), otherwise use requested mode const actualMode = data.actual_mode || data.mode; const runKey = this._makeRunningKey(data.strategy_id, actualMode); this.runningStrategies.set(runKey, { mode: actualMode, requested_mode: data.mode, instance_id: data.instance_id, strategy_name: data.strategy_name, initial_balance: data.initial_balance, testnet: data.testnet, exchange: data.exchange, max_position_pct: data.max_position_pct, circuit_breaker_pct: data.circuit_breaker_pct, start_time: new Date().toISOString() }); // Notify statistics module if (UI.statistics) { UI.statistics.registerRunningStrategy(data.strategy_id, { name: data.strategy_name, mode: actualMode, testnet: data.testnet, initial_balance: data.initial_balance, start_time: new Date().toISOString() }); } // Update the UI to reflect running state this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); // Show success notification const modeInfo = data.testnet !== undefined ? `${actualMode} (${data.testnet ? 'testnet' : 'production'})` : actualMode; const successMsg = `Strategy '${data.strategy_name}' started successfully in ${modeInfo} mode!`; // Show warning first if present, then show success confirmation if (data.warning) { alert(`${data.warning}\n\n${successMsg}`); } else { alert(successMsg); } } const modeInfo = data.testnet !== undefined ? `${data.actual_mode || data.mode} (${data.testnet ? 'testnet' : 'production'})` : (data.actual_mode || data.mode); console.log(`Strategy '${data.strategy_name}' started in ${modeInfo} mode.`); } /** * Handles successful strategy stop. * @param {Object} data - Response data from server. */ handleStrategyStopped(data) { console.log("Strategy stopped:", data); if (data.strategy_id) { const stopMode = data.actual_mode || data.mode; const runKey = this._makeRunningKey(data.strategy_id, stopMode); this.runningStrategies.delete(runKey); // Notify statistics module if (UI.statistics) { UI.statistics.unregisterStrategy(data.strategy_id); } // Update the UI to reflect stopped state this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } // Show final stats if available if (data.final_stats) { const stats = data.final_stats; console.log(`Final balance: ${stats.final_balance}, Trades: ${stats.total_trades}`); // Optionally show summary to user if (stats.final_balance !== undefined) { const pnl = stats.final_balance - (stats.initial_balance || 10000); const pnlPercent = ((pnl / (stats.initial_balance || 10000)) * 100).toFixed(2); console.log(`P&L: ${pnl.toFixed(2)} (${pnlPercent}%)`); } } } /** * Handles strategy run errors. * @param {Object} data - Error data from server. */ handleStrategyRunError(data) { console.error("Strategy run error:", data.message, data); const errorCode = data.error_code; const missing = data.missing_exchanges; // Handle exchange requirement errors with detailed messages if (missing && missing.length > 0) { const exchanges = missing.join(', '); let message; switch (errorCode) { case 'missing_edm_data': message = `Historical data not available for these exchanges:\n\n${exchanges}\n\n` + `These exchanges may not be supported by the Exchange Data Manager.`; break; case 'missing_config': message = `Please configure API keys for:\n\n${exchanges}\n\n` + `Go to Exchange Settings to add your credentials.`; break; case 'invalid_exchange': message = `Unknown or unsupported exchanges:\n\n${exchanges}`; break; case 'edm_unreachable': message = `Cannot validate exchange availability - data service unreachable.`; break; default: message = `This strategy requires: ${exchanges}`; } alert(message); } else { alert(`Failed to start strategy: ${data.message || data.error || 'Unknown error'}`); } } /** * Handles strategy stop errors. * @param {Object} data - Error data from server. */ handleStrategyStopError(data) { console.error("Strategy stop error:", data.message); alert(`Failed to stop strategy: ${data.message}`); } /** * Handles strategy status response. * @param {Object} data - Status data from server. */ handleStrategyStatus(data) { console.log("Strategy status:", data); if (data.running_strategies) { // Update running strategies map using composite keys this.runningStrategies.clear(); data.running_strategies.forEach(strat => { const runKey = this._makeRunningKey(strat.strategy_id, strat.mode); this.runningStrategies.set(runKey, { mode: strat.mode, instance_id: strat.instance_id, strategy_name: strat.strategy_name, balance: strat.balance, positions: strat.positions || [], trade_count: strat.trade_count || 0, testnet: strat.testnet, circuit_breaker: strat.circuit_breaker }); }); // Update UI this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } } /** * Handles real-time strategy execution events from the server. * @param {Object} data - The event data containing strategy_id, mode, and events array. */ handleStrategyEvents(data) { console.log("Strategy events received:", data); if (!data || !data.strategy_id || !data.events) { return; } const { strategy_id, mode, events } = data; const runKey = this._makeRunningKey(strategy_id, mode); const running = this.runningStrategies.get(runKey); if (!running) { console.warn(`Received events for strategy ${strategy_id} but it's not in runningStrategies`); return; } // Process each event let needsUIUpdate = false; for (const event of events) { switch (event.type) { case 'tick_complete': if (typeof event.balance === 'number') { running.balance = event.balance; needsUIUpdate = true; } if (typeof event.trades === 'number') { running.trade_count = event.trades; needsUIUpdate = true; } if (typeof event.profit_loss === 'number') { running.profit_loss = event.profit_loss; needsUIUpdate = true; } break; case 'trade_executed': console.log(`Trade executed: ${event.side} ${event.amount} @ ${event.price}`); running.trade_count = (running.trade_count || 0) + 1; needsUIUpdate = true; // TODO: Add to trade history display break; case 'signal_triggered': console.log(`Signal triggered: ${event.signal}`); // TODO: Add to activity feed break; case 'error': console.error(`Strategy error: ${event.message}`); alert(`Strategy ${running.strategy_name || strategy_id} error: ${event.message}`); break; case 'strategy_exited': this.runningStrategies.delete(runKey); needsUIUpdate = true; break; default: console.log(`Unknown event type: ${event.type}`, event); } } // Update the map and refresh UI if needed if (needsUIUpdate) { this.runningStrategies.set(runKey, running); this.uiManager.updateStrategiesHtml(this.dataManager.getAllStrategies()); } } /** * Checks if a strategy is currently running (in any mode). * @param {string} strategyId - The strategy tbl_key. * @param {string} mode - Optional mode to check specifically. * @returns {boolean} - True if running. */ isStrategyRunning(strategyId, mode = null) { if (mode) { return this.runningStrategies.has(this._makeRunningKey(strategyId, mode)); } // Check if running in any mode for (const key of this.runningStrategies.keys()) { if (key.startsWith(strategyId + ':')) { return true; } } return false; } /** * Gets the running info for a strategy. * @param {string} strategyId - The strategy tbl_key. * @param {string} mode - Optional mode to get specifically. * @returns {Object|null} - Running info or null. */ getRunningInfo(strategyId, mode = null) { if (mode) { return this.runningStrategies.get(this._makeRunningKey(strategyId, mode)) || null; } // Return first matching strategy regardless of mode for (const [key, value] of this.runningStrategies) { if (key.startsWith(strategyId + ':')) { return value; } } return null; } /** * Gets all running modes for a strategy. * @param {string} strategyId - The strategy tbl_key. * @returns {string[]} - Array of modes the strategy is running in. */ getRunningModes(strategyId) { const modes = []; for (const [key, value] of this.runningStrategies) { if (key.startsWith(strategyId + ':')) { modes.push(value.mode); } } return modes; } // ========== AI Strategy Builder Wrappers ========== /** * Opens the AI strategy builder dialog. */ openAIDialog() { this.uiManager.openAIDialog(); } /** * Closes the AI strategy builder dialog. */ closeAIDialog() { this.uiManager.closeAIDialog(); } /** * Generates a strategy from natural language using AI. */ async generateWithAI() { await this.uiManager.generateWithAI(); } // ========== Public Strategy Subscription Methods ========== /** * Requests list of public strategies from the server. */ requestPublicStrategies() { if (this.comms) { this.comms.sendToApp('get_public_strategies', {}); } } /** * Subscribes to a public strategy. * @param {string} tbl_key - The strategy's tbl_key. */ subscribeToStrategy(tbl_key) { if (!tbl_key) { console.error('subscribeToStrategy: No tbl_key provided'); return; } if (this.comms) { this.comms.sendToApp('subscribe_strategy', { strategy_tbl_key: tbl_key }); } } /** * Unsubscribes from a strategy. * @param {string} tbl_key - The strategy's tbl_key. */ unsubscribeFromStrategy(tbl_key) { if (!tbl_key) { console.error('unsubscribeFromStrategy: No tbl_key provided'); return; } // Check if strategy is running (in any mode) if (this.isStrategyRunning(tbl_key)) { alert('Cannot unsubscribe while strategy is running. Stop it first.'); return; } if (!confirm('Unsubscribe from this strategy?')) { return; } if (this.comms) { this.comms.sendToApp('unsubscribe_strategy', { strategy_tbl_key: tbl_key }); } } /** * Handles public strategies list from server. * @param {Object} data - Response containing strategies array. */ handlePublicStrategies(data) { console.log('Received public strategies:', data); if (data && data.strategies) { this.uiManager.renderPublicStrategyModal(data.strategies); } } /** * Handles successful subscription response. * @param {Object} data - Response containing success info. */ handleStrategySubscribed(data) { console.log('Strategy subscribed:', data); if (data && data.success) { // Refresh strategy list to include new subscription this.dataManager.fetchSavedStrategies(this.comms, this.data); // Close the public strategy browser modal if open const modal = document.getElementById('public-strategy-modal'); if (modal) { modal.remove(); } // Show success feedback if (data.strategy_name) { alert(`Successfully subscribed to "${data.strategy_name}"`); } } } /** * Handles successful unsubscription response. * @param {Object} data - Response containing success info. */ handleStrategyUnsubscribed(data) { console.log('Strategy unsubscribed:', data); if (data && data.success) { // Refresh strategy list to remove unsubscribed strategy this.dataManager.fetchSavedStrategies(this.comms, this.data); // Update public strategy browser if open const modal = document.getElementById('public-strategy-modal'); if (modal) { // Refresh the modal content this.requestPublicStrategies(); } } } /** * Handles subscription error response. * @param {Object} data - Response containing error info. */ handleSubscriptionError(data) { console.error('Subscription error:', data); const message = data && data.message ? data.message : 'Subscription operation failed'; alert(message); } }