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.
This commit is contained in:
Chris Amow 2026-08-26 03:04:52 -05:00
parent c28b0ce535
commit f5ba154c98
10 changed files with 259 additions and 14 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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