horizontal line to start of trendline problem
This commit is contained in:
parent
8ab054cf67
commit
a921db4610
11 changed files with 334 additions and 49 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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
88
docs/esquotes.md
Normal 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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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); } }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {}},
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue