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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue