From a46d2006f301d31e0e53888df6561f39a991e9c1 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 10 Mar 2026 15:57:38 -0300 Subject: [PATCH] Update formations plan for Lightweight Charts v5 compatibility Addresses Codex review feedback: - Use series-based coordinate conversion (series.priceToCoordinate) - Add infinite line extension math (_getInfiniteLineEndpoints) - Fix message pattern: use sendToApp() not emit('message') - Remove wall clock fallback in get_current_candle_time() - Clarify architecture: socket handlers in BrighterTrades, not app.py - Clarify UX: shape appears at center, drag to adjust (no click-to-place) Co-Authored-By: Claude Opus 4.5 --- FORMATIONS_PLAN.md | 153 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 30 deletions(-) diff --git a/FORMATIONS_PLAN.md b/FORMATIONS_PLAN.md index 7ef5b46..88dd4de 100644 --- a/FORMATIONS_PLAN.md +++ b/FORMATIONS_PLAN.md @@ -1,5 +1,10 @@ # 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: @@ -305,8 +310,8 @@ Follow the three-class pattern from `signals.js`: **Modify:** `src/static/general.js` ```javascript this.formations = new Formations(this); -// After charts init: -this.formations.initOverlay('chart1', this.charts.chart_1); +// After charts init - pass candleSeries for v5 coordinate conversion: +this.formations.initOverlay('chart1', this.charts.chart_1, this.charts.candleSeries); ``` --- @@ -333,8 +338,9 @@ this.formations.initOverlay('chart1', this.charts.chart_1); ```javascript class FormationOverlay { - constructor(chartContainerId, chart) { + 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} @@ -386,19 +392,24 @@ class FormationOverlay { 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 + // Update line element with infinite extension const lineEl = formation.lineElements[i]; - if (lineEl && p1 && p2) { - lineEl.setAttribute('x1', p1.x); - lineEl.setAttribute('y1', p1.y); - lineEl.setAttribute('x2', p2.x); - lineEl.setAttribute('y2', p2.y); + 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 + // Update anchor elements at defined points (not extended) const anchor1 = formation.anchorElements[i * 2]; const anchor2 = formation.anchorElements[i * 2 + 1]; if (anchor1 && p1) { @@ -414,11 +425,11 @@ class FormationOverlay { _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 priceScale = this.chart.priceScale('right'); const x = timeScale.timeToCoordinate(time); - const y = priceScale.priceToCoordinate(price); + const y = this.candleSeries.priceToCoordinate(price); if (x === null || y === null) return null; return { x, y }; @@ -426,26 +437,104 @@ class FormationOverlay { _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 priceScale = this.chart.priceScale('right'); const time = timeScale.coordinateToTime(x); - const price = priceScale.coordinateToPrice(y); + 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-Place UX +### 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 (not following mouse - simpler) -3. User clicks chart to place the shape -4. Shape becomes editable with visible anchor points -5. User drags anchors to adjust position -6. User clicks "Save" or hits Enter to persist +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 @@ -769,8 +858,11 @@ class StrategyInstance: 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. """ - return self.current_candle.get('time', int(time.time())) + 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) @@ -849,10 +941,7 @@ initialize(targetId) { loadFormationsForScope(exchange, market, timeframe) { this.currentScope = { exchange, market, timeframe }; - this.comms.emit('message', { - type: 'request_formations', - exchange, market, timeframe - }); + this.comms.sendToApp('request_formations', { exchange, market, timeframe }); } // On scope change (if we add dynamic switching later): @@ -878,16 +967,20 @@ onChartScopeChanged(newScope) { ### Modified Files | File | Changes | |------|---------| -| `src/Database.py` | Add formations table | -| `src/BrighterTrades.py` | Initialize Formations manager | -| `src/app.py` | Add socket handlers | +| `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` | Load formations for backtest scope | +| `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 | +| `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