brighter-trading/FORMATIONS_PLAN.md

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

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

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

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:

  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
// 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 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):

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

  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

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)

  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.