From f5ba154c98f93b588a4f2394cc97707512c8c0dc Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Wed, 26 Aug 2026 03:04:52 -0500 Subject: [PATCH] Count short tape holes in source index so 1m lines stay straight. Prod already owned the empty 1m slots; source index still treated the bars on either side as adjacent. Also post a geometry SNAPDBG on ?diag=1. --- AGENTS.md | 7 ++- app/analysis/bar_space.py | 27 ++++++++ app/api/routes.py | 3 +- app/api/ws.py | 3 +- docs/NEXT_STEPS.md | 5 +- docs/implementation.md | 16 +++++ static/chart.js | 117 ++++++++++++++++++++++++++++++++--- tests/e2e/trendline.test.mjs | 58 +++++++++++++++++ tests/test_auth.py | 10 +++ tests/test_bar_space.py | 27 +++++++- 10 files changed, 259 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e31982..00cc49c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,11 @@ docker compose logs api | grep SNAPDBG With it on, every snap the trendline tool computes is posted to `/api/debug/snap` and logged server-side — the cursor's time, price and x, the snapped time and price, how many bars were held, the first and last bar, and the -chart's width. Throttled to about one a second. It reads the client's own -numbers, which is exactly what "works in my headless run" cannot tell you. +chart's width. After bars and levels load it also posts one `kind=geometry` +report: 1m holes, whether `futureSpace` owns them, and each visible manual +line's off-median screen segments. Throttled to about one a second. It reads +the client's own numbers, which is exactly what "works in my headless run" +cannot tell you. Do not ask the user to paste that from the console. Extend it when the next geometry puzzle appears; the endpoint takes whatever fields `SnapReport` declares. diff --git a/app/analysis/bar_space.py b/app/analysis/bar_space.py index a84f1f9..22aa14a 100644 --- a/app/analysis/bar_space.py +++ b/app/analysis/bar_space.py @@ -12,6 +12,32 @@ from bisect import bisect_right from app.bars.models import Timeframe from app.bars.session import bucket_duration, next_bucket_start +# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes +# occupy empty slots on the chart, so source index must count them too. +MAX_INTRADAY_GAP_SECONDS = 30 * 60 + + +def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]: + """Insert missing bucket opens inside short intraday holes. + + A 9-minute 1m hole is eight empty columns on screen. Without these times, + source index treats the two surrounding bars as adjacent and the line + goes flat across the hole. Settlement and weekends stay compressed. + """ + if tf is Timeframe.D1 or len(times) < 2: + return times + step = tf.seconds + if step <= 0: + return times + filled = [times[0]] + for time in times[1:]: + prev = filled[-1] + gap = time - prev + if step < gap <= MAX_INTRADAY_GAP_SECONDS: + filled.extend(range(prev + step, time, step)) + filled.append(time) + return filled + def index_at(times: list[int], t: int) -> float: """Fractional index of a timestamp within an ascending bar-time series.""" @@ -91,6 +117,7 @@ def price_in_timeframe_space( # Endpoints may deliberately sit in the projection area. They use the same # repeated-source-bucket approximation as the browser; the live evaluation # instant itself must still belong to held source history. + times = fill_short_gaps(times, tf) start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True) end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True) target_index = timeframe_index_at(times, t, tf) diff --git a/app/api/routes.py b/app/api/routes.py index 0c3391f..8a946e2 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -10,7 +10,7 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from app.bars.models import Timeframe from app.config import DEFAULT_MAX_BARS_PER_TF @@ -395,6 +395,7 @@ def create_comment(request: Request, payload: CommentCreate): class SnapReport(BaseModel): """What the browser computed for one snap, for diagnosing chart geometry.""" + model_config = ConfigDict(extra="allow") cursor_t: int | None = None cursor_p: float | None = None cursor_x: float | None = None diff --git a/app/api/ws.py b/app/api/ws.py index e63d222..6efd708 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -6,6 +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.bars.session import bucket_duration, future_bucket_starts from app.analysis.confluence import cluster_levels @@ -70,7 +71,7 @@ def trendline_geometry(runtime) -> dict: def trendline_series(runtime, tf: Timeframe) -> dict: - times = [bar.t for bar in runtime.store.get(tf)] + times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf) value = {"times": times} if tf is Timeframe.D1: value["durations"] = [bucket_duration(t, tf) for t in times] diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 316cd2e..9851865 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -10,8 +10,9 @@ decisions behind them in `docs/plan.md`. ### Revisit the trendline live-edge bend with painted-pixel evidence -Production still appears to show a bend where 30m manual lines cross from the -last real candle into future whitespace. The deployed code is verified to use +Mid-session 1m tape holes are now counted in source index as well as on the +time scale. Production still appears to show a bend where 30m manual lines +cross from the last real candle into future whitespace. The deployed code is verified to use one canvas `LineSeries`, retained transparent time-scale points, and canonical source-space prices. Diagnostic values can report unequal historical/future screen slopes when the final candles are sparse, but synthetic browser coverage diff --git a/docs/implementation.md b/docs/implementation.md index de0fef4..4fda312 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1336,3 +1336,19 @@ alert before the deferred global rebuild. Finally, source-dependent shift operations no longer silently keep the old time or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate, and nudge abort cleanly when the canonical shift cannot be represented. + +### 2026-08-26 — prod 1m holes were slotted, source index was not + +A production 1m dump showed 23 holes. Every 1–10 minute tape hole already owned +its empty `futureSpace` slots, including the Yahoo→Schwab join at 02:18–02:27 +CDT. Settlement (15:59–17:00) and the weekend stay compressed on purpose. + +The remaining bend is source index: a 1m/5m line still treated the bars on +either side of a slotted hole as adjacent, so nine screen slots carried one +minute of price. `fill_short_gaps` now inserts the missing bucket opens (same +30-minute bound as the display) in trendline geometry and in +`price_in_timeframe_space`. Weekends and settlement are unchanged. + +`?diag=1` also posts one `kind=geometry` SNAPDBG after bars load — gap ownership +and off-median line segments — so the next check is `logs --since 20m`, not +another console paste. diff --git a/static/chart.js b/static/chart.js index e95802c..501ebe2 100644 --- a/static/chart.js +++ b/static/chart.js @@ -683,6 +683,7 @@ class ConfluenceChart { } this.syncRthLines(); this.scheduleOverlays(true); + this.scheduleGeometryDiagnostic(); } setTrendlineGeometry(geometry) { @@ -750,6 +751,7 @@ class ConfluenceChart { source.futureTimes = futureTimes.map(Number); source.futureDurations = futureDurations.map(Number); source.allTimes = [...source.times, ...source.futureTimes]; + source._filled = null; return true; } @@ -1441,6 +1443,7 @@ class ConfluenceChart { this.pendingView = null; } this.scheduleOverlays(true); + this.scheduleGeometryDiagnostic(); } renderContextLabels() { @@ -1738,6 +1741,90 @@ class ConfluenceChart { if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); } + postDebugSnap(body) { + fetch('/api/debug/snap', { + method: 'POST', + headers: { 'Content-Type': 'application/json', + ...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) }, + body: JSON.stringify(body), + }).catch(() => {}); + } + + scheduleGeometryDiagnostic() { + if (!ConfluenceChart.diagnosticMode()) return; + clearTimeout(this._geometryDiagTimer); + this._geometryDiagTimer = setTimeout(() => this.reportGeometryDiagnostic(), 400); + } + + reportGeometryDiagnostic() { + if (!ConfluenceChart.diagnosticMode() || !this.bars.length) return; + const now = Date.now(); + if (this._lastGeometryReport && now - this._lastGeometryReport < 2000) return; + this._lastGeometryReport = now; + const step = ConfluenceChart.barInterval(this.bars) || 60; + const space = new Set((this.futureSpace?.data() || []).map(point => Number(point.time))); + const gaps = []; + for (let index = 1; index < this.bars.length; index += 1) { + const delta = this.bars[index].t - this.bars[index - 1].t; + if (delta <= step) continue; + let owned = 0; + for (let time = this.bars[index - 1].t + step; time < this.bars[index].t; time += step) { + if (space.has(time)) owned += 1; + } + gaps.push({ + from: this.bars[index - 1].t, + to: this.bars[index].t, + missing: delta / step - 1, + owned, + }); + } + const lines = this.levels + .filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) + .slice(0, 24) + .map(level => { + const pts = this.levelSeries.get(level.id)?.series.data() || []; + const segs = []; + for (let index = 1; index < pts.length; index += 1) { + const first = pts[index - 1]; + const second = pts[index]; + const x1 = this.chart.timeScale().timeToCoordinate(first.time); + const x2 = this.chart.timeScale().timeToCoordinate(second.time); + const y1 = this.candles.priceToCoordinate(first.value); + const y2 = this.candles.priceToCoordinate(second.value); + if ([x1, x2, y1, y2].some(value => value == null) || x2 === x1) continue; + segs.push({ + t1: Number(first.time), + t2: Number(second.time), + dtMin: (Number(second.time) - Number(first.time)) / 60, + dx: +(x2 - x1).toFixed(2), + slope: +((y2 - y1) / (x2 - x1)).toFixed(4), + }); + } + const slopes = segs.map(seg => seg.slope).sort((a, b) => a - b); + const med = slopes.length ? slopes[Math.floor(slopes.length / 2)] : null; + const thresh = Math.abs(med || 1) * 0.08; + return { + n: level.number, + tf: level.tf, + pts: pts.length, + med, + bad: segs.filter(seg => med != null && Math.abs(seg.slope - med) > thresh).slice(0, 12), + }; + }); + this.postDebugSnap({ + kind: 'geometry', + tf: this.bars[0].tf, + bars_held: this.bars.length, + first_bar_t: this.bars[0].t, + last_bar_t: this.bars[this.bars.length - 1].t, + chart_w: this.chartEl?.clientWidth, + chart_h: this.chartEl?.clientHeight, + note: `gaps ${gaps.length} lines ${lines.length}`, + gaps: gaps.slice(0, 40), + lines, + }); + } + showSnapDot(snapped, point = {}) { if (snapped.t == null) return this.hideSnapDot(); const x = this.coordinateAtTime(snapped.t); @@ -1789,12 +1876,7 @@ class ConfluenceChart { : `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`, }; this.lastSnapDiagnostic = body; - fetch('/api/debug/snap', { - method: 'POST', - headers: { 'Content-Type': 'application/json', - ...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) }, - body: JSON.stringify(body), - }).catch(() => {}); + this.postDebugSnap(body); } } } @@ -2396,8 +2478,29 @@ class ConfluenceChart { ?? source.futureDurations[source.futureDurations.length - 1] ?? null; } + static fillShortGaps(source) { + const times = source?.allTimes || source?.times || []; + const step = source?.duration; + if (!(step > 0) || times.length < 2) return times; + const filled = [times[0]]; + for (let index = 1; index < times.length; index += 1) { + const prev = filled[filled.length - 1]; + const next = times[index]; + const gap = next - prev; + if (gap > step && gap <= ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) { + for (let time = prev + step; time < next; time += step) filled.push(time); + } + filled.push(next); + } + return filled; + } + static sourceTimes(source) { - return source?.allTimes || source?.times || []; + if (!source) return []; + if (source._filled) return source._filled; + const filled = ConfluenceChart.fillShortGaps(source); + source._filled = filled; + return filled; } static timeframeIndexAt(source, time, allowFuture = true) { diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 56da4ca..5798eb6 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -410,6 +410,64 @@ test('a 30m line stays straight on 1m when the bucket is missing its first minut }); }); +test('a 1m line stays straight across a slotted mid-session hole', + { timeout: 180000 }, async () => { + await withChart(async page => { + const result = await page.evaluate(() => { + const c = window.__chart; + const minute = 60; + const start = 500000; + const bars = []; + for (let t = start; t < start + 20 * minute; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + for (let t = start + 29 * minute; t < start + 50 * minute; t += minute) { + bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); + } + const line = { + id: '1m-hole', kind: 'manual', tf: '1m', side: 'support', label: 'hole', + anchor_t: start, anchor_p: 100, last_t: start + 10 * minute, + slope: 1 / minute, 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: { '1m': { times: bars.map(bar => bar.t), duration: minute } }, + }); + 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 + 18 * minute); + const b = point(start + 19 * minute); + const d = point(start + 29 * minute); + const e = point(start + 30 * minute); + return { + before: (b.y - a.y) / (b.x - a.x), + across: (d.y - b.y) / (d.x - b.x), + after: (e.y - d.y) / (e.x - d.x), + filled: ConfluenceChart.sourceTimes(c.trendlineSourceSeries.get('1m')) + .includes(start + 20 * minute), + }; + }); + assert.equal(result.filled, true, 'short 1m hole was not inserted into source times'); + assert.ok(Math.abs(result.across - result.before) < 1e-6, + `1m line kinked across hole from ${result.before} to ${result.across}`); + assert.ok(Math.abs(result.after - result.before) < 1e-6, + `1m line kinked after hole from ${result.before} to ${result.after}`); + assertNoPageErrors(page, assert); + }); + }); + test('painted canvas pixels keep the same slope through the live edge', { timeout: 180000 }, async () => { await withChart(async page => { diff --git a/tests/test_auth.py b/tests/test_auth.py index efc8d59..c72aebf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -40,6 +40,16 @@ def test_open_when_no_token_configured(client): assert client("").get("/api/bars").status_code == 200 +def test_debug_snap_keeps_geometry_fields(client): + response = client("").post("/api/debug/snap", json={ + "kind": "geometry", + "tf": "1m", + "gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}], + "lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}], + }) + assert response.status_code == 204 + + def test_oauth_callback_stays_open_when_a_token_is_set(): # The provider redirects a browser here and cannot attach the chart token. # A 401 would break the login flow at its last step, on production only, diff --git a/tests/test_bar_space.py b/tests/test_bar_space.py index d792068..6b89939 100644 --- a/tests/test_bar_space.py +++ b/tests/test_bar_space.py @@ -1,4 +1,7 @@ -from app.analysis.bar_space import index_at, price_in_bar_space, price_in_timeframe_space +from app.analysis.bar_space import ( + 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 @@ -110,3 +113,25 @@ def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser assert price_in_timeframe_space( line, source_times, Timeframe.M30, 2 * half_hour, ) == 102.0 + + +def test_a_short_1m_hole_still_advances_one_index_per_minute(): + times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE)) + filled = fill_short_gaps(times, Timeframe.M1) + assert 10 * MINUTE in filled + assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19 + line = sloped(0, 100.0, 30 * MINUTE, 130.0) + assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0 + + +def test_a_weekend_is_not_filled_as_short_gaps(): + weekend = 49 * 3600 + times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE] + assert fill_short_gaps(times, Timeframe.M1) == times + + +def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled(): + five = 5 * MINUTE + times = [0, five, 3 * five] + filled = fill_short_gaps(times, Timeframe.M5) + assert filled == [0, five, 2 * five, 3 * five]