44 KiB
Chart Formations Feature Implementation Plan
Lightweight Charts Version: v5.1.0 (upgraded March 2026)
- Uses series-based coordinate conversion:
series.priceToCoordinate()/series.coordinateToPrice() - Markers via
createSeriesMarkers()API - Series creation via
chart.addSeries(SeriesType, options)
Summary
Add chart formations feature with:
- 9 pattern types: Line, Channel, Triangle, H&S, Double Bottom/Top, Adam & Eve, Triple Bottom/Top
- Interactive drawing: Click-to-place shape, drag anchors to adjust (SVG overlay)
- HUD panel: Manage formations like indicators/signals
- Strategy integration: Reference formations in Blockly (by tbl_key, not name)
- Auto-targets: Calculate and draw projected target levels
- Infinite extension: Lines extrapolate beyond drawn endpoints
- Ownership model: Users own formations; public strategies use creator's formations
- Scope: Formations tied to exchange/market/timeframe
Rendering approach: SVG overlay with requestAnimationFrame sync (NOT Lightweight Charts line series - avoids infinite loops with bound charts).
Supported Patterns
| Pattern | Points | Description |
|---|---|---|
| Support/Resistance | 2 | Single line (horizontal or diagonal) |
| Channel | 3 | Two parallel trendlines |
| Triangle | 3 | Three connected lines (ascending/descending/symmetrical) |
| Head & Shoulders | 5 | Reversal pattern with neckline + 3 peaks |
| Double Bottom (W) | 3 | Two bottoms with middle peak (bullish reversal) |
| Double Top (M) | 3 | Two tops with middle trough (bearish reversal) |
| Adam and Eve | 3 | Sharp V bottom + rounded U bottom variant |
| Triple Bottom | 5 | Three bottoms with two peaks (strong bullish reversal) |
| Triple Top | 5 | Three tops with two troughs (strong bearish reversal) |
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Formations HUD Panel │
│ (Draw tools, formation cards, CRUD) │
├─────────────────────────────────────────────────────────────────┤
│ FormationOverlay │
│ (SVG layer positioned over chart, draggable anchors) │
├─────────────────────────────────────────────────────────────────┤
│ Formations.py (Backend) │
│ (CRUD, value calculation, target projection, scope) │
├─────────────────────────────────────────────────────────────────┤
│ SQLite (formations table) │
│ (id, user_id, name, type, scope, lines_json) │
└─────────────────────────────────────────────────────────────────┘
Strategy Integration:
Blockly Block → PythonGenerator → process_formation(tbl_key, property, timestamp) → price
Rendering Approach:
- SVG overlay sits absolutely positioned over the Lightweight Charts canvas
- Formations drawn as SVG elements (line, polygon, circle anchors)
- Chart sync via polling (requestAnimationFrame), NOT event subscriptions
- Avoids infinite loops from subscribeVisibleTimeRangeChange
Key Design Decisions
-
Ownership: Users own their formations. Public strategies reference creator's formations (subscribers can't see/edit them but the strategy uses them). Uses
formation_owner_idpattern (parallel toindicator_owner_id). -
Line Extension: All lines extend infinitely (extrapolate beyond drawn endpoints). MVP has no extension options - all lines are infinite.
-
Target Projection: Auto-calculate target levels as horizontal lines (e.g., H&S target = neckline - pattern_height). Added in Phase B, not MVP.
-
Scope: Uses
exchange,market,timeframe(matching existing patterns -marketnotsymbol). -
References: Blocks reference formations by
tbl_key(stable UUID) not name (avoids collisions). -
Timestamps: All timestamps in seconds UTC. Backtest uses bar timestamp, not wall clock. Out-of-range returns extrapolated value (infinite lines).
-
MVP Scope: Ship Line + Channel first. Complex patterns (Triangle, H&S, etc.) in Phase B.
Phase 1: Backend Foundation
1.1 Database Schema
In src/Formations.py - Use _ensure_table_exists() pattern (like Signals/Trades):
class Formations:
TABLE_NAME = 'formations'
def __init__(self, data_cache: DataCache, database: Database):
self.data_cache = data_cache
self.database = database
self._ensure_table_exists()
self._init_cache()
def _ensure_table_exists(self):
"""Create formations table if it doesn't exist (repo pattern)."""
self.database.execute_sql('''
CREATE TABLE IF NOT EXISTS formations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tbl_key TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
formation_type TEXT NOT NULL,
exchange TEXT NOT NULL,
market TEXT NOT NULL,
timeframe TEXT NOT NULL,
lines_json TEXT NOT NULL,
color TEXT DEFAULT '#667eea',
visible INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, name, exchange, market, timeframe)
)
''')
self.database.execute_sql('''
CREATE INDEX IF NOT EXISTS idx_formations_scope
ON formations(user_id, exchange, market, timeframe)
''')
def _init_cache(self):
"""Initialize formations cache using DataCache pattern."""
self.data_cache.create_cache('formations', {})
Lines JSON Structure (MVP - infinite lines only):
{
"lines": [
{"point1": {"time": 1709900000, "price": 65000.0},
"point2": {"time": 1710500000, "price": 66000.0}}
],
"targets": [
{"price": 68000.0, "label": "target_1"}
]
}
Note: MVP uses infinite line extension only. No extend field needed.
1.2 Backend Module
New file: src/Formations.py
class Formations:
# Full signature - see 1.1 for __init__ implementation
# def __init__(self, data_cache: DataCache, database: Database)
# CRUD operations
def create(self, user_id: int, data: dict) -> dict
def update(self, user_id: int, data: dict) -> dict
def delete(self, user_id: int, tbl_key: str) -> dict
# Queries - always constrained by user_id
def get_for_scope(self, user_id: int, exchange: str, market: str, timeframe: str) -> list
def get_by_tbl_key(self, user_id: int, tbl_key: str) -> dict | None
def get_by_tbl_key_for_strategy(self, tbl_key: str, owner_user_id: int) -> dict | None
"""For strategy execution - uses strategy owner's formations"""
# Value calculation
def calculate_line_value(self, line: dict, timestamp: int) -> float
def calculate_target_value(self, formation: dict, target_name: str) -> float
def get_property_value(self, formation: dict, property: str, timestamp: int) -> float
Value calculation (core algorithm - infinite extension):
def calculate_line_value(self, line: dict, timestamp: int) -> float:
"""Calculate price at timestamp using linear interpolation/extrapolation.
Args:
line: {"point1": {"time": int, "price": float}, "point2": {...}}
timestamp: Unix timestamp in seconds UTC
Returns:
Extrapolated price value (works for any timestamp, past or future)
Edge case: t1 == t2 is a vertical line (same time, different prices).
This shouldn't happen in normal drawing but we handle it gracefully.
"""
t1, p1 = line['point1']['time'], line['point1']['price']
t2, p2 = line['point2']['time'], line['point2']['price']
if t1 == t2:
# Vertical line in time (same timestamp) - invalid for price lookup
# Return average price as fallback
logger.warning(f"Vertical line detected (t1==t2={t1}), returning average price")
return (p1 + p2) / 2
slope = (p2 - p1) / (t2 - t1)
return p1 + slope * (timestamp - t1)
def calculate_targets(self, formation_type: str, lines: list, points: dict) -> list:
"""Auto-calculate target levels based on formation type.
Returns list of horizontal target lines:
- H&S: neckline - pattern_height
- Double bottom: neckline + pattern_height
- Triangle: apex projection
"""
targets = []
# Pattern-specific target calculation logic
return targets
1.3 Socket Events
File: src/BrighterTrades.py - Add to process_incoming_message() at line ~1548
Keep ALL socket handling in BrighterTrades.process_incoming_message, not app.py. Use existing reply envelope shape (reply + data):
# In process_incoming_message():
elif message_type == 'request_formations':
formations = self.formations.get_for_scope(user_id, data)
return {'reply': 'formations', 'data': {'formations': formations}}
elif message_type == 'new_formation':
result = self.formations.create(user_id, data)
return {'reply': 'formation_created', 'data': result}
elif message_type == 'edit_formation':
result = self.formations.update(user_id, data)
return {'reply': 'formation_updated', 'data': result}
elif message_type == 'delete_formation':
result = self.formations.delete(user_id, data['tbl_key'])
return {'reply': 'formation_deleted', 'data': result}
Frontend handler (in formations.js, follows signals.js pattern):
// In Formations.registerSocketHandlers() - use comms.on() pattern
registerSocketHandlers() {
this.comms.on('formations', this.handleFormationsResponse.bind(this));
this.comms.on('formation_created', this.handleFormationCreated.bind(this));
this.comms.on('formation_updated', this.handleFormationUpdated.bind(this));
this.comms.on('formation_deleted', this.handleFormationDeleted.bind(this));
this.comms.on('formation_error', this.handleFormationError.bind(this));
}
Phase 2: Frontend HUD Panel
2.1 Template
New file: src/templates/formations_hud.html
<!-- MVP: Line + Channel only. More patterns added in Phase B -->
<div class="content" id="formations_panel">
<h4 style="margin: 5px 0;">Draw Formation</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 5px;">
<button class="btn btn-sm" onclick="UI.formations.startDrawing('support_resistance')">
━ Line
</button>
<button class="btn btn-sm" onclick="UI.formations.startDrawing('channel')">
═ Channel
</button>
</div>
<!-- Phase B: Add more patterns here -->
<!--
<h4 style="margin: 10px 0 5px;">Patterns</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5px;">
<button class="btn btn-sm" onclick="UI.formations.startDrawing('triangle')">△ Triangle</button>
<button class="btn btn-sm" onclick="UI.formations.startDrawing('head_shoulders')">⌒ H&S</button>
<button class="btn btn-sm" onclick="UI.formations.startDrawing('double_bottom')">W Bottom</button>
<button class="btn btn-sm" onclick="UI.formations.startDrawing('double_top')">M Top</button>
</div>
-->
<hr>
<h3>Formations</h3>
<div class="formations-container" id="formations_list"></div>
</div>
2.2 JavaScript Controller
New file: src/static/formations.js
Follow the three-class pattern from signals.js:
| Class | Purpose |
|---|---|
FormationsUIManager |
DOM rendering, card creation |
FormationsDataManager |
In-memory formations array |
Formations |
Socket handlers, coordinates UI + Data |
New file: src/static/formation_overlay.js
| Class | Purpose |
|---|---|
FormationOverlay |
SVG rendering, anchor dragging, chart sync |
2.3 Integration Points
Modify: src/templates/control_panel.html
<button class="collapsible bg_blue">Formations</button>
{% include "formations_hud.html" %}
Modify: src/static/general.js
this.formations = new Formations(this);
// After charts init - use dynamic chart ID, pass candleSeries for v5 coordinate conversion:
this.formations.initOverlay(this.data.chart1_id, this.charts.chart_1, this.charts.candleSeries);
Phase 3: Chart Drawing (SVG Overlay Approach)
3.1 Why SVG Overlay Instead of addLineSeries
Problem with addLineSeries approach:
- Lightweight Charts
subscribeVisibleTimeRangeChangetriggers infinite loops when multiple charts are bound together - Adding guard flags (
_syncing) helped but still caused page freezing after a few seconds - Line series participate in chart scaling, causing unwanted price scale changes
- No native support for draggable anchor points
SVG Overlay benefits:
- Completely decoupled from chart internals - no event subscription loops
- Full control over anchor points and drag behavior
- Lines don't affect price scale calculations
- Simpler mental model: SVG is a transparent layer on top of chart
3.2 FormationOverlay Class
New file: src/static/formation_overlay.js
class FormationOverlay {
constructor(chartContainerId, chart, candleSeries) {
this.chart = chart;
this.candleSeries = candleSeries; // Required for v5 coordinate conversion
this.container = document.getElementById(chartContainerId);
this.svg = null;
this.formations = new Map(); // tbl_key -> {group, anchors, lines}
this.selectedFormation = null;
this.draggingAnchor = null;
this._createSvgLayer();
this._startSyncLoop(); // NOT event subscription
}
_createSvgLayer() {
// Create SVG absolutely positioned over chart canvas
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.style.cssText = `
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none; // Let clicks pass through to chart
z-index: 10;
`;
this.container.style.position = 'relative';
this.container.appendChild(this.svg);
}
_startSyncLoop() {
// CRITICAL: Use requestAnimationFrame polling, NOT subscribeVisibleTimeRangeChange
// This avoids the infinite loop problem with bound charts
this._loopRunning = true;
const sync = () => {
// Guard: only run if there's something to sync
if (!this._loopRunning) return;
if (this.formations.size > 0 || this.tempFormation) {
this._updateAllPositions();
}
this._animationFrameId = requestAnimationFrame(sync);
};
this._animationFrameId = requestAnimationFrame(sync);
}
stopSyncLoop() {
this._loopRunning = false;
if (this._animationFrameId) {
cancelAnimationFrame(this._animationFrameId);
this._animationFrameId = null;
}
}
// Call when component is destroyed
destroy() {
this.stopSyncLoop();
if (this.svg && this.svg.parentNode) {
this.svg.parentNode.removeChild(this.svg);
}
}
_updateAllPositions() {
// Reposition all SVG elements based on current chart coordinates
for (const [tblKey, formation] of this.formations) {
this._updateFormationPosition(tblKey, formation);
}
}
_updateFormationPosition(tblKey, formation) {
const lines = formation.data.lines;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Get infinite line endpoints (extends to viewport edges)
const extended = this._getInfiniteLineEndpoints(line);
// Get anchor positions (at defined points, not extended)
const p1 = this._chartToPixel(line.point1.time, line.point1.price);
const p2 = this._chartToPixel(line.point2.time, line.point2.price);
// Update line element with infinite extension
const lineEl = formation.lineElements[i];
if (lineEl && extended) {
lineEl.setAttribute('x1', extended.x1);
lineEl.setAttribute('y1', extended.y1);
lineEl.setAttribute('x2', extended.x2);
lineEl.setAttribute('y2', extended.y2);
}
// Update anchor elements at defined points (not extended)
const anchor1 = formation.anchorElements[i * 2];
const anchor2 = formation.anchorElements[i * 2 + 1];
if (anchor1 && p1) {
anchor1.setAttribute('cx', p1.x);
anchor1.setAttribute('cy', p1.y);
}
if (anchor2 && p2) {
anchor2.setAttribute('cx', p2.x);
anchor2.setAttribute('cy', p2.y);
}
}
}
_chartToPixel(time, price) {
// Convert chart coordinates to SVG pixel coordinates
// NOTE: In Lightweight Charts v5, use series for price conversion
const timeScale = this.chart.timeScale();
const x = timeScale.timeToCoordinate(time);
const y = this.candleSeries.priceToCoordinate(price);
if (x === null || y === null) return null;
return { x, y };
}
_pixelToChart(x, y) {
// Convert SVG pixel coordinates to chart coordinates
// NOTE: In Lightweight Charts v5, use series for price conversion
const timeScale = this.chart.timeScale();
const time = timeScale.coordinateToTime(x);
const price = this.candleSeries.coordinateToPrice(y);
return { time, price };
}
_getInfiniteLineEndpoints(line) {
// Calculate extended line endpoints to viewport edges (infinite extension)
// Returns pixel coordinates that extend beyond the defined points
const p1 = line.point1;
const p2 = line.point2;
// Get viewport bounds in pixels
const svgRect = this.svg.getBoundingClientRect();
const viewportWidth = svgRect.width;
const viewportHeight = svgRect.height;
// Convert line points to pixels
const px1 = this._chartToPixel(p1.time, p1.price);
const px2 = this._chartToPixel(p2.time, p2.price);
if (!px1 || !px2) return null;
// Handle vertical line (same x)
if (Math.abs(px2.x - px1.x) < 0.001) {
return {
x1: px1.x, y1: 0,
x2: px1.x, y2: viewportHeight
};
}
// Calculate slope and y-intercept: y = mx + b
const m = (px2.y - px1.y) / (px2.x - px1.x);
const b = px1.y - m * px1.x;
// Find intersection with viewport edges
// Left edge (x=0): y = b
// Right edge (x=viewportWidth): y = m * viewportWidth + b
// Top edge (y=0): x = -b / m
// Bottom edge (y=viewportHeight): x = (viewportHeight - b) / m
const intersections = [];
// Left edge
const yAtLeft = b;
if (yAtLeft >= 0 && yAtLeft <= viewportHeight) {
intersections.push({ x: 0, y: yAtLeft });
}
// Right edge
const yAtRight = m * viewportWidth + b;
if (yAtRight >= 0 && yAtRight <= viewportHeight) {
intersections.push({ x: viewportWidth, y: yAtRight });
}
// Top edge (if not horizontal)
if (Math.abs(m) > 0.001) {
const xAtTop = -b / m;
if (xAtTop >= 0 && xAtTop <= viewportWidth) {
intersections.push({ x: xAtTop, y: 0 });
}
}
// Bottom edge (if not horizontal)
if (Math.abs(m) > 0.001) {
const xAtBottom = (viewportHeight - b) / m;
if (xAtBottom >= 0 && xAtBottom <= viewportWidth) {
intersections.push({ x: xAtBottom, y: viewportHeight });
}
}
// Return the two furthest-apart intersections
if (intersections.length >= 2) {
// Sort by x coordinate and take first and last
intersections.sort((a, b) => a.x - b.x);
return {
x1: intersections[0].x, y1: intersections[0].y,
x2: intersections[intersections.length - 1].x,
y2: intersections[intersections.length - 1].y
};
}
// Fallback to original endpoints
return { x1: px1.x, y1: px1.y, x2: px2.x, y2: px2.y };
}
}
3.3 Click-to-Draw UX
User interaction flow:
- User clicks "Line" button in HUD panel
- A default line shape appears at chart center with visible anchor points
- User drags anchors to adjust position (no click-to-place step)
- User clicks "Save" or hits Enter to persist
// In FormationOverlay
startDrawing(type) {
this.drawingMode = type;
// Create default shape at chart center
const center = this._getChartCenter();
const defaultPoints = this._getDefaultPointsForType(type, center);
// Create temporary formation (not saved yet)
this.tempFormation = this._createFormationElements(
'temp_' + Date.now(),
defaultPoints,
{ color: '#667eea', isTemp: true }
);
// Enable anchor dragging
this._enableAnchors(this.tempFormation);
// Show save/cancel buttons
this._showDrawingControls();
}
_getDefaultPointsForType(type, center) {
const offset = 50; // pixels
switch (type) {
case 'support_resistance':
return {
lines: [{
point1: this._pixelToChart(center.x - offset, center.y),
point2: this._pixelToChart(center.x + offset, center.y)
}]
};
case 'channel':
return {
lines: [
{
point1: this._pixelToChart(center.x - offset, center.y - 20),
point2: this._pixelToChart(center.x + offset, center.y - 20)
},
{
point1: this._pixelToChart(center.x - offset, center.y + 20),
point2: this._pixelToChart(center.x + offset, center.y + 20)
}
]
};
// ... other types
}
}
completeDrawing() {
if (!this.tempFormation) return;
// Convert temp formation to permanent
const formationData = {
formation_type: this.drawingMode,
lines_json: JSON.stringify(this.tempFormation.data),
color: '#667eea'
};
// Prompt for name
const name = prompt('Formation name:');
if (!name) {
this.cancelDrawing();
return;
}
formationData.name = name;
// Save via socket
this.onSave(formationData); // Callback to formations.js
// Cleanup temp
this._removeFormationElements(this.tempFormation);
this.tempFormation = null;
this.drawingMode = null;
}
3.4 Draggable Anchors
_createAnchor(x, y, lineIndex, pointIndex, formation) {
const anchor = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
anchor.setAttribute('cx', x);
anchor.setAttribute('cy', y);
anchor.setAttribute('r', 6);
anchor.setAttribute('fill', '#667eea');
anchor.setAttribute('stroke', '#fff');
anchor.setAttribute('stroke-width', 2);
anchor.style.cursor = 'grab';
anchor.style.pointerEvents = 'all'; // Enable interaction
// Drag handlers
anchor.addEventListener('mousedown', (e) => {
e.stopPropagation();
this.draggingAnchor = { anchor, lineIndex, pointIndex, formation };
anchor.style.cursor = 'grabbing';
});
return anchor;
}
_setupDragListeners() {
document.addEventListener('mousemove', (e) => {
if (!this.draggingAnchor) return;
const rect = this.svg.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Guard: skip if outside SVG bounds
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
// Update anchor position
const { anchor, lineIndex, pointIndex, formation } = this.draggingAnchor;
anchor.setAttribute('cx', x);
anchor.setAttribute('cy', y);
// Update formation data with null guard
const chartCoords = this._pixelToChart(x, y);
if (!chartCoords || chartCoords.time === null || chartCoords.price === null) {
return; // Invalid coordinates - don't mutate formation data
}
const pointKey = pointIndex === 0 ? 'point1' : 'point2';
formation.data.lines[lineIndex][pointKey] = {
time: chartCoords.time,
price: chartCoords.price
};
// Update line element
this._updateFormationPosition(formation.tblKey, formation);
});
document.addEventListener('mouseup', () => {
if (this.draggingAnchor) {
this.draggingAnchor.anchor.style.cursor = 'grab';
this.draggingAnchor = null;
// If editing existing formation, auto-save
if (this.selectedFormation && !this.selectedFormation.isTemp) {
this._autoSaveFormation(this.selectedFormation);
}
}
});
}
3.5 Drawing Interaction Summary
| Formation Type | Default Shape | Anchor Points |
|---|---|---|
| Support/Resistance | Horizontal line at center | 2 (endpoints) |
| Channel | Two parallel lines | 4 (2 per line) |
| Triangle | Three connected lines | 3 (vertices) |
| Head & Shoulders | 5-point pattern | 5 (key points) |
| Double Bottom (W) | W-shape | 3 (bottoms + peak) |
| Double Top (M) | M-shape | 3 (tops + trough) |
Key UX principles:
- Shape appears instantly on button click (no multi-click placement)
- Drag anchors to adjust - intuitive and immediate feedback
- Save explicitly via button/Enter
- Cancel via Escape or cancel button
3.6 Lessons Learned (from First Attempt)
DO NOT:
- Use
subscribeVisibleTimeRangeChangefor chart sync - causes infinite loops with bound charts - Add line series that participate in price scale calculations
- Rely on chart events for positioning updates
DO:
- Use
requestAnimationFramepolling for smooth, loop-free updates - Keep SVG layer completely separate from chart internals
- Use
pointer-events: noneon SVG container,allonly on anchors - Store chart coordinates (time, price) not pixel coordinates in data
Phase 4: Blockly Integration
4.1 Block Definition
New file: src/static/blocks/formation_blocks.js
Dynamic blocks generated per formation (like signals/indicators):
// Block type: formation_<sanitized_name>
// Message: "Formation: {name} {property} at time {timestamp}"
// Output: dynamic_value
// Properties vary by type (all return price at timestamp or constant):
Properties by Pattern Type:
| Pattern | Properties | Description |
|---|---|---|
| support_resistance | line, target_1 |
Line + auto-target (if applicable) |
| channel | upper, lower, midline |
Upper/lower bounds + calculated midline |
| triangle | side_1, side_2, side_3, apex, target_1 |
Sides + apex price + breakout target |
| head_shoulders | neckline, left_shoulder, head, right_shoulder, pattern_height, target_1 |
+ auto-calculated target |
| double_bottom | neckline, left_bottom, right_bottom, pattern_height, target_1 |
Neckline + bullish target |
| double_top | neckline, left_top, right_top, pattern_height, target_1 |
Neckline + bearish target |
| adam_eve | neckline, adam, eve, pattern_height, target_1 |
Same as double_bottom |
| triple_bottom | neckline, bottom_1, bottom_2, bottom_3, pattern_height, target_1 |
Connects peaks + target |
| triple_top | neckline, top_1, top_2, top_3, pattern_height, target_1 |
Connects troughs + target |
Target Calculation:
- H&S:
target_1 = neckline_price - pattern_height(bearish target) - Double Bottom:
target_1 = neckline_price + pattern_height(bullish target) - Triangle:
target_1 = apex_price + pattern_height(breakout direction) - Targets are drawn as horizontal lines on chart
Strategy Usage Examples:
# Reference by tbl_key (UUID), not name - avoids collisions
# Check if price breaks above double bottom neckline
price > process_formation('f8a3b2c1-...', 'neckline', current_time)
# Get auto-calculated target level
target = process_formation('f8a3b2c1-...', 'target_1', current_time)
# Current candle time for live/backtest
process_formation('f8a3b2c1-...', 'line', get_current_candle_time())
4.2 Python Generator
Modify: src/PythonGenerator.py
def handle_formation(self, node, indent_level):
"""Generate process_formation call using tbl_key (stable) not name."""
fields = node.get('fields', {})
inputs = node.get('inputs', {})
tbl_key = fields.get('TBL_KEY') # UUID reference, not name
property = fields.get('PROPERTY', 'line')
timestamp = self.generate_condition_code(inputs.get('TIMESTAMP', {}), indent_level)
# Track formation usage for dependency resolution
if not hasattr(self, 'formations_used'):
self.formations_used = []
self.formations_used.append({'tbl_key': tbl_key, 'property': property})
return f"process_formation('{tbl_key}', '{property}', {timestamp})"
def handle_current_candle_time(self, node, indent_level):
return "get_current_candle_time()"
4.4 Blockly Toolbox Refresh
Issue: Dynamic blocks are initialized once (blocksDefined flag in Strategies.js:989).
New formations won't appear until toolbox rebuild.
Solution: Add formation change listener to trigger toolbox refresh:
// In formations.js - after formation CRUD
handleFormationCreated(data) {
this.dataManager.addFormation(data.formation);
this.uiManager.renderFormations(this.dataManager.formations);
// Trigger toolbox rebuild
UI.strats.workspaceManager.rebuildToolbox();
}
// In Strategies.js - add rebuildToolbox method
rebuildToolbox() {
// Clear and rebuild dynamic categories
defineFormationBlocks(); // Re-run block definitions
this.workspace.updateToolbox(document.getElementById('toolbox_advanced'));
}
4.3 Strategy Instance Integration
Critical: Must be wired into ALL strategy instances (paper/live/backtest).
Modify: src/StrategyInstance.py (at line ~76, exec context setup)
class StrategyInstance:
def __init__(self, ..., formations_manager=None, formation_owner_id=None):
# ... existing init
self.formations_manager = formations_manager
# formation_owner_id = strategy creator for subscribed strategies
# Parallel to existing indicator_owner_id pattern
self.formation_owner_id = formation_owner_id or strategy.get('user_id')
def _build_exec_context(self) -> dict:
"""Add process_formation to exec context (line ~76)."""
context = {
# ... existing functions
'process_formation': self.process_formation,
'get_current_candle_time': self.get_current_candle_time,
}
return context
def process_formation(self, tbl_key: str, property: str, timestamp: int = None) -> float:
"""Returns price value of formation property at timestamp.
Uses formation_owner_id (not current user) for subscribed strategies.
Parallel to indicator_owner_id pattern.
"""
if not self.formations_manager:
logger.warning("Formations manager not initialized")
return None
# Default timestamp: current candle time (not wall clock)
if timestamp is None:
timestamp = self.get_current_candle_time()
formation = self.formations_manager.get_by_tbl_key_for_strategy(
tbl_key, self.formation_owner_id
)
if not formation:
return None
return self.formations_manager.get_property_value(formation, property, timestamp)
def get_current_candle_time(self) -> int:
"""Returns current candle timestamp in seconds UTC.
IMPORTANT: In backtest, returns bar timestamp (not wall clock).
In live/paper, returns current candle's open time.
NEVER falls back to wall clock - that would corrupt backtest results.
"""
if not self.current_candle or 'time' not in self.current_candle:
raise ValueError("current_candle.time not set - cannot use wall clock for formations")
return self.current_candle['time']
Modify: src/Strategies.py (strategy instance creation, line ~268)
# When creating strategy instances, inject formations manager + owner_id:
instance = PaperStrategyInstance(
...,
formations_manager=self.brighter_trades.formations,
formation_owner_id=strategy.get('user_id') # Creator's formations
)
Phase 5: Backtest Support
Timestamp Rules:
- Backtest:
get_current_candle_time()returns the bar's timestamp being processed - Live/Paper: Returns current candle's open time
- Never use wall clock for formation lookups in strategy code
Modify: src/backtest_strategy_instance.py
class BacktestStrategyInstance(StrategyInstance):
def __init__(self, ..., formations_manager=None, formation_owner_id=None):
super().__init__(
...,
formations_manager=formations_manager,
formation_owner_id=formation_owner_id
)
def get_current_candle_time(self) -> int:
"""In backtest, return the bar timestamp being processed.
This is critical: backtest must use historical bar time,
not wall clock time, for accurate formation value lookups.
"""
return self.current_bar_time # Set by backtest loop
# process_formation inherited - uses get_current_candle_time()
Modify: src/backtesting.py (line ~854, strategy instance creation)
# Inject formations manager + owner_id when creating backtest instance
instance = BacktestStrategyInstance(
...,
formations_manager=brighter_trades.formations,
formation_owner_id=strategy.get('user_id')
)
Phase 6: Chart Scope Lifecycle
Issue: Chart scope changes via form submit/page reload (price_chart.html:11), not dynamic events.
Solution: Formation loading tied to page lifecycle:
// In formations.js
initialize(targetId) {
this.uiManager.initUI(targetId);
this.registerSocketHandlers();
// Load formations for current chart scope on page load
const chartView = window.bt_data?.chart_view;
if (chartView) {
this.loadFormationsForScope(chartView.exchange, chartView.market, chartView.timeframe);
}
}
loadFormationsForScope(exchange, market, timeframe) {
this.currentScope = { exchange, market, timeframe };
this.comms.sendToApp('request_formations', { exchange, market, timeframe });
}
// On scope change (if we add dynamic switching later):
onChartScopeChanged(newScope) {
this.drawingManager.clearAllFormations();
this.loadFormationsForScope(newScope.exchange, newScope.market, newScope.timeframe);
}
Files Summary
New Files
| File | Purpose |
|---|---|
src/Formations.py |
Backend CRUD + value calculation |
src/static/formations.js |
Frontend controller (UI, Data, Socket handlers) |
src/static/formation_overlay.js |
SVG overlay, anchor dragging, chart sync |
src/templates/formations_hud.html |
HUD panel template |
src/static/blocks/formation_blocks.js |
Blockly block definitions |
Modified Files
| File | Changes |
|---|---|
src/BrighterTrades.py |
Initialize Formations manager + add socket handlers in process_incoming_message() |
src/PythonGenerator.py |
Add handle_formation, handle_current_candle_time |
src/StrategyInstance.py |
Add process_formation, get_current_candle_time |
src/backtest_strategy_instance.py |
Override get_current_candle_time for bar time |
src/Strategies.py |
Inject formations_manager + formation_owner_id |
src/backtesting.py |
Inject formations_manager |
src/templates/control_panel.html |
Include formations_hud.html |
src/static/general.js |
Initialize UI.formations + overlay |
src/static/communication.js |
Add reply handlers for formations |
src/templates/index.html |
Include formations.js + formation_overlay.js |
Note: Table creation uses _ensure_table_exists() pattern in Formations.py (not Database.py).
Socket handling stays in BrighterTrades.process_incoming_message() (not app.py).
Verification Plan
- Database: Run app, verify
formationstable created - CRUD: Create/edit/delete formation via HUD panel, verify persistence
- Drawing: Draw line on chart using SVG overlay, drag anchors to adjust, verify it renders and persists on page reload. Scroll/zoom chart while formation visible - verify no freezing.
- Scope filtering: Switch exchange/symbol/timeframe, verify formations update
- Blockly: Create strategy referencing formation, verify code generates correctly
- Backtest: Run backtest with formation-based strategy, verify values calculated correctly
- Tests: Add unit tests for
calculate_line_value()and formation CRUD
Implementation Order
Phase A: MVP (ship first - Line + Channel only)
src/Formations.py- CRUD,_ensure_table_exists(), DataCache, line value calcsrc/BrighterTrades.py- Add toprocess_incoming_message()(~line 1548)src/templates/formations_hud.html- HUD panel (Line + Channel buttons only)src/static/formations.js- UIManager, DataManager, Controllersrc/static/formation_overlay.js- SVG layer, anchor dragging, requestAnimationFrame syncsrc/static/communication.js- Add reply handlers (~line 88)src/static/blocks/formation_blocks.js- Dynamic blocks (tbl_key reference)src/PythonGenerator.py- Addhandle_formation,handle_current_candle_timesrc/StrategyInstance.py- Add to exec context (~line 76), inject formations_managersrc/Strategies.py- Inject formations_manager + formation_owner_id (~line 268)src/backtest_strategy_instance.py- Overrideget_current_candle_time()for bar timesrc/backtesting.py- Inject formations_manager (~line 854)- Toolbox refresh logic (rebuild on formation create/delete)
tests/test_formations.py- CRUD, value calc, ownership tests
Phase B: Complex Patterns (iterate after MVP ships) 14. Triangle pattern (3-point drawing) 15. Double Bottom/Top (W/M patterns) 16. Adam and Eve variant 17. Head & Shoulders (5-point) 18. Triple Bottom/Top
Phase C: Targets (after patterns stable)
19. Auto-calculate target levels per pattern type
20. Render targets as horizontal lines on chart
21. Add target_1 property to Blockly blocks
Tests Required
MVP Tests (Phase A - ship with Line + Channel)
Backend (tests/test_formations.py):
def test_create_formation_unique_constraint():
"""Cannot create duplicate name in same scope"""
def test_calculate_line_value_interpolation():
"""Value at midpoint timestamp"""
def test_calculate_line_value_extrapolation():
"""Value beyond endpoints (infinite extension)"""
def test_get_by_tbl_key_for_strategy_owner():
"""Strategy uses owner's formations, not current user's"""
def test_delete_formation():
"""Formation deleted from DB and cache"""
def test_update_formation_lines():
"""Lines JSON updated correctly"""
Generator (tests/test_strategy_generation.py):
def test_formation_block_generates_process_formation():
"""Block with tbl_key generates correct function call"""
Phase C Tests (add when implementing targets)
Backend (tests/test_formations.py):
def test_calculate_targets_head_shoulders():
"""Target = neckline - pattern_height"""
def test_calculate_targets_double_bottom():
"""Target = neckline + pattern_height"""
def test_calculate_targets_triangle():
"""Target = apex projection"""
Integration (tests/test_strategy_execution.py):
def test_process_formation_in_paper_strategy():
"""Formation value accessible in paper trading"""
def test_process_formation_in_backtest():
"""Formation value correct at historical timestamp"""
def test_subscribed_strategy_uses_creator_formations():
"""Subscriber runs strategy with creator's formations"""
Verification (End-to-End Test)
-
Start app:
cd src && python app.py -
Create formation:
- Open browser to
http://127.0.0.1:5002 - Navigate to Formations panel
- Click "Line" button - line appears at chart center with visible anchors
- Drag anchors to desired position
- Click "Save" and enter a name
- Verify it appears in panel and persists on page reload
- Scroll chart - verify no freezing (critical regression test)
- Open browser to
-
Use in strategy:
- Create new strategy in Blockly
- Add Formation block (should see your formation by name, references tbl_key internally)
- Set condition:
if price > formation.line at current_time - Save strategy
-
Run backtest:
- Run backtest with the strategy
- Verify formation value calculated correctly at historical timestamps
- Check logs for
process_formationcalls
-
Run tests:
pytest tests/test_formations.py -v
Appendix: Lessons Learned from First Implementation Attempt
The first implementation attempt used Lightweight Charts addLineSeries() to render formations. This caused page freezing issues. Here's what we learned:
What Went Wrong
-
Infinite Loop with Bound Charts
- Used
subscribeVisibleTimeRangeChange()to sync formation positions when chart scrolled - BrighterTrading has multiple charts bound together (main chart + indicator charts)
- When one chart scrolled, the sync callback triggered, which updated formation positions, which triggered another scroll event on bound charts, creating an infinite loop
- Adding
_syncingguard flags reduced but didn't eliminate the problem
- Used
-
Line Series Side Effects
addLineSeries()adds data that participates in price scale calculations- Formation lines affected the auto-scaling of the price axis
- Lines were not truly "overlay" - they were part of chart data
-
Incremental Fixes Created Instability
- Each bug fix introduced new edge cases
- Guard flags, debouncing, and throttling added complexity without solving root cause
- Codebase became fragile and hard to reason about
The SVG Overlay Solution
-
Complete Separation
- SVG layer is absolutely positioned over chart canvas
- No interaction with Lightweight Charts internals
- No event subscriptions that could loop
-
Polling Instead of Events
- Use
requestAnimationFrameto poll chart coordinates - Smooth 60fps updates without triggering events
- No risk of infinite loops
- Use
-
Simpler UX Model
- Click-to-place with instant shape appearance
- Drag anchors to adjust (familiar interaction pattern)
- Clear separation between "drawing" and "adjusting"
Key Takeaways
| Problem | Bad Approach | Good Approach |
|---|---|---|
| Chart sync | subscribeVisibleTimeRangeChange |
requestAnimationFrame polling |
| Rendering | addLineSeries() |
SVG overlay |
| Placement | Multi-click point collection | Shape appears, drag to adjust |
| Event handling | Chart event subscriptions | Minimal DOM events on SVG only |
Reference Implementation
The experimental SVG overlay code is preserved in branch feature/formations-svg-overlay-wip for reference. While that implementation still had issues (the page freeze wasn't fully resolved), the overall SVG overlay architecture is sound and should be used as a starting point.