fix canonical future geometry

This commit is contained in:
Chris Amow 2026-08-25 05:03:42 -05:00
parent cb81672a37
commit 90fc77df92
9 changed files with 241 additions and 16 deletions

View file

@ -111,6 +111,35 @@ trendlines: historical/future canonical price changes, their screen slopes, and
whether the future canvas point exists. Include it in a capture when a line whether the future canvas point exists. Include it in a capture when a line
looks kinked at the live edge; it separates bad geometry from a bad renderer. looks kinked at the live edge; it separates bad geometry from a bad renderer.
## Future whitespace is a high-risk boundary
Drawing bugs repeatedly appear to the right of the last real candle. Treat any
change involving future slots, projection, drawing movement, or selection as a
geometry change that needs explicit browser verification.
- The displayed time scale, a drawing's source-timeframe bar space, and the
server session calendar are different coordinate systems. Never substitute
wall-clock seconds or the displayed grid for canonical source geometry.
- Daily future slots must skip non-session days. Intraday future slots cross the
settlement break and weekend today; an endpoint that looks valid before the
break can become unresolved when real history arrives.
- Do not clamp a future click to the last real candle. Do not let one null or
unpriceable future sample disable an otherwise valid object's whole hit target.
- DOM/SVG overlays must use the chart's actual future coordinates. Extrapolating
from the last two real candles is wrong when sparse-gap slots were inserted.
- Audit every drawing type, not just trendlines: Fibonacci hit testing and body
targets, pinned comments/symbols, keyboard nudges, duplicate, cutoff, handles,
selection glow, and drag persistence have separate paths.
- Tests must cover placement, selection, body drag, endpoint drag, duplicate,
nudge, and cutoff beyond the live edge and across session boundaries. Several
older future-interaction E2E tests remain quarantined against mutable live
state; a skipped test is not protection.
Relevant history is concentrated near the trendline/future entries in
`docs/implementation.md`. Before fixing another symptom, measure timestamps,
logical/x coordinates, canonical prices, and painted pixels in the user's
viewport; self-consistent chart API numbers have missed real rendering bugs.
## Keep the two documents current ## Keep the two documents current
This is a running system under continual change, not a build being executed, so This is a running system under continual change, not a build being executed, so

View file

@ -82,6 +82,11 @@ def trendline_series(runtime, tf: Timeframe) -> dict:
return value return value
def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
bars = runtime.store.get(tf, 1)
return future_bucket_starts(bars[-1].t, tf) if bars else []
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent() events, events_more = runtime.events.recent()
return { return {
@ -95,6 +100,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
else None, else None,
"session_open": session_open(runtime), "session_open": session_open(runtime),
"trendline_geometry": trendline_geometry(runtime), "trendline_geometry": trendline_geometry(runtime),
"future_times": displayed_future_times(runtime, tf),
"events": events, "events": events,
"events_more": events_more, "events_more": events_more,
} }
@ -171,6 +177,7 @@ async def websocket_endpoint(websocket: WebSocket):
{ {
"type": "bar", "tf": tf.value, "type": "bar", "tf": tf.value,
"bar": bar.to_dict(), "bar": bar.to_dict(),
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime), "session_open": session_open(runtime),
"trendline_first_t": ( "trendline_first_t": (
first_source_t first_source_t
@ -180,6 +187,7 @@ async def websocket_endpoint(websocket: WebSocket):
"trendline_previous_t": previous_source_t, "trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []), "trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []), "trendline_future_durations": source_geometry.get("future_durations", []),
"future_times": source_geometry.get("future_times", []),
} }
) )
elif ( elif (

View file

@ -7,6 +7,7 @@ UTC = ZoneInfo("UTC")
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
SESSION_OPEN = time(18, 0) SESSION_OPEN = time(18, 0)
SESSION_CLOSE = time(17, 0) SESSION_CLOSE = time(17, 0)
FUTURE_SLOT_COUNT = 180
def _session_open_local(current: datetime) -> datetime: def _session_open_local(current: datetime) -> datetime:
@ -40,9 +41,20 @@ def bucket_duration(t: int, tf: Timeframe) -> int:
def next_bucket_start(t: int, tf: Timeframe) -> int: def next_bucket_start(t: int, tf: Timeframe) -> int:
"""Next projected source-bar open, with the daily weekend skipped.""" """Next projected source-bar open, skipping known CME closures."""
if tf is not Timeframe.D1: if tf is not Timeframe.D1:
return t + tf.seconds candidate = datetime.fromtimestamp(t + tf.seconds, UTC).astimezone(EASTERN)
weekday = candidate.weekday()
wall_time = candidate.timetz().replace(tzinfo=None)
if weekday == 5: # Saturday -> Sunday open.
candidate = datetime.combine(candidate.date() + timedelta(days=1), SESSION_OPEN, EASTERN)
elif weekday == 6 and wall_time < SESSION_OPEN:
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
elif weekday in {0, 1, 2, 3} and SESSION_CLOSE <= wall_time < SESSION_OPEN:
candidate = datetime.combine(candidate.date(), SESSION_OPEN, EASTERN)
elif weekday == 4 and wall_time >= SESSION_CLOSE:
candidate = datetime.combine(candidate.date() + timedelta(days=2), SESSION_OPEN, EASTERN)
return int(candidate.timestamp())
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
candidate = current.date() + timedelta(days=1) candidate = current.date() + timedelta(days=1)
# Daily futures sessions open Sunday through Thursday. # Daily futures sessions open Sunday through Thursday.
@ -51,7 +63,7 @@ def next_bucket_start(t: int, tf: Timeframe) -> int:
return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp()) return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp())
def future_bucket_starts(t: int, tf: Timeframe, count: int = 64) -> list[int]: def future_bucket_starts(t: int, tf: Timeframe, count: int = FUTURE_SLOT_COUNT) -> list[int]:
values = [] values = []
for _ in range(count): for _ in range(count):
t = next_bucket_start(t, tf) t = next_bucket_start(t, tf)

View file

@ -990,9 +990,8 @@ duplication shift timestamps in source bars rather than whatever timeframe is
currently displayed. Endpoint dragging can still choose a finer-timeframe currently displayed. Endpoint dragging can still choose a finer-timeframe
instant; it becomes a fractional coordinate in the source bar. Snapshots also instant; it becomes a fractional coordinate in the source bar. Snapshots also
carry future source slots so editing and duplication use the same timestamps on carry future source slots so editing and duplication use the same timestamps on
both sides; daily slots stay at 18:00 ET and skip the weekend. Intraday future both sides. Daily slots stay at 18:00 ET and skip the weekend; intraday slots
slots retain the separate, pre-existing limitation that the future axis is not skip the 17:00-18:00 ET settlement halt and Friday-to-Sunday closure.
session-aware.
The first browser implementation rebuilt `historical + future` timestamps for The first browser implementation rebuilt `historical + future` timestamps for
every sampled point. The math test passed, but the next trendline run slowed its every sampled point. The math test passed, but the next trendline run slowed its
@ -1275,3 +1274,22 @@ click.
Levels no longer span the full chart: they start at the first click and run Levels no longer span the full chart: they start at the first click and run
to the right, with a vertical rail at the origin so the swing is obvious. to the right, with a vertical rail at the origin so the swing is obvious.
### 2026-08-25 — one canonical future calendar
The browser exposed 180 fixed-interval future slots while source geometry sent
64 session-aware slots. Daily display points therefore included Friday/Saturday,
intraday endpoints could land inside settlement or the weekend, and sufficiently
distant edits crossed the server horizon. Some endpoints looked valid when
created and became unresolved after real post-break history arrived.
`future_bucket_starts` now owns one 180-slot calendar for display and source
geometry. Daily and intraday slots both skip known CME closures; snapshots and
live bar messages carry the displayed future timestamps, and selected daily bar
updates carry their real 23-hour duration. The browser no longer extrapolates
beyond a supplied canonical horizon. **End trendline here** also preserves an
actual future click instead of reducing it to the nearest real candle.
Regression coverage pins daily weekends, intraday settlement/weekend rollover,
the shared horizon, display payload, daily live duration, beyond-horizon refusal,
and future cutoff timestamps.

View file

@ -494,6 +494,7 @@ createApp({
if (message.type === 'snapshot') { if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
chartApi.setTrendlineGeometry(message.trendline_geometry); chartApi.setTrendlineGeometry(message.trendline_geometry);
chartApi.setDisplayFutureTimes(message.future_times);
chartApi.setBars(message.bars); chartApi.setBars(message.bars);
levels.value = message.levels || []; levels.value = message.levels || [];
if (!drawColorInitialized) { if (!drawColorInitialized) {
@ -517,7 +518,7 @@ createApp({
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
if (message.trendline_first_t != null) { if (message.trendline_first_t != null) {
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, null, message.trendline_first_t, message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
message.trendline_previous_t, message.trendline_previous_t,
message.trendline_future_times, message.trendline_future_durations, message.trendline_future_times, message.trendline_future_durations,
); );
@ -525,6 +526,7 @@ createApp({
socket.send(JSON.stringify({ type: 'trendline_geometry' })); socket.send(JSON.stringify({ type: 'trendline_geometry' }));
} }
} }
chartApi.setDisplayFutureTimes(message.future_times);
chartApi.updateBar(message.bar); chartApi.updateBar(message.bar);
price.value = message.bar.c; price.value = message.bar.c;
sessionOpen.value = message.session_open ?? sessionOpen.value; sessionOpen.value = message.session_open ?? sessionOpen.value;

View file

@ -263,6 +263,7 @@ class ConfluenceChart {
this.fibPrimitive = null; this.fibPrimitive = null;
this.fibPreview = null; this.fibPreview = null;
this.futureSpace = null; this.futureSpace = null;
this.displayFutureTimes = [];
this.snapDot = null; this.snapDot = null;
this.snapLabel = null; this.snapLabel = null;
this.snapLeader = null; this.snapLeader = null;
@ -672,11 +673,11 @@ class ConfluenceChart {
// A time range names the instant, so later series cannot move it. // A time range names the instant, so later series cannot move it.
if (bars.length) { if (bars.length) {
const last = bars[bars.length - 1]; const last = bars[bars.length - 1];
// Five bars of right-hand breathing room, in seconds.
const step = ConfluenceChart.barInterval(bars) || 60; const step = ConfluenceChart.barInterval(bars) || 60;
const future = this.futureTimes(bars);
this.pendingView = { this.pendingView = {
from: bars[Math.max(0, bars.length - 160)].t, from: bars[Math.max(0, bars.length - 160)].t,
to: last.t + step * 5, to: future[Math.min(4, future.length - 1)]?.time ?? last.t + step * 5,
}; };
this.chart.timeScale().setVisibleRange(this.pendingView); this.chart.timeScale().setVisibleRange(this.pendingView);
} }
@ -702,6 +703,13 @@ class ConfluenceChart {
); );
} }
setDisplayFutureTimes(times) {
this.displayFutureTimes = (times || []).map(Number);
if (this.bars.length && this.futureSpace) {
this.futureSpace.setData(this.timeScaleSlots(this.bars));
}
}
updateTrendlineTime( updateTrendlineTime(
tf, time, duration = null, firstTime = null, previousTime = null, tf, time, duration = null, firstTime = null, previousTime = null,
futureTimes = [], futureDurations = [], futureTimes = [], futureDurations = [],
@ -760,6 +768,10 @@ class ConfluenceChart {
futureTimes(bars) { futureTimes(bars) {
if (bars.length < 2) return []; if (bars.length < 2) return [];
const last = bars[bars.length - 1]; const last = bars[bars.length - 1];
const canonical = this.displayFutureTimes
.filter(time => time > last.t)
.slice(0, ConfluenceChart.FUTURE_SLOTS);
if (canonical.length) return canonical.map(time => ({ time, value: 0 }));
const step = ConfluenceChart.barInterval(bars); const step = ConfluenceChart.barInterval(bars);
if (step <= 0) return []; if (step <= 0) return [];
const slots = []; const slots = [];
@ -815,7 +827,8 @@ class ConfluenceChart {
} }
return; return;
} }
const futureTime = bar.t + ConfluenceChart.FUTURE_SLOTS * step; const futureTime = this.futureTimes(this.bars).at(-1)?.time;
if (futureTime == null) return;
this.futureSpace.update({ time: futureTime, value: 0 }); this.futureSpace.update({ time: futureTime, value: 0 });
for (const level of this.levels) { for (const level of this.levels) {
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level) if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)
@ -2352,13 +2365,15 @@ class ConfluenceChart {
const duration = ConfluenceChart.sourceDuration(source, lower); const duration = ConfluenceChart.sourceDuration(source, lower);
const elapsed = time - times[lower]; const elapsed = time - times[lower];
if (!(duration > 0) || elapsed < 0) return null; if (!(duration > 0) || elapsed < 0) return null;
if (elapsed > duration && !(allowFuture && lower === times.length - 1)) return null; if (elapsed > duration && (source.futureTimes?.length
|| !(allowFuture && lower === times.length - 1))) return null;
return lower + elapsed / duration; return lower + elapsed / duration;
} }
static timeframeTimeAt(source, index) { static timeframeTimeAt(source, index) {
const times = ConfluenceChart.sourceTimes(source); const times = ConfluenceChart.sourceTimes(source);
if (!times.length || index < 0) return null; if (!times.length || index < 0) return null;
if (source.futureTimes?.length && index > times.length - 1) return null;
const lower = Math.min(Math.floor(index), times.length - 1); const lower = Math.min(Math.floor(index), times.length - 1);
const duration = ConfluenceChart.sourceDuration(source, lower); const duration = ConfluenceChart.sourceDuration(source, lower);
if (!(duration > 0)) return null; if (!(duration > 0)) return null;
@ -2864,7 +2879,10 @@ class ConfluenceChart {
event.preventDefault(); event.preventDefault();
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!point || !this.withinPlot(point) || point.t == null) return; if (!point || !this.withinPlot(point) || point.t == null) return;
const cutoff = this.bars.reduce((nearest, bar) => const lastBar = this.bars[this.bars.length - 1];
const cutoff = point.t > lastBar.t
? Math.round(point.t)
: this.bars.reduce((nearest, bar) =>
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
).t; ).t;
this.contextLineId = level.id; this.contextLineId = level.id;

View file

@ -196,6 +196,90 @@ test('future 5m projections keep their slope after the settlement break',
}); });
}); });
test('daily future slots and live duration follow the server session calendar',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const day = 24 * 3600;
const thursday = 1786658400;
const sunday = 1786917600;
const monday = sunday + day;
const bars = [thursday - 3 * day, thursday - 2 * day, thursday - day, thursday]
.map(t => ({ tf: '1d', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }));
c.setDisplayFutureTimes([sunday, monday]);
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf',
series: { '1d': { times: bars.map(bar => bar.t), durations: bars.map(() => 23 * 3600),
future_times: [sunday, monday], future_durations: [23 * 3600, 23 * 3600] } },
});
c.updateTrendlineTime(
'1d', sunday, 23 * 3600, bars[0].t, thursday, [monday], [23 * 3600],
);
const source = c.trendlineSourceSeries.get('1d');
return {
displayed: c.futureTimes(c.bars).map(point => point.time),
duration: source.durations.at(-1),
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
};
});
assert.deepEqual(result.displayed, [1786917600, 1787004000]);
assert.equal(result.duration, 23 * 3600);
assert.equal(result.beyondHorizon, null);
assertNoPageErrors(page, assert);
});
});
test('ending a trendline in future whitespace keeps the future timestamp',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const targetTime = await page.evaluate(() => {
const c = window.__chart;
const step = 300;
const bars = Array.from({ length: 30 }, (_, index) => ({
tf: '5m', t: 400000 + index * step, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
const future = Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step);
const line = {
id: 'future-cutoff', kind: 'manual', tf: '5m', side: 'support', label: 'cutoff',
anchor_t: bars[5].t, anchor_p: 99.5, last_t: bars[15].t, slope: 0.25 / 3000,
cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false,
};
c.setDisplayFutureTimes(future);
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step,
future_times: future, future_durations: future.map(() => step) } },
});
c.syncLevels([line]);
c.setSelectedLine(line.id);
return future[2];
});
await page.waitForTimeout(100);
const result = await page.evaluate(time => {
const c = window.__chart;
const line = c.levels.find(level => level.id === 'future-cutoff');
const plot = c.plotCanvas().getBoundingClientRect();
const event = {
clientX: plot.left + c.chart.timeScale().timeToCoordinate(time),
clientY: plot.top + c.candles.priceToCoordinate(c.sourceLinePriceAt(line, time)),
preventDefault() {},
};
const point = c.eventPoint(event);
c.showContextMenu(event);
return { time, cutoff: c.contextCutoff, point, within: point && c.withinPlot(point),
selected: c.selectedLineId, y: event.clientY - plot.top };
}, targetTime);
assert.equal(result.cutoff, result.time,
`future cutoff was clamped to the final candle: ${JSON.stringify(result)}`);
assertNoPageErrors(page, assert);
});
});
test('a short final data gap does not kink a future trendline', test('a short final data gap does not kink a future trendline',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
@ -214,6 +298,9 @@ test('a short final data gap does not kink a future trendline',
line_width: 2, provisional: false, line_width: 2, provisional: false,
}; };
const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute); const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute);
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
));
c.setBars(bars); c.setBars(bars);
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } }, mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } },
@ -265,6 +352,9 @@ test('painted canvas pixels keep the same slope through the live edge',
slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 3, provisional: false, line_width: 3, provisional: false,
}; };
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
));
c.setBars(bars); c.setBars(bars);
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', mode: 'source_tf',
@ -315,6 +405,10 @@ test('a manual line series owns its future 5m slots without an SVG projection',
slope: 1 / (4 * step), cutoff_t: null, hidden: false, color: '#65b7cf', slope: 1 / (4 * step), cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 2, provisional: false, line_width: 2, provisional: false,
}; };
const initialFuture = Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * step,
);
c.setDisplayFutureTimes(initialFuture);
c.setBars(bars); c.setBars(bars);
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } }, mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } },
@ -325,6 +419,10 @@ test('a manual line series owns its future 5m slots without an SVG projection',
const before = c.levelSeries.get(line.id).series.data(); const before = c.levelSeries.get(line.id).series.data();
const futurePoint = before.find(point => Number(point.time) === futureTime); const futurePoint = before.find(point => Number(point.time) === futureTime);
const next = { ...bars.at(-1), t: bars.at(-1).t + step }; const next = { ...bars.at(-1), t: bars.at(-1).t + step };
const nextFuture = Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => next.t + (index + 1) * step,
);
c.setDisplayFutureTimes(nextFuture);
c.updateBar(next); c.updateBar(next);
const after = c.levelSeries.get(line.id).series.data(); const after = c.levelSeries.get(line.id).series.data();
return { return {
@ -332,7 +430,7 @@ test('a manual line series owns its future 5m slots without an SVG projection',
expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)), expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)),
futureSlotBeforeLine, futureSlotBeforeLine,
tailTime: Number(after.at(-1).time), tailTime: Number(after.at(-1).time),
expectedTailTime: next.t + ConfluenceChart.FUTURE_SLOTS * step, expectedTailTime: nextFuture.at(-1),
visibleProjection: Boolean(document.querySelector('.chart-projections')), visibleProjection: Boolean(document.querySelector('.chart-projections')),
}; };
}); });

View file

@ -4,7 +4,13 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_duration, bucket_start, next_bucket_start from app.bars.session import (
FUTURE_SLOT_COUNT,
bucket_duration,
bucket_start,
future_bucket_starts,
next_bucket_start,
)
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -69,3 +75,25 @@ def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern():
sunday = epoch("2026-08-16T18:00:00", ET) sunday = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(thursday, Timeframe.D1) == sunday assert next_bucket_start(thursday, Timeframe.D1) == sunday
def test_intraday_future_geometry_skips_the_settlement_halt():
monday_last = epoch("2026-08-10T16:55:00", ET)
monday_reopen = epoch("2026-08-10T18:00:00", ET)
assert next_bucket_start(monday_last, Timeframe.M5) == monday_reopen
def test_intraday_future_geometry_skips_the_weekend():
friday_last = epoch("2026-08-14T16:55:00", ET)
sunday_reopen = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(friday_last, Timeframe.M5) == sunday_reopen
def test_future_geometry_uses_the_shared_180_slot_horizon():
start = epoch("2026-08-13T18:00:00", ET)
future = future_bucket_starts(start, Timeframe.D1)
assert len(future) == FUTURE_SLOT_COUNT == 180
assert all(datetime.fromtimestamp(value, ET).weekday() in {6, 0, 1, 2, 3} for value in future)

View file

@ -105,6 +105,18 @@ def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
assert source["duration"] == 1800 assert source["duration"] == 1800
assert source["future_times"][:2] == [4600, 6400] assert source["future_times"][:2] == [4600, 6400]
assert source["future_durations"][:2] == [1800, 1800] assert source["future_durations"][:2] == [1800, 1800]
assert len(source["future_times"]) == 180
def test_daily_snapshot_carries_session_aware_display_future_times(tmp_path):
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
thursday = 1786658400 # 2026-08-13 18:00 America/New_York
runtime.store.put(Bar(Timeframe.D1, thursday, 1, 1, 1, 1, 1, True, "/ES", "test"))
message = snapshot(runtime, Timeframe.D1)
assert len(message["future_times"]) == 180
assert message["future_times"][0] == 1786917600 # Sunday 18:00 Eastern
def test_snapshot_rollback_gate_omits_source_geometry(tmp_path): def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):