horizontal line to start of trendline problem

This commit is contained in:
Chris Amow 2026-08-14 06:06:16 -05:00
parent 8ab054cf67
commit a921db4610
11 changed files with 334 additions and 49 deletions

View file

@ -10,7 +10,7 @@ disagree — measured at 147 points across a weekend on a real /ES chart.
from bisect import bisect_right from bisect import bisect_right
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_duration from app.bars.session import bucket_duration, next_bucket_start
def index_at(times: list[int], t: int) -> float: def index_at(times: list[int], t: int) -> float:
@ -66,7 +66,20 @@ def timeframe_index_at(
elapsed = t - times[lower] elapsed = t - times[lower]
if duration <= 0 or elapsed < 0: if duration <= 0 or elapsed < 0:
return None return None
if elapsed > duration and not (allow_future and lower == len(times) - 1): if elapsed > duration:
if not (allow_future and lower == len(times) - 1):
return None
current = times[lower]
index = float(lower)
for _ in range(10000):
following = next_bucket_start(current, tf)
if t < following:
active = bucket_duration(current, tf)
return index + (t - current) / active if t <= current + active else None
index += 1
current = following
if t == current:
return index
return None return None
return lower + elapsed / duration return lower + elapsed / duration

View file

@ -6,7 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.api.deps import SESSION_COOKIE, session_matches, token_matches
from app.analysis.levels import LevelKind from app.analysis.levels import LevelKind
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_duration from app.bars.session import bucket_duration, future_bucket_starts
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
router = APIRouter() router = APIRouter()
@ -65,16 +65,23 @@ def trendline_geometry(runtime) -> dict:
return {"mode": "legacy", "series": {}} return {"mode": "legacy", "series": {}}
series = {} series = {}
for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value): for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value):
times = [bar.t for bar in runtime.store.get(tf)] series[tf.value] = trendline_series(runtime, tf)
value = {"times": times}
if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times]
else:
value["duration"] = tf.seconds
series[tf.value] = value
return {"mode": "source_tf", "series": series} return {"mode": "source_tf", "series": series}
def trendline_series(runtime, tf: Timeframe) -> dict:
times = [bar.t for bar in runtime.store.get(tf)]
value = {"times": times}
if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times]
else:
value["duration"] = tf.seconds
future = future_bucket_starts(times[-1], tf) if times else []
value["future_times"] = future
value["future_durations"] = [bucket_duration(t, tf) for t in future]
return value
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
return { return {
"type": "snapshot", "type": "snapshot",
@ -130,6 +137,10 @@ async def websocket_endpoint(websocket: WebSocket):
"clusters": [cluster.to_dict() for cluster in clusters], "clusters": [cluster.to_dict() for cluster in clusters],
} }
) )
elif message.get("type") == "trendline_geometry":
await websocket.send_json(
{"type": "trendline_geometry", "geometry": trendline_geometry(runtime)}
)
except WebSocketDisconnect: except WebSocketDisconnect:
queue.put_nowait({"type": "disconnect"}) queue.put_nowait({"type": "disconnect"})
@ -144,6 +155,14 @@ async def websocket_endpoint(websocket: WebSocket):
source_tfs = trendline_timeframes(runtime) source_tfs = trendline_timeframes(runtime)
source_bars = runtime.store.get(bar.tf) source_bars = runtime.store.get(bar.tf)
first_source_t = source_bars[0].t if source_bars else None first_source_t = source_bars[0].t if source_bars else None
source_index = next(
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
)
previous_source_t = (
source_bars[source_index - 1].t
if source_index is not None and source_index > 0 else None
)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
if bar.tf is tf: if bar.tf is tf:
await websocket.send_json( await websocket.send_json(
{ {
@ -155,6 +174,9 @@ async def websocket_endpoint(websocket: WebSocket):
if runtime.settings.trendline_source_geometry if runtime.settings.trendline_source_geometry
and bar.tf in source_tfs else None and bar.tf in source_tfs else None
), ),
"trendline_previous_t": previous_source_t,
"trendline_future_times": source_geometry.get("future_times", []),
"trendline_future_durations": source_geometry.get("future_durations", []),
} }
) )
elif ( elif (
@ -168,6 +190,9 @@ async def websocket_endpoint(websocket: WebSocket):
"t": bar.t, "t": bar.t,
"duration": bucket_duration(bar.t, bar.tf), "duration": bucket_duration(bar.t, bar.tf),
"first_t": first_source_t, "first_t": first_source_t,
"previous_t": previous_source_t,
"future_times": source_geometry.get("future_times", []),
"future_durations": source_geometry.get("future_durations", []),
} }
) )
elif event["type"] == "levels": elif event["type"] == "levels":

View file

@ -37,3 +37,23 @@ def bucket_duration(t: int, tf: Timeframe) -> int:
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN) next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN)
return int(next_close.timestamp()) - t return int(next_close.timestamp()) - t
def next_bucket_start(t: int, tf: Timeframe) -> int:
"""Next projected source-bar open, with the daily weekend skipped."""
if tf is not Timeframe.D1:
return t + tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
candidate = current.date() + timedelta(days=1)
# Daily futures sessions open Sunday through Thursday.
while candidate.weekday() not in {6, 0, 1, 2, 3}:
candidate += timedelta(days=1)
return int(datetime.combine(candidate, SESSION_OPEN, EASTERN).timestamp())
def future_bucket_starts(t: int, tf: Timeframe, count: int = 64) -> list[int]:
values = []
for _ in range(count):
t = next_bucket_start(t, tf)
values.append(t)
return values

88
docs/esquotes.md Normal file
View file

@ -0,0 +1,88 @@
# /ES futures-options quote finder
**Status:** future feature; no quote-finder UI, API route, or futures-options
subscription is implemented yet.
## Purpose
Find a small, user-selected set of `/ES` futures options more quickly than the
thinkorswim option-chain UI, show their live tradable quotes, and produce an
unambiguous leg or spread description that the user can copy into thinkorswim.
The app assists research and pricing only. The user reviews and enters every
order in thinkorswim; it must not submit, simulate, or automate orders.
The first useful workflow is deliberately narrow:
1. Select a futures-option expiration and call/put side.
2. Select one or more strikes, including the two legs of a vertical spread.
3. Fetch a current snapshot of bid, ask, mark, last, volume, and open interest
for that small set of exact contracts.
4. Copy a thinkorswim-tested contract or spread description for manual entry.
Do not subscribe to or poll the whole option universe. Discovery should narrow
the candidates first, then one REST request obtains the selected quotes. A user
may explicitly refresh a stale snapshot; continuous streaming is out of scope.
## Schwab API status
Checked against the configured live Schwab credentials on 2026-08-14. These
were read-only requests; no account or order endpoint was called.
| Capability | Result | Implication |
|---|---|---|
| OAuth token and Schwab client | Available | The existing app already authenticates and streams `/ES` futures. |
| `get_quotes(["/ES"])` | Works: HTTP 200, resolving to active future `/ESU26` with `assetMainType: FUTURE` | Use the plural quote endpoint. `get_quote("/ES")` puts the slash in a URL path and can return the `ES` equity instead. |
| `get_option_expiration_chain("/ES")` | Works: HTTP 200; it returned four `/ES` expiry entries and option root `ES` | Use it to enumerate available expiration dates and roots. |
| `get_option_chain("/ES")` | Fails: HTTP 400 | It is not a usable `/ES` futures-options chain/discovery endpoint. |
| `get_option_chain("/ESU26")` | Fails: HTTP 400 | Resolving the active underlying contract does not make the chain endpoint work. |
| `get_quotes()` for the supplied thinkorswim text `./E3AQ26P7780:XCME` | Fails semantically: HTTP 200 with `errors.invalidSymbols` | A thinkorswim identifier is not automatically a valid Schwab REST symbol. Resolve the API equivalent explicitly. |
| `get_quotes(["./E3AQ26P7780"])` | Works: `assetMainType: FUTURE_OPTION`; its reference description is `./E3AQ26P7780:XCME` | For this verified contract, the API form is the thinkorswim text with the `:XCME` exchange suffix removed. The snapshot included bid, ask, mark, last, volume, and open interest. |
| `LEVEL_ONE_FUTURES_OPTIONS` for `./E3AQ26P7780:XCME` | The subscription command was accepted, but no quote handler message arrived during a 15-second premarket probe | Not needed for this feature: it uses snapshots, not continuous updates. This result is retained only as future reference. |
| Historical futures-options prices | Not available | Schwab price history is not available for futures or options; the finder is a current-quote tool, not a historical-pricing system. |
| Futures-options order entry | Not available for this workflow | Keep execution in thinkorswim. Schwab Trader API support for equities and standard options must not be mistaken for `/ES` futures-options routing. |
The expiration endpoint narrows the date/root, but it does **not** return every
strike or a quoteable option symbol. Exact futures-option symbol resolution is
therefore the feature's critical discovery problem, not a formatting detail.
## Symbol and thinkorswim contract
Do not invent a futures-option symbol from a guessed `ES`, month, strike, and
call/put pattern. The supplied thinkorswim string `./E3AQ26P7780:XCME` maps to
the verified API symbol `./E3AQ26P7780`: removing `:XCME` returned the intended
`FUTURE_OPTION`, whose description returned the original thinkorswim form. This
is one tested mapping, not yet a general rule for every exchange, product,
expiration, or option root. Before building the finder, prove the following for
representative current `/ES` options:
1. Schwab `get_quotes()` returns the intended futures option, including a valid
bid and ask rather than an equity or an error.
2. The finder output can be pasted or searched in thinkorswim to select the
same leg. A two-leg spread must preserve buy/sell direction and quantity as
well as strike, expiration, and call/put side.
Store separately any Schwab API symbol and the thinkorswim copy text. They may
be identical, but that is an acceptance criterion to prove, not an assumption.
## Implementation constraints
- Keep future-options code isolated with the existing broker integration in
`app/market/`; no analysis, bars, or UI code should call Schwab directly.
- Request REST snapshots only after a user has selected exact contracts or
explicitly asked to refresh. Batch all displayed contracts into one
`get_quotes()` request; do not open a futures-options WebSocket subscription.
- Show quote freshness and whether data is delayed. A stale, wide, or missing
market is more important than a calculated spread mark.
- Treat a spread mark as a display calculation from the two current legs, not
an executable price. Preserve both bid/ask combinations so the UI can show
realistic debit/credit bounds.
- Do not persist credentials, account details, or order state in this feature.
## First implementation gate
Add a small read-only probe using representative current `/ES` options copied
from thinkorswim and their mapped Schwab API identifiers. Capture both forms,
the REST response type and quote fields, snapshot timestamp or delay status,
and the expiration/strike shown by each system. Add a regression fixture only
after that probe establishes a stable real payload and mapping rule. Until
then, a full-chain UI or automatic symbol construction would be speculative.

View file

@ -969,16 +969,26 @@ WebSocket snapshots carry timestamp-only source series for the timeframes used
by sloped manual lines, plus live timestamp updates. A levels delta sends a full by sloped manual lines, plus live timestamp updates. A levels delta sends a full
replacement when another browser introduces a new source timeframe, and each replacement when another browser introduces a new source timeframe, and each
bar carries the server ring buffer's first timestamp so long-lived tabs trim bar carries the server ring buffer's first timestamp so long-lived tabs trim
evicted history too. The browser evaluates the same source-space line and evicted history too. Each delta also names its predecessor; a gap caused by
samples it only onto displayed candle timestamps, so queue pressure or a sleeping tab makes the browser request a full replacement
instead of silently losing one logical bar. The browser evaluates the same
source-space line and samples it only onto displayed candle timestamps, so
manual lines cannot add points to Lightweight Charts' shared scale. Historical manual lines cannot add points to Lightweight Charts' shared scale. Historical
series, future projection, hit testing and the selected-line hit polyline all series, future projection, hit testing and the selected-line hit polyline all
call that price function. Whole-line dragging, horizontal keyboard nudging and call that price function. Whole-line dragging, horizontal keyboard nudging and
duplication shift timestamps in source bars rather than whatever timeframe is 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. Future endpoints instant; it becomes a fractional coordinate in the source bar. Snapshots also
use the same repeated-source-bucket approximation on both sides; the separate, carry future source slots so editing and duplication use the same timestamps on
pre-existing limitation that future axis times are not session-aware remains. 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.
The first browser implementation rebuilt `historical + future` timestamps for
every sampled point. The math test passed, but the next trendline run slowed its
first case from about 4 seconds to 16 and then timed out unrelated placement
tests. Caching the combined array once per source update restored the run to its
normal duration. This was allocation pressure, not a geometry or feed failure.
If source history no longer reaches an anchor, `geometry_resolved` is false. If source history no longer reaches an anchor, `geometry_resolved` is false.
The drawing remains available, but confluence and alerts exclude it instead of The drawing remains available, but confluence and alerts exclude it instead of
@ -999,7 +1009,28 @@ browser test proves a 30m line with both anchors outside the 1m window prices
correctly, samples only displayed timestamps, nudges by a 30m source bar, and correctly, samples only displayed timestamps, nudges by a 30m source bar, and
returns to the old wrong result when rollback mode is selected. It also proves returns to the old wrong result when rollback mode is selected. It also proves
that evicted source timestamps are trimmed. that evicted source timestamps are trimmed.
Full result: 148 backend tests passed; 31 browser Full result: 150 backend tests passed; 31 browser
tests passed with 9 previously quarantined tests skipped. Live Chromium then tests passed with 9 previously quarantined tests skipped. Live Chromium then
showed active persisted lines #12, #13 and #14 with a zero-point delta between showed active persisted lines #12, #13 and #14 with a zero-point delta between
their final rendered sample and server `current_p`, and no console errors. their final rendered sample and server `current_p`, and no console errors.
**Selected-line black triangle on a coarser timeframe.** The source-geometry
change made the invisible whole-line drag target a sampled SVG `polyline`
instead of a two-point `line`. Its stroke was transparent, but SVG polylines
default to a black fill. A selected 30m line viewed on 1h therefore closed its
hit path into a giant black polygon; one edge looked like a horizontal line from
the left side of the pane to a point at the price axis. `.chart-line-hit` now
sets `fill:none`. Browser coverage checks the computed fill because the visual
artifact only exists in SVG rendering, not in trendline arithmetic.
The black fill hid a second defect in the same report: after removing it, every
half-hour 30m anchor still had a near-horizontal segment from the left edge when
viewed on 1h. `coordinateAtTime` trusted any non-null `timeToCoordinate` answer.
Lightweight Charts uses a shared union of every series timestamp, so it could
answer for the 30m `:30` anchor even though that instant was not a displayed 1h
candle; in the reproduced case it answered `x=0`. The real first 1h sample was
at `x=1082`, producing the long segment. The first fallback still failed because
`logicalToCoordinate` clamps a far shared logical slot to zero. Coordinates now
interpolate between the surrounding displayed candles' own pixel coordinates,
which puts `:30` halfway between the hourly candles and remains correct even
when another series has inserted foreign timestamps into the shared scale.

View file

@ -309,15 +309,31 @@ createApp({
// outright. The event carries its timeframe, so honour it. // outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return; if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
chartApi.updateTrendlineTime( if (message.trendline_first_t != null) {
message.bar.tf, message.bar.t, null, message.trendline_first_t, const geometryCurrent = chartApi.updateTrendlineTime(
); message.bar.tf, message.bar.t, null, message.trendline_first_t,
message.trendline_previous_t,
message.trendline_future_times, message.trendline_future_durations,
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
}
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;
status.value.last_bar_t = message.bar.t; status.value.last_bar_t = message.bar.t;
} else if (message.type === 'trendline_bar') { } else if (message.type === 'trendline_bar') {
chartApi.updateTrendlineTime(message.tf, message.t, message.duration, message.first_t); const geometryCurrent = chartApi.updateTrendlineTime(
message.tf, message.t, message.duration, message.first_t, message.previous_t,
message.future_times, message.future_durations,
);
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
}
syncVisibleLevels();
} else if (message.type === 'trendline_geometry') {
chartApi.setTrendlineGeometry(message.geometry);
syncVisibleLevels(); syncVisibleLevels();
} else if (message.type === 'levels') { } else if (message.type === 'levels') {
// A delta, not a replacement: VWAP changes every minute while the // A delta, not a replacement: VWAP changes every minute while the

View file

@ -443,21 +443,42 @@ class ConfluenceChart {
setTrendlineGeometry(geometry) { setTrendlineGeometry(geometry) {
this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy'; this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy';
this.trendlineSourceSeries = new Map( this.trendlineSourceSeries = new Map(
Object.entries(geometry?.series || {}).map(([tf, value]) => [tf, { Object.entries(geometry?.series || {}).map(([tf, value]) => {
times: (value.times || []).map(Number), const times = (value.times || []).map(Number);
duration: value.duration == null ? null : Number(value.duration), const futureTimes = (value.future_times || []).map(Number);
durations: (value.durations || []).map(Number), return [tf, {
}]), times,
duration: value.duration == null ? null : Number(value.duration),
durations: (value.durations || []).map(Number),
futureTimes,
futureDurations: (value.future_durations || []).map(Number),
allTimes: [...times, ...futureTimes],
}];
}),
); );
} }
updateTrendlineTime(tf, time, duration = null, firstTime = null) { updateTrendlineTime(
if (this.trendlineGeometryMode !== 'source_tf') return; tf, time, duration = null, firstTime = null, previousTime = null,
futureTimes = [], futureDurations = [],
) {
if (this.trendlineGeometryMode !== 'source_tf') return true;
let source = this.trendlineSourceSeries.get(tf); let source = this.trendlineSourceSeries.get(tf);
if (!source) { if (!source) {
source = { times: [], duration: duration == null ? null : Number(duration), durations: [] }; source = {
times: [], duration: duration == null ? null : Number(duration), durations: [],
futureTimes: [], futureDurations: [],
allTimes: [],
};
this.trendlineSourceSeries.set(tf, source); this.trendlineSourceSeries.set(tf, source);
} }
const t = Number(time);
const lastBeforeUpdate = source.times[source.times.length - 1];
if (lastBeforeUpdate != null && t > lastBeforeUpdate
&& previousTime != null && lastBeforeUpdate !== Number(previousTime)) {
return false;
}
if (lastBeforeUpdate == null && previousTime != null) return false;
if (firstTime != null) { if (firstTime != null) {
const first = Number(firstTime); const first = Number(firstTime);
const remove = source.times.findIndex(value => value >= first); const remove = source.times.findIndex(value => value >= first);
@ -467,7 +488,6 @@ class ConfluenceChart {
if (source.duration == null) source.durations.splice(0, count); if (source.duration == null) source.durations.splice(0, count);
} }
} }
const t = Number(time);
const last = source.times.length - 1; const last = source.times.length - 1;
if (last < 0 || t > source.times[last]) { if (last < 0 || t > source.times[last]) {
source.times.push(t); source.times.push(t);
@ -475,6 +495,10 @@ class ConfluenceChart {
} else if (t === source.times[last] && source.duration == null && duration != null) { } else if (t === source.times[last] && source.duration == null && duration != null) {
source.durations[last] = Number(duration); source.durations[last] = Number(duration);
} }
source.futureTimes = futureTimes.map(Number);
source.futureDurations = futureDurations.map(Number);
source.allTimes = [...source.times, ...source.futureTimes];
return true;
} }
/** /**
@ -1171,19 +1195,21 @@ class ConfluenceChart {
coordinateAtIndex(index) { coordinateAtIndex(index) {
if (!this.bars.length) return null; if (!this.bars.length) return null;
const scale = this.chart.timeScale(); const scale = this.chart.timeScale();
const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t); if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t);
const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX); const last = this.bars.length - 1;
if (lastLogical == null) return null; const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index);
return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1)); const upper = lower + 1;
const x1 = scale.timeToCoordinate(this.bars[lower].t);
const x2 = scale.timeToCoordinate(this.bars[upper].t);
if (x1 == null || x2 == null) return null;
return x1 + (x2 - x1) * (index - lower);
} }
coordinateAtTime(time) { coordinateAtTime(time) {
const last = this.bars[this.bars.length - 1]; if (!this.bars.length) return null;
if (!last) return null; // Interpolate between the displayed candles' own pixel coordinates. The
if (time <= last.t) { // shared scale may contain foreign timestamps from other series, and its
const direct = this.chart.timeScale().timeToCoordinate(time); // logicalToCoordinate path clamps a visible 30m :30 anchor to x=0 on 1h.
if (direct != null) return direct;
}
return this.coordinateAtIndex(this.indexAt(time)); return this.coordinateAtIndex(this.indexAt(time));
} }
@ -1644,15 +1670,28 @@ class ConfluenceChart {
durations: duration == null durations: duration == null
? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400)
: [], : [],
futureTimes: [],
futureDurations: [],
allTimes: this.bars.map(bar => bar.t),
}; };
} }
static sourceDuration(source, index) { static sourceDuration(source, index) {
return source.duration ?? source.durations[index] ?? source.durations[source.durations.length - 1] ?? null; if (source.duration != null) return source.duration;
if (index < source.times.length) {
return source.durations[index] ?? source.durations[source.durations.length - 1] ?? null;
}
const futureIndex = index - source.times.length;
return source.futureDurations[futureIndex]
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
}
static sourceTimes(source) {
return source?.allTimes || source?.times || [];
} }
static timeframeIndexAt(source, time, allowFuture = true) { static timeframeIndexAt(source, time, allowFuture = true) {
const times = source?.times || []; const times = ConfluenceChart.sourceTimes(source);
if (!times.length) return null; if (!times.length) return null;
let lo = 0; let lo = 0;
let hi = times.length; let hi = times.length;
@ -1671,7 +1710,7 @@ class ConfluenceChart {
} }
static timeframeTimeAt(source, index) { static timeframeTimeAt(source, index) {
const times = source?.times || []; const times = ConfluenceChart.sourceTimes(source);
if (!times.length || index < 0) return null; if (!times.length || index < 0) 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);
@ -1887,7 +1926,8 @@ class ConfluenceChart {
}, },
]; ];
const visible = this.visibleBarIndexes(); const visible = this.visibleBarIndexes();
const hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); const naturalHitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t);
const hitEnd = naturalHitEnd == null ? null : Math.min(naturalHitEnd, visible?.to ?? naturalHitEnd);
const hitStart = this.indexAt(level.anchor_t); const hitStart = this.indexAt(level.anchor_t);
const hitIndexes = []; const hitIndexes = [];
if (hitEnd != null) { if (hitEnd != null) {

View file

@ -13,7 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } }
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } } @media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } }
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } } @keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }

View file

@ -47,6 +47,15 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
slope: 3 / (sourceTimes[3] - sourceTimes[0]), slope: 3 / (sourceTimes[3] - sourceTimes[0]),
cutoff_t: null, cutoff_t: null,
}; };
const coordinateProbe = {
bars: [{ t: 0 }, { t: halfHour * 2 }],
chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) },
indexAt: () => 0.5,
coordinateAtIndex: () => 123,
};
const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call(
coordinateProbe, halfHour,
);
c.bars = displayed; c.bars = displayed;
c.setTrendlineGeometry({ c.setTrendlineGeometry({
mode: 'source_tf', mode: 'source_tf',
@ -57,6 +66,10 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
const shifted = c.shiftLineTime(line, line.anchor_t, 1); const shifted = c.shiftLineTime(line, line.anchor_t, 1);
c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]); c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]);
const trimmedTimes = c.trendlineSourceSeries.get('30m').times; const trimmedTimes = c.trendlineSourceSeries.get('30m').times;
const gapAccepted = c.updateTrendlineTime(
'30m', sourceTimes[5] + halfHour, halfHour, sourceTimes[2], sourceTimes[3],
);
const timesAfterGap = c.trendlineSourceSeries.get('30m').times;
c.setTrendlineGeometry({ mode: 'legacy', series: {} }); c.setTrendlineGeometry({ mode: 'legacy', series: {} });
const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1); const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1);
return { return {
@ -68,6 +81,9 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
displayedTimes: displayed.map(bar => bar.t), displayedTimes: displayed.map(bar => bar.t),
trimmedTimes, trimmedTimes,
expectedTrimmedTimes: sourceTimes.slice(2), expectedTrimmedTimes: sourceTimes.slice(2),
gapAccepted,
timesAfterGap,
fractionalCoordinate,
}; };
}); });
@ -81,6 +97,14 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
'manual line samples introduced timestamps outside the displayed candle grid'); 'manual line samples introduced timestamps outside the displayed candle grid');
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
'source timestamps evicted by the server remained in the browser'); 'source timestamps evicted by the server remained in the browser');
assert.equal(result.gapAccepted, false,
'a dropped source timestamp was accepted without requesting a full replacement');
assert.deepEqual(result.timesAfterGap, result.expectedTrimmedTimes,
'a lossy source delta mutated geometry before resynchronization');
assert.equal(result.fractionalCoordinate, 123,
'a non-candle source anchor trusted a foreign shared-scale coordinate');
assert.equal(await page.locator('.chart-line-hit').evaluate(node => getComputedStyle(node).fill),
'none', 'the invisible polyline hit region filled the chart as a black polygon');
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });

View file

@ -4,7 +4,7 @@ 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 from app.bars.session import bucket_duration, bucket_start, next_bucket_start
UTC = ZoneInfo("UTC") UTC = ZoneInfo("UTC")
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -62,3 +62,10 @@ def test_daily_geometry_excludes_the_settlement_halt():
start = epoch("2026-08-10T18:00:00", ET) start = epoch("2026-08-10T18:00:00", ET)
assert bucket_duration(start, Timeframe.D1) == 23 * 3600 assert bucket_duration(start, Timeframe.D1) == 23 * 3600
def test_daily_future_geometry_skips_the_weekend_and_opens_at_1800_eastern():
thursday = epoch("2026-08-13T18:00:00", ET)
sunday = epoch("2026-08-16T18:00:00", ET)
assert next_bucket_start(thursday, Timeframe.D1) == sunday

View file

@ -77,10 +77,12 @@ def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
geometry = snapshot(runtime, Timeframe.M1)["trendline_geometry"] geometry = snapshot(runtime, Timeframe.M1)["trendline_geometry"]
assert geometry == { assert geometry["mode"] == "source_tf"
"mode": "source_tf", source = geometry["series"]["30m"]
"series": {"30m": {"times": [1000, 2800], "duration": 1800}}, assert source["times"] == [1000, 2800]
} assert source["duration"] == 1800
assert source["future_times"][:2] == [4600, 6400]
assert source["future_durations"][:2] == [1800, 1800]
def test_snapshot_rollback_gate_omits_source_geometry(tmp_path): def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):
@ -92,3 +94,22 @@ def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):
assert snapshot(runtime, Timeframe.M1)["trendline_geometry"] == { assert snapshot(runtime, Timeframe.M1)["trendline_geometry"] == {
"mode": "legacy", "series": {}, "mode": "legacy", "series": {},
} }
def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path):
app = FastAPI()
app.include_router(ws_router)
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
trendline_source_geometry=True,
))
app.state.runtime = runtime
with TestClient(app).websocket_connect("/ws") as websocket:
websocket.receive_json()
websocket.send_json({"type": "trendline_geometry"})
assert websocket.receive_json() == {
"type": "trendline_geometry",
"geometry": {"mode": "source_tf", "series": {}},
}