Compare commits

...

2 commits

Author SHA1 Message Date
5468bbec60 fix future drawing interactions 2026-08-25 05:41:55 -05:00
90fc77df92 fix canonical future geometry 2026-08-25 05:03:42 -05:00
14 changed files with 603 additions and 72 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
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 and intraday future slots must skip non-session time. An endpoint that
looks valid before a settlement/weekend break must remain resolvable 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
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
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:
events, events_more = runtime.events.recent()
return {
@ -95,6 +100,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
else None,
"session_open": session_open(runtime),
"trendline_geometry": trendline_geometry(runtime),
"future_times": displayed_future_times(runtime, tf),
"events": events,
"events_more": events_more,
}
@ -171,6 +177,7 @@ async def websocket_endpoint(websocket: WebSocket):
{
"type": "bar", "tf": tf.value,
"bar": bar.to_dict(),
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime),
"trendline_first_t": (
first_source_t
@ -180,6 +187,7 @@ async def websocket_endpoint(websocket: WebSocket):
"trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []),
"future_times": source_geometry.get("future_times", []),
}
)
elif (

View file

@ -7,6 +7,7 @@ UTC = ZoneInfo("UTC")
EASTERN = ZoneInfo("America/New_York")
SESSION_OPEN = time(18, 0)
SESSION_CLOSE = time(17, 0)
FUTURE_SLOT_COUNT = 180
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:
"""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:
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)
candidate = current.date() + timedelta(days=1)
# 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())
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 = []
for _ in range(count):
t = next_bucket_start(t, tf)

View file

@ -100,7 +100,7 @@ class Runtime:
self.broadcast({"type": "bar", "bar": provisional})
return
evaluate_alerts = False
alert_bar: Bar | None = None
for aggregated in self.aggregator.update(bar):
self.store.put(aggregated)
if self.seeding:
@ -116,14 +116,18 @@ class Runtime:
self.request_rebuild()
if aggregated.tf is Timeframe.M1 and aggregated.closed:
self.price = aggregated.c
evaluate_alerts = True
if evaluate_alerts:
alert_bar = aggregated
if alert_bar is not None:
values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# VWAP re-prices every minute, so levels are rebuilt here too. The
# broadcast is a delta, which is what keeps that affordable.
self.request_rebuild()
self.rebuild_clusters(evaluate_alerts=True)
self.rebuild_clusters(
evaluate_alerts=True,
alert_t=alert_bar.t,
alert_price=alert_bar.c,
)
def provisional_higher(self, bar: Bar) -> list[Bar]:
"""Higher-timeframe bars including the minute still being traded.
@ -209,7 +213,9 @@ class Runtime:
self.broadcast_level_delta()
self.rebuild_clusters()
def position_manual_levels(self, levels: list[Level], bars: list[Bar]) -> None:
def position_manual_levels(
self, levels: list[Level], bars: list[Bar], now: int | None = None,
) -> None:
"""Price sloped lines across bars rather than seconds.
The chart spaces bars evenly, so the line a person drew advances per bar.
@ -219,7 +225,7 @@ class Runtime:
if not bars:
return
minute_times = [bar.t for bar in bars]
now = minute_times[-1]
now = minute_times[-1] if now is None else now
for level in levels:
if not level.slope:
continue
@ -245,7 +251,12 @@ class Runtime:
if changed or removed:
self.broadcast({"type": "levels", "changed": changed, "removed": removed})
def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
def rebuild_clusters(
self,
evaluate_alerts: bool = False,
alert_t: int | None = None,
alert_price: float | None = None,
) -> None:
if self.price is None or self.stream.last_bar_t is None:
return
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
@ -254,12 +265,26 @@ class Runtime:
# Evaluated over every level, deliberately ignoring per-connection
# layer preferences: those are a display choice made in one browser,
# and a push notification has no business depending on them.
evaluation_t = self.stream.last_bar_t if alert_t is None else alert_t
evaluation_price = self.price if alert_price is None else alert_price
alert_levels = [replace(level) for level in self.levels]
self.position_manual_levels(
alert_levels,
self.store.get(Timeframe.M1),
evaluation_t,
)
alert_clusters = cluster_levels(
alert_levels,
evaluation_t,
evaluation_price,
self.atr15,
)
self.dispatch_alerts(
self.alert_engine.evaluate(
self.clusters,
self.price,
alert_clusters,
evaluation_price,
self.atr15,
self.stream.last_bar_t,
evaluation_t,
self.stream.symbol,
self.watched_ma_levels(),
)

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
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
both sides; daily slots stay at 18:00 ET and skip the weekend. Intraday future
slots retain the separate, pre-existing limitation that the future axis is not
session-aware.
both sides. Daily slots stay at 18:00 ET and skip the weekend; intraday slots
skip the 17:00-18:00 ET settlement halt and Friday-to-Sunday closure.
The first browser implementation rebuilt `historical + future` timestamps for
every sampled point. The math test passed, but the next trendline run slowed its
@ -1275,3 +1274,48 @@ click.
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.
### 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.
### 2026-08-25 — future interactions use owned coordinates and valid samples
The canonical calendar fixed timestamps but several interaction paths still used
the last two real candles as a pixel ruler. Sparse-gap whitespace made that ruler
wrong for future handles, hit targets, Fibonacci rails, and annotations. Exact
bars and server-owned future slots now use Lightweight Charts' own coordinates;
future index conversion advances through the actual shared logical scale.
The same audit found independent interaction bugs: hidden sloped lines could win
hit testing; Fibonacci levels selected left of their origin and exposed an inert
diagonal move target; pinned annotation nudges invented calendar timestamps;
floating/parked annotations used the outer chart instead of plot dimensions.
Hidden lines are excluded, Fibonacci selects only on its visible side and drags
from its vertical rail, annotation shifts use visible canonical slots, and all
annotation fractions/clamps are plot-relative.
Alert evaluation also mixed a settled price with the provisional next minute's
trendline timestamp. Alerts now clone and reprice manual levels at the exact
closed bar before clustering. That same fresh positioning excludes an anchor as
soon as source-ring eviction makes it unresolved, rather than allowing one stale
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.

View file

@ -494,6 +494,7 @@ createApp({
if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now();
chartApi.setTrendlineGeometry(message.trendline_geometry);
chartApi.setDisplayFutureTimes(message.future_times);
chartApi.setBars(message.bars);
levels.value = message.levels || [];
if (!drawColorInitialized) {
@ -517,7 +518,7 @@ createApp({
dataReceivedAt.value = Date.now();
if (message.trendline_first_t != null) {
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_future_times, message.trendline_future_durations,
);
@ -525,6 +526,7 @@ createApp({
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
}
chartApi.setDisplayFutureTimes(message.future_times);
chartApi.updateBar(message.bar);
price.value = message.bar.c;
sessionOpen.value = message.session_open ?? sessionOpen.value;
@ -1052,7 +1054,7 @@ createApp({
}
function shiftTimeByBars(line, time, count = 10) {
return chartApi?.shiftLineTime(line, time, count) ?? time;
return chartApi?.shiftLineTime(line, time, count) ?? null;
}
function duplicateName(line) {
@ -1068,6 +1070,8 @@ createApp({
if (line.geometry_resolved === false) return;
const anchorT = shiftTimeByBars(line, line.anchor_t);
const endT = shiftTimeByBars(line, line.last_t);
const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t);
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) return;
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -1077,7 +1081,7 @@ createApp({
end_t: endT, end_p: endP,
note: duplicateName(line), hidden: line.hidden,
color: line.color || '#65b7cf', line_width: line.line_width || 2,
cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t),
cutoff_t: cutoffT,
armed: line.armed,
}),
});
@ -1253,10 +1257,15 @@ createApp({
+ vertical * ConfluenceChart.TICK;
if (horizontal) {
if (line.geometry_resolved === false) continue;
line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal);
const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
const lastT = chartApi.shiftLineTime(line, line.last_t, horizontal);
const cutoffT = line.cutoff_t == null
? null : chartApi.shiftLineTime(line, line.cutoff_t, horizontal);
if (anchorT == null || lastT == null || (line.cutoff_t != null && cutoffT == null)) continue;
line.anchor_t = anchorT;
line.last_t = lastT;
if (line.cutoff_t != null) {
line.cutoff_t = chartApi.shiftLineTime(line, line.cutoff_t, horizontal);
line.cutoff_t = cutoffT;
}
}
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
@ -1272,9 +1281,7 @@ createApp({
);
}
if (horizontal) {
changes.anchor_t = chartApi.timeAtIndex(
chartApi.indexAt(comment.anchor_t) + horizontal,
);
changes.anchor_t = chartApi.shiftDisplayedTime(comment.anchor_t, horizontal);
}
if (Object.keys(changes).length) await updateCommentGeometry(comment, changes);
} else {
@ -1283,9 +1290,9 @@ createApp({
const clamp = value => Math.min(Math.max(value, 0), 1);
await updateCommentGeometry(comment, {
x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0)
/ chartApi.chartEl.clientWidth),
/ chartApi.overlayLayer.clientWidth),
y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0)
/ chartApi.chartEl.clientHeight),
/ chartApi.overlayLayer.clientHeight),
});
}
}

View file

@ -263,6 +263,7 @@ class ConfluenceChart {
this.fibPrimitive = null;
this.fibPreview = null;
this.futureSpace = null;
this.displayFutureTimes = [];
this.snapDot = null;
this.snapLabel = null;
this.snapLeader = null;
@ -672,11 +673,11 @@ class ConfluenceChart {
// A time range names the instant, so later series cannot move it.
if (bars.length) {
const last = bars[bars.length - 1];
// Five bars of right-hand breathing room, in seconds.
const step = ConfluenceChart.barInterval(bars) || 60;
const future = this.futureTimes(bars);
this.pendingView = {
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);
}
@ -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(
tf, time, duration = null, firstTime = null, previousTime = null,
futureTimes = [], futureDurations = [],
@ -760,6 +768,10 @@ class ConfluenceChart {
futureTimes(bars) {
if (bars.length < 2) return [];
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);
if (step <= 0) return [];
const slots = [];
@ -815,7 +827,8 @@ class ConfluenceChart {
}
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 });
for (const level of this.levels) {
if (level.kind !== 'manual' || level.hidden || ConfluenceChart.isFlat(level)
@ -1066,8 +1079,8 @@ class ConfluenceChart {
// and a floating one must ignore the time scale entirely.
renderComments() {
if (!this.commentLayer) return;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
const width = this.overlayLayer.clientWidth;
const height = this.overlayLayer.clientHeight;
const seen = new Set();
for (const comment of this.comments || []) {
seen.add(comment.id);
@ -1102,8 +1115,10 @@ class ConfluenceChart {
if (!comment.pinned) {
// A fraction of the pane, so it holds its place through any zoom.
node.root.style.left = `${Math.round(comment.x * width)}px`;
node.root.style.top = `${Math.round(comment.y * height)}px`;
const left = Math.min(Math.max(comment.x * width, 0), Math.max(0, width - node.root.offsetWidth));
const top = Math.min(Math.max(comment.y * height, 0), Math.max(0, height - node.root.offsetHeight));
node.root.style.left = `${Math.round(left)}px`;
node.root.style.top = `${Math.round(top)}px`;
node.root.classList.remove('off-left', 'off-right');
continue;
}
@ -1229,8 +1244,8 @@ class ConfluenceChart {
}
return;
}
const x = (upEvent.clientX - bounds.left - 8) / bounds.width;
const y = (upEvent.clientY - bounds.top - 8) / bounds.height;
const x = (upEvent.clientX - bounds.left - root.offsetWidth / 2) / bounds.width;
const y = (upEvent.clientY - bounds.top - root.offsetHeight / 2) / bounds.height;
const clamp = value => Math.min(Math.max(value, 0), 1);
if (this.onCommentMove) this.onCommentMove(node.comment, { x: clamp(x), y: clamp(y) });
};
@ -1647,6 +1662,11 @@ class ConfluenceChart {
const scale = this.chart.timeScale();
if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t);
const last = this.bars.length - 1;
if (index >= last) {
const lastX = scale.timeToCoordinate(this.bars[last].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX);
return lastLogical == null ? null : scale.logicalToCoordinate(lastLogical + index - last);
}
const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index);
const upper = lower + 1;
const x1 = scale.timeToCoordinate(this.bars[lower].t);
@ -1657,6 +1677,9 @@ class ConfluenceChart {
coordinateAtTime(time) {
if (!this.bars.length) return null;
const owned = this.bars.some(bar => bar.t === time)
|| this.timeScaleSlots(this.bars).some(point => point.time === time);
if (owned) return this.chart.timeScale().timeToCoordinate(time);
// Interpolate between the displayed candles' own pixel coordinates. The
// shared scale may contain foreign timestamps from other series, and its
// logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h.
@ -2253,7 +2276,8 @@ class ConfluenceChart {
const distance = y == null ? Infinity : Math.abs(y - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
for (const level of this.levels.filter(value =>
value.kind === 'manual' && !ConfluenceChart.isFlat(value) && !value.hidden)) {
const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from);
const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t);
const endIndex = Math.min(naturalEnd, visible.to);
@ -2266,6 +2290,8 @@ class ConfluenceChart {
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
for (const fib of this.fibs.filter(item => !item.hidden)) {
const originX = this.coordinateAtTime(fib.anchor_t);
if (originX == null || param.point.x < originX) continue;
for (const level of ConfluenceChart.fibPrices(fib)) {
const y = this.candles.priceToCoordinate(level.price);
const distance = y == null ? Infinity : Math.abs(y - param.point.y);
@ -2289,11 +2315,13 @@ class ConfluenceChart {
}
const last = bars.length - 1;
if (time >= bars[last].t) {
// Future slots use the median interval, not the tail gap. After the CME
// settlement break the final gap is 65 minutes, and using it here makes a
// 5m future projection advance thirteen bars per visible slot.
const step = ConfluenceChart.barInterval(bars);
return last + (step ? (time - bars[last].t) / step : 0);
const future = this.futureTimes(bars).map(point => point.time);
if (!future.length) return last;
let lower = -1;
while (lower + 1 < future.length && future[lower + 1] <= time) lower += 1;
if (lower < 0) return last + (time - bars[last].t) / (future[0] - bars[last].t);
if (future[lower] === time || lower === future.length - 1) return last + lower + 1;
return last + lower + 1 + (time - future[lower]) / (future[lower + 1] - future[lower]);
}
let lo = 0;
let hi = last;
@ -2352,13 +2380,15 @@ class ConfluenceChart {
const duration = ConfluenceChart.sourceDuration(source, lower);
const elapsed = time - times[lower];
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;
}
static timeframeTimeAt(source, index) {
const times = ConfluenceChart.sourceTimes(source);
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 duration = ConfluenceChart.sourceDuration(source, lower);
if (!(duration > 0)) return null;
@ -2379,9 +2409,18 @@ class ConfluenceChart {
shiftLineTime(level, time, bars) {
const source = this.sourceSeries(level);
if (!source) return this.timeAtIndex(this.indexAt(time) + bars);
if (!source) return this.shiftDisplayedTime(time, bars);
const index = ConfluenceChart.timeframeIndexAt(source, time);
return index == null ? time : ConfluenceChart.timeframeTimeAt(source, index + bars) ?? time;
return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars);
}
shiftDisplayedTime(time, slots) {
const scale = this.chart.timeScale();
const x = this.coordinateAtTime(time);
const logical = x == null ? null : scale.coordinateToLogical(x);
const targetX = logical == null ? null : scale.logicalToCoordinate(logical + slots);
const shifted = targetX == null ? null : scale.coordinateToTime(targetX);
return shifted == null ? null : Number(shifted);
}
timeAtIndex(index) {
@ -2390,8 +2429,15 @@ class ConfluenceChart {
if (index <= 0) return Math.round(bars[0].t + index * (bars[1].t - bars[0].t));
const last = bars.length - 1;
if (index >= last) {
const step = ConfluenceChart.barInterval(bars);
return Math.round(bars[last].t + (index - last) * step);
const offset = index - last;
if (offset === 0) return bars[last].t;
const future = this.futureTimes(bars).map(point => point.time);
const lower = Math.floor(offset);
const fraction = offset - lower;
const t1 = lower === 0 ? bars[last].t : future[lower - 1];
const t2 = future[lower];
if (t1 == null) return bars[last].t;
return t2 == null ? t1 : Math.round(t1 + fraction * (t2 - t1));
}
const lower = Math.floor(index);
const fraction = index - lower;
@ -2663,6 +2709,34 @@ class ConfluenceChart {
value: level.anchor_p + level.slope * (level.last_t - level.anchor_t),
},
];
if (level.kind === 'fibonacci') {
const x = this.coordinateAtTime(level.anchor_t);
const y1 = this.candles.priceToCoordinate(points[0].value);
const y2 = this.candles.priceToCoordinate(points[1].value);
if ([x, y1, y2].some(value => value == null)) {
this.lineHitTarget.setAttribute('hidden', '');
} else {
this.lineHitTarget.removeAttribute('hidden');
this.lineHitTarget.setAttribute('points', `${x},${y1} ${x},${y2}`);
this.lineHitTarget.setAttribute('x1', x);
this.lineHitTarget.setAttribute('y1', y1);
this.lineHitTarget.setAttribute('x2', x);
this.lineHitTarget.setAttribute('y2', y2);
}
points.forEach((point, index) => {
const handle = this.anchorHandles[index];
const handleX = this.coordinateAtTime(point.time);
const handleY = this.candles.priceToCoordinate(point.value);
if (handleX == null || handleY == null) handle.setAttribute('hidden', '');
else {
handle.removeAttribute('hidden');
handle.setAttribute('cx', handleX);
handle.setAttribute('cy', handleY);
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
}
});
return;
}
const visible = this.visibleBarIndexes();
const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd);
@ -2715,7 +2789,7 @@ class ConfluenceChart {
startLineDrag(event) {
if (event.button !== 0) return;
const level = this.levels.find(value => value.id === this.selectedLineId);
const level = this.handleLevel(this.selectedLineId);
const point = this.eventPoint(event);
if (!level || level.geometry_resolved === false || !point || point.t == null) return;
event.preventDefault();
@ -2733,7 +2807,7 @@ class ConfluenceChart {
moveLine(event) {
if (!this.draggingLine) return;
const point = this.eventPoint(event);
const level = this.levels.find(value => value.id === this.draggingLine.id);
const level = this.handleLevel(this.draggingLine.id);
if (!point || point.t == null || !this.withinPlot(point) || !level) return;
event.preventDefault();
const { original, start } = this.draggingLine;
@ -2752,6 +2826,13 @@ class ConfluenceChart {
const lastT = source
? this.shiftLineTime(original, original.last_t, indexShift)
: this.timeAtIndex(this.indexAt(original.last_t) + indexShift);
if (anchorT == null || lastT == null) return;
const cutoffT = original.cutoff_t == null
? null
: source
? this.shiftLineTime(original, original.cutoff_t, indexShift)
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
if (original.cutoff_t != null && cutoffT == null) return;
const endPrice = original.anchor_p
+ original.slope * (original.last_t - original.anchor_t) + priceShift;
level.anchor_t = anchorT;
@ -2759,13 +2840,12 @@ class ConfluenceChart {
level.anchor_p = original.anchor_p + priceShift;
level.slope = (endPrice - level.anchor_p) / (lastT - anchorT);
if (original.cutoff_t != null) {
level.cutoff_t = source
? this.shiftLineTime(original, original.cutoff_t, indexShift)
: this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift);
level.cutoff_t = cutoffT;
}
this.draggingLine.moved = indexShift !== 0 || priceShift !== 0;
const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level));
if (level.kind === 'fibonacci') this.syncFibLines();
this.renderAnchorHandles();
this.renderLineBridges();
}
@ -2773,7 +2853,7 @@ class ConfluenceChart {
finishLineDrag(event) {
if (!this.draggingLine) return;
event.preventDefault();
const level = this.levels.find(value => value.id === this.draggingLine.id);
const level = this.handleLevel(this.draggingLine.id);
const moved = this.draggingLine.moved;
const original = this.draggingLine.original;
this.draggingLine = null;
@ -2864,7 +2944,10 @@ class ConfluenceChart {
event.preventDefault();
const point = this.eventPoint(event);
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
).t;
this.contextLineId = level.id;

View file

@ -167,7 +167,7 @@ test('the status row shows the exact local time data last arrived',
return freshness?.textContent.trim() !== '—';
});
const state = await page.evaluate(() => {
const freshness = document.querySelector('.data-freshness');
const freshness = document.querySelector('.data-freshness span:last-child');
return {
freshness: freshness.textContent.trim(),
deployMetadata: document.querySelector('.app-meta'),
@ -198,7 +198,7 @@ test('the quote, current-price marker, and tool order retain their trading conte
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
assert.deepEqual(state.sections.slice(0, 3), ['Layers', 'Config', 'Tools']);
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Trendline', 'Comment']);
assert.deepEqual(state.tools, ['Mark', 'Price level', 'Fibonacci', 'Trendline', 'Comment']);
assert.equal(state.candleOptions.lastValueVisible, false);
assert.equal(state.candleOptions.priceLineVisible, false);
assert.equal(state.markerOptions.lineWidth, 1);
@ -219,7 +219,7 @@ test('a daily crosshair shows the session date, not the previous evening',
return {
actual: c.chart.options().localization.timeFormatter(time),
expected: new Date(time * 1000).toLocaleString(undefined, {
timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric',
timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
}),
};
});

View file

@ -53,6 +53,9 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as
}, TEXT);
assert.ok(anchor, 'the comment was not created');
await page.locator('.config-section summary').click();
await page.locator('.config-section label:has-text("Hide lower-TF drawings") input').uncheck();
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowUp');
@ -88,11 +91,8 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
assert.deepEqual(controls, ['collapse-toggle', 'color'],
'the comment color picker is not the rightmost control');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), false,
'clicking drawing-list content selected the comment');
await row.locator('.line-select').check();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
'selecting the comment checkbox did not point out the comment on the chart');
'clicking drawing-list content did not point out the comment on the chart');
await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection');
@ -132,6 +132,31 @@ test('a floating comment holds its position while the chart scrolls', { timeout:
});
});
test('a floating comment stays inside the plot at the future-side edge',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.evaluate(() => {
const chart = window.__chart;
chart.setComments([{
id: 'floating-edge', kind: 'comment', tf: '1m', note: 'edge', hidden: false,
pinned: false, x: 1, y: 0.5, collapsed: false, color: '#c8992f', scale: 1,
}]);
});
await page.waitForFunction(() => document.querySelector('[data-drawing-id="floating-edge"]'));
const result = await page.evaluate(() => {
const chart = window.__chart;
const node = document.querySelector('[data-drawing-id="floating-edge"]');
const box = node.getBoundingClientRect();
const plot = chart.plotCanvas().getBoundingClientRect();
return { right: box.right, bottom: box.bottom, plotRight: plot.right, plotBottom: plot.bottom };
});
assert.ok(result.right <= result.plotRight + 1, 'floating comment overflowed the right price axis');
assert.ok(result.bottom <= result.plotBottom + 1, 'floating comment overflowed the plot bottom');
assertNoPageErrors(page, assert);
});
});
test('unchecking Drawings hides comments on the chart', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);

View file

@ -302,7 +302,7 @@ test('a price level can be edited from the list and adjusted on the chart',
await row.locator('input[aria-label="blue row annotation"]').press('Tab');
assert.equal(await page.evaluate(() =>
JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure');
await row.locator('button[aria-label="Use blue2 (#4699FE)"]').click();
await row.locator('button[aria-label="Use blue2 (#4699FE)"]').evaluate(node => node.click());
const id = await page.evaluate(name =>
window.__chart.levels.find(level => level.label === name)?.id || null, label);
assert.ok(id, 'the price level was not created');
@ -326,9 +326,6 @@ test('a price level can be edited from the list and adjusted on the chart',
'an unselected price level exposed its full name on the price axis');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
'clicking drawing-list content selected the drawing');
await row.locator('.line-select').check();
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), label,
@ -461,9 +458,9 @@ test('trendline slope in the tooltip is signed points per hour',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const labels = await page.evaluate(() => ({
up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }),
up: window.__chart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }),
down: window.__chart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }),
flat: window.__chart.slopeLabel({ kind: 'manual', slope: 0 }),
}));
assert.equal(labels.up, '+2.00 /h');
assert.equal(labels.down, '-0.50 /h');

View file

@ -52,6 +52,7 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) },
indexAt: () => 0.5,
coordinateAtIndex: () => 123,
timeScaleSlots: () => [],
};
const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call(
coordinateProbe, halfHour,
@ -165,6 +166,10 @@ test('future 5m projections keep their slope after the settlement break',
slope: 6 / thirtyMinutes, cutoff_t: null,
};
c.bars = displayed;
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS },
(_, index) => displayed.at(-1).t + (index + 1) * fiveMinutes,
));
c.setTrendlineGeometry({
mode: 'source_tf',
series: {
@ -196,6 +201,98 @@ 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');
const horizonLine = {
kind: 'manual', tf: '1d', anchor_t: sunday, anchor_p: 100,
last_t: monday, slope: 1 / day,
};
return {
displayed: c.futureTimes(c.bars).map(point => point.time),
duration: source.durations.at(-1),
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
shifted: c.shiftDisplayedTime(thursday, 1),
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
};
});
assert.deepEqual(result.displayed, [1786917600, 1787004000]);
assert.equal(result.duration, 23 * 3600);
assert.equal(result.beyondHorizon, null);
assert.equal(result.shifted, 1786917600);
assert.equal(result.shiftBeyondHorizon, 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',
{ timeout: 180000 }, async () => {
await withChart(async page => {
@ -214,6 +311,9 @@ test('a short final data gap does not kink a future trendline',
line_width: 2, provisional: false,
};
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.setTrendlineGeometry({
mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } },
@ -235,6 +335,9 @@ test('a short final data gap does not kink a future trendline',
historicalSlots: (current.x - previous.x) / (future.x - current.x),
missingSlotOwned: c.futureSpace.data()
.some(point => Number(point.time) === previousTime + minute),
overlayDelta: Math.abs(
c.coordinateAtTime(futureTime) - c.chart.timeScale().timeToCoordinate(futureTime),
),
};
});
@ -242,6 +345,8 @@ test('a short final data gap does not kink a future trendline',
`a nine-minute data gap occupied ${result.historicalSlots} slots`);
assert.equal(result.missingSlotOwned, true,
'the invisible scale series discarded the missing intraday timestamp');
assert.ok(result.overlayDelta < 0.01,
`future overlay missed the chart coordinate by ${result.overlayDelta}px`);
assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9,
`line kinked from ${result.historicalSlope} to ${result.futureSlope}`);
assertNoPageErrors(page, assert);
@ -265,6 +370,9 @@ test('painted canvas pixels keep the same slope through the live edge',
slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf',
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.setTrendlineGeometry({
mode: 'source_tf',
@ -315,6 +423,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',
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.setTrendlineGeometry({
mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } },
@ -325,6 +437,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 futurePoint = before.find(point => Number(point.time) === futureTime);
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);
const after = c.levelSeries.get(line.id).series.data();
return {
@ -332,7 +448,7 @@ test('a manual line series owns its future 5m slots without an SVG projection',
expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)),
futureSlotBeforeLine,
tailTime: Number(after.at(-1).time),
expectedTailTime: next.t + ConfluenceChart.FUTURE_SLOTS * step,
expectedTailTime: nextFuture.at(-1),
visibleProjection: Boolean(document.querySelector('.chart-projections')),
};
});
@ -350,6 +466,78 @@ test('a manual line series owns its future 5m slots without an SVG projection',
});
});
test('hidden trendlines cannot steal hits and Fibonacci uses its visible rail',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const step = 300;
const bars = Array.from({ length: 30 }, (_, index) => ({
tf: '5m', t: 500000 + index * step, o: 100, h: 102, l: 98, c: 100,
v: 1, closed: true,
}));
c.setDisplayFutureTimes(Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step));
c.setBars(bars);
const hidden = {
id: 'hidden-line', kind: 'manual', tf: '5m', side: 'support', label: 'hidden',
anchor_t: bars[5].t, anchor_p: 99, last_t: bars[15].t, slope: 1 / 3000,
cutoff_t: null, hidden: true, color: '#65b7cf', line_width: 2, provisional: false,
};
c.syncLevels([hidden]);
const hiddenX = c.coordinateAtTime(bars[20].t);
const hiddenP = c.linePriceAtIndex(hidden, c.indexAt(bars[20].t));
const hiddenHit = c.hitTest({
point: { x: hiddenX, y: c.candles.priceToCoordinate(hiddenP) }, time: bars[20].t,
});
const fib = {
id: 'fib-hit', kind: 'fibonacci', tf: '5m', anchor_t: bars[12].t,
anchor_p: 99, last_t: bars[20].t, slope: 2 / (bars[20].t - bars[12].t),
hidden: false, color: '#c8992f', line_width: 2,
};
c.setFibs([fib]);
c.chart.timeScale().setVisibleRange({ from: bars[0].t, to: bars.at(-1).t });
const originX = c.coordinateAtTime(fib.anchor_t);
const levelY = c.candles.priceToCoordinate(ConfluenceChart.fibPrices(fib)[3].price);
const historicalHit = c.hitTest({ point: { x: originX - 20, y: levelY }, time: bars[10].t });
const visibleHit = c.hitTest({ point: { x: originX + 20, y: levelY }, time: bars[14].t });
c.setSelectedLine(fib.id);
const rail = {
x1: Number(c.lineHitTarget.getAttribute('x1')),
x2: Number(c.lineHitTarget.getAttribute('x2')),
y1: Number(c.lineHitTarget.getAttribute('y1')),
y2: Number(c.lineHitTarget.getAttribute('y2')),
};
const plot = c.plotCanvas().getBoundingClientRect();
const event = {
button: 0, clientX: plot.left + Math.min(rail.x1, plot.width - 20),
clientY: plot.top + (rail.y1 + rail.y2) / 2,
preventDefault() {}, stopPropagation() {}, currentTarget: { setPointerCapture() {} }, pointerId: 1,
};
const originalFib = { anchor_t: fib.anchor_t, anchor_p: fib.anchor_p };
c.startLineDrag(event);
const dragging = c.draggingLine?.id;
c.moveLine({
...event, clientX: event.clientX - 30, clientY: event.clientY + 20,
});
const fibMoved = fib.anchor_t !== originalFib.anchor_t || fib.anchor_p !== originalFib.anchor_p;
const movedFlag = c.draggingLine?.moved;
c.draggingLine = null;
c.detachPointers('line');
return { hiddenHit, historicalHit, visibleHit, rail, dragging, fibMoved, movedFlag };
});
assert.equal(result.hiddenHit, null, 'a hidden trendline stole chart selection');
assert.equal(result.historicalHit, null, 'Fibonacci selected left of its visible origin');
assert.equal(result.visibleHit, 'fib-hit');
assert.equal(result.rail.x1, result.rail.x2, 'Fibonacci body target is not its vertical rail');
assert.equal(result.dragging, 'fib-hit', 'Fibonacci rail did not start a body drag');
assert.equal(result.fibMoved, true,
`Fibonacci body drag did not move its anchors: ${JSON.stringify(result)}`);
assertNoPageErrors(page, assert);
});
});
test('higher-timeframe trendlines are dashed and twice their stored width',
{ timeout: 180000 }, async () => {
await withChart(async page => {

View file

@ -109,6 +109,79 @@ def test_tick_bars_update_higher_timeframes_without_doubling_volume(tmp_path):
assert hour.closed is False
def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_slope", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen = {}
def evaluate(clusters, price, atr15, at, symbol, watched):
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base
asyncio.run(instance.on_bar(minute(base, 100.0)))
instance.rebuild_levels()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert seen["at"] == base + 60
assert seen["price"] == 101.0
assert 101.0 in seen["centers"]
assert 102.0 not in seen["centers"]
def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
tmp_path, monkeypatch,
):
from app.bars.models import Bar
instance = runtime(tmp_path, max_bars_per_tf=2)
base = 1786356000
instance.manual_lines.add(ManualLine(
"ml_evicted", Timeframe.M1, Side.SUPPORT,
base, 100.0, 1 / 60, base + 60, base,
))
seen_members = []
def evaluate(clusters, price, atr15, at, symbol, watched):
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
return []
monkeypatch.setattr(instance.alert_engine, "evaluate", evaluate)
monkeypatch.setattr("app.runtime.atr", lambda bars, period: [1.0])
def minute(t, close, closed=True):
return Bar(Timeframe.M1, t, close, close, close, close, 1, closed, "/ES", "test")
instance.stream.last_bar_t = base + 60
asyncio.run(instance.on_bar(minute(base, 100.0)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
instance.rebuild_levels()
assert next(level for level in instance.levels if level.id == "ml_evicted").geometry_resolved
seen_members.clear()
instance.stream.last_bar_t = base + 120
asyncio.run(instance.on_bar(minute(base + 120, 102.0, closed=False)))
asyncio.run(instance.on_bar(minute(base + 60, 101.0)))
assert "ml_evicted" not in seen_members
def test_a_tripped_manual_alert_stays_disarmed_after_rebuild_and_restart(tmp_path):
instance = runtime(tmp_path)
instance.manual_lines.add(

View file

@ -4,7 +4,13 @@ from zoneinfo import ZoneInfo
import pytest
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")
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)
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["future_times"][:2] == [4600, 6400]
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):