large timeframes dashed

This commit is contained in:
Chris Amow 2026-08-23 18:25:55 -05:00
parent dae60fdf67
commit 7c5f0a7627
5 changed files with 102 additions and 4 deletions

View file

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

View file

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

View file

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

View file

@ -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' };

View file

@ -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);