2347 lines
94 KiB
JavaScript
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, '<')
|
|
.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 = `
|
|
<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 = '−'; // 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 = `<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()">×</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);
|
|
}
|
|
}
|