diff --git a/app/analysis/bar_space.py b/app/analysis/bar_space.py index 4093c43..a84f1f9 100644 --- a/app/analysis/bar_space.py +++ b/app/analysis/bar_space.py @@ -10,7 +10,7 @@ disagree — measured at 147 points across a weekend on a real /ES chart. from bisect import bisect_right 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: @@ -66,7 +66,20 @@ def timeframe_index_at( elapsed = t - times[lower] if duration <= 0 or elapsed < 0: 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 lower + elapsed / duration diff --git a/app/api/ws.py b/app/api/ws.py index fda3e95..0b2b1b7 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.api.deps import SESSION_COOKIE, session_matches, token_matches from app.analysis.levels import LevelKind from app.bars.models import Timeframe -from app.bars.session import bucket_duration +from app.bars.session import bucket_duration, future_bucket_starts from app.analysis.confluence import cluster_levels router = APIRouter() @@ -65,16 +65,23 @@ def trendline_geometry(runtime) -> dict: return {"mode": "legacy", "series": {}} series = {} for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value): - 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 - series[tf.value] = value + series[tf.value] = trendline_series(runtime, tf) 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: return { "type": "snapshot", @@ -130,6 +137,10 @@ async def websocket_endpoint(websocket: WebSocket): "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: queue.put_nowait({"type": "disconnect"}) @@ -144,6 +155,14 @@ async def websocket_endpoint(websocket: WebSocket): source_tfs = trendline_timeframes(runtime) source_bars = runtime.store.get(bar.tf) 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: await websocket.send_json( { @@ -155,6 +174,9 @@ async def websocket_endpoint(websocket: WebSocket): if runtime.settings.trendline_source_geometry 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 ( @@ -168,6 +190,9 @@ async def websocket_endpoint(websocket: WebSocket): "t": bar.t, "duration": bucket_duration(bar.t, bar.tf), "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": diff --git a/app/bars/session.py b/app/bars/session.py index 6a9683b..dbcb1da 100644 --- a/app/bars/session.py +++ b/app/bars/session.py @@ -37,3 +37,23 @@ def bucket_duration(t: int, tf: Timeframe) -> int: current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN) 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 diff --git a/docs/esquotes.md b/docs/esquotes.md new file mode 100644 index 0000000..5dd5aae --- /dev/null +++ b/docs/esquotes.md @@ -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. diff --git a/docs/implementation.md b/docs/implementation.md index ad23a17..e761ee8 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -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 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 -evicted history too. The browser evaluates the same source-space line and -samples it only onto displayed candle timestamps, so +evicted history too. Each delta also names its predecessor; a gap caused by +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 series, future projection, hit testing and the selected-line hit polyline all call that price function. Whole-line dragging, horizontal keyboard nudging and 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. Future endpoints -use the same repeated-source-bucket approximation on both sides; the separate, -pre-existing limitation that future axis times are not session-aware remains. +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. + +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. 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 returns to the old wrong result when rollback mode is selected. It also proves 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 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. + +**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. diff --git a/static/app.js b/static/app.js index 3865ef5..89ddb09 100644 --- a/static/app.js +++ b/static/app.js @@ -309,15 +309,31 @@ createApp({ // outright. The event carries its timeframe, so honour it. if (message.tf && message.tf !== timeframe.value) return; dataReceivedAt.value = Date.now(); - chartApi.updateTrendlineTime( - message.bar.tf, message.bar.t, null, message.trendline_first_t, - ); + if (message.trendline_first_t != null) { + 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); price.value = message.bar.c; sessionOpen.value = message.session_open ?? sessionOpen.value; status.value.last_bar_t = message.bar.t; } 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(); } else if (message.type === 'levels') { // A delta, not a replacement: VWAP changes every minute while the diff --git a/static/chart.js b/static/chart.js index 84234a4..b5a889b 100644 --- a/static/chart.js +++ b/static/chart.js @@ -443,21 +443,42 @@ class ConfluenceChart { setTrendlineGeometry(geometry) { this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy'; this.trendlineSourceSeries = new Map( - Object.entries(geometry?.series || {}).map(([tf, value]) => [tf, { - times: (value.times || []).map(Number), - duration: value.duration == null ? null : Number(value.duration), - durations: (value.durations || []).map(Number), - }]), + Object.entries(geometry?.series || {}).map(([tf, value]) => { + const times = (value.times || []).map(Number); + const futureTimes = (value.future_times || []).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) { - if (this.trendlineGeometryMode !== 'source_tf') return; + updateTrendlineTime( + tf, time, duration = null, firstTime = null, previousTime = null, + futureTimes = [], futureDurations = [], + ) { + if (this.trendlineGeometryMode !== 'source_tf') return true; let source = this.trendlineSourceSeries.get(tf); 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); } + 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) { const first = Number(firstTime); const remove = source.times.findIndex(value => value >= first); @@ -467,7 +488,6 @@ class ConfluenceChart { if (source.duration == null) source.durations.splice(0, count); } } - const t = Number(time); const last = source.times.length - 1; if (last < 0 || t > source.times[last]) { source.times.push(t); @@ -475,6 +495,10 @@ class ConfluenceChart { } else if (t === source.times[last] && source.duration == null && duration != null) { 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) { if (!this.bars.length) return null; const scale = this.chart.timeScale(); - const lastX = scale.timeToCoordinate(this.bars[this.bars.length - 1].t); - const lastLogical = lastX == null ? null : scale.coordinateToLogical(lastX); - if (lastLogical == null) return null; - return scale.logicalToCoordinate(Number(lastLogical) + index - (this.bars.length - 1)); + if (this.bars.length === 1) return scale.timeToCoordinate(this.bars[0].t); + const last = this.bars.length - 1; + const lower = index <= 0 ? 0 : index >= last ? last - 1 : Math.floor(index); + 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) { - const last = this.bars[this.bars.length - 1]; - if (!last) return null; - if (time <= last.t) { - const direct = this.chart.timeScale().timeToCoordinate(time); - if (direct != null) return direct; - } + if (!this.bars.length) return null; + // 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. return this.coordinateAtIndex(this.indexAt(time)); } @@ -1644,15 +1670,28 @@ class ConfluenceChart { durations: duration == null ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) : [], + futureTimes: [], + futureDurations: [], + allTimes: this.bars.map(bar => bar.t), }; } 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) { - const times = source?.times || []; + const times = ConfluenceChart.sourceTimes(source); if (!times.length) return null; let lo = 0; let hi = times.length; @@ -1671,7 +1710,7 @@ class ConfluenceChart { } static timeframeTimeAt(source, index) { - const times = source?.times || []; + const times = ConfluenceChart.sourceTimes(source); if (!times.length || index < 0) return null; const lower = Math.min(Math.floor(index), times.length - 1); const duration = ConfluenceChart.sourceDuration(source, lower); @@ -1887,7 +1926,8 @@ class ConfluenceChart { }, ]; 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 hitIndexes = []; if (hitEnd != null) { diff --git a/static/style.css b/static/style.css index 47af86a..b645daa 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } .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); } -#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%); } } @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); } } diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs index 5245fb0..9fc90a6 100644 --- a/tests/e2e/trendline.test.mjs +++ b/tests/e2e/trendline.test.mjs @@ -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]), 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.setTrendlineGeometry({ 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); c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]); 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: {} }); const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1); 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), trimmedTimes, 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'); assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, '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); }); }); diff --git a/tests/test_session.py b/tests/test_session.py index cd35835..3081322 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo import pytest 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") 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) 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 diff --git a/tests/test_ws_preferences.py b/tests/test_ws_preferences.py index 5e35630..d940228 100644 --- a/tests/test_ws_preferences.py +++ b/tests/test_ws_preferences.py @@ -77,10 +77,12 @@ def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path): geometry = snapshot(runtime, Timeframe.M1)["trendline_geometry"] - assert geometry == { - "mode": "source_tf", - "series": {"30m": {"times": [1000, 2800], "duration": 1800}}, - } + assert geometry["mode"] == "source_tf" + source = geometry["series"]["30m"] + 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): @@ -92,3 +94,22 @@ def test_snapshot_rollback_gate_omits_source_geometry(tmp_path): assert snapshot(runtime, Timeframe.M1)["trendline_geometry"] == { "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": {}}, + }