From 78dfd71303c87ae934b174c9b5055b064e3b5356 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 2 Mar 2026 04:39:41 -0400 Subject: [PATCH] Add statistics dashboard and strategy UI enhancements Statistics HUD: - Full statistics dashboard with running strategies list - Strategy performance metrics (P&L, balance, trades, win rate) - Mini equity curve chart placeholder - Real-time stats updates via SocketIO Strategy Improvements: - Orphaned strategy cleanup function - User existence validation - Enhanced strategy card display - Improved new strategy popup UI/UX Enhancements: - Updated control panel layout - Indicator blocks improvements - Additional CSS styling - Welcome template - Communication.js strategy event handling Backend: - Strategy instance tracking updates - Paper strategy instance improvements - User session handling updates - App.py route additions Co-Authored-By: Claude Opus 4.5 --- src/Strategies.py | 124 ++++++++++++- src/StrategyInstance.py | 5 + src/Users.py | 6 +- src/app.py | 31 +++- src/paper_strategy_instance.py | 3 + src/static/Strategies.js | 220 +++++++++++++++++++++- src/static/blocks/indicator_blocks.js | 20 ++ src/static/brighterStyles.css | 41 ++++- src/static/communication.js | 17 +- src/static/data.js | 6 +- src/templates/control_panel.html | 8 +- src/templates/index.html | 1 + src/templates/indicators_hud.html | 2 +- src/templates/new_strategy_popup.html | 35 ++-- src/templates/statistics_hud.html | 251 +++++++++++++++++++++++++- src/templates/welcome.html | 167 +++++++++++++++++ 16 files changed, 901 insertions(+), 36 deletions(-) create mode 100644 src/templates/welcome.html diff --git a/src/Strategies.py b/src/Strategies.py index 5920490..35ff7b5 100644 --- a/src/Strategies.py +++ b/src/Strategies.py @@ -421,6 +421,68 @@ class Strategies: logger.error(f"Failed to delete strategy '{tbl_key}': {e}", exc_info=True) return {"success": False, "message": f"Failed to delete strategy: {str(e)}"} + def cleanup_orphaned_strategies(self) -> dict: + """ + Removes strategies that belong to non-existent users from the database. + This is a maintenance operation to clean up orphaned data. + + :return: A dictionary with cleanup results. + """ + try: + # Get all strategies without filtering + all_strategies = self.data_cache.get_all_rows_from_datacache(cache_name='strategies') + + if all_strategies is None or all_strategies.empty: + return {"success": True, "removed": 0, "message": "No strategies found."} + + removed_count = 0 + removed_names = [] + + for idx, row in all_strategies.iterrows(): + creator = row.get('creator') + tbl_key = row.get('tbl_key') + name = row.get('name', 'unknown') + + # Check for invalid or non-existent creator + should_remove = False + reason = "" + + if creator is None: + should_remove = True + reason = "null creator" + elif isinstance(creator, bytes): + should_remove = True + reason = "corrupted creator (bytes)" + elif isinstance(creator, str) and not creator.isdigit(): + should_remove = True + reason = f"invalid creator format: {creator}" + else: + try: + creator_id = int(creator) + if not self._user_exists(creator_id): + should_remove = True + reason = f"user {creator_id} does not exist" + except (ValueError, TypeError): + should_remove = True + reason = "creator conversion error" + + if should_remove and tbl_key: + logger.info(f"Removing orphaned strategy '{name}' (tbl_key: {tbl_key}) - {reason}") + self.delete_strategy(tbl_key) + removed_count += 1 + removed_names.append(name) + + return { + "success": True, + "removed": removed_count, + "removed_strategies": removed_names, + "message": f"Cleaned up {removed_count} orphaned strategies." + } + + except Exception as e: + logger.error(f"Failed to cleanup orphaned strategies: {e}", exc_info=True) + return {"success": False, "removed": 0, "message": f"Cleanup failed: {str(e)}"} + def get_all_strategy_names(self, user_id: int) -> list | None: """ Return a list of all public and user strategy names stored in the cache or database. @@ -432,6 +494,63 @@ class Strategies: return strategies_df['name'].tolist() return None + def _user_exists(self, user_id: int) -> bool: + """ + Check if a user exists in the users cache/database. + + :param user_id: The user ID to check. + :return: True if the user exists, False otherwise. + """ + if user_id is None: + return False + try: + # Try to get the username for this user_id + username = self.data_cache.get_datacache_item( + item_name='user_name', + cache_name='users', + filter_vals=('id', int(user_id)) + ) + return username is not None + except (ValueError, TypeError): + # Invalid user_id format (e.g., corrupted data) + return False + + def _filter_valid_strategies(self, strategies_df: pd.DataFrame) -> pd.DataFrame: + """ + Filter out strategies with non-existent or invalid creators. + + :param strategies_df: DataFrame of strategies to filter. + :return: Filtered DataFrame with only valid strategies. + """ + if strategies_df is None or strategies_df.empty: + return strategies_df + + valid_indices = [] + for idx, row in strategies_df.iterrows(): + creator = row.get('creator') + # Check for valid creator + try: + # Handle corrupted data (binary garbage, etc.) + if creator is None: + continue + if isinstance(creator, bytes): + logger.warning(f"Skipping strategy with corrupted creator field (bytes): {row.get('name', 'unknown')}") + continue + if isinstance(creator, str) and not creator.isdigit(): + logger.warning(f"Skipping strategy with invalid creator field: {row.get('name', 'unknown')}") + continue + + creator_id = int(creator) + if self._user_exists(creator_id): + valid_indices.append(idx) + else: + logger.debug(f"Filtering out strategy '{row.get('name', 'unknown')}' - creator {creator_id} does not exist") + except (ValueError, TypeError) as e: + logger.warning(f"Skipping strategy with invalid creator: {row.get('name', 'unknown')} - {e}") + continue + + return strategies_df.loc[valid_indices].reset_index(drop=True) + def get_all_strategies(self, user_id: int | None, form: str, include_all: bool = False): """ Return stored strategies in various formats. @@ -470,8 +589,11 @@ class Strategies: else: strategies_df = public_df + # Filter out strategies from non-existent or invalid users + strategies_df = self._filter_valid_strategies(strategies_df) + # Return None if no strategies found - if strategies_df.empty: + if strategies_df is None or strategies_df.empty: return None # Return the strategies in the requested format diff --git a/src/StrategyInstance.py b/src/StrategyInstance.py index ba48036..eed0a07 100644 --- a/src/StrategyInstance.py +++ b/src/StrategyInstance.py @@ -359,6 +359,11 @@ class StrategyInstance: :return: Result of the execution. """ try: + # Log the generated code once for debugging + if not hasattr(self, '_code_logged'): + logger.info(f"Strategy {self.strategy_id} generated code:\n{self.generated_code}") + self._code_logged = True + # Compile the generated code with a meaningful filename compiled_code = compile(self.generated_code, '', 'exec') exec(compiled_code, self.exec_context) diff --git a/src/Users.py b/src/Users.py index a1a5ea5..91cfd15 100644 --- a/src/Users.py +++ b/src/Users.py @@ -37,13 +37,15 @@ class BaseUser: Retrieves the user ID based on the username. :param user_name: The name of the user. - :return: The ID of the user as an integer. + :return: The ID of the user as an integer (native Python int). """ - return self.data.get_datacache_item( + user_id = self.data.get_datacache_item( item_name='id', cache_name='users', filter_vals=('user_name', user_name) ) + # Convert numpy.int64 to native int for SQLite compatibility + return int(user_id) if user_id is not None else None def get_username(self, user_id: int) -> str: """ diff --git a/src/app.py b/src/app.py index 3451331..35fd9d8 100644 --- a/src/app.py +++ b/src/app.py @@ -128,12 +128,16 @@ def strategy_execution_loop(): logger.info(f"Strategy {strategy_id} generated {len(events)} events: {events}") # Emit events to the user's room user_name = brighter_trades.users.get_username(user_id=user_id) + logger.info(f"Emitting to user_id={user_id}, user_name={user_name}") if user_name: socketio.emit('strategy_events', sanitize_for_json({ 'strategy_id': strategy_id, 'mode': mode, 'events': events }), room=user_name) + logger.info(f"Emitted strategy_events to room={user_name}") + else: + logger.warning(f"Could not find username for user_id={user_id}") except Exception as e: logger.warning(f"Could not get price for {symbol}: {e}") @@ -190,6 +194,14 @@ def resolve_user_name(payload: dict | None) -> str | None: @app.route('/') +def welcome(): + """ + Serves the welcome/landing page. + """ + return render_template('welcome.html') + + +@app.route('/app') # @cross_origin(supports_credentials=True) def index(): """ @@ -341,7 +353,12 @@ def settings(): params = data.get('indicator', {}) else: setting = request.form.get('setting') - params = request.form.to_dict() + # Use to_dict(flat=False) to get lists for multi-value fields (like checkboxes) + params = request.form.to_dict(flat=False) + # Convert single-value lists back to single values, except for 'indicator' + for key, value in params.items(): + if key != 'indicator' and isinstance(value, list) and len(value) == 1: + params[key] = value[0] if not setting: return jsonify({"success": False, "message": "No setting provided"}), 400 @@ -354,7 +371,7 @@ def settings(): return jsonify({"success": True}), 200 # Redirect if this is a form submission (non-async request) - return redirect('/') + return redirect('/app') except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @@ -394,6 +411,16 @@ def history(): return jsonify({'error': str(e)}), 500 +@app.route('/docs') +def docs(): + """ + Documentation page - placeholder for now. + """ + # TODO: Link to actual documentation when available + flash('Documentation coming soon! Redirecting to the app.') + return redirect('/app') + + @app.route('/signup') def signup(): return render_template('sign_up.html', title='title') diff --git a/src/paper_strategy_instance.py b/src/paper_strategy_instance.py index c7c0dcb..c87d63f 100644 --- a/src/paper_strategy_instance.py +++ b/src/paper_strategy_instance.py @@ -112,6 +112,9 @@ class PaperStrategyInstance(StrategyInstance): This method translates the Blockly-generated order call to the PaperBroker interface. """ + logger.info(f"trade_order called: type={trade_type}, size={size}, order_type={order_type}") + logger.info(f" stop_loss={stop_loss}, take_profit={take_profit}, source={source}") + # Extract symbol from source symbol = 'BTC/USDT' # Default if source: diff --git a/src/static/Strategies.js b/src/static/Strategies.js index 02b1695..347f24b 100644 --- a/src/static/Strategies.js +++ b/src/static/Strategies.js @@ -44,6 +44,15 @@ class StratUIManager { return; } + // Remove any existing warning banner + const existingWarning = this.formElement.querySelector('.running-strategy-warning'); + if (existingWarning) { + existingWarning.remove(); + } + + // Track the strategy being edited (for restart prompt after save) + this._editingStrategyId = null; + // Update form based on action if (action === 'new') { headerTitle.textContent = "Create New Strategy"; @@ -59,6 +68,41 @@ class StratUIManager { 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; + + // Check if strategy is currently running and show warning + if (UI.strats && UI.strats.isStrategyRunning(strategyData.tbl_key)) { + const runningInfo = UI.strats.getRunningInfo(strategyData.tbl_key); + const modeText = runningInfo ? runningInfo.mode : 'unknown'; + + // Create warning banner + const warningBanner = document.createElement('div'); + warningBanner.className = 'running-strategy-warning'; + warningBanner.innerHTML = ` + ⚠️ + This strategy is currently running in ${modeText} mode. + Changes will not take effect until you restart it. + `; + warningBanner.style.cssText = ` + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 5px; + padding: 10px; + margin: 10px; + color: #856404; + font-size: 13px; + display: flex; + align-items: center; + `; + + // Insert after header + const header = this.formElement.querySelector('#draggable_header'); + if (header && header.nextSibling) { + header.parentNode.insertBefore(warningBanner, header.nextSibling); + } + } } // Display the form @@ -149,10 +193,8 @@ class StratUIManager { if (isRunning) { UI.strats.stopStrategy(strat.tbl_key); } else { - // Show mode selection in hover panel or use default - const modeSelect = document.getElementById(`mode-select-${strat.tbl_key}`); - const mode = modeSelect ? modeSelect.value : 'paper'; - UI.strats.runStrategy(strat.tbl_key, mode); + // Use runStrategyWithOptions to honor testnet checkbox and show warnings + UI.strats.runStrategyWithOptions(strat.tbl_key); } }); strategyItem.appendChild(runButton); @@ -533,6 +575,12 @@ class StratWorkspaceManager { 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.'); } @@ -676,6 +724,48 @@ class StratWorkspaceManager { }; } + /** + * 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. @@ -772,6 +862,7 @@ class Strategies { 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)); // Fetch saved strategies using DataManager this.dataManager.fetchSavedStrategies(this.comms, this.data); @@ -864,6 +955,35 @@ class Strategies { } 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}`); @@ -1256,9 +1376,21 @@ class Strategies { testnet: data.testnet, exchange: data.exchange, max_position_pct: data.max_position_pct, - circuit_breaker_pct: data.circuit_breaker_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()); @@ -1294,6 +1426,11 @@ class Strategies { 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()); } @@ -1359,6 +1496,79 @@ class Strategies { } } + /** + * 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. diff --git a/src/static/blocks/indicator_blocks.js b/src/static/blocks/indicator_blocks.js index 4c9b45b..22ff55b 100644 --- a/src/static/blocks/indicator_blocks.js +++ b/src/static/blocks/indicator_blocks.js @@ -18,6 +18,26 @@ export function defineIndicatorBlocks() { return; } + // Check if there are any indicators to display + const indicatorNames = Object.keys(indicatorOutputs); + if (indicatorNames.length === 0) { + // Add helpful message when no indicators exist + const labelElement = document.createElement('label'); + labelElement.setAttribute('text', 'No indicators configured yet.'); + toolboxCategory.appendChild(labelElement); + + const labelElement2 = document.createElement('label'); + labelElement2.setAttribute('text', 'Add indicators from the Indicators panel'); + toolboxCategory.appendChild(labelElement2); + + const labelElement3 = document.createElement('label'); + labelElement3.setAttribute('text', 'on the right side of the screen.'); + toolboxCategory.appendChild(labelElement3); + + console.log('No indicators available - added help message to toolbox.'); + return; + } + for (let indicatorName in indicatorOutputs) { const outputs = indicatorOutputs[indicatorName]; diff --git a/src/static/brighterStyles.css b/src/static/brighterStyles.css index 175a9e1..705c3ca 100644 --- a/src/static/brighterStyles.css +++ b/src/static/brighterStyles.css @@ -396,14 +396,47 @@ height: 500px; } .content { - padding: 0 18px; - max-height: 0; - min-height: 50px; - height:500px; /* Max height in the html style defines the distance a panel will slide down. This defines the max*/ + padding: 5px 18px; + max-height: 60px; /* Preview height when minimized - shows key buttons/info */ overflow: hidden; transition: max-height 0.2s ease-out; background-color: #f1f1f1; } + +/* When panel is expanded */ +.collapsible.active + .content { + overflow-y: auto; + max-height: 400px; /* Expanded height */ +} + +/* Hide scrollbar by default, show on hover */ +.content::-webkit-scrollbar { + width: 8px; + background: transparent; +} + +.content::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; +} + +.content:hover::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.3); +} + +.content:hover::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.5); +} + +/* Firefox scrollbar hiding */ +.content { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +.content:hover { + scrollbar-color: rgba(0, 0, 0, 0.3) transparent; +} .bg_red{ background-color:#9F180F; } diff --git a/src/static/communication.js b/src/static/communication.js index e8f6f94..ea05de5 100644 --- a/src/static/communication.js +++ b/src/static/communication.js @@ -72,6 +72,12 @@ class Comms { this.emit(data.reply, data.data); } }); + + // Handle strategy execution events (tick_complete, trade_executed, etc.) + this.socket.on('strategy_events', (data) => { + console.log('Strategy events received:', data); + this.emit('strategy_events', data); + }); } /** @@ -307,8 +313,15 @@ class Comms { * @param {string} tradingPair - The trading pair to subscribe to. */ setExchangeCon(interval, tradingPair) { - tradingPair = tradingPair.toLowerCase(); - const ws = `wss://stream.binance.com:9443/ws/${tradingPair}@kline_${interval}`; + // Convert trading pair to Binance WebSocket format + // e.g., "BTC/USD" -> "btcusdt", "ETH/USDT" -> "ethusdt" + let binanceSymbol = tradingPair.toLowerCase().replace('/', ''); + // Binance doesn't have USD pairs, only USDT + if (binanceSymbol.endsWith('usd') && !binanceSymbol.endsWith('usdt')) { + binanceSymbol = binanceSymbol.replace(/usd$/, 'usdt'); + } + console.log(`Connecting to Binance stream: ${binanceSymbol}@kline_${interval}`); + const ws = `wss://stream.binance.com:9443/ws/${binanceSymbol}@kline_${interval}`; this.exchangeCon = new WebSocket(ws); this.exchangeCon.onmessage = (event) => { diff --git a/src/static/data.js b/src/static/data.js index a54d342..2d908e2 100644 --- a/src/static/data.js +++ b/src/static/data.js @@ -59,8 +59,10 @@ class Data { } candle_update(new_candle){ // This is called everytime a candle update comes from the local server. - window.UI.charts.update_main_chart(new_candle); - //console.log('Candle update:'); + // Guard against race condition where updates arrive before chart is initialized + if (window.UI && window.UI.charts && window.UI.charts.update_main_chart) { + window.UI.charts.update_main_chart(new_candle); + } this.last_price = new_candle.close; } registerCallback_i_updates(call_back){ diff --git a/src/templates/control_panel.html b/src/templates/control_panel.html index 5432f31..395ad98 100644 --- a/src/templates/control_panel.html +++ b/src/templates/control_panel.html @@ -28,10 +28,12 @@ coll[i].addEventListener("click", function() { this.classList.toggle("active"); var content = this.nextElementSibling; - if (content.style.maxHeight){ - content.style.maxHeight = null; + if (this.classList.contains("active")) { + // Expand to full content height (max 400px from CSS) + content.style.maxHeight = Math.min(content.scrollHeight, 400) + "px"; } else { - content.style.maxHeight = content.scrollHeight + "px"; + // Collapse to preview height (60px from CSS) + content.style.maxHeight = null; } }); } diff --git a/src/templates/index.html b/src/templates/index.html index 817799e..142168f 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -31,6 +31,7 @@ + diff --git a/src/templates/indicators_hud.html b/src/templates/indicators_hud.html index ba33057..1664bec 100644 --- a/src/templates/indicators_hud.html +++ b/src/templates/indicators_hud.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/templates/new_strategy_popup.html b/src/templates/new_strategy_popup.html index bb899cb..9c36eb2 100644 --- a/src/templates/new_strategy_popup.html +++ b/src/templates/new_strategy_popup.html @@ -108,12 +108,13 @@