# 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
1. **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_id` pattern (parallel to `indicator_owner_id`).
2. **Line Extension**: All lines extend infinitely (extrapolate beyond drawn endpoints). MVP has no extension options - all lines are infinite.
3. **Target Projection**: Auto-calculate target levels as horizontal lines (e.g., H&S target = neckline - pattern_height). Added in Phase B, not MVP.
4. **Scope**: Uses `exchange`, `market`, `timeframe` (matching existing patterns - `market` not `symbol`).
5. **References**: Blocks reference formations by `tbl_key` (stable UUID) not name (avoids collisions).
6. **Timestamps**: All timestamps in seconds UTC. Backtest uses bar timestamp, not wall clock. Out-of-range returns extrapolated value (infinite lines).
7. **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):
```python
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):**
```json
{
"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`
```python
class Formations:
def __init__(self, database: Database):
self.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):**
```python
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`):
```python
# 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 `communication.js`, follows existing pattern at line ~88):
```javascript
// Socket response handler
socket.on('message', (msg) => {
if (msg.reply === 'formations') {
UI.formations.handleFormations(msg.data);
} else if (msg.reply === 'formation_created') {
UI.formations.handleFormationCreated(msg.data);
}
// ... etc
});
```
---
## Phase 2: Frontend HUD Panel
### 2.1 Template
**New file:** `src/templates/formations_hud.html`
```html
Draw Formation
Formations
```
### 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`
```html
{% include "formations_hud.html" %}
```
**Modify:** `src/static/general.js`
```javascript
this.formations = new Formations(this);
// After charts init - pass candleSeries for v5 coordinate conversion:
this.formations.initOverlay('chart1', 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 `subscribeVisibleTimeRangeChange` triggers 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`
```javascript
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
const sync = () => {
this._updateAllPositions();
this._animationFrameId = requestAnimationFrame(sync);
};
this._animationFrameId = requestAnimationFrame(sync);
}
stopSyncLoop() {
if (this._animationFrameId) {
cancelAnimationFrame(this._animationFrameId);
}
}
_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:**
1. User clicks "Line" button in HUD panel
2. A default line shape appears at chart center with visible anchor points
3. User drags anchors to adjust position (no click-to-place step)
4. User clicks "Save" or hits Enter to persist
```javascript
// 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
```javascript
_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;
// Update anchor position
const { anchor, lineIndex, pointIndex, formation } = this.draggingAnchor;
anchor.setAttribute('cx', x);
anchor.setAttribute('cy', y);
// Update formation data
const chartCoords = this._pixelToChart(x, y);
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 `subscribeVisibleTimeRangeChange` for 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 `requestAnimationFrame` polling for smooth, loop-free updates
- Keep SVG layer completely separate from chart internals
- Use `pointer-events: none` on SVG container, `all` only 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):
```javascript
// Block type: formation_
// 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:**
```python
# 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`
```python
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:
```javascript
// 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)
```python
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)
```python
# 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`
```python
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)
```python
# 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:
```javascript
// 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
1. **Database**: Run app, verify `formations` table created
2. **CRUD**: Create/edit/delete formation via HUD panel, verify persistence
3. **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.
4. **Scope filtering**: Switch exchange/symbol/timeframe, verify formations update
5. **Blockly**: Create strategy referencing formation, verify code generates correctly
6. **Backtest**: Run backtest with formation-based strategy, verify values calculated correctly
7. **Tests**: Add unit tests for `calculate_line_value()` and formation CRUD
---
## Implementation Order
**Phase A: MVP (ship first - Line + Channel only)**
1. `src/Formations.py` - CRUD, `_ensure_table_exists()`, DataCache, line value calc
2. `src/BrighterTrades.py` - Add to `process_incoming_message()` (~line 1548)
3. `src/templates/formations_hud.html` - HUD panel (Line + Channel buttons only)
4. `src/static/formations.js` - UIManager, DataManager, Controller
5. `src/static/formation_overlay.js` - SVG layer, anchor dragging, requestAnimationFrame sync
6. `src/static/communication.js` - Add reply handlers (~line 88)
7. `src/static/blocks/formation_blocks.js` - Dynamic blocks (tbl_key reference)
8. `src/PythonGenerator.py` - Add `handle_formation`, `handle_current_candle_time`
9. `src/StrategyInstance.py` - Add to exec context (~line 76), inject formations_manager
10. `src/Strategies.py` - Inject formations_manager + formation_owner_id (~line 268)
11. `src/backtest_strategy_instance.py` - Override `get_current_candle_time()` for bar time
12. `src/backtesting.py` - Inject formations_manager (~line 854)
13. Toolbox refresh logic (rebuild on formation create/delete)
14. `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
**Backend (`tests/test_formations.py`):**
```python
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_calculate_targets_head_shoulders():
"""Target = neckline - pattern_height"""
def test_calculate_targets_double_bottom():
"""Target = neckline + pattern_height"""
```
**Generator (`tests/test_strategy_generation.py`):**
```python
def test_formation_block_generates_process_formation():
"""Block with tbl_key generates correct function call"""
```
**Integration (`tests/test_strategy_execution.py`):**
```python
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)
1. **Start app**: `cd src && python app.py`
2. **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)
3. **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
4. **Run backtest**:
- Run backtest with the strategy
- Verify formation value calculated correctly at historical timestamps
- Check logs for `process_formation` calls
5. **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
1. **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 `_syncing` guard flags reduced but didn't eliminate the problem
2. **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
3. **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
1. **Complete Separation**
- SVG layer is absolutely positioned over chart canvas
- No interaction with Lightweight Charts internals
- No event subscriptions that could loop
2. **Polling Instead of Events**
- Use `requestAnimationFrame` to poll chart coordinates
- Smooth 60fps updates without triggering events
- No risk of infinite loops
3. **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.