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