brighter-trading/src/static/Strategies.js

2347 lines
94 KiB
JavaScript

/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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 = `
<span style="margin-right: 8px;">📊</span>
<span>Strategy was saved with <strong>${savedExchange.toUpperCase()} ${savedSymbol} (${savedTimeframe})</strong>.
You're viewing <strong>${currentExchange.toUpperCase()} ${currentSymbol} (${currentTimeframe})</strong>.
Update below if you want to change the trading source.</span>
`;
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 = `
<span style="margin-right: 8px;">⚠️</span>
<span>This strategy is currently running in <strong>${modeText}</strong> mode.
Changes will not take effect until you restart it.</span>
`;
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 = '&#8722;'; // 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 = '&#10008;';
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 ? '&#9632;' : '&#9654;'; // 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 = `<strong>${escapeHtml(strat.name || 'Unnamed Strategy')}</strong>`;
// 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 = '<span style="background: #28a745; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; margin-left: 4px;">TESTNET</span>';
} else {
modeBadge = '<span style="background: #dc3545; color: white; padding: 1px 4px; border-radius: 3px; font-size: 9px; margin-left: 4px;">PRODUCTION</span>';
}
}
let statusHtml = `
<div class="strategy-status running">
Running in <strong>${safeModeDisplay}</strong> mode ${modeBadge}`;
// Show balance if available
if (runningInfo.balance !== undefined) {
statusHtml += `<br>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 += `<br><span style="color: #dc3545;">⚠️ Circuit Breaker TRIPPED: ${safeCircuitReason}</span>`;
}
statusHtml += `</div>`;
hoverHtml += statusHtml;
}
// Stats
if (strat.stats && Object.keys(strat.stats).length > 0) {
hoverHtml += `<br><small>Stats: ${escapeHtml(JSON.stringify(strat.stats, null, 2))}</small>`;
}
// Run controls
hoverHtml += `
<div class="strategy-controls">
<select id="mode-select-${strategyKeyHtml}" ${isRunning ? 'disabled' : ''}
onchange="UI.strats.onModeChange('${strategyKeyJs}', this.value)">
<option value="paper" ${runningInfo?.mode === 'paper' ? 'selected' : ''}>Paper Trading</option>
<option value="live" ${runningInfo?.mode === 'live' ? 'selected' : ''}>Live Trading</option>
</select>
<div id="live-options-${strategyKeyHtml}" style="display: none; margin-top: 5px;">
<label style="font-size: 10px; display: block;">
<input type="checkbox" id="testnet-${strategyKeyHtml}" checked>
Testnet Mode (Recommended)
</label>
<small style="color: #ff6600; font-size: 9px; display: block; margin-top: 3px;">
⚠️ Unchecking uses REAL MONEY
</small>
</div>
<button class="btn-run ${isRunning ? 'running' : ''}"
onclick="event.stopPropagation(); ${isRunning
? `UI.strats.stopStrategy('${strategyKeyJs}')`
: `UI.strats.runStrategyWithOptions('${strategyKeyJs}')`
}">
${isRunning ? 'Stop Strategy' : 'Run Strategy'}
</button>
</div>`;
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 = `
<div class="modal-content public-strategy-browser">
<div class="modal-header">
<h2>Public Strategies</h2>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">&times;</button>
</div>
<div class="modal-body">
<div class="public-strategies-list">
${strategies.length === 0
? '<p class="no-strategies">No public strategies available.</p>'
: strategies.map(s => {
const keyRaw = String(s.tbl_key || '');
const keyHtml = escapeHtml(keyRaw);
const keyJs = escapeHtml(escapeJsString(keyRaw));
const nameHtml = escapeHtml(s.name || 'Unnamed Strategy');
const creatorHtml = escapeHtml(s.creator_name || 'Unknown');
const action = s.is_subscribed ? 'unsubscribeFromStrategy' : 'subscribeToStrategy';
const label = s.is_subscribed ? 'Unsubscribe' : 'Subscribe';
return `
<div class="public-strategy-item ${s.is_subscribed ? 'subscribed' : ''}" data-tbl-key="${keyHtml}">
<div class="strategy-info">
<strong>${nameHtml}</strong>
<span class="creator">by @${creatorHtml}</span>
</div>
<button class="subscribe-btn ${s.is_subscribed ? 'subscribed' : ''}"
onclick="UI.strats.${action}('${keyJs}')">
${label}
</button>
</div>
`;
}).join('')
}
</div>
</div>
</div>
`;
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);
}
}