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:
parent
18d773320c
commit
d2f31e7111
|
|
@ -83,7 +83,8 @@ class BrighterTrades:
|
||||||
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
|
self.backtester = Backtester(data_cache=self.data, strategies=self.strategies,
|
||||||
indicators=self.indicators, socketio=socketio,
|
indicators=self.indicators, socketio=socketio,
|
||||||
edm_client=self.edm_client,
|
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)
|
self.backtests = {} # In-memory storage for backtests (replace with DB access in production)
|
||||||
|
|
||||||
# Wallet manager for Bitcoin wallets and credits ledger
|
# 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
|
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
|
# Store fee tracking info on the instance
|
||||||
if strategy_run_id:
|
if strategy_run_id:
|
||||||
instance.strategy_run_id = strategy_run_id
|
instance.strategy_run_id = strategy_run_id
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,8 @@ class PythonGenerator:
|
||||||
handler_method = self.handle_indicator
|
handler_method = self.handle_indicator
|
||||||
elif node_type.startswith('signal_'):
|
elif node_type.startswith('signal_'):
|
||||||
handler_method = self.handle_signal
|
handler_method = self.handle_signal
|
||||||
|
elif node_type.startswith('formation_') or node_type == 'formation':
|
||||||
|
handler_method = self.handle_formation
|
||||||
else:
|
else:
|
||||||
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
||||||
handler_code = handler_method(node, indent_level)
|
handler_code = handler_method(node, indent_level)
|
||||||
|
|
@ -195,6 +197,8 @@ class PythonGenerator:
|
||||||
handler_method = self.handle_indicator
|
handler_method = self.handle_indicator
|
||||||
elif node_type.startswith('signal_'):
|
elif node_type.startswith('signal_'):
|
||||||
handler_method = self.handle_signal
|
handler_method = self.handle_signal
|
||||||
|
elif node_type.startswith('formation_') or node_type == 'formation':
|
||||||
|
handler_method = self.handle_formation
|
||||||
else:
|
else:
|
||||||
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
handler_method = getattr(self, f'handle_{node_type}', self.handle_default)
|
||||||
condition_code = handler_method(condition_node, indent_level=indent_level)
|
condition_code = handler_method(condition_node, indent_level=indent_level)
|
||||||
|
|
@ -291,6 +295,46 @@ class PythonGenerator:
|
||||||
logger.debug(f"Generated signal condition: {expr}")
|
logger.debug(f"Generated signal condition: {expr}")
|
||||||
return 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
|
# Balances Handlers
|
||||||
# ==============================
|
# ==============================
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ class StrategyInstance:
|
||||||
'notify_user': self.notify_user,
|
'notify_user': self.notify_user,
|
||||||
'process_indicator': self.process_indicator,
|
'process_indicator': self.process_indicator,
|
||||||
'process_signal': self.process_signal,
|
'process_signal': self.process_signal,
|
||||||
|
'process_formation': self.process_formation,
|
||||||
'get_strategy_profit_loss': self.get_strategy_profit_loss,
|
'get_strategy_profit_loss': self.get_strategy_profit_loss,
|
||||||
'is_in_profit': self.is_in_profit,
|
'is_in_profit': self.is_in_profit,
|
||||||
'is_in_loss': self.is_in_loss,
|
'is_in_loss': self.is_in_loss,
|
||||||
|
|
@ -814,6 +815,59 @@ class StrategyInstance:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False if output_field == 'triggered' else None
|
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:
|
def get_strategy_profit_loss(self, strategy_id: str) -> float:
|
||||||
"""
|
"""
|
||||||
Retrieves the current profit or loss of the strategy.
|
Retrieves the current profit or loss of the strategy.
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,16 @@ class BacktestStrategyInstance(StrategyInstance):
|
||||||
logger.warning(f"Could not get candle datetime: {e}")
|
logger.warning(f"Could not get candle datetime: {e}")
|
||||||
return dt.datetime.now()
|
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:
|
def get_collected_alerts(self) -> list:
|
||||||
"""
|
"""
|
||||||
Returns the list of collected alerts for inclusion in backtest results.
|
Returns the list of collected alerts for inclusion in backtest results.
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class EquityCurveAnalyzer(bt.Analyzer):
|
||||||
# Backtester Class
|
# Backtester Class
|
||||||
class Backtester:
|
class Backtester:
|
||||||
def __init__(self, data_cache: DataCache, strategies: Strategies, indicators: Indicators, socketio,
|
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 """
|
""" Initialize the Backtesting class with a cache for back-tests """
|
||||||
self.data_cache = data_cache
|
self.data_cache = data_cache
|
||||||
self.strategies = strategies
|
self.strategies = strategies
|
||||||
|
|
@ -50,6 +50,8 @@ class Backtester:
|
||||||
self.socketio = socketio
|
self.socketio = socketio
|
||||||
self.edm_client = edm_client
|
self.edm_client = edm_client
|
||||||
self.external_indicators = external_indicators
|
self.external_indicators = external_indicators
|
||||||
|
self.signals = signals
|
||||||
|
self.formations = formations
|
||||||
|
|
||||||
# Ensure 'tests' cache exists
|
# Ensure 'tests' cache exists
|
||||||
self.data_cache.create_cache(
|
self.data_cache.create_cache(
|
||||||
|
|
@ -883,6 +885,11 @@ class Backtester:
|
||||||
indicator_owner_id=indicator_owner_id, # For subscribed strategies, use creator's indicators
|
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
|
# Cache the backtest
|
||||||
self.cache_backtest(backtest_key, msg_data, strategy_instance_id)
|
self.cache_backtest(backtest_key, msg_data, strategy_instance_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,10 @@ class StratWorkspaceManager {
|
||||||
const signalBlocksModule = await import('./blocks/signal_blocks.js');
|
const signalBlocksModule = await import('./blocks/signal_blocks.js');
|
||||||
signalBlocksModule.defineSignalBlocks();
|
signalBlocksModule.defineSignalBlocks();
|
||||||
|
|
||||||
|
// Load and define formation blocks
|
||||||
|
const formationBlocksModule = await import('./blocks/formation_blocks.js');
|
||||||
|
formationBlocksModule.defineFormationBlocks();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading Blockly modules: ", error);
|
console.error("Error loading Blockly modules: ", error);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -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.`);
|
||||||
|
}
|
||||||
|
|
@ -385,10 +385,11 @@ class Formations {
|
||||||
this.registerSocketHandlers();
|
this.registerSocketHandlers();
|
||||||
|
|
||||||
// Get current scope from chart data
|
// Get current scope from chart data
|
||||||
|
// Note: bt_data uses 'exchange_name' not 'exchange', and 'timeframe' not 'interval'
|
||||||
this.currentScope = {
|
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',
|
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
|
// Fetch formations for current scope
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,11 @@ and you set fee to 50%, you earn $0.50 per profitable trade.
|
||||||
<!-- Signal blocks will be added here dynamically -->
|
<!-- Signal blocks will be added here dynamically -->
|
||||||
</category>
|
</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 -->
|
<!-- Balances Subcategory -->
|
||||||
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
|
<category name="Balances" colour="#E69500" tooltip="Access strategy and account balance information">
|
||||||
<label text="Track your trading capital"></label>
|
<label text="Track your trading capital"></label>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue