large timeframes dashed
This commit is contained in:
parent
dae60fdf67
commit
7c5f0a7627
5 changed files with 102 additions and 4 deletions
22
AGENTS.md
22
AGENTS.md
|
|
@ -174,3 +174,25 @@ markers.
|
||||||
snapshot made 1m look empty past ~1am while the store held 5,000. The
|
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,
|
store cap is `max_bars_per_tf`. The next step is a visible-window fetch,
|
||||||
not another silent number.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
the pixels were visibly displaced. A deterministic browser test requires actual
|
||||||
historical and future line pixels to exist and keep the same slope.
|
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
|
### 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.
|
Compressing the 1m time scale stopped around 01:00, then looked empty.
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ if your parser doesn't filter those, that fixture will catch it.
|
||||||
| Decision | Choice | Why |
|
| Decision | Choice | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Backend | FastAPI (already scaffolded) | Repo already runs it; native WebSocket support |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 → price | `series.coordinateToPrice(param.point.y)` |
|
||||||
| Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` |
|
| 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 |
|
| 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 |
|
| Select | Click within ~6px of the canonical price at that displayed bar |
|
||||||
| Delete | `Delete`/`Backspace` on selection, plus a button |
|
| Delete | `Delete`/`Backspace` on selection, plus a button |
|
||||||
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars |
|
| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars |
|
||||||
|
|
|
||||||
|
|
@ -1250,6 +1250,20 @@ class ConfluenceChart {
|
||||||
return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal';
|
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) {
|
seriesTitle(level) {
|
||||||
if (ConfluenceChart.isContextLevel(level)) return '';
|
if (ConfluenceChart.isContextLevel(level)) return '';
|
||||||
if (level.kind === 'manual') {
|
if (level.kind === 'manual') {
|
||||||
|
|
@ -1342,16 +1356,21 @@ class ConfluenceChart {
|
||||||
const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
|
const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
|
||||||
const isMa = level.kind === 'ma';
|
const isMa = level.kind === 'ma';
|
||||||
const dailyChart = this.bars[0]?.tf === '1d';
|
const dailyChart = this.bars[0]?.tf === '1d';
|
||||||
|
const manualStyle = this.manualLineDisplayStyle(level);
|
||||||
const options = {
|
const options = {
|
||||||
color: ConfluenceChart.levelColor(level),
|
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,
|
// 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
|
// but on the daily chart each point is a new sample and should connect
|
||||||
// normally rather than drawing a staircase.
|
// normally rather than drawing a staircase.
|
||||||
lineType: isMa && !dailyChart
|
lineType: isMa && !dailyChart
|
||||||
? LightweightCharts.LineType.WithSteps
|
? LightweightCharts.LineType.WithSteps
|
||||||
: LightweightCharts.LineType.Simple,
|
: 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,
|
priceLineVisible: false,
|
||||||
lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
|
lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null,
|
||||||
title: this.seriesTitle(level),
|
title: this.seriesTitle(level),
|
||||||
|
|
@ -2443,7 +2462,9 @@ class ConfluenceChart {
|
||||||
node.setAttribute('x2', x2);
|
node.setAttribute('x2', x2);
|
||||||
node.setAttribute('y2', y2);
|
node.setAttribute('y2', y2);
|
||||||
node.setAttribute('stroke', ConfluenceChart.levelColor(level));
|
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;
|
return node;
|
||||||
};
|
};
|
||||||
const bridges = [];
|
const bridges = [];
|
||||||
|
|
@ -3053,6 +3074,10 @@ ConfluenceChart.tfSeconds = {
|
||||||
'1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600,
|
'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
|
// VWAP and the prior-day levels are both stamped 1d, so without their own
|
||||||
// colours they would be indistinguishable from the daily moving averages.
|
// colours they would be indistinguishable from the daily moving averages.
|
||||||
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
|
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const box = await chartBox(page);
|
const box = await chartBox(page);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue