Revert "Drop HTF source times that sit inside a compressed 1m halt."

This reverts commit e6fa6beaf0.
This commit is contained in:
Chris Amow 2026-08-31 03:45:43 -05:00
parent e6fa6beaf0
commit 2fbb5957f3
6 changed files with 15 additions and 172 deletions

View file

@ -39,33 +39,6 @@ def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
return filled 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: def index_at(times: list[int], t: int) -> float:
"""Fractional index of a timestamp within an ascending bar-time series.""" """Fractional index of a timestamp within an ascending bar-time series."""
if not times: if not times:

View file

@ -6,7 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.api.deps import SESSION_COOKIE, session_matches, token_matches
from app.analysis.levels import LevelKind from app.analysis.levels import LevelKind
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.analysis.bar_space import drop_times_in_compressed_gaps, fill_short_gaps from app.analysis.bar_space import fill_short_gaps
from app.bars.session import bucket_duration, future_bucket_starts from app.bars.session import bucket_duration, future_bucket_starts
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
@ -72,10 +72,6 @@ def trendline_geometry(runtime) -> dict:
def trendline_series(runtime, tf: Timeframe) -> dict: def trendline_series(runtime, tf: Timeframe) -> dict:
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf) 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} value = {"times": times}
if tf is Timeframe.D1: if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times] value["durations"] = [bucket_duration(t, tf) for t in times]

View file

@ -1402,20 +1402,6 @@ 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 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. `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: **Animate current price** was two infinite CSS animations across the full plot:
`background-position` on a masked `repeating-linear-gradient`, plus a `background-position` on a masked `repeating-linear-gradient`, plus a
`color-mix` shine. That cannot run on the compositor. Turning it off dropped `color-mix` shine. That cannot run on the compositor. Turning it off dropped

View file

@ -765,8 +765,6 @@ class ConfluenceChart {
source.futureDurations = futureDurations.map(Number); source.futureDurations = futureDurations.map(Number);
source.allTimes = [...source.times, ...source.futureTimes]; source.allTimes = [...source.times, ...source.futureTimes];
source._filled = null; source._filled = null;
source._stripped = null;
source._stripKey = null;
return true; return true;
} }
@ -2498,7 +2496,7 @@ class ConfluenceChart {
sourceSeries(level) { sourceSeries(level) {
if (this.trendlineGeometryMode !== 'source_tf') return null; if (this.trendlineGeometryMode !== 'source_tf') return null;
const source = this.trendlineSourceSeries.get(level.tf); const source = this.trendlineSourceSeries.get(level.tf);
if (!source) { if (source) return source;
if (this.bars[0]?.tf !== level.tf) return null; if (this.bars[0]?.tf !== level.tf) return null;
const duration = ConfluenceChart.tfSeconds[level.tf] || null; const duration = ConfluenceChart.tfSeconds[level.tf] || null;
return { return {
@ -2512,47 +2510,6 @@ class ConfluenceChart {
allTimes: this.bars.map(bar => bar.t), 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) { static sourceDuration(source, index) {
if (source.duration != null) return source.duration; if (source.duration != null) return source.duration;

View file

@ -414,66 +414,6 @@ 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', test('a 1m line stays straight across a slotted mid-session hole',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {

View file

@ -1,6 +1,6 @@
from app.analysis.bar_space import ( from app.analysis.bar_space import (
drop_times_in_compressed_gaps, fill_short_gaps, index_at, fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
price_in_bar_space, price_in_timeframe_space, timeframe_index_at, timeframe_index_at,
) )
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
@ -130,15 +130,6 @@ def test_a_weekend_is_not_filled_as_short_gaps():
assert fill_short_gaps(times, Timeframe.M1) == times 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(): def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
five = 5 * MINUTE five = 5 * MINUTE
times = [0, five, 3 * five] times = [0, five, 3 * five]