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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ==============================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue