fix canonical future geometry
This commit is contained in:
parent
cb81672a37
commit
90fc77df92
9 changed files with 241 additions and 16 deletions
29
AGENTS.md
29
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,9 +2879,12 @@ 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];
|
||||||
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
const cutoff = point.t > lastBar.t
|
||||||
).t;
|
? Math.round(point.t)
|
||||||
|
: this.bars.reduce((nearest, bar) =>
|
||||||
|
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
||||||
|
).t;
|
||||||
this.contextLineId = level.id;
|
this.contextLineId = level.id;
|
||||||
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
||||||
this.contextEndButton.hidden = this.contextCutoff == null;
|
this.contextEndButton.hidden = this.contextCutoff == null;
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue