From 7c5f0a7627a68a3539657a2a11947eae6528383d Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sun, 23 Aug 2026 18:25:55 -0500 Subject: [PATCH] large timeframes dashed --- AGENTS.md | 22 ++++++++++++++++++++ docs/implementation.md | 10 +++++++++ docs/plan.md | 3 ++- static/chart.js | 31 +++++++++++++++++++++++++--- tests/e2e/trendline.test.mjs | 40 ++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 13ecf4c..c42b731 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,3 +174,25 @@ markers. snapshot made 1m look empty past ~1am while the store held 5,000. The store cap is `max_bars_per_tf`. The next step is a visible-window fetch, not another silent number. + +## Stay cheap + +Performance is a product feature, not a later cleanup. The app already +pays for a live stream, 5k bars, and a canvas. New work must not add +cost on the hot path unless the screen or an alert has to change. + +- Nothing extra on the event loop each closed bar or tick. Watch + `loop_lag_ms`. CPU stays in the threadpool or off the loop — see + `docs/async_refactor.md`. +- Do not grow a payload because it is easier than asking what the + client needs. Caps are named settings, not leftover literals. +- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not + rebuild overlays that only depend on the viewport. +- Overlay redraws go through `scheduleOverlays`. Force when data or + size changed; let the range-key skip no-ops. +- `window` pointermove/up attach only while a tool is armed or a drag + is live. Do not leave them on for the life of the page. +- Prefer one rAF over N DOM rebuilds. Read `offsetWidth` only if the + layout actually changed. +- A feature that needs a per-frame or per-tick loop needs a reason, + and an off switch. diff --git a/docs/implementation.md b/docs/implementation.md index b1f03f5..3c0df1d 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1084,6 +1084,16 @@ deliberate: a prior overlay bug produced internally consistent coordinates while the pixels were visibly displaced. A deterministic browser test requires actual historical and future line pixels to exist and keep the same slope. +### 2026-08-15 — higher-timeframe trendlines read differently on lower charts + +A manual trendline viewed below its attributed timeframe now renders dashed at +twice its persisted `line_width`. The stored drawing is unchanged; returning to +its native timeframe restores the configured width and solid stroke. This makes +30m structure recognizable on 1m/5m without assigning semantic colours. The +same effective style is applied to rare SVG endpoint bridges so an off-grid +anchor cannot introduce a visible style seam. Browser coverage checks 30m-on-5m +and native 30m rendering separately. + ### 2026-08-15 — 1m zoom-out died at ~1am because the socket sent 1,000 bars Compressing the 1m time scale stopped around 01:00, then looked empty. diff --git a/docs/plan.md b/docs/plan.md index b19f7e7..3c0cf2e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -91,7 +91,7 @@ if your parser doesn't filter those, that fixture will catch it. | Decision | Choice | Why | |---|---|---| | Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support | -| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial. Destination is Vite — see `docs/vite_build.md`. Do not treat this row as a reason to reject that move. | +| Frontend | Vue 3 from CDN, **no build step** | Matches existing `static/` setup; keeps deploy trivial. Destination is Vite — see `docs/vite_build.md`. Do not treat this row as a reason to reject that move. Stay cheap — `AGENTS.md` § Stay cheap. No per-pixel overlay work, no extra work on the event loop each tick. | | Charting | TradingView Lightweight Charts **v5.2.0**, standalone build | Apache-2.0, canvas, built for incremental realtime updates | | Data source | **Pluggable `MarketDataSource`.** Yahoo first, Schwab later | Yahoo needs no API key *and* has the history Schwab lacks — see §2.1 | | Persistence | **In-memory first**, behind a `BarStore` interface | User confirmed deferring persistence is fine for v1 | @@ -627,6 +627,7 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`. | Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | | Render | One `LineSeries`, sampled onto displayed candles and existing future-whitespace slots; SVG only bridges off-grid endpoints and provides interaction overlays | +| Higher-TF style | When viewed below its attributed timeframe, a manual trendline is dashed and rendered at twice its stored width; native/lower-TF views use the stored width and solid style | | Select | Click within ~6px of the canonical price at that displayed bar | | Delete | `Delete`/`Backspace` on selection, plus a button | | Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | diff --git a/static/chart.js b/static/chart.js index f245443..b3a363b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -1250,6 +1250,20 @@ class ConfluenceChart { return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal'; } + manualLineDisplayStyle(level) { + const baseWidth = level.line_width || 1; + const displayedTf = this.bars[0]?.tf; + const higherTimeframe = level.kind === 'manual' && level.slope + && ConfluenceChart.tfRank[level.tf] > ConfluenceChart.tfRank[displayedTf]; + return { + lineWidth: higherTimeframe ? baseWidth * 2 : baseWidth, + lineStyle: higherTimeframe + ? LightweightCharts.LineStyle.Dashed + : level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, + higherTimeframe, + }; + } + seriesTitle(level) { if (ConfluenceChart.isContextLevel(level)) return ''; if (level.kind === 'manual') { @@ -1342,16 +1356,21 @@ class ConfluenceChart { const hasPoints = level.kind === 'ma' || level.kind === 'vwap'; const isMa = level.kind === 'ma'; const dailyChart = this.bars[0]?.tf === '1d'; + const manualStyle = this.manualLineDisplayStyle(level); const options = { color: ConfluenceChart.levelColor(level), - lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), + lineWidth: level.kind === 'manual' + ? manualStyle.lineWidth + : level.line_width || (level.tf === '1d' ? 2 : 1), // A daily value is held constant while projected over intraday bars, // but on the daily chart each point is a new sample and should connect // normally rather than drawing a staircase. lineType: isMa && !dailyChart ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, - lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, + lineStyle: level.kind === 'manual' + ? manualStyle.lineStyle + : level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null, title: this.seriesTitle(level), @@ -2443,7 +2462,9 @@ class ConfluenceChart { node.setAttribute('x2', x2); node.setAttribute('y2', y2); node.setAttribute('stroke', ConfluenceChart.levelColor(level)); - node.setAttribute('stroke-width', level.line_width || 2); + const style = this.manualLineDisplayStyle(level); + node.setAttribute('stroke-width', style.lineWidth); + if (style.higherTimeframe) node.setAttribute('stroke-dasharray', '7 5'); return node; }; const bridges = []; @@ -3053,6 +3074,10 @@ ConfluenceChart.tfSeconds = { '1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600, }; +ConfluenceChart.tfRank = { + '1m': 1, '2m': 2, '5m': 3, '15m': 4, '30m': 5, '1h': 6, '1d': 7, +}; + // VWAP and the prior-day levels are both stamped 1d, so without their own // colours they would be indistinguishable from the daily moving averages. ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 2b67ed9..b1b30c8 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -318,6 +318,46 @@ test('a manual line series owns its future 5m slots without an SVG projection', }); }); +test('higher-timeframe trendlines are dashed and twice their stored width', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const bars = tf => Array.from({ length: 20 }, (_, index) => ({ + tf, t: 300000 + index * (tf === '5m' ? 300 : 1800), + o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, + })); + const line = { + id: 'higher-style', kind: 'manual', tf: '30m', side: 'support', label: 'style', + anchor_t: 300000, anchor_p: 99, last_t: 301800, slope: 1 / 1800, + cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 3, provisional: false, + }; + const sourceTimes = bars('30m').map(bar => bar.t); + const optionsAt = displayed => { + c.setBars(bars(displayed)); + c.setTrendlineGeometry({ + mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 1800 } }, + }); + c.syncLevels([line]); + const options = c.levelSeries.get(line.id).series.options(); + return { width: options.lineWidth, style: options.lineStyle }; + }; + const lower = optionsAt('5m'); + const native = optionsAt('30m'); + return { + lower, + native, + dashed: LightweightCharts.LineStyle.Dashed, + solid: LightweightCharts.LineStyle.Solid, + }; + }); + + assert.deepEqual(result.lower, { width: 6, style: result.dashed }); + assert.deepEqual(result.native, { width: 3, style: result.solid }); + assertNoPageErrors(page, assert); + }); + }); + test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page);