From d2f31e7111f9ff0d828c202f9667a6d8222f7c1f Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 11 Mar 2026 00:51:56 -0300 Subject: [PATCH] Add Blockly integration for chart formations in strategies - Add formation_blocks.js with dynamic block generation per formation - Add handle_formation() to PythonGenerator for code generation - Add process_formation() to StrategyInstance for runtime execution - Inject formations manager into paper/backtest strategy instances - Add get_current_candle_time() override for backtest bar timestamps - Add Formations category to Blockly toolbox - Fix scope property names in formations.js (exchange_name, interval) Formations can now be referenced in strategy logic by their line properties (line, upper, lower, midline) and values are calculated at candle time. Co-Authored-By: Claude Opus 4.5 --- src/BrighterTrades.py | 8 +- src/PythonGenerator.py | 44 ++++++++++ src/StrategyInstance.py | 54 ++++++++++++ src/backtest_strategy_instance.py | 10 +++ src/backtesting.py | 9 +- src/static/Strategies.js | 4 + src/static/blocks/formation_blocks.js | 121 ++++++++++++++++++++++++++ src/static/formations.js | 5 +- src/templates/new_strategy_popup.html | 5 ++ 9 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 src/static/blocks/formation_blocks.js diff --git a/src/BrighterTrades.py b/src/BrighterTrades.py index 2dc5758..e2f0bc9 100644 --- a/src/BrighterTrades.py +++ b/src/BrighterTrades.py @@ -83,7 +83,8 @@ class BrighterTrades: self.backtester = Backtester(data_cache=self.data, strategies=self.strategies, indicators=self.indicators, socketio=socketio, edm_client=self.edm_client, - external_indicators=self.external_indicators) + external_indicators=self.external_indicators, + signals=self.signals, formations=self.formations) self.backtests = {} # In-memory storage for backtests (replace with DB access in production) # Wallet manager for Bitcoin wallets and credits ledger @@ -1009,6 +1010,11 @@ class BrighterTrades: indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators ) + # Inject signals and formations managers for strategy execution + instance.signals = self.signals + instance.formations = self.formations + instance.formation_owner_id = indicator_owner_id if indicator_owner_id else user_id + # Store fee tracking info on the instance if strategy_run_id: instance.strategy_run_id = strategy_run_id diff --git a/src/PythonGenerator.py b/src/PythonGenerator.py index 05513b6..e56d580 100644 --- a/src/PythonGenerator.py +++ b/src/PythonGenerator.py @@ -145,6 +145,8 @@ class PythonGenerator: handler_method = self.handle_indicator elif node_type.startswith('signal_'): handler_method = self.handle_signal + elif node_type.startswith('formation_') or node_type == 'formation': + handler_method = self.handle_formation else: handler_method = getattr(self, f'handle_{node_type}', self.handle_default) handler_code = handler_method(node, indent_level) @@ -195,6 +197,8 @@ class PythonGenerator: handler_method = self.handle_indicator elif node_type.startswith('signal_'): handler_method = self.handle_signal + elif node_type.startswith('formation_') or node_type == 'formation': + handler_method = self.handle_formation else: handler_method = getattr(self, f'handle_{node_type}', self.handle_default) condition_code = handler_method(condition_node, indent_level=indent_level) @@ -291,6 +295,46 @@ class PythonGenerator: logger.debug(f"Generated signal condition: {expr}") return expr + # ============================== + # Formations Handlers + # ============================== + + def handle_formation(self, node: Dict[str, Any], indent_level: int) -> str: + """ + Handles formation nodes by generating a function call to get formation value. + Uses tbl_key (stable UUID) for referencing formations. + + :param node: The formation node. + :param indent_level: Current indentation level. + :return: A string representing the formation value lookup. + """ + fields = node.get('fields', {}) + + # Get formation reference by tbl_key (stable) not name + tbl_key = fields.get('TBL_KEY') + property_name = fields.get('PROPERTY', 'line') + formation_name = fields.get('NAME', 'unknown') + + if not tbl_key: + logger.error(f"formation node missing TBL_KEY. fields={fields}") + return 'None' + + # Track formation usage for dependency resolution + if not hasattr(self, 'formations_used'): + self.formations_used = [] + self.formations_used.append({ + 'tbl_key': tbl_key, + 'name': formation_name, + 'property': property_name + }) + + # Generate code that calls process_formation + # Uses current candle time by default (timestamp=None) + expr = f"process_formation('{tbl_key}', '{property_name}')" + + logger.debug(f"Generated formation lookup: {expr}") + return expr + # ============================== # Balances Handlers # ============================== diff --git a/src/StrategyInstance.py b/src/StrategyInstance.py index 629b4ec..f4c41f6 100644 --- a/src/StrategyInstance.py +++ b/src/StrategyInstance.py @@ -87,6 +87,7 @@ class StrategyInstance: 'notify_user': self.notify_user, 'process_indicator': self.process_indicator, 'process_signal': self.process_signal, + 'process_formation': self.process_formation, 'get_strategy_profit_loss': self.get_strategy_profit_loss, 'is_in_profit': self.is_in_profit, 'is_in_loss': self.is_in_loss, @@ -814,6 +815,59 @@ class StrategyInstance: traceback.print_exc() return False if output_field == 'triggered' else None + def process_formation(self, tbl_key: str, property_name: str = 'line', timestamp: int = None) -> float: + """ + Gets the price value of a formation property at a given timestamp. + + Uses formation_owner_id (not current user) for subscribed strategies. + Parallel to indicator_owner_id pattern. + + :param tbl_key: Unique key of the formation (UUID). + :param property_name: Property to retrieve ('line', 'upper', 'lower', 'midline', etc.). + :param timestamp: Unix timestamp in seconds UTC. If None, uses current candle time. + :return: Price value at the timestamp, or None on error. + """ + try: + # Check if formations manager is available + if not hasattr(self, 'formations') or self.formations is None: + logger.warning(f"Formations manager not available in StrategyInstance") + return None + + # Default timestamp: use current candle time if available + if timestamp is None: + timestamp = self.get_current_candle_time() + + # Use formation_owner_id for subscribed strategies (parallel to indicator_owner_id) + owner_id = getattr(self, 'formation_owner_id', self.user_id) + + # Look up the formation by tbl_key using owner's formations + formation = self.formations.get_by_tbl_key_for_strategy(tbl_key, owner_id) + if formation is None: + logger.warning(f"Formation with tbl_key '{tbl_key}' not found for owner {owner_id}") + return None + + # Get the property value at the timestamp + value = self.formations.get_property_value(formation, property_name, timestamp) + logger.debug(f"Formation '{formation.get('name')}' {property_name} at {timestamp}: {value}") + return value + + except Exception as e: + logger.error( + f"Error processing formation '{tbl_key}' in StrategyInstance '{self.strategy_instance_id}': {e}", + exc_info=True) + traceback.print_exc() + return None + + def get_current_candle_time(self) -> int: + """ + Returns the current candle timestamp in seconds UTC. + + In backtest mode, this is overridden to return the historical bar time. + In live/paper mode, returns the current time. + """ + import time + return int(time.time()) + def get_strategy_profit_loss(self, strategy_id: str) -> float: """ Retrieves the current profit or loss of the strategy. diff --git a/src/backtest_strategy_instance.py b/src/backtest_strategy_instance.py index 445e8eb..68c6e0b 100644 --- a/src/backtest_strategy_instance.py +++ b/src/backtest_strategy_instance.py @@ -414,6 +414,16 @@ class BacktestStrategyInstance(StrategyInstance): logger.warning(f"Could not get candle datetime: {e}") return dt.datetime.now() + def get_current_candle_time(self) -> int: + """ + Returns the current candle timestamp in seconds UTC. + + In backtest mode, returns the historical bar time being processed. + This is critical for accurate formation value lookups. + """ + candle_datetime = self.get_current_candle_datetime() + return int(candle_datetime.timestamp()) + def get_collected_alerts(self) -> list: """ Returns the list of collected alerts for inclusion in backtest results. diff --git a/src/backtesting.py b/src/backtesting.py index f8f0a08..006e1bb 100644 --- a/src/backtesting.py +++ b/src/backtesting.py @@ -42,7 +42,7 @@ class EquityCurveAnalyzer(bt.Analyzer): # Backtester Class class Backtester: def __init__(self, data_cache: DataCache, strategies: Strategies, indicators: Indicators, socketio, - edm_client=None, external_indicators=None): + edm_client=None, external_indicators=None, signals=None, formations=None): """ Initialize the Backtesting class with a cache for back-tests """ self.data_cache = data_cache self.strategies = strategies @@ -50,6 +50,8 @@ class Backtester: self.socketio = socketio self.edm_client = edm_client self.external_indicators = external_indicators + self.signals = signals + self.formations = formations # Ensure 'tests' cache exists self.data_cache.create_cache( @@ -883,6 +885,11 @@ class Backtester: indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators ) + # Inject signals and formations for strategy execution + strategy_instance.signals = self.signals + strategy_instance.formations = self.formations + strategy_instance.formation_owner_id = indicator_owner_id if indicator_owner_id else user_id + # Cache the backtest self.cache_backtest(backtest_key, msg_data, strategy_instance_id) diff --git a/src/static/Strategies.js b/src/static/Strategies.js index d1d0ce2..991af5c 100644 --- a/src/static/Strategies.js +++ b/src/static/Strategies.js @@ -1053,6 +1053,10 @@ class StratWorkspaceManager { const signalBlocksModule = await import('./blocks/signal_blocks.js'); signalBlocksModule.defineSignalBlocks(); + // Load and define formation blocks + const formationBlocksModule = await import('./blocks/formation_blocks.js'); + formationBlocksModule.defineFormationBlocks(); + } catch (error) { console.error("Error loading Blockly modules: ", error); return; diff --git a/src/static/blocks/formation_blocks.js b/src/static/blocks/formation_blocks.js new file mode 100644 index 0000000..abb2d01 --- /dev/null +++ b/src/static/blocks/formation_blocks.js @@ -0,0 +1,121 @@ +// client/formation_blocks.js + +// Define Blockly blocks for user-created formations +export function defineFormationBlocks() { + + // Ensure Blockly.JSON is available + if (!Blockly.JSON) { + console.error('Blockly.JSON is not defined. Ensure json_generators.js is loaded before formation_blocks.js.'); + return; + } + + // Get all user formations for current scope + const formations = window.UI?.formations?.dataManager?.getAllFormations?.() || []; + const toolboxCategory = document.querySelector('#toolbox_advanced category[name="Formations"]'); + + if (!toolboxCategory) { + console.error('Formations category not found in the toolbox.'); + return; + } + + // Clear existing formation blocks (for refresh) + const existingBlocks = toolboxCategory.querySelectorAll('block[type^="formation_"]'); + existingBlocks.forEach(block => block.remove()); + + // Check if there are any formations to display + if (formations.length === 0) { + // Add helpful message when no formations exist + if (!toolboxCategory.querySelector('label')) { + const labelElement = document.createElement('label'); + labelElement.setAttribute('text', 'No formations configured yet.'); + toolboxCategory.appendChild(labelElement); + + const labelElement2 = document.createElement('label'); + labelElement2.setAttribute('text', 'Draw formations on the chart'); + toolboxCategory.appendChild(labelElement2); + + const labelElement3 = document.createElement('label'); + labelElement3.setAttribute('text', 'using the Formations panel.'); + toolboxCategory.appendChild(labelElement3); + } + + console.log('No formations available - added help message to toolbox.'); + return; + } + + // Remove help labels if formations exist + const labels = toolboxCategory.querySelectorAll('label'); + labels.forEach(label => label.remove()); + + // Property options vary by formation type + const propertyOptionsByType = { + 'support_resistance': [ + ['line value', 'line'] + ], + 'channel': [ + ['upper line', 'upper'], + ['lower line', 'lower'], + ['midline', 'midline'] + ] + }; + + // Default properties for unknown types + const defaultProperties = [ + ['line value', 'line'] + ]; + + for (const formation of formations) { + const formationName = formation.name; + const formationType = formation.formation_type; + const tblKey = formation.tbl_key; + + // Create a unique block type using tbl_key (stable UUID) + const sanitizedName = formationName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, ''); + const blockType = 'formation_' + sanitizedName + '_' + tblKey.substring(0, 8); + + // Get property options for this formation type + const propertyOptions = propertyOptionsByType[formationType] || defaultProperties; + + // Define the block for this formation + Blockly.defineBlocksWithJsonArray([{ + "type": blockType, + "message0": `Formation: ${formationName} %1`, + "args0": [ + { + "type": "field_dropdown", + "name": "PROPERTY", + "options": propertyOptions + } + ], + "output": "dynamic_value", + "colour": 290, // Purple-ish color for formations + "tooltip": `Get the price value of formation '${formationName}' (${formationType}) at current candle time`, + "helpUrl": "" + }]); + + // Define the JSON generator for this block + Blockly.JSON[blockType] = function(block) { + const selectedProperty = block.getFieldValue('PROPERTY'); + const json = { + type: 'formation', + fields: { + TBL_KEY: tblKey, + NAME: formationName, + PROPERTY: selectedProperty + } + }; + // Output as dynamic_value + return { + type: 'dynamic_value', + values: [json] + }; + }; + + // Append the newly created block to the Formations category in the toolbox + const blockElement = document.createElement('block'); + blockElement.setAttribute('type', blockType); + toolboxCategory.appendChild(blockElement); + } + + console.log(`Formation blocks defined: ${formations.length} formations added to toolbox.`); +} diff --git a/src/static/formations.js b/src/static/formations.js index e27fe0d..ac145d5 100644 --- a/src/static/formations.js +++ b/src/static/formations.js @@ -385,10 +385,11 @@ class Formations { this.registerSocketHandlers(); // Get current scope from chart data + // Note: bt_data uses 'exchange_name' not 'exchange', and 'timeframe' not 'interval' this.currentScope = { - exchange: this.data?.exchange || window.bt_data?.exchange || 'kucoin', + exchange: this.data?.exchange || window.bt_data?.exchange_name || 'binance', market: this.data?.trading_pair || window.bt_data?.trading_pair || 'BTC/USDT', - timeframe: this.data?.timeframe || window.bt_data?.timeframe || '1h' + timeframe: this.data?.interval || window.bt_data?.timeframe || '1h' }; // Fetch formations for current scope diff --git a/src/templates/new_strategy_popup.html b/src/templates/new_strategy_popup.html index 78278a5..78afb8a 100644 --- a/src/templates/new_strategy_popup.html +++ b/src/templates/new_strategy_popup.html @@ -201,6 +201,11 @@ and you set fee to 50%, you earn $0.50 per profitable trade. + + + + +