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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-03-10 15:57:38 -03:00
parent 5c37fb00b6
commit a46d2006f3
1 changed files with 123 additions and 30 deletions

View File

@ -1,5 +1,10 @@
# Chart Formations Feature Implementation Plan # 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 ## Summary
Add chart formations feature with: Add chart formations feature with:
@ -305,8 +310,8 @@ Follow the three-class pattern from `signals.js`:
**Modify:** `src/static/general.js` **Modify:** `src/static/general.js`
```javascript ```javascript
this.formations = new Formations(this); this.formations = new Formations(this);
// After charts init: // After charts init - pass candleSeries for v5 coordinate conversion:
this.formations.initOverlay('chart1', this.charts.chart_1); this.formations.initOverlay('chart1', this.charts.chart_1, this.charts.candleSeries);
``` ```
--- ---
@ -333,8 +338,9 @@ this.formations.initOverlay('chart1', this.charts.chart_1);
```javascript ```javascript
class FormationOverlay { class FormationOverlay {
constructor(chartContainerId, chart) { constructor(chartContainerId, chart, candleSeries) {
this.chart = chart; this.chart = chart;
this.candleSeries = candleSeries; // Required for v5 coordinate conversion
this.container = document.getElementById(chartContainerId); this.container = document.getElementById(chartContainerId);
this.svg = null; this.svg = null;
this.formations = new Map(); // tbl_key -> {group, anchors, lines} this.formations = new Map(); // tbl_key -> {group, anchors, lines}
@ -386,19 +392,24 @@ class FormationOverlay {
const lines = formation.data.lines; const lines = formation.data.lines;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[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 p1 = this._chartToPixel(line.point1.time, line.point1.price);
const p2 = this._chartToPixel(line.point2.time, line.point2.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]; const lineEl = formation.lineElements[i];
if (lineEl && p1 && p2) { if (lineEl && extended) {
lineEl.setAttribute('x1', p1.x); lineEl.setAttribute('x1', extended.x1);
lineEl.setAttribute('y1', p1.y); lineEl.setAttribute('y1', extended.y1);
lineEl.setAttribute('x2', p2.x); lineEl.setAttribute('x2', extended.x2);
lineEl.setAttribute('y2', p2.y); lineEl.setAttribute('y2', extended.y2);
} }
// Update anchor elements // Update anchor elements at defined points (not extended)
const anchor1 = formation.anchorElements[i * 2]; const anchor1 = formation.anchorElements[i * 2];
const anchor2 = formation.anchorElements[i * 2 + 1]; const anchor2 = formation.anchorElements[i * 2 + 1];
if (anchor1 && p1) { if (anchor1 && p1) {
@ -414,11 +425,11 @@ class FormationOverlay {
_chartToPixel(time, price) { _chartToPixel(time, price) {
// Convert chart coordinates to SVG pixel coordinates // Convert chart coordinates to SVG pixel coordinates
// NOTE: In Lightweight Charts v5, use series for price conversion
const timeScale = this.chart.timeScale(); const timeScale = this.chart.timeScale();
const priceScale = this.chart.priceScale('right');
const x = timeScale.timeToCoordinate(time); const x = timeScale.timeToCoordinate(time);
const y = priceScale.priceToCoordinate(price); const y = this.candleSeries.priceToCoordinate(price);
if (x === null || y === null) return null; if (x === null || y === null) return null;
return { x, y }; return { x, y };
@ -426,26 +437,104 @@ class FormationOverlay {
_pixelToChart(x, y) { _pixelToChart(x, y) {
// Convert SVG pixel coordinates to chart coordinates // Convert SVG pixel coordinates to chart coordinates
// NOTE: In Lightweight Charts v5, use series for price conversion
const timeScale = this.chart.timeScale(); const timeScale = this.chart.timeScale();
const priceScale = this.chart.priceScale('right');
const time = timeScale.coordinateToTime(x); const time = timeScale.coordinateToTime(x);
const price = priceScale.coordinateToPrice(y); const price = this.candleSeries.coordinateToPrice(y);
return { time, price }; 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:** **User interaction flow:**
1. User clicks "Line" button in HUD panel 1. User clicks "Line" button in HUD panel
2. A default line shape appears at chart center (not following mouse - simpler) 2. A default line shape appears at chart center with visible anchor points
3. User clicks chart to place the shape 3. User drags anchors to adjust position (no click-to-place step)
4. Shape becomes editable with visible anchor points 4. User clicks "Save" or hits Enter to persist
5. User drags anchors to adjust position
6. User clicks "Save" or hits Enter to persist
```javascript ```javascript
// In FormationOverlay // In FormationOverlay
@ -769,8 +858,11 @@ class StrategyInstance:
IMPORTANT: In backtest, returns bar timestamp (not wall clock). IMPORTANT: In backtest, returns bar timestamp (not wall clock).
In live/paper, returns current candle's open time. 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) **Modify:** `src/Strategies.py` (strategy instance creation, line ~268)
@ -849,10 +941,7 @@ initialize(targetId) {
loadFormationsForScope(exchange, market, timeframe) { loadFormationsForScope(exchange, market, timeframe) {
this.currentScope = { exchange, market, timeframe }; this.currentScope = { exchange, market, timeframe };
this.comms.emit('message', { this.comms.sendToApp('request_formations', { exchange, market, timeframe });
type: 'request_formations',
exchange, market, timeframe
});
} }
// On scope change (if we add dynamic switching later): // On scope change (if we add dynamic switching later):
@ -878,16 +967,20 @@ onChartScopeChanged(newScope) {
### Modified Files ### Modified Files
| File | Changes | | File | Changes |
|------|---------| |------|---------|
| `src/Database.py` | Add formations table | | `src/BrighterTrades.py` | Initialize Formations manager + add socket handlers in `process_incoming_message()` |
| `src/BrighterTrades.py` | Initialize Formations manager |
| `src/app.py` | Add socket handlers |
| `src/PythonGenerator.py` | Add handle_formation, handle_current_candle_time | | `src/PythonGenerator.py` | Add handle_formation, handle_current_candle_time |
| `src/StrategyInstance.py` | Add process_formation, get_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/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 | | `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 ## Verification Plan