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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-11 00:51:56 -03:00
parent 18d773320c
commit d2f31e7111
9 changed files with 256 additions and 4 deletions

View File

@ -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

View File

@ -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
# ==============================

View File

@ -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.

View File

@ -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.

View File

@ -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)

View File

@ -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;

View File

@ -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.`);
}

View File

@ -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

View File

@ -201,6 +201,11 @@ and you set fee to 50%, you earn $0.50 per profitable trade.
<!-- Signal blocks will be added here dynamically -->
</category>
<!-- Formations Category -->
<category name="Formations" colour="290" tooltip="Use chart formations (trendlines, channels) in strategy logic">
<!-- Formation blocks will be added here dynamically -->
</category>
<!-- Balances Subcategory -->
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
<label text="Track your trading capital"></label>