diff --git a/app/analysis/bar_space.py b/app/analysis/bar_space.py index 22aa14a..892238e 100644 --- a/app/analysis/bar_space.py +++ b/app/analysis/bar_space.py @@ -39,6 +39,33 @@ def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]: return filled +def drop_times_in_compressed_gaps( + times: list[int], display_times: list[int], +) -> list[int]: + """Drop HTF opens that sit inside a 1m halt/weekend the chart compresses. + + Yahoo 30m/1h history can print through Saturday. Those timestamps add + index steps while 1m display is one slot, so a 30m line kinks at Sunday + 17:00. Short holes stay — they are already filled on both series. + """ + if len(display_times) < 2 or len(times) < 2: + return times + kept = [] + for time in times: + after = bisect_right(display_times, time) + before = after - 1 + if before < 0 or after >= len(display_times): + kept.append(time) + continue + left = display_times[before] + right = display_times[after] + gap = right - left + if gap > MAX_INTRADAY_GAP_SECONDS and left < time < right: + continue + kept.append(time) + return kept + + def index_at(times: list[int], t: int) -> float: """Fractional index of a timestamp within an ascending bar-time series.""" if not times: diff --git a/app/api/ws.py b/app/api/ws.py index 8f70030..65358a2 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.analysis.levels import LevelKind from app.bars.models import Timeframe -from app.analysis.bar_space import fill_short_gaps +from app.analysis.bar_space import drop_times_in_compressed_gaps, fill_short_gaps from app.bars.session import bucket_duration, future_bucket_starts from app.analysis.confluence import cluster_levels @@ -72,6 +72,10 @@ def trendline_geometry(runtime) -> dict: def trendline_series(runtime, tf: Timeframe) -> dict: times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf) + if tf is not Timeframe.M1: + times = drop_times_in_compressed_gaps( + times, [bar.t for bar in runtime.store.get(Timeframe.M1)], + ) value = {"times": times} if tf is Timeframe.D1: value["durations"] = [bucket_duration(t, tf) for t in times] diff --git a/docs/implementation.md b/docs/implementation.md index cf994bc..1936e84 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1402,6 +1402,20 @@ The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz. Schwab still prints many forming ticks per frame. Those now coalesce to one `candles.update` on the next animation frame. A new minute flushes immediately. +### 2026-08-31 — #63 kinked at 17:00: HTF source times inside a 1m halt + +Capture `c-FnHdmYtvsOJn` (deleted after inspect): 30m-looking line on 1m, corner +at 17:00 CT session open — steep through the last pre-halt candles, then flat. +Short 1–10 minute holes were already slotted and counted. This was the other +coordinate-system disagreement: Yahoo 30m/1h history can print through +settlement/Saturday. Those opens add source-index steps while 1m display +compresses the halt to one slot, so one screen column carries a weekend of +30m slope. + +`drop_times_in_compressed_gaps` strips HTF timestamps that sit strictly inside +a 1m gap longer than 30 minutes. Friday 15:30 and Sunday 17:00 become adjacent +again. Short holes and native 1m sampling are unchanged. + **Animate current price** was two infinite CSS animations across the full plot: `background-position` on a masked `repeating-linear-gradient`, plus a `color-mix` shine. That cannot run on the compositor. Turning it off dropped diff --git a/static/chart.js b/static/chart.js index b428759..8d2f435 100644 --- a/static/chart.js +++ b/static/chart.js @@ -765,6 +765,8 @@ class ConfluenceChart { source.futureDurations = futureDurations.map(Number); source.allTimes = [...source.times, ...source.futureTimes]; source._filled = null; + source._stripped = null; + source._stripKey = null; return true; } @@ -2496,19 +2498,60 @@ class ConfluenceChart { sourceSeries(level) { if (this.trendlineGeometryMode !== 'source_tf') return null; const source = this.trendlineSourceSeries.get(level.tf); - if (source) return source; - if (this.bars[0]?.tf !== level.tf) return null; - const duration = ConfluenceChart.tfSeconds[level.tf] || null; - return { - times: this.bars.map(bar => bar.t), - duration, - durations: duration == null - ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) - : [], - futureTimes: [], - futureDurations: [], - allTimes: this.bars.map(bar => bar.t), + if (!source) { + if (this.bars[0]?.tf !== level.tf) return null; + const duration = ConfluenceChart.tfSeconds[level.tf] || null; + return { + times: this.bars.map(bar => bar.t), + duration, + durations: duration == null + ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) + : [], + futureTimes: [], + futureDurations: [], + allTimes: this.bars.map(bar => bar.t), + }; + } + if (this.bars[0]?.tf === level.tf) return source; + return this.stripSourceCompressedGaps(source); + } + + stripSourceCompressedGaps(source) { + const bars = this.bars; + if (bars.length < 2) return source; + const key = `${bars[0].t}:${bars[bars.length - 1].t}:${bars.length}`; + if (source._stripKey === key && source._stripped) return source._stripped; + const times = ConfluenceChart.sourceTimes(source); + const max = ConfluenceChart.MAX_INTRADAY_GAP_SECONDS; + const kept = []; + for (const time of times) { + let lo = 0; + let hi = bars.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (bars[mid].t < time) lo = mid + 1; + else hi = mid; + } + const before = lo - 1; + if (before >= 0 && lo < bars.length) { + const left = bars[before].t; + const right = bars[lo].t; + if (right - left > max && left < time && time < right) continue; + } + kept.push(time); + } + const historical = kept.filter(time => time <= bars[bars.length - 1].t); + const future = kept.filter(time => time > bars[bars.length - 1].t); + const stripped = { + ...source, + times: historical, + futureTimes: future, + allTimes: kept, + _filled: kept, }; + source._stripKey = key; + source._stripped = stripped; + return stripped; } static sourceDuration(source, index) { diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 2a280d9..a7f73e3 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -414,6 +414,66 @@ test('a 30m line stays straight on 1m when the bucket is missing its first minut }); }); +test('a 30m line stays straight on 1m when source times print through a halt', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const minute = 60; + const half = 30 * minute; + const halt = 65 * minute; + const start = 800000; + const bars = []; + for (let t = start; t < start + 2 * half; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + const resume = start + 2 * half + halt; + for (let t = resume; t < resume + 2 * half; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + const sourceTimes = []; + for (let t = start; t <= resume + 2 * half; t += half) sourceTimes.push(t); + const line = { + id: 'halt-30m', kind: 'manual', tf: '30m', side: 'support', label: 'halt', + anchor_t: start, anchor_p: 100, last_t: start + half, + slope: 3 / half, cutoff_t: null, hidden: false, color: '#65b7cf', + line_width: 2, provisional: false, + }; + c.setDisplayFutureTimes(Array.from( + { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, + )); + c.setBars(bars); + c.setTrendlineGeometry({ + mode: 'source_tf', + series: { '30m': { times: sourceTimes, duration: half } }, + }); + c.syncLevels([line]); + const data = c.levelSeries.get(line.id).series.data(); + const point = time => { + const row = data.find(value => Number(value.time) === time); + return { + x: c.chart.timeScale().timeToCoordinate(time), + y: row ? c.candles.priceToCoordinate(row.value) : null, + }; + }; + const a = point(start); + const b = point(start + half); + const d = point(start + 5 * half); + const e = point(start + 6 * half); + return { + before: (b.y - a.y) / (b.x - a.x), + after: (e.y - d.y) / (e.x - d.x), + dropped: !ConfluenceChart.sourceTimes(c.sourceSeries(line)) + .includes(start + 2 * half), + }; + }); + assert.equal(result.dropped, true, 'halt 30m open remained in source index'); + assert.ok(Math.abs(result.before - result.after) < 1e-4, + `30m line kinked from ${result.before} to ${result.after}`); + assertNoPageErrors(page, assert); + }); + }); + test('a 1m line stays straight across a slotted mid-session hole', { timeout: 180000 }, async () => { await withChart(async page => { diff --git a/tests/test_bar_space.py b/tests/test_bar_space.py index 6b89939..c211cce 100644 --- a/tests/test_bar_space.py +++ b/tests/test_bar_space.py @@ -1,6 +1,6 @@ from app.analysis.bar_space import ( - fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space, - timeframe_index_at, + drop_times_in_compressed_gaps, fill_short_gaps, index_at, + price_in_bar_space, price_in_timeframe_space, timeframe_index_at, ) from app.analysis.levels import Level, LevelKind, Side from app.bars.models import Timeframe @@ -130,6 +130,15 @@ def test_a_weekend_is_not_filled_as_short_gaps(): assert fill_short_gaps(times, Timeframe.M1) == times +def test_weekend_30m_prints_inside_a_1m_halt_are_dropped(): + weekend = 49 * 3600 + display = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE] + source = [0, MINUTE, MINUTE + 30 * MINUTE, MINUTE + weekend] + assert drop_times_in_compressed_gaps(source, display) == [ + 0, MINUTE, MINUTE + weekend, + ] + + def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled(): five = 5 * MINUTE times = [0, five, 3 * five]