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 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,14 +65,21 @@ def trendline_geometry(runtime) -> dict:
|
|||
return {"mode": "legacy", "series": {}}
|
||||
series = {}
|
||||
for tf in sorted(trendline_timeframes(runtime), key=lambda value: 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
|
||||
series[tf.value] = value
|
||||
return {"mode": "source_tf", "series": series}
|
||||
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:
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {}},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue