Drop HTF source times that sit inside a compressed 1m halt.
Yahoo 30m/1h can print through Saturday; those opens added index steps while 1m display is one slot, kinking the line at 17:00. Short holes and settlement compression are unchanged.
This commit is contained in:
parent
fd315326b4
commit
e6fa6beaf0
6 changed files with 172 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue