Compare commits

..

31 commits

Author SHA1 Message Date
fced18528b Backfill the history missed while the live stream was down.
History was only fetched by the startup seed, so a Schwab outage stayed a
hole until the next deploy — Sept 9 to 24 after a refresh token expired.
A reconnect more than two minutes past the last bar now fetches the gap
from Yahoo, fills empty buckets only, refolds the live forming buckets,
rebuilds levels without alerting, and resyncs every socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:48:54 -05:00
234b57e8b6 Stamp drawings and alerts with symbol; snap from the instrument profile.
Missing JSON still loads as /ES. No switcher and no second stream.
2026-09-07 04:01:08 -05:00
d0a0f9f6d6 Document End trendline here: cutoff clips, last_t does not move.
The menu hid the item unless the click was past the second anchor.
2026-09-04 03:34:58 -05:00
3d27ef285c Show End trendline here for any click after the first anchor.
It used to require a time past last_t, so a line whose second point
already sits at the live edge never offered the item.
2026-09-04 03:32:42 -05:00
72bcd0fa61 Paint session H/L in the candle primitive so they track price zoom.
DOM marks only moved on overlay redraw, so a price-axis drag left them
stuck until something else refreshed.
2026-09-01 22:47:16 -05:00
bc9fed764a Show session high/low as short ticks at the live edge.
Forming daily H/L, not a full-width line. Config default on.
A forming tick only moves the marks if the range actually expanded.
2026-09-01 22:39:37 -05:00
2fbb5957f3 Revert "Drop HTF source times that sit inside a compressed 1m halt."
This reverts commit e6fa6beaf0.
2026-08-31 03:45:43 -05:00
e6fa6beaf0 Drop HTF source times that sit inside a compressed 1m halt.
Yahoo 30m/1h can print through Saturday; those opens added index steps
while 1m display is one slot, kinking the line at 17:00. Short holes
and settlement compression are unchanged.
2026-08-31 03:29:40 -05:00
fd315326b4 Stop chart pointerdown from eating Duplicate on the context menu.
The menu sits inside the chart element, so pointerdown bubbled into
pan/tool capture and the Duplicate click never ran.
2026-08-28 15:20:32 -05:00
a057341bbf Put Duplicate on the drawings list; do not require a time under the click.
The handler never ran — no event, no POST. Right-click in empty time
also hid the menu. List button uses the selected trendline.
2026-08-28 15:17:19 -05:00
c1d508cb4f Duplicate a line even when 10 source bars would pass the horizon.
Prod never saw POST /api/lines: the browser aborted the shift. Clamp to
the last owned source slot so Duplicate still creates a copy.
2026-08-28 15:01:47 -05:00
6f03560e74 Default confluence zone alerts off; Config can turn them back on.
Armed drawings and DMA bells still fire. The pref lives in user_prefs
so pushes follow it, not a display checkbox.
2026-08-27 17:09:23 -05:00
3626795e03 Say why Duplicate did nothing.
It aborted on unresolved geometry, a failed 10-bar shift, or HTTP
error with only a console.error.
2026-08-27 02:26:02 -05:00
a1567fe82a Restore the live-price animation default.
Off in Config is per-browser; do not change the default for that.
2026-08-26 03:46:02 -05:00
168352872e Default live-price animation off.
The overlay still costs a few percent. LWC LargeDashed marks the line
without it.
2026-08-26 03:45:37 -05:00
25c2714850 Use larger CSS triangles for the live-price ends.
Same plot overlay, no extra axis width.
2026-08-26 03:42:21 -05:00
a16a1d0e6e Do not widen the price scale for the live-price arrows.
← sits on the plot, just left of the label. After-the-digits needs
space we should not take from candles.
2026-08-26 03:41:38 -05:00
b28c0ff75f Widen the price scale so 7687.00 ← fits after the label.
The axis was sized to the digits; there was no leftover strip.
2026-08-26 03:40:42 -05:00
e1e0e40c10 Keep the live-price ← just inside the chart, after the label.
The plot overlay cannot reach the axis; this one sits on #chart.
2026-08-26 03:39:56 -05:00
bcc86fbfd2 Put live-price arrows on the plot: → left, ← right.
The last arrow sat off the right edge of the chart.
2026-08-26 03:35:52 -05:00
54d7f880af Mark live price with a left arrow after the axis label.
The scale-wide frame sat too far right and barely read.
2026-08-26 03:33:43 -05:00
20d02fc3d2 Use LWC LargeDashed for the live price line.
Custom overlay dash was the wrong place to spend. LWC already paints
that line with the last candle.
2026-08-26 03:30:50 -05:00
8108f2dea3 Draw the live price as a dash-dot ray across the plot.
LWC keeps the axis label only. The overlay uses a 14-4-2-4 dash so it
does not look like a trendline.
2026-08-26 03:29:30 -05:00
5708150ba3 Show the live-price frame: keyframes were fading from opacity 0. 2026-08-26 03:28:14 -05:00
c784f9ed12 Pulse a frame around the live price label, not the whole plot.
Opacity on a ~50x20 box can use the compositor. The old full-width
gradient could not.
2026-08-26 03:26:26 -05:00
111d367224 Animate the live-price cue with transform only.
background-position on a full-width gradient was ~15% CPU. The dash
stays put; only a small shine translates.
2026-08-26 03:25:30 -05:00
f7fbee1187 Coalesce forming 1m ticks to one candle update per frame.
Schwab can print many times a frame; only the last OHLC is visible.
2026-08-26 03:22:22 -05:00
fe4e6b1ae8 Send only the live candle on a forming 1m tick.
Future calendars and HTF geometry wait until that timeframe advances.
The quote paints the header directly so Vue does not rerender the page
at 4 Hz.
2026-08-26 03:19:26 -05:00
60ffaf4657 Stop rebuilding the 1m time scale on every forming tick.
Identical future_times are now a no-op, and HTF ticks only resync lines
when a source bar is actually appended. Document the slotted-hole kink.
2026-08-26 03:12:00 -05:00
f5ba154c98 Count short tape holes in source index so 1m lines stay straight.
Prod already owned the empty 1m slots; source index still treated the
bars on either side as adjacent. Also post a geometry SNAPDBG on ?diag=1.
2026-08-26 03:04:52 -05:00
c28b0ce535 potential 1m fix 2026-08-26 02:27:32 -05:00
34 changed files with 1810 additions and 136 deletions

View file

@ -14,6 +14,7 @@ deferred fixes. Mobile interaction work also has its own detailed plan in
[`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are [`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md). [`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md).
Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md). Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md).
Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md).
## Tests earn their place by catching a real bug ## Tests earn their place by catching a real bug
@ -89,8 +90,11 @@ docker compose logs api | grep SNAPDBG
With it on, every snap the trendline tool computes is posted to With it on, every snap the trendline tool computes is posted to
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the `/api/debug/snap` and logged server-side — the cursor's time, price and x, the
snapped time and price, how many bars were held, the first and last bar, and the snapped time and price, how many bars were held, the first and last bar, and the
chart's width. Throttled to about one a second. It reads the client's own chart's width. After bars and levels load it also posts one `kind=geometry`
numbers, which is exactly what "works in my headless run" cannot tell you. report: 1m holes, whether `futureSpace` owns them, and each visible manual
line's off-median screen segments. Throttled to about one a second. It reads
the client's own numbers, which is exactly what "works in my headless run"
cannot tell you. Do not ask the user to paste that from the console.
Extend it when the next geometry puzzle appears; the endpoint takes whatever Extend it when the next geometry puzzle appears; the endpoint takes whatever
fields `SnapReport` declares. fields `SnapReport` declares.
@ -149,6 +153,10 @@ geometry change that needs explicit browser verification.
- The displayed time scale, a drawing's source-timeframe bar space, and the - The displayed time scale, a drawing's source-timeframe bar space, and the
server session calendar are different coordinate systems. Never substitute server session calendar are different coordinate systems. Never substitute
wall-clock seconds or the displayed grid for canonical source geometry. wall-clock seconds or the displayed grid for canonical source geometry.
- A short 1m hole that already owns empty `futureSpace` slots still kinks a
line if source index treats the surrounding bars as adjacent. Count both
before changing geometry — see the 2026-08-26 entry in
`docs/implementation.md`. Settlement and weekend compression are intended.
- Daily and intraday future slots must skip non-session time. An endpoint that - Daily and intraday future slots must skip non-session time. An endpoint that
looks valid before a settlement/weekend break must remain resolvable when real looks valid before a settlement/weekend break must remain resolvable when real
history arrives. history arrives.
@ -243,6 +251,19 @@ Performance is a product feature, not a later cleanup. The app already
pays for a live stream, 5k bars, and a canvas. New work must not add pays for a live stream, 5k bars, and a canvas. New work must not add
cost on the hot path unless the screen or an alert has to change. cost on the hot path unless the screen or an alert has to change.
**A forming tick may update the live candle and the price label. That
is all.** It must not rebuild, `setData`, walk bars, walk drawings, or
recompute geometry. If the work cannot be an `update()` of one point,
it does not belong on the tick. Naming a different function does not
make it cheap — we banned `scheduleOverlays(true)` on ticks and then
shipped `futureSpace.setData` plus `syncLevels` on the same path.
Before you finish any change that touches bars, sockets, overlays,
drawings, or the time scale, answer: *what happens on a 1m forming
tick at 4 Hz with 5k bars?* If the answer is more than `candles.update`
+ price line, stop and make the rest a no-op unless a timestamp
actually advanced.
- Nothing extra on the event loop each closed bar or tick. Watch - Nothing extra on the event loop each closed bar or tick. Watch
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see `loop_lag_ms`. CPU stays in the threadpool or off the loop — see
`docs/async_refactor.md`. `docs/async_refactor.md`.
@ -251,10 +272,8 @@ cost on the hot path unless the screen or an alert has to change.
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not - Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
rebuild overlays that only depend on the viewport. rebuild overlays that only depend on the viewport.
- Overlay redraws go through `scheduleOverlays`. Force when bars are - Overlay redraws go through `scheduleOverlays`. Force when bars are
replaced, a new bar opens, or size changed. **A forming-bar tick is replaced, a new bar opens, or size changed. A forming-bar tick is
not that.** `updateBar` must not `scheduleOverlays(true)` on every not that.
tick — that walked every trendline across 5k bars at ~4 Hz and
melted the tab. We wrote this section and then shipped that anyway.
- Diagnostic `getImageData` / painted-pixel sampling is never on the - Diagnostic `getImageData` / painted-pixel sampling is never on the
live overlay path. `?diag=1` is not a license to sync the GPU every live overlay path. `?diag=1` is not a license to sync the GPU every
frame. frame.
@ -264,3 +283,6 @@ cost on the hot path unless the screen or an alert has to change.
layout actually changed. layout actually changed.
- A feature that needs a per-frame or per-tick loop needs a reason, - A feature that needs a per-frame or per-tick loop needs a reason,
and an off switch. and an off switch.
- Chart overlays may animate only `transform` or `opacity`. Animating
`background-position` on a full-width gradient cost ~15% CPU; the
compositor will not save you from a paint property.

View file

@ -7,6 +7,7 @@ from pathlib import Path
from app.analysis.confluence import Cluster from app.analysis.confluence import Cluster
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -43,6 +44,7 @@ class Alert:
class _Fired: class _Fired:
center: float center: float
at: int at: int
symbol: str = DEFAULT_SYMBOL
class AlertEngine: class AlertEngine:
@ -83,7 +85,14 @@ class AlertEngine:
if isinstance(payload, dict): if isinstance(payload, dict):
self._next_number = int(payload.get("next_number", 1)) self._next_number = int(payload.get("next_number", 1))
payload = payload.get("fired", []) payload = payload.get("fired", [])
return [_Fired(float(item["center"]), int(item["at"])) for item in payload] return [
_Fired(
float(item["center"]),
int(item["at"]),
str(item.get("symbol") or DEFAULT_SYMBOL),
)
for item in payload
]
except Exception: except Exception:
# Corrupt state costs one burst of duplicate alerts, which is a far # Corrupt state costs one burst of duplicate alerts, which is a far
# better failure than refusing to start the stream. # better failure than refusing to start the stream.
@ -115,7 +124,12 @@ class AlertEngine:
{ {
"next_number": self._next_number, "next_number": self._next_number,
"fired": [ "fired": [
{"center": entry.center, "at": entry.at} for entry in self._fired {
"center": entry.center,
"at": entry.at,
"symbol": entry.symbol,
}
for entry in self._fired
], ],
}, },
indent=2, indent=2,
@ -137,10 +151,12 @@ class AlertEngine:
now: int, now: int,
symbol: str, symbol: str,
watched: list[Level] | None = None, watched: list[Level] | None = None,
confluence: bool = True,
) -> list[Alert]: ) -> list[Alert]:
tolerance = 0.5 * atr15 tolerance = 0.5 * atr15
if tolerance <= 0: if tolerance <= 0:
return [] return []
root = instrument_for_symbol(symbol).schwab_symbol
# Two zones within an ATR of each other are the same zone as far as # Two zones within an ATR of each other are the same zone as far as
# being told about them goes. # being told about them goes.
merge_distance = 2 * tolerance merge_distance = 2 * tolerance
@ -184,7 +200,7 @@ class AlertEngine:
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on # from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
# score would mean a line you deliberately drew could never alert. # score would mean a line you deliberately drew could never alert.
# A disarmed one has already had its say and no longer qualifies. # A disarmed one has already had its say and no longer qualifies.
if not drawn and cluster.score < self.min_score: if not drawn and (not confluence or cluster.score < self.min_score):
continue continue
if abs(cluster.center - current_price) > tolerance and not early_drawn: if abs(cluster.center - current_price) > tolerance and not early_drawn:
continue continue
@ -194,10 +210,12 @@ class AlertEngine:
# was oscillating on re-alerted on every crossing — which is exactly # was oscillating on re-alerted on every crossing — which is exactly
# when a level is least newsworthy, not most. # when a level is least newsworthy, not most.
if any( if any(
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired entry.symbol == root
and abs(entry.center - cluster.center) <= merge_distance
for entry in self._fired
): ):
continue continue
self._fired.append(_Fired(cluster.center, now)) self._fired.append(_Fired(cluster.center, now, root))
changed = True changed = True
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
@ -238,9 +256,13 @@ class AlertEngine:
price = level.current_p if level.current_p is not None else level.anchor_p price = level.current_p if level.current_p is not None else level.anchor_p
if abs(price - current_price) > tolerance: if abs(price - current_price) > tolerance:
continue continue
if any(abs(entry.center - price) <= merge_distance for entry in self._fired): if any(
entry.symbol == root
and abs(entry.center - price) <= merge_distance
for entry in self._fired
):
continue continue
self._fired.append(_Fired(price, now)) self._fired.append(_Fired(price, now, root))
changed = True changed = True
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH" direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
name = f"{level.period} DMA" if level.period else level.label name = f"{level.period} DMA" if level.period else level.label

View file

@ -12,6 +12,32 @@ from bisect import bisect_right
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_duration, next_bucket_start from app.bars.session import bucket_duration, next_bucket_start
# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes
# occupy empty slots on the chart, so source index must count them too.
MAX_INTRADAY_GAP_SECONDS = 30 * 60
def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
"""Insert missing bucket opens inside short intraday holes.
A 9-minute 1m hole is eight empty columns on screen. Without these times,
source index treats the two surrounding bars as adjacent and the line
goes flat across the hole. Settlement and weekends stay compressed.
"""
if tf is Timeframe.D1 or len(times) < 2:
return times
step = tf.seconds
if step <= 0:
return times
filled = [times[0]]
for time in times[1:]:
prev = filled[-1]
gap = time - prev
if step < gap <= MAX_INTRADAY_GAP_SECONDS:
filled.extend(range(prev + step, time, step))
filled.append(time)
return filled
def index_at(times: list[int], t: int) -> float: def index_at(times: list[int], t: int) -> float:
"""Fractional index of a timestamp within an ascending bar-time series.""" """Fractional index of a timestamp within an ascending bar-time series."""
@ -91,6 +117,7 @@ def price_in_timeframe_space(
# Endpoints may deliberately sit in the projection area. They use the same # Endpoints may deliberately sit in the projection area. They use the same
# repeated-source-bucket approximation as the browser; the live evaluation # repeated-source-bucket approximation as the browser; the live evaluation
# instant itself must still belong to held source history. # instant itself must still belong to held source history.
times = fill_short_gaps(times, tf)
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True) start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True) end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
target_index = timeframe_index_at(times, t, tf) target_index = timeframe_index_at(times, t, tf)

View file

@ -3,6 +3,8 @@ import logging
import time import time
from pathlib import Path from pathlib import Path
from app.instrument import DEFAULT_SYMBOL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DAY_SECONDS = 86400 DAY_SECONDS = 86400
@ -39,12 +41,16 @@ class EventLog:
except Exception: except Exception:
logger.warning("Could not persist event log", exc_info=True) logger.warning("Could not persist event log", exc_info=True)
def add(self, kind: str, message: str, *, number: int | None = None, at: int | None = None) -> dict: def add(
self, kind: str, message: str, *, number: int | None = None,
at: int | None = None, symbol: str | None = None,
) -> dict:
entry = { entry = {
"kind": kind, "kind": kind,
"message": message, "message": message,
"number": number, "number": number,
"at": int(at if at is not None else time.time()), "at": int(at if at is not None else time.time()),
"symbol": symbol or DEFAULT_SYMBOL,
} }
self._entries.append(entry) self._entries.append(entry)
self._save() self._save()

View file

@ -3,6 +3,7 @@ from enum import Enum
from typing import Any from typing import Any
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.instrument import DEFAULT_SYMBOL
class LevelKind(str, Enum): class LevelKind(str, Enum):
@ -55,6 +56,7 @@ class Level:
# price this line safely. Such a line remains visible but cannot cluster or # price this line safely. Such a line remains visible but cannot cluster or
# alert using the absolute-time fallback. # alert using the absolute-time fallback.
geometry_resolved: bool = True geometry_resolved: bool = True
symbol: str = DEFAULT_SYMBOL
def price_at(self, t: int) -> float: def price_at(self, t: int) -> float:
return self.anchor_p + self.slope * (t - self.anchor_t) return self.anchor_p + self.slope * (t - self.anchor_t)

View file

@ -6,6 +6,7 @@ from threading import RLock
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.config import TIMEFRAME_WEIGHT from app.config import TIMEFRAME_WEIGHT
from app.instrument import DEFAULT_SYMBOL
@dataclass(slots=True) @dataclass(slots=True)
@ -37,6 +38,7 @@ class ManualLine:
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays # Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
# on (anchor_t, anchor_p) regardless of this value. # on (anchor_t, anchor_p) regardless of this value.
scale: float = 1.0 scale: float = 1.0
symbol: str = DEFAULT_SYMBOL
@property @property
def drawing_kind(self) -> str: def drawing_kind(self) -> str:
@ -93,6 +95,7 @@ class ManualLine:
cutoff_t=self.cutoff_t, cutoff_t=self.cutoff_t,
armed=self.armed, armed=self.armed,
alert_early_points=self.alert_early_points, alert_early_points=self.alert_early_points,
symbol=self.symbol,
) )
def to_dict(self) -> dict: def to_dict(self) -> dict:
@ -130,6 +133,7 @@ class ManualLine:
if value.get("alert_early_points") is not None else None if value.get("alert_early_points") is not None else None
), ),
scale=float(value.get("scale", 1.0) or 1.0), scale=float(value.get("scale", 1.0) or 1.0),
symbol=str(value.get("symbol") or DEFAULT_SYMBOL),
) )

View file

@ -10,7 +10,7 @@ from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.config import DEFAULT_MAX_BARS_PER_TF from app.config import DEFAULT_MAX_BARS_PER_TF
@ -132,6 +132,7 @@ class LineRestore(BaseModel):
icon: str = "" icon: str = ""
alert_early_points: float | None = None alert_early_points: float | None = None
scale: float = Field(1.0, ge=0.5, le=3) scale: float = Field(1.0, ge=0.5, le=3)
symbol: str = ""
class LinePatch(BaseModel): class LinePatch(BaseModel):
@ -175,6 +176,7 @@ def status(request: Request):
"worst": round(runtime.loop_lag_worst * 1000, 1), "worst": round(runtime.loop_lag_worst * 1000, 1),
}, },
"needs_login": runtime.needs_login(), "needs_login": runtime.needs_login(),
"instrument": runtime.settings.profile.payload(),
} }
@ -231,6 +233,18 @@ def confluence(request: Request):
} }
@router.get("/prefs/confluence-alerts")
def get_confluence_alerts(request: Request):
return {"enabled": request.app.state.runtime.confluence_alerts_enabled()}
@router.put("/prefs/confluence-alerts")
def put_confluence_alerts(request: Request, payload: dict):
if "enabled" not in payload or not isinstance(payload["enabled"], bool):
raise HTTPException(400, "enabled must be a boolean")
return request.app.state.runtime.set_confluence_alerts(payload["enabled"])
@router.get("/prefs/ma-alerts") @router.get("/prefs/ma-alerts")
def get_ma_alerts(request: Request): def get_ma_alerts(request: Request):
return request.app.state.runtime.ma_alerts() return request.app.state.runtime.ma_alerts()
@ -267,6 +281,7 @@ def create_line(request: Request, payload: LineCreate):
cutoff_t=payload.cutoff_t, cutoff_t=payload.cutoff_t,
armed=payload.armed, armed=payload.armed,
kind=payload.kind, kind=payload.kind,
symbol=request.app.state.runtime.settings.profile.schwab_symbol,
) )
runtime = request.app.state.runtime runtime = request.app.state.runtime
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
@ -307,6 +322,7 @@ def restore_line(request: Request, payload: LineRestore):
icon=payload.icon, icon=payload.icon,
alert_early_points=payload.alert_early_points, alert_early_points=payload.alert_early_points,
scale=payload.scale, scale=payload.scale,
symbol=payload.symbol or runtime.settings.profile.schwab_symbol,
) )
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
@ -336,6 +352,7 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
color=payload.color, color=payload.color,
line_width=payload.line_width, line_width=payload.line_width,
alert_early_points=payload.alert_early_points, alert_early_points=payload.alert_early_points,
symbol=runtime.settings.profile.schwab_symbol,
) )
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
runtime.rebuild_levels() runtime.rebuild_levels()
@ -388,6 +405,7 @@ def create_comment(request: Request, payload: CommentCreate):
y=payload.y, y=payload.y,
# A comment must never alert, whatever else changes around it. # A comment must never alert, whatever else changes around it.
armed=False, armed=False,
symbol=runtime.settings.profile.schwab_symbol,
) )
line = runtime.manual_lines.add(line) line = runtime.manual_lines.add(line)
return line.to_dict() return line.to_dict()
@ -395,6 +413,7 @@ def create_comment(request: Request, payload: CommentCreate):
class SnapReport(BaseModel): class SnapReport(BaseModel):
"""What the browser computed for one snap, for diagnosing chart geometry.""" """What the browser computed for one snap, for diagnosing chart geometry."""
model_config = ConfigDict(extra="allow")
cursor_t: int | None = None cursor_t: int | None = None
cursor_p: float | None = None cursor_p: float | None = None
cursor_x: float | None = None cursor_x: float | None = None

View file

@ -6,6 +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.analysis.bar_space import fill_short_gaps
from app.bars.session import bucket_duration, future_bucket_starts from app.bars.session import bucket_duration, future_bucket_starts
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
@ -53,6 +54,15 @@ def session_open(runtime) -> float | None:
return bars[-1].o if bars else None return bars[-1].o if bars else None
def session_range(runtime) -> dict | None:
"""High/low of the forming CME session — today's range, not prior day."""
bars = runtime.store.get(Timeframe.D1, 1)
if not bars:
return None
bar = bars[-1]
return {"t": bar.t, "high": bar.h, "low": bar.l}
def trendline_timeframes(runtime) -> set[Timeframe]: def trendline_timeframes(runtime) -> set[Timeframe]:
return { return {
level.tf for level in runtime.levels level.tf for level in runtime.levels
@ -70,7 +80,7 @@ def trendline_geometry(runtime) -> dict:
def trendline_series(runtime, tf: Timeframe) -> dict: def trendline_series(runtime, tf: Timeframe) -> dict:
times = [bar.t for bar in runtime.store.get(tf)] times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
value = {"times": times} value = {"times": times}
if tf is Timeframe.D1: if tf is Timeframe.D1:
value["durations"] = [bucket_duration(t, tf) for t in times] value["durations"] = [bucket_duration(t, tf) for t in times]
@ -87,8 +97,87 @@ def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
return future_bucket_starts(bars[-1].t, tf) if bars else [] return future_bucket_starts(bars[-1].t, tf) if bars else []
def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None:
"""What one socket should hear about a bar.
A forming tick of an already-seen minute is just the live candle. Future
calendars and HTF geometry go out only when that timeframe's timestamp
advances — otherwise 1m at 4 Hz resends 180 slots and walks the store.
"""
if bar.tf is displayed_tf:
payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()}
if not full:
return payload
source_tfs = trendline_timeframes(runtime)
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
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
)
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
payload.update(
{
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime),
**extra,
"trendline_first_t": (
first_source_t
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", []),
"future_times": source_geometry.get("future_times", []),
}
)
return payload
if (
full
and runtime.settings.trendline_source_geometry
and bar.tf in trendline_timeframes(runtime)
):
source_bars = runtime.store.get(bar.tf)
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
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
)
return {
"type": "trendline_bar",
"tf": bar.tf.value,
"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", []),
}
return None
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
events, events_more = runtime.events.recent() events, events_more = runtime.events.recent()
rng = session_range(runtime)
extra = {
"session_t": rng["t"],
"session_high": rng["high"],
"session_low": rng["low"],
} if rng else {}
return { return {
"type": "snapshot", "type": "snapshot",
"tf": tf.value, "tf": tf.value,
@ -99,10 +188,12 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
if runtime.store.get(Timeframe.M1, 1) if runtime.store.get(Timeframe.M1, 1)
else None, else None,
"session_open": session_open(runtime), "session_open": session_open(runtime),
**extra,
"trendline_geometry": trendline_geometry(runtime), "trendline_geometry": trendline_geometry(runtime),
"future_times": displayed_future_times(runtime, tf), "future_times": displayed_future_times(runtime, tf),
"events": events, "events": events,
"events_more": events_more, "events_more": events_more,
"instrument": runtime.settings.profile.payload(),
} }
@ -126,6 +217,7 @@ async def websocket_endpoint(websocket: WebSocket):
prefs = None prefs = None
await websocket.send_json(snapshot(runtime, tf, prefs)) await websocket.send_json(snapshot(runtime, tf, prefs))
geometry_tfs = trendline_timeframes(runtime) geometry_tfs = trendline_timeframes(runtime)
last_bar_t: dict[Timeframe, int] = {}
async def receive(): async def receive():
nonlocal tf, prefs nonlocal tf, prefs
@ -159,53 +251,19 @@ async def websocket_endpoint(websocket: WebSocket):
event = await queue.get() event = await queue.get()
if event["type"] == "disconnect": if event["type"] == "disconnect":
break break
if event["type"] == "bar": if event["type"] == "resync":
# History changed behind the live edge (a backfilled outage).
# Bar deltas only move the tail, so the whole series is resent.
last_bar_t.clear()
await websocket.send_json(snapshot(runtime, tf, prefs))
elif event["type"] == "bar":
bar = event["bar"] bar = event["bar"]
source_tfs = trendline_timeframes(runtime) full = last_bar_t.get(bar.tf) != bar.t
source_bars = runtime.store.get(bar.tf) if full:
first_source_t = source_bars[0].t if source_bars else None last_bar_t[bar.tf] = bar.t
source_index = next( payload = bar_client_message(runtime, bar, tf, full=full)
(index for index, value in enumerate(source_bars) if value.t == bar.t), None, if payload is not None:
) await websocket.send_json(payload)
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(
{
"type": "bar", "tf": tf.value,
"bar": bar.to_dict(),
"duration": bucket_duration(bar.t, bar.tf),
"session_open": session_open(runtime),
"trendline_first_t": (
first_source_t
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", []),
"future_times": source_geometry.get("future_times", []),
}
)
elif (
runtime.settings.trendline_source_geometry
and bar.tf in source_tfs
):
await websocket.send_json(
{
"type": "trendline_bar",
"tf": bar.tf.value,
"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": elif event["type"] == "levels":
message = { message = {
"type": "levels", "changed": event["changed"], "removed": event["removed"], "type": "levels", "changed": event["changed"], "removed": event["removed"],

View file

@ -46,6 +46,35 @@ class InMemoryBarStore:
# Buckets are ordered, so nothing further back can match. # Buckets are ordered, so nothing further back can match.
return return
def fill(self, bars: list[Bar]) -> int:
"""Insert history into buckets the store has no bar for.
``put`` only lands a bar at the tail or a few buckets behind it, so a
stretch missed while the stream was down cannot reach it — the live
bars that arrived on reconnect are already newer. Existing bars always
win: they are the live source's own figures, and the bucket either side
of the hole is the live aggregator's to finish. Returns how many bars
were inserted.
"""
added = 0
by_tf: dict[Timeframe, list[Bar]] = defaultdict(list)
for bar in bars:
by_tf[bar.tf].append(bar)
for tf, incoming in by_tf.items():
held = self._bars[tf]
merged = {bar.t: bar for bar in held}
for bar in incoming:
if bar.t not in merged:
merged[bar.t] = bar
added += 1
if len(merged) == len(held):
continue
ordered = [merged[t] for t in sorted(merged)]
held.clear()
# maxlen keeps the newest, which is the history the chart shows.
held.extend(ordered)
return added
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]: def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
bars = list(self._bars[tf]) bars = list(self._bars[tf])
return bars[-limit:] if limit is not None else bars return bars[-limit:] if limit is not None else bars

View file

@ -3,6 +3,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.instrument import get_instrument
DEFAULT_MAX_BARS_PER_TF = 5000 DEFAULT_MAX_BARS_PER_TF = 5000
@ -24,6 +25,7 @@ class Settings(BaseSettings):
live_source: str = "yahoo" live_source: str = "yahoo"
seed_source: str = "yahoo" seed_source: str = "yahoo"
instrument: str = "es"
yahoo_symbol: str = "ES=F" yahoo_symbol: str = "ES=F"
yahoo_poll_seconds: float = 20 yahoo_poll_seconds: float = 20
seed_1h_range: str = "730d" seed_1h_range: str = "730d"
@ -73,6 +75,10 @@ class Settings(BaseSettings):
alert_timezone: str = "America/Chicago" alert_timezone: str = "America/Chicago"
replay_file: Path | None = None replay_file: Path | None = None
@property
def profile(self):
return get_instrument(self.instrument)
@property @property
def live_symbol(self) -> str: def live_symbol(self) -> str:
"""What the live source calls the instrument. """What the live source calls the instrument.

51
app/instrument.py Normal file
View file

@ -0,0 +1,51 @@
from dataclasses import dataclass
DEFAULT_SYMBOL = "/ES"
@dataclass(frozen=True)
class Instrument:
id: str
yahoo_symbol: str
schwab_symbol: str
tick: float
decimals: int = 2
session: str = "globex_18_17"
rth: str = "spy_rth"
def snap(self, price: float) -> float:
return round(round(price / self.tick) * self.tick, self.decimals)
def payload(self) -> dict:
return {
"id": self.id,
"yahoo_symbol": self.yahoo_symbol,
"schwab_symbol": self.schwab_symbol,
"tick": self.tick,
"decimals": self.decimals,
"session": self.session,
"rth": self.rth,
}
INSTRUMENTS = {
"es": Instrument("es", "ES=F", "/ES", 0.25, rth="spy_rth"),
"nq": Instrument("nq", "NQ=F", "/NQ", 0.25, rth="spy_rth"),
"gc": Instrument("gc", "GC=F", "/GC", 0.10, rth="none"),
"cl": Instrument("cl", "CL=F", "/CL", 0.01, rth="nymex_day"),
}
def get_instrument(instrument_id: str) -> Instrument:
try:
return INSTRUMENTS[instrument_id]
except KeyError:
raise ValueError(f"Unknown instrument: {instrument_id}") from None
def instrument_for_symbol(symbol: str) -> Instrument:
for instrument in INSTRUMENTS.values():
if symbol in (instrument.id, instrument.schwab_symbol, instrument.yahoo_symbol):
return instrument
return INSTRUMENTS["es"]

View file

@ -19,6 +19,12 @@ class StreamService:
self._handlers: list[BarHandler] = [] self._handlers: list[BarHandler] = []
self._stop = asyncio.Event() self._stop = asyncio.Event()
self.on_drop = None self.on_drop = None
# Called with (last bar before the outage, first bar after it) when a
# new connection opens further past the last bar than this. Reconnect
# alone only resumes the present; nothing else fetches what was missed.
self.on_resume: Callable[[int, int], None] | None = None
self.resume_gap_seconds = 120
self.reconnect_seconds = 5.0
def add_handler(self, handler: BarHandler) -> None: def add_handler(self, handler: BarHandler) -> None:
self._handlers.append(handler) self._handlers.append(handler)
@ -45,11 +51,21 @@ class StreamService:
async def run(self) -> None: async def run(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
first = True
try: try:
async for bar in self.source.stream(self.symbol): async for bar in self.source.stream(self.symbol):
self.status = "replay" if self.source.name == "replay" else "connected" self.status = "replay" if self.source.name == "replay" else "connected"
self.last_error = None self.last_error = None
before = self.last_bar_t
await self._emit(bar) await self._emit(bar)
if first:
first = False
if (
self.on_resume is not None
and before is not None
and bar.t - before > self.resume_gap_seconds
):
self.on_resume(before, bar.t)
if self._stop.is_set(): if self._stop.is_set():
break break
if self.source.name == "replay": if self.source.name == "replay":
@ -64,7 +80,7 @@ class StreamService:
self.on_drop(str(exc)) self.on_drop(str(exc))
self.status = "disconnected" self.status = "disconnected"
try: try:
await asyncio.wait_for(self._stop.wait(), timeout=5) await asyncio.wait_for(self._stop.wait(), timeout=self.reconnect_seconds)
except TimeoutError: except TimeoutError:
pass pass

View file

@ -27,6 +27,15 @@ from app.notify.ntfy import send_ntfy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _range_seconds(range_: str) -> int:
"""How far back a Yahoo range reaches: "8d" is eight days."""
units = {"d": 86400, "wk": 7 * 86400, "mo": 31 * 86400, "y": 366 * 86400}
for suffix, seconds in units.items():
if range_.endswith(suffix) and range_[: -len(suffix)].isdigit():
return int(range_[: -len(suffix)]) * seconds
return 8 * 86400
@dataclass @dataclass
class Runtime: class Runtime:
settings: Settings settings: Settings
@ -44,6 +53,7 @@ class Runtime:
alert_engine: AlertEngine = field(init=False) alert_engine: AlertEngine = field(init=False)
_sent_levels: dict[str, dict] = field(default_factory=dict) _sent_levels: dict[str, dict] = field(default_factory=dict)
_notify_tasks: set[asyncio.Task] = field(default_factory=set) _notify_tasks: set[asyncio.Task] = field(default_factory=set)
_backfill_tasks: set[asyncio.Task] = field(default_factory=set)
# The loop that owns the subscriber queues. Set once the app is running; # The loop that owns the subscriber queues. Set once the app is running;
# None while a test drives the runtime directly. # None while a test drives the runtime directly.
_loop: asyncio.AbstractEventLoop | None = None _loop: asyncio.AbstractEventLoop | None = None
@ -81,6 +91,7 @@ class Runtime:
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol) self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
self.stream.add_handler(self.on_bar) self.stream.add_handler(self.on_bar)
self.stream.on_drop = self._on_stream_drop self.stream.on_drop = self._on_stream_drop
self.stream.on_resume = self._on_stream_resume
async def on_bar(self, bar: Bar) -> None: async def on_bar(self, bar: Bar) -> None:
# A tick-built bar is provisional and arrives many times a minute. It # A tick-built bar is provisional and arrives many times a minute. It
@ -287,9 +298,17 @@ class Runtime:
evaluation_t, evaluation_t,
self.stream.symbol, self.stream.symbol,
self.watched_ma_levels(), self.watched_ma_levels(),
confluence=self.confluence_alerts_enabled(),
) )
) )
def confluence_alerts_enabled(self) -> bool:
return bool(self.user_prefs.get("confluence_alerts"))
def set_confluence_alerts(self, enabled: bool) -> dict:
self.user_prefs.put("confluence_alerts", bool(enabled))
return {"enabled": self.confluence_alerts_enabled()}
def ma_alerts(self) -> dict: def ma_alerts(self) -> dict:
stored = self.user_prefs.get("ma_alerts") or {} stored = self.user_prefs.get("ma_alerts") or {}
return {"1d": list(stored.get("1d") or [])} return {"1d": list(stored.get("1d") or [])}
@ -309,12 +328,108 @@ class Runtime:
def _on_stream_drop(self, error: str) -> None: def _on_stream_drop(self, error: str) -> None:
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream" kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
self.events.add(kind, error.split("\n", 1)[0][:200]) self.events.add(
kind, error.split("\n", 1)[0][:200],
symbol=self.settings.profile.schwab_symbol,
)
def _on_stream_resume(self, after: int, before: int) -> None:
task = asyncio.create_task(self.backfill_gap(after, before), name="gap-backfill")
self._backfill_tasks.add(task)
task.add_done_callback(self._backfill_tasks.discard)
async def backfill_gap(self, after: int, before: int) -> None:
"""Fill the history missed while the stream was down.
The seed runs once, at startup, so an outage between deploys used to
stay a hole until the next one — fifteen days of it after a Schwab
refresh token expired. Yahoo serves futures about ten minutes late, so
the minutes just before reconnect are fetched again once they exist.
"""
try:
await self.fill_gap(after, before)
delay = getattr(seed_source(self.settings), "delay_minutes", 0) or 0
if delay:
await asyncio.sleep(delay * 60 + 60)
await self.fill_gap(max(after, before - (delay + 5) * 60), before)
except asyncio.CancelledError:
raise
except Exception:
# A failed backfill leaves the hole it found; the live stream is fine.
logger.exception("Gap backfill failed")
async def fill_gap(self, after: int, before: int) -> int:
source = seed_source(self.settings)
if source is None or not source.supports_history():
return 0
symbol = self.settings.yahoo_symbol
now = int(time.time())
# Native coarse history first: those buckets are complete, where one
# rebuilt from 1m is only as old as Yahoo's 1m reach.
passes = (
(Timeframe.H1, self.settings.seed_1h_range),
(Timeframe.M30, self.settings.seed_30m_range),
(Timeframe.M1, self.settings.seed_1m_range),
)
added = 0
for tf, range_ in passes:
start = max(bucket_start(after, tf), now - _range_seconds(range_))
if start >= before:
continue
try:
bars = await source.history(symbol, tf, start, before)
except Exception:
logger.exception("Gap backfill: %s history failed", tf.value)
continue
# A fresh aggregator: the live one is already past the hole, and
# feeding it history would reopen buckets it has closed.
aggregator = Aggregator(self.settings.enabled_timeframes)
derived: dict[tuple[Timeframe, int], Bar] = {}
for bar in bars:
if not start <= bar.t < before:
continue
for aggregated in aggregator.update(replace(bar, closed=True)):
derived[(aggregated.tf, aggregated.t)] = aggregated
added += self.store.fill(list(derived.values()))
if added:
logger.info("Gap backfill: %d bars between %d and %d", added, after, before)
self.refold_forming(before)
values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# Levels only, never alerts: a touch during the outage is not news.
self.rebuild_levels()
self.broadcast({"type": "resync"})
return added
def refold_forming(self, before: int) -> None:
"""Rebuild the live buckets that opened before the stream came back.
The live aggregator started today's daily bar — and the current hour —
from the first minute after reconnect, so its open, high and low
ignore everything the backfill just recovered. Refolding from the stored
minutes is idempotent, so the delayed second pass can run it again.
"""
minutes = [bar for bar in self.store.get(Timeframe.M1) if bar.closed]
for tf, forming in self.aggregator.forming.items():
if forming.t >= before:
continue
inside = [bar for bar in minutes if bucket_start(bar.t, tf) == forming.t]
if not inside or inside[0].t >= before:
continue
forming.o = inside[0].o
forming.h = max(bar.h for bar in inside)
forming.l = min(bar.l for bar in inside)
forming.c = inside[-1].c
forming.v = sum(bar.v for bar in inside)
self.store.put(replace(forming))
def dispatch_alerts(self, alerts: list[Alert]) -> None: def dispatch_alerts(self, alerts: list[Alert]) -> None:
tripped: set[str] = set() tripped: set[str] = set()
for alert in alerts: for alert in alerts:
self.events.add("alert", alert.message, number=alert.number, at=alert.at) self.events.add(
"alert", alert.message, number=alert.number, at=alert.at,
symbol=self.settings.profile.schwab_symbol,
)
self.broadcast({ self.broadcast({
"type": "alert", "type": "alert",
"cluster": alert.cluster, "cluster": alert.cluster,

View file

@ -10,8 +10,9 @@ decisions behind them in `docs/plan.md`.
### Revisit the trendline live-edge bend with painted-pixel evidence ### Revisit the trendline live-edge bend with painted-pixel evidence
Production still appears to show a bend where 30m manual lines cross from the Mid-session 1m tape holes are now counted in source index as well as on the
last real candle into future whitespace. The deployed code is verified to use time scale. Production still appears to show a bend where 30m manual lines
cross from the last real candle into future whitespace. The deployed code is verified to use
one canvas `LineSeries`, retained transparent time-scale points, and canonical one canvas `LineSeries`, retained transparent time-scale points, and canonical
source-space prices. Diagnostic values can report unequal historical/future source-space prices. Diagnostic values can report unequal historical/future
screen slopes when the final candles are sparse, but synthetic browser coverage screen slopes when the final candles are sparse, but synthetic browser coverage

View file

@ -1336,3 +1336,123 @@ alert before the deferred global rebuild.
Finally, source-dependent shift operations no longer silently keep the old time Finally, source-dependent shift operations no longer silently keep the old time
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate, or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
and nudge abort cleanly when the canonical shift cannot be represented. and nudge abort cleanly when the canonical shift cannot be represented.
### 2026-08-26 — bent 1m trendlines: slotted holes, uncounted source index
This looked like a live-edge renderer bug, then like a Yahoo→Schwab join hole,
then like missing `futureSpace` slots. It was none of those. Write this down
before "fixing" the next kink.
**What it looked like.** 30m/1m manual lines on the production 1m chart bent
mid-session. Local Yahoo-only did not. The join after each Schwab deploy is a
~9-minute 1m hole (today 02:18→02:27 CDT). That was the obvious suspect.
**What we measured.** Last-80 1m times had that one hole. The full tape had 23
holes. For each hole, count missing minutes vs timestamps already owned by
`futureSpace`:
- 1–10 minute Yahoo holes, including the 02:18 join and the nightly
22:58–23:09 gap: `owned === missing`. Slots were already there.
- Daily 15:59→17:00 (60 missing) and Fri 15:59→Sun 17:10: `owned === 0`.
Compressed on purpose (`MAX_INTRADAY_GAP_SECONDS = 30m`). Local Yahoo has
those too.
So the join-hole theory was dead. Do not insert more slots for a hole that
already owns them. Do not "fix" settlement or the weekend unless the product
decision changes.
**What was actually bent.** The time scale and the line's source index
disagreed. `futureSpace` gave the hole eight empty columns. Source times were
still the real bars only, so `timeframeIndexAt` treated 02:18 and 02:27 as
neighbours: one index step, nine slots of x, the segment went flat, then
resumed. Worst on 1m-native and 5m (aggregator does not synthesise empty
buckets). 30m often survived because Yahoo seeds 30m separately, so those
opens still existed.
**The check, in order, next time a line kinks:**
1. List 1m holes and `futureSpace` ownership (`kind=geometry` SNAPDBG).
2. If `owned === missing`, the scale is not the bug. Compare source index
across the hole to slot count. They must match.
3. If `owned === 0` and the hole is ≤30m, slots were dropped — that is a
scale bug. If the hole is settlement/weekend, compression is intended.
4. Do not patch live-edge geometry from a mid-session tape hole. Do not
ask for another console paste; `?diag=1` posts the report.
**The fix.** `fill_short_gaps` inserts missing bucket opens inside the same
30-minute bound, in `trendline_series`, in `price_in_timeframe_space`, and in
the browser's `sourceTimes`. A 9-minute hole becomes nine index steps.
Weekends and settlement stay one step.
`?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership
and off-median segments). Read it with `logs --since 20m`.
### 2026-08-26 — 1m tab CPU after the overlay ban
Banning `scheduleOverlays(true)` on ticks was not enough. Forming 1m ticks
still `futureSpace.setData`'d 5k-gap + 180 future points, `syncLevels`'d every
HTF line, and the socket still attached 180 `future_times` (twice) plus a
`trendline_bar` per sloped HTF. Vue then re-rendered the whole page because
`price` and `dataReceivedAt` are refs on the one root component.
A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF
geometry and future calendars go out when that timeframe's timestamp advances.
The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz.
Schwab still prints many forming ticks per frame. Those now coalesce to one
`candles.update` on the next animation frame. A new minute flushes immediately.
**Animate current price** was two infinite CSS animations across the full plot:
`background-position` on a masked `repeating-linear-gradient`, plus a
`color-mix` shine. That cannot run on the compositor. Turning it off dropped
tab CPU from ~20% to ~5%. The cue is now a 20px frame around the right-hand
price label, opacity only.
### 2026-08-31 — End trendline here was hidden unless the click was past last_t
The context item only appeared when `cutoff > last_t`. A line whose second
anchor already sits at or past the live edge never offered it; Duplicate and
Delete still showed. The click does not move `last_t`. It sets `cutoff_t`,
which clips drawing. Slope and both handles stay. Extend clears the cutoff.
The menu now offers End here for any click after the earlier of the two
anchors, including on the body between them and at `last_t` itself (that
stops the projection without moving the end handle).
### 2026-08-31 — instrument profile and symbol stamps
Drawings, alert-state rows, and events now carry `symbol`, default `/ES`.
A missing field loads as ES so production JSON does not need a rewrite
before the first save. Fired zones for `/ES` and `ES=F` are the same
root; `/GC` at the same price is not. The browser snap grid reads `tick`
from the snapshot/status profile instead of a chart constant. No
switcher and no second stream — see `docs/investigate_added_symbols.md`.
### 2026-09-24 — a Schwab outage stayed a hole until the next deploy
The chart was missing Sept 9 → 24. The Schwab refresh token was dead
(`invalid_grant` every 5 s). After reauth, live bars resumed but the gap did
not fill. History was only ever fetched by the startup seed; the stream's
reconnect loop just resumes the present. The container had been up since the
Sept 4 deploy.
`put()` could not have taken the recovered bars anyway: it only lands a bar at
the tail or within 8 buckets of it, and the live bars were already newer. Hence
`InMemoryBarStore.fill`, which inserts into empty buckets only and never
replaces a live bar. A `deque` with `maxlen` also raises on `insert` when full,
so it rebuilds the sorted deque and lets `maxlen` drop the oldest.
A reconnect more than 120 s past the last bar now triggers a backfill (`docs/plan.md`, after
"In production both run at once"). Measured against real Yahoo for this outage: 11,173 bars in about
1 s including network, 3 ms of that in `fill`. 1h/30m/1d cover all 15 days. 1m
only reaches back 8 days, so 1m/5m/15m start Sept 17, and the 5,000-bar 1m cap
keeps about 3.5 days of that.
Two things that looked fine and were not:
- Yahoo `ES=F` is ~10 minutes late. The first pass always leaves the last
~10 minutes before reconnect empty, so a second pass runs after
`delay_minutes`.
- The live aggregator opened today's daily bar (and the current hour) at
the reconnect minute, so filling holes alone left the day's open/high/low
wrong, and tomorrow's prior-day levels would inherit that. `refold_forming`
rebuilds those buckets from the stored minutes, idempotently.

View file

@ -0,0 +1,160 @@
# Added symbols — `/ES`, `/NQ`, `/GC`, `/CL`
**Status: placeholders in, switcher not.** Agreed 2026-08-31. Profile
and `symbol` stamps are live; still one stream and no UI switcher.
The process is one chart of one contract. Yahoo and Schwab already know
the other roots. Almost everything after the stream is *this* instrument.
The likely set is four: **ES, NQ, gold, oil**. Design for N, not a
boolean ES/gold switch.
---
## Decision
**Persist `symbol` now. Do not split `Runtime` now.**
Same move as `user_id: "shared"` in `docs/multi_user.md`: cheap while
there is one value, expensive after two files exist.
### Do first (placeholders, still one live series)
1. **Instrument profile** the settings and the browser both see:
```
id: "es" | "nq" | "gc" | "cl"
yahoo_symbol: "ES=F" | "NQ=F" | "GC=F" | "CL=F"
schwab_symbol: "/ES" | "/NQ" | "/GC" | "/CL"
tick: 0.25 | 0.25 | 0.10 | 0.01
decimals: 2
session: globex_18_17
rth: spy_rth | spy_rth | none | nymex_day
```
Snap, nudge, alert inputs, and the status label read `tick` / names
from here. Kill `ConfluenceChart.TICK = 0.25`. Session code stays
shared. RTH follows the profile (`none` hides SPY marks on gold).
2. **Stamp persistence.** Drawings, alert-state rows, and events get
`symbol` (Schwab root, e.g. `/ES`). Missing field means `/ES`. New
writes always stamp the current profile. Do not wait for a second
chart.
3. **Keep one store, one stream, one seed.** `Bar.symbol` already exists.
`InMemoryBarStore` stays `tf → bars` until something actually switches.
Today’s env still selects the one live profile (`YAHOO_SYMBOL` /
`SCHWAB_SYMBOL` or an `INSTRUMENT=es` key). Default remains ES.
### Then (after placeholders have been live)
4. Prove a second root as a **replace**: point env at NQ or GC, restart,
confirm Yahoo history + Schwab stream + tick snaps. NQ is the cheap
proof (same tick and RTH as ES). GC or CL is the proof that tick/RTH
actually split.
5. **Switcher** last: UI picks the profile; snapshot replace (`setBars`),
not a tick; filter drawings/alerts by `symbol`; lazy-seed the other
series; do not Schwab-sub the hidden root.
### Not in this plan
Two live streams (ES and gold on screen together). That is a second tick
path and a second 5k-bar store inside Stay cheap, plus an unverified
double `CHART_FUTURES` sub on one token. Not until the switcher has been
used.
---
## Instrument table
| | `/ES` | `/NQ` | `/GC` | `/CL` |
|---|---|---|---|---|
| Tick | 0.25 | 0.25 | 0.10 | 0.01 |
| Display | 2 dp | 2 dp | 2 dp | 2 dp |
| Yahoo | `ES=F` | `NQ=F` | `GC=F` | `CL=F` |
| Schwab | `/ES` | `/NQ` | `/GC` | `/CL` |
| Globex 18:00–17:00 | yes | yes | yes | yes |
| SPY RTH overlay | yes | yes | no | no (NYMEX day 9:00–14:30 ET) |
| Options UI | keep | hide or later | hide | hide |
NQ is the cheapest second chart. Gold and oil are why tick cannot stay a
chart constant. `toFixed(2)` covers all four.
---
## Why the placeholders
The current process is one `Runtime`, one Schwab socket, one bar store,
one `manual_lines.json`, one alert-state file, one ntfy topic.
| File | Today | After step 2 |
|---|---|---|
| `data/manual_lines.json` | no symbol | each row `symbol: "/ES"` |
| `data/alert_state.json` | zones by price | zone + symbol |
| `data/events.json` | one log | tagged or filtered by symbol |
| `data/user_prefs.json` | global | leave global until the switcher |
| `localStorage` | layers, theme | leave until the switcher |
Without `symbol` on drawings, a switcher would mix ES lines onto NQ.
Without it on alert state, gold 2650 would be silenced by an old ES
2650. Adding the field later is a migration of production JSON.
`ConfluenceChart.TICK = 0.25` feeds every snap, keyboard nudge, and the
price-alert `step`. Gold cannot ship with that literal. Pulling it into
the profile is not scaffolding — it is deleting a lie.
Schwab continuous roots (`/ES`, `/NQ`, `/GC`, `/CL`) should auto-resolve
the front month the same way `/ES` → `/ESU26`. Verify each with
`scripts/check_stream.py` before trusting it. Singular `get_quote("/GC")`
is still the equity slash trap; always `get_quotes`.
---
## What stays generic
Aggregator, VWAP, daily MAs, prior-day H/L/C (session is shared Globex),
WebSocket snapshot shape, drawing tools, Fibonacci, comments, confluence
*math*, ntfy, auth. They work if the bars and the profile are the
instrument’s.
What does not: RTH marks, tick grid, options panel, confluence *score*
(28 and the 4h cooldown were calibrated on ES — re-run
`scripts/calibrate_alerts.py` per tape before turning confluence on).
Yahoo daily bars stay unused. 1h → session 1d, same as ES.
---
## Stay cheap
The tick budget is one series: forming tick = candle + price label.
A symbol switch is `setBars`. Do not subscribe Schwab to a hidden root.
Do not 2× the ~82s Yahoo seed; lazy-load the next instrument on first
view.
Two symbols on one Schwab socket is unverified. Two *processes* both
opening a stream still kick each other off.
---
## What not to do
- Do not split `Runtime` or the bar store until the switcher exists.
- Do not add a disabled switcher, a second seed, or a second Schwab sub
in the placeholder change.
- Do not add a second `package.json`, Vue app, or process “for gold.”
- Do not leave `TICK = 0.25` and “just chart gold.”
- Do not reuse ES confluence calibration.
- Do not show SPY RTH on metals or oil.
- Do not build gold/oil/NQ options in the same change as the chart.
- Do not put two symbols in one `manual_lines.json` without a symbol key.
- Do not start two live streams.
---
## Open, before the switcher (not before placeholders)
1. First extra root to prove as a replace: NQ (easy) or GC (forces tick)?
2. Does `/NQ` `/GC` `/CL` on this account stream `delayed: false`?
3. Does each Yahoo `*F` 1h series go back far enough for a daily 200 SMA?

View file

@ -176,6 +176,16 @@ Nothing downstream of these may know which source it is using. Selection is one
var. **In production both run at once:** Yahoo seeds history at startup, Schwab var. **In production both run at once:** Yahoo seeds history at startup, Schwab
provides the live tail. provides the live tail.
When the live stream reconnects more than two minutes past its last bar,
`Runtime.backfill_gap` fetches the missed stretch from the same seed source
(1h, 30m, 1m, each limited to its seed range), rebuilds higher timeframes with a
fresh aggregator, and inserts only into empty buckets (`InMemoryBarStore.fill`).
Live bars always win. The live aggregator's still-forming buckets are then
refolded from the stored minutes, levels are rebuilt without evaluating alerts,
and every socket gets a `resync` → full `snapshot`. Yahoo is ~10 minutes late,
so a second pass runs after that delay for the minutes just before reconnect.
The bucket that was forming when the stream *died* keeps only what it had.
### Do not use Yahoo's daily bars ### Do not use Yahoo's daily bars
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
@ -816,8 +826,8 @@ Server → client:
Rules: Rules:
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch - Send `bar` on **every** update of the forming bar (that is the live chart) but batch
`levels` — they only change on higher-TF closes. `levels` — they only change on higher-TF closes.
- Always send a full `snapshot` on connect and after any reconnect. The client must - Always send a full `snapshot` on connect and after any reconnect, and after the
never try to reconcile a gap. server backfills a gap (`resync`). The client must never try to reconcile a gap.
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is - Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
the entire point: a 4h line drawn through a 1m chart. the entire point: a 4h line drawn through a 1m chart.
@ -1143,6 +1153,9 @@ this is a complete, useful product with zero hand-drawn input and zero tuning.
**Done when:** a replayed tape produces a sane number of alerts (single digits per **Done when:** a replayed tape produces a sane number of alerts (single digits per
session), each corresponding to a real multi-timeframe convergence. session), each corresponding to a real multi-timeframe convergence.
**Update 2026-08-27:** Auto confluence (ZONE) alerts are off by default. Config →
Confluence alerts. Armed drawings and DMA bells still fire.
### M5 — Manual trendlines ### M5 — Manual trendlines
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a). Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
Hand-drawn lines are authoritative — full weight, no quality discount. Hand-drawn lines are authoritative — full weight, no quality discount.

View file

@ -135,6 +135,7 @@ createApp({
const drawingFilter = ref(''); const drawingFilter = ref('');
const drawingKind = ref('all'); const drawingKind = ref('all');
const maAlerts = ref({ '1d': [] }); const maAlerts = ref({ '1d': [] });
const confluenceAlerts = ref(false);
const drawingTf = ref('all'); const drawingTf = ref('all');
const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false); const captureBusy = ref(false);
@ -153,6 +154,8 @@ createApp({
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false'); const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false');
const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true'); const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true');
const tick = ref(0.25);
const sessionRange = ref(localStorage.getItem('chart-session-range') !== 'false');
const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false'); const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false');
const optionPrefs = (() => { const optionPrefs = (() => {
try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; } try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; }
@ -363,7 +366,13 @@ createApp({
async function refreshStatus() { async function refreshStatus() {
const response = await apiFetch('/api/status'); const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json(); if (response.ok) {
status.value = await response.json();
if (status.value.instrument && chartApi) {
chartApi.setInstrument(status.value.instrument);
tick.value = status.value.instrument.tick || tick.value;
}
}
} }
function selectedExpiration() { function selectedExpiration() {
@ -481,6 +490,34 @@ createApp({
}); });
watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs); watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs);
let livePriceFlush = null;
let pendingLivePrice = null;
function paintQuote(next) {
if (next == null) return;
const priceEl = document.querySelector('.quote .price');
if (priceEl) priceEl.textContent = next.toFixed(2);
const open = sessionOpen.value;
const changeEl = document.querySelector('.quote .quote-change');
if (!changeEl || !(open > 0)) return;
const points = next - open;
const percent = points / open * 100;
changeEl.textContent =
`${points >= 0 ? '+' : ''}${points.toFixed(2)} (${percent >= 0 ? '+' : ''}${percent.toFixed(2)}%)`;
changeEl.classList.toggle('positive', points > 0);
changeEl.classList.toggle('negative', points < 0);
changeEl.classList.toggle('flat', points === 0);
}
function noteLivePrice(next) {
if (next == null) return;
paintQuote(next);
pendingLivePrice = next;
if (next === price.value || livePriceFlush) return;
livePriceFlush = setTimeout(() => {
livePriceFlush = null;
if (pendingLivePrice != null) price.value = pendingLivePrice;
}, 1000);
}
function connect() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
@ -495,6 +532,10 @@ createApp({
if (message.type === 'snapshot') { if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
chartApi.setTrendlineGeometry(message.trendline_geometry); chartApi.setTrendlineGeometry(message.trendline_geometry);
if (message.instrument) {
chartApi.setInstrument(message.instrument);
tick.value = message.instrument.tick || tick.value;
}
chartApi.setDisplayFutureTimes(message.future_times); chartApi.setDisplayFutureTimes(message.future_times);
chartApi.setBars(message.bars); chartApi.setBars(message.bars);
levels.value = message.levels || []; levels.value = message.levels || [];
@ -506,6 +547,9 @@ createApp({
drawColorInitialized = true; drawColorInitialized = true;
} }
syncVisibleLevels(); syncVisibleLevels();
chartApi.setSessionRange({
t: message.session_t, high: message.session_high, low: message.session_low,
});
price.value = message.price; price.value = message.price;
sessionOpen.value = message.session_open ?? sessionOpen.value; sessionOpen.value = message.session_open ?? sessionOpen.value;
hydrateEvents(message.events, message.events_more); hydrateEvents(message.events, message.events_more);
@ -518,6 +562,7 @@ createApp({
if (message.tf && message.tf !== timeframe.value) return; if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now(); dataReceivedAt.value = Date.now();
if (message.trendline_first_t != null) { if (message.trendline_first_t != null) {
const lastSource = chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t, message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
message.trendline_previous_t, message.trendline_previous_t,
@ -525,22 +570,34 @@ createApp({
); );
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) { if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' })); socket.send(JSON.stringify({ type: 'trendline_geometry' }));
} else if (chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1) !== lastSource) {
syncVisibleLevels();
} }
} }
chartApi.setDisplayFutureTimes(message.future_times); if (message.future_times) chartApi.setDisplayFutureTimes(message.future_times);
if (message.session_t != null) {
chartApi.setSessionRange({
t: message.session_t, high: message.session_high, low: message.session_low,
});
}
chartApi.updateBar(message.bar); chartApi.updateBar(message.bar);
price.value = message.bar.c; noteLivePrice(message.bar.c);
sessionOpen.value = message.session_open ?? sessionOpen.value; if (message.session_open != null) sessionOpen.value = message.session_open;
status.value.last_bar_t = message.bar.t; if (status.value.last_bar_t !== message.bar.t) {
status.value.last_bar_t = message.bar.t;
dataReceivedAt.value = Date.now();
}
} else if (message.type === 'trendline_bar') { } else if (message.type === 'trendline_bar') {
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
const geometryCurrent = chartApi.updateTrendlineTime( const geometryCurrent = chartApi.updateTrendlineTime(
message.tf, message.t, message.duration, message.first_t, message.previous_t, message.tf, message.t, message.duration, message.first_t, message.previous_t,
message.future_times, message.future_durations, message.future_times, message.future_durations,
); );
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) { if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'trendline_geometry' })); socket.send(JSON.stringify({ type: 'trendline_geometry' }));
} else if (chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1) !== lastSource) {
syncVisibleLevels();
} }
syncVisibleLevels();
} else if (message.type === 'trendline_geometry') { } else if (message.type === 'trendline_geometry') {
chartApi.setTrendlineGeometry(message.geometry); chartApi.setTrendlineGeometry(message.geometry);
syncVisibleLevels(); syncVisibleLevels();
@ -1058,6 +1115,14 @@ createApp({
return chartApi?.shiftLineTime(line, time, count) ?? null; return chartApi?.shiftLineTime(line, time, count) ?? null;
} }
function duplicateShiftCount(line, count = 10) {
const times = [line.anchor_t, line.last_t];
if (line.cutoff_t != null) times.push(line.cutoff_t);
const allowed = times.map(time => chartApi?.maxLineShift(line, time, count));
if (allowed.some(value => value == null)) return null;
return Math.min(...allowed);
}
function duplicateName(line) { function duplicateName(line) {
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/); const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
const base = match[1]; const base = match[1];
@ -1067,12 +1132,64 @@ createApp({
return `${base} ${number}`; return `${base} ${number}`;
} }
const selectedTrendline = computed(() => {
const id = selectedDrawing.value;
if (!id) return null;
const line = levels.value.find(level => level.id === id);
if (!line || line.kind !== 'manual' || !line.slope) return null;
return line;
});
async function duplicateSelected() {
const line = selectedTrendline.value;
if (!line) {
addEvent('DUPLICATE FAILED · select one trendline', 'error');
return;
}
await duplicateLine(line);
}
async function duplicateLine(line) { async function duplicateLine(line) {
if (line.geometry_resolved === false) return; const fail = reason => {
const anchorT = shiftTimeByBars(line, line.anchor_t); addEvent(`DUPLICATE #${line.number} FAILED · ${reason}`, 'error');
const endT = shiftTimeByBars(line, line.last_t); fetch('/api/debug/snap', {
const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t); method: 'POST',
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) return; headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'duplicate',
tf: line.tf,
note: `#${line.number} ${reason} last_t ${line.last_t} cutoff ${line.cutoff_t}`,
}),
}).catch(() => {});
};
fetch('/api/debug/snap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'duplicate',
tf: line.tf,
note: `start #${line.number} last_t ${line.last_t} cutoff ${line.cutoff_t}`,
}),
}).catch(() => {});
if (line.geometry_resolved === false) {
fail('no source geometry');
return;
}
const count = duplicateShiftCount(line);
if (count == null) {
fail('cannot shift');
return;
}
const shift = (time, bars) => (count > 0
? shiftTimeByBars(line, time, bars)
: chartApi.shiftDisplayedTime(time, 10));
const anchorT = shift(line.anchor_t, count);
const endT = shift(line.last_t, count);
const cutoffT = line.cutoff_t == null ? null : shift(line.cutoff_t, count);
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) {
fail('cannot shift');
return;
}
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
const response = await apiFetch('/api/lines', { const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -1087,7 +1204,7 @@ createApp({
}), }),
}); });
if (!response.ok) { if (!response.ok) {
console.error(`Unable to duplicate line: HTTP ${response.status}`); addEvent(`DUPLICATE #${line.number} FAILED · HTTP ${response.status}`, 'error');
return; return;
} }
const saved = await response.json(); const saved = await response.json();
@ -1248,14 +1365,14 @@ createApp({
if (item.line) { if (item.line) {
const line = { ...item.line }; const line = { ...item.line };
const previous = { ...item.line }; const previous = { ...item.line };
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); if (vertical) line.anchor_p = chartApi.snapPrice(line.anchor_p + vertical * chartApi.tick);
if (line.slope === 0) { if (line.slope === 0) {
if (vertical) await updateLineGeometry(line, previous); if (vertical) await updateLineGeometry(line, previous);
continue; continue;
} }
const endPrice = item.line.anchor_p const endPrice = item.line.anchor_p
+ item.line.slope * (item.line.last_t - item.line.anchor_t) + item.line.slope * (item.line.last_t - item.line.anchor_t)
+ vertical * ConfluenceChart.TICK; + vertical * chartApi.tick;
if (horizontal) { if (horizontal) {
if (line.geometry_resolved === false) continue; if (line.geometry_resolved === false) continue;
const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal); const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
@ -1277,8 +1394,8 @@ createApp({
if (comment.pinned) { if (comment.pinned) {
const changes = {}; const changes = {};
if (vertical) { if (vertical) {
changes.anchor_p = ConfluenceChart.snapToTick( changes.anchor_p = chartApi.snapPrice(
comment.anchor_p + vertical * ConfluenceChart.TICK, comment.anchor_p + vertical * chartApi.tick,
); );
} }
if (horizontal) { if (horizontal) {
@ -1432,6 +1549,11 @@ createApp({
if (response.ok) maAlerts.value = await response.json(); if (response.ok) maAlerts.value = await response.json();
} }
async function refreshConfluenceAlerts() {
const response = await apiFetch('/api/prefs/confluence-alerts');
if (response.ok) confluenceAlerts.value = Boolean((await response.json()).enabled);
}
async function toggleMaAlert(period) { async function toggleMaAlert(period) {
const armed = new Set(maAlerts.value['1d'] || []); const armed = new Set(maAlerts.value['1d'] || []);
if (armed.has(period)) armed.delete(period); if (armed.has(period)) armed.delete(period);
@ -1507,6 +1629,14 @@ createApp({
localStorage.setItem('chart-color-row-labels', JSON.stringify(value)); localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
}, { deep: true }); }, { deep: true });
watch(confluenceAlerts, value => {
apiFetch('/api/prefs/confluence-alerts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: Boolean(value) }),
}).catch(() => {});
});
watch(animateCurrentPrice, value => { watch(animateCurrentPrice, value => {
localStorage.setItem('chart-animate-current-price', String(value)); localStorage.setItem('chart-animate-current-price', String(value));
if (chartApi) chartApi.setCurrentPriceAnimation(value); if (chartApi) chartApi.setCurrentPriceAnimation(value);
@ -1517,6 +1647,11 @@ createApp({
if (chartApi) chartApi.setAutoScrollLivePrice(value); if (chartApi) chartApi.setAutoScrollLivePrice(value);
}); });
watch(sessionRange, value => {
localStorage.setItem('chart-session-range', String(value));
if (chartApi) chartApi.setSessionRangeEnabled(value);
});
watch(extraDetail, value => { watch(extraDetail, value => {
localStorage.setItem('chart-extra-detail', String(value)); localStorage.setItem('chart-extra-detail', String(value));
}); });
@ -1548,6 +1683,7 @@ createApp({
chartApi.setSnap(snap.value); chartApi.setSnap(snap.value);
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value); chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value); chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
chartApi.setSessionRangeEnabled(sessionRange.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment }); chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
nextTick(() => { nextTick(() => {
@ -1559,6 +1695,7 @@ createApp({
await refreshStatus(); await refreshStatus();
await refreshComments(); await refreshComments();
await refreshMaAlerts(); await refreshMaAlerts();
await refreshConfluenceAlerts();
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' }; build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
connect(); connect();
@ -1571,6 +1708,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo }; return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, sessionRange, hideLowerTfDrawings, confluenceAlerts, tick, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, selectedTrendline, duplicateSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -195,6 +195,87 @@ class FibLevelsPrimitive {
} }
} }
class SessionRangePrimitive {
constructor() {
this._high = null;
this._low = null;
this._enabled = true;
this._series = null;
this._requestUpdate = null;
this._items = [];
this._view = {
update: () => this._sync(),
renderer: () => this._renderer,
};
this._renderer = { draw: target => this._draw(target) };
}
attached(param) {
this._series = param.series;
this._requestUpdate = param.requestUpdate;
}
detached() {
this._series = null;
this._requestUpdate = null;
}
setRange(high, low, enabled) {
this._high = high;
this._low = low;
this._enabled = Boolean(enabled);
if (this._requestUpdate) this._requestUpdate();
}
updateAllViews() {
this._view.update();
}
paneViews() {
return [this._view];
}
_sync() {
this._items = [];
if (!this._enabled || !this._series || this._high == null || this._low == null) return;
const highY = this._series.priceToCoordinate(this._high);
const lowY = this._series.priceToCoordinate(this._low);
if (highY != null) this._items.push({ key: 'H', value: this._high, y: highY, color: '#27825c' });
if (lowY != null) this._items.push({ key: 'L', value: this._low, y: lowY, color: '#bd4545' });
if (this._items.length === 2 && Math.abs(this._items[0].y - this._items[1].y) < 14) {
this._items[0].y -= 7;
this._items[1].y += 7;
}
}
_draw(target) {
if (!this._items.length) return;
target.useMediaCoordinateSpace(scope => {
const ctx = scope.context;
const right = scope.mediaSize.width;
const height = scope.mediaSize.height;
ctx.save();
ctx.font = '9px "IBM Plex Mono", ui-monospace, monospace';
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (const item of this._items) {
if (item.y < 0 || item.y > height) continue;
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(right - 22, item.y + 0.5);
ctx.lineTo(right, item.y + 0.5);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillText(`${item.key} ${Number(item.value).toFixed(2)}`, right - 26, item.y);
}
ctx.restore();
});
}
}
class ConfluenceChart { class ConfluenceChart {
constructor() { constructor() {
this.chart = null; this.chart = null;
@ -211,6 +292,10 @@ class ConfluenceChart {
this.animateCurrentPrice = true; this.animateCurrentPrice = true;
this.autoScrollLivePrice = true; this.autoScrollLivePrice = true;
this.lastCurrentPrice = null; this.lastCurrentPrice = null;
this.pendingBar = null;
this.pendingBarFrame = null;
this.tick = 0.25;
this.rthMode = 'spy_rth';
this.previewLine = null; this.previewLine = null;
this.lineBridgeLayer = null; this.lineBridgeLayer = null;
this.bars = []; this.bars = [];
@ -272,6 +357,11 @@ class ConfluenceChart {
this.commentNodes = new Map(); this.commentNodes = new Map();
this.commentLayer = null; this.commentLayer = null;
this.contextLabelLayer = null; this.contextLabelLayer = null;
this.sessionRangePrimitive = null;
this.sessionRangeEnabled = true;
this.sessionT = null;
this.sessionHigh = null;
this.sessionLow = null;
this.overlayFrame = null; this.overlayFrame = null;
this.overlayForce = false; this.overlayForce = false;
this.overlayRangeKey = ''; this.overlayRangeKey = '';
@ -296,8 +386,6 @@ class ConfluenceChart {
}; };
} }
static TICK = 0.25;
// Pixels of travel between press and release that make a gesture a drag // Pixels of travel between press and release that make a gesture a drag
// rather than a click. Wide enough to survive a twitch on a deliberate click. // rather than a click. Wide enough to survive a twitch on a deliberate click.
static DRAG_THRESHOLD = 12; static DRAG_THRESHOLD = 12;
@ -344,8 +432,20 @@ class ConfluenceChart {
return localStorage.getItem('chart-diag') === '1'; return localStorage.getItem('chart-diag') === '1';
} }
static snapToTick(price) { static snapToTick(price, tick = 0.25) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; const step = tick > 0 ? tick : 0.25;
return Math.round(price / step) * step;
}
snapPrice(price) {
return ConfluenceChart.snapToTick(price, this.tick);
}
setInstrument(instrument) {
if (!instrument) return;
if (instrument.tick > 0) this.tick = Number(instrument.tick);
if (instrument.rth) this.rthMode = instrument.rth;
this.syncRthLines();
} }
create(el) { create(el) {
@ -455,6 +555,8 @@ class ConfluenceChart {
this.candles.attachPrimitive(this.rthPrimitive); this.candles.attachPrimitive(this.rthPrimitive);
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time)); this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
this.candles.attachPrimitive(this.fibPrimitive); this.candles.attachPrimitive(this.fibPrimitive);
this.sessionRangePrimitive = new SessionRangePrimitive();
this.candles.attachPrimitive(this.sessionRangePrimitive);
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, { this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
priceScaleId: 'right', priceScaleId: 'right',
color: 'rgba(0,0,0,0)', color: 'rgba(0,0,0,0)',
@ -492,6 +594,7 @@ class ConfluenceChart {
this.overlayLayer.appendChild(this.ohlcReadout); this.overlayLayer.appendChild(this.ohlcReadout);
this.currentPricePulse = document.createElement('div'); this.currentPricePulse = document.createElement('div');
this.currentPricePulse.className = 'current-price-pulse'; this.currentPricePulse.className = 'current-price-pulse';
this.currentPricePulse.innerHTML = '<span class="current-price-tri current-price-tri-right"></span><span class="current-price-tri current-price-tri-left"></span>';
this.currentPricePulse.hidden = true; this.currentPricePulse.hidden = true;
this.overlayLayer.appendChild(this.currentPricePulse); this.overlayLayer.appendChild(this.currentPricePulse);
this.projectionDiagnostic = document.createElement('pre'); this.projectionDiagnostic = document.createElement('pre');
@ -564,9 +667,11 @@ class ConfluenceChart {
duplicate.type = 'button'; duplicate.type = 'button';
duplicate.dataset.action = 'duplicate'; duplicate.dataset.action = 'duplicate';
duplicate.textContent = 'Duplicate'; duplicate.textContent = 'Duplicate';
duplicate.addEventListener('click', event => { duplicate.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const level = this.levels.find(value => value.id === this.contextLineId); const id = this.contextLineId || this.selectedLineId;
const level = this.levels.find(value => value.id === id);
this.hideContextMenu(); this.hideContextMenu();
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level }); if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
}); });
@ -580,6 +685,7 @@ class ConfluenceChart {
this.hideContextMenu(); this.hideContextMenu();
if (id && this.onLineDelete) this.onLineDelete(id); if (id && this.onLineDelete) this.onLineDelete(id);
}); });
this.contextMenu.addEventListener('pointerdown', event => event.stopPropagation());
this.contextMenu.addEventListener('click', event => event.stopPropagation()); this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.append(endHere, extend, duplicate, deleteLine); this.contextMenu.append(endHere, extend, duplicate, deleteLine);
this.overlayLayer.appendChild(this.contextMenu); this.overlayLayer.appendChild(this.contextMenu);
@ -683,6 +789,7 @@ class ConfluenceChart {
} }
this.syncRthLines(); this.syncRthLines();
this.scheduleOverlays(true); this.scheduleOverlays(true);
this.scheduleGeometryDiagnostic();
} }
setTrendlineGeometry(geometry) { setTrendlineGeometry(geometry) {
@ -704,7 +811,10 @@ class ConfluenceChart {
} }
setDisplayFutureTimes(times) { setDisplayFutureTimes(times) {
this.displayFutureTimes = (times || []).map(Number); const next = (times || []).map(Number);
const prev = this.displayFutureTimes || [];
if (next.length === prev.length && next.every((time, index) => time === prev[index])) return;
this.displayFutureTimes = next;
if (this.bars.length && this.futureSpace) { if (this.bars.length && this.futureSpace) {
this.futureSpace.setData(this.timeScaleSlots(this.bars)); this.futureSpace.setData(this.timeScaleSlots(this.bars));
} }
@ -747,9 +857,14 @@ 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); const nextFuture = futureTimes.map(Number);
const sameFuture = nextFuture.length === source.futureTimes.length
&& nextFuture.every((time, index) => time === source.futureTimes[index]);
if (sameFuture && last >= 0 && t === source.times[last]) return true;
source.futureTimes = nextFuture;
source.futureDurations = futureDurations.map(Number); source.futureDurations = futureDurations.map(Number);
source.allTimes = [...source.times, ...source.futureTimes]; source.allTimes = [...source.times, ...source.futureTimes];
source._filled = null;
return true; return true;
} }
@ -796,6 +911,30 @@ class ConfluenceChart {
} }
updateBar(bar) { updateBar(bar) {
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
if (!last || bar.t > last.t) {
this.flushPendingBar();
this.applyBar(bar);
return;
}
this.pendingBar = bar;
if (this.pendingBarFrame == null) {
this.pendingBarFrame = requestAnimationFrame(() => this.flushPendingBar());
}
}
flushPendingBar() {
if (this.pendingBarFrame != null) {
cancelAnimationFrame(this.pendingBarFrame);
this.pendingBarFrame = null;
}
const bar = this.pendingBar;
this.pendingBar = null;
if (bar) this.applyBar(bar);
}
applyBar(bar) {
// update() throws on anything older than the series' last point, which // update() throws on anything older than the series' last point, which
// takes the whole app down rather than dropping one stale bar. Ticks made // takes the whole app down rather than dropping one stale bar. Ticks made
// this reachable often enough to matter, so refuse it here as well as at // this reachable often enough to matter, so refuse it here as well as at
@ -809,11 +948,54 @@ class ConfluenceChart {
this.volume.update(ConfluenceChart.toVolume(bar)); this.volume.update(ConfluenceChart.toVolume(bar));
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar); else this.bars.push(bar);
this.expandSessionRange(bar);
if (advanced) this.extendManualLineSeries(last, bar); if (advanced) this.extendManualLineSeries(last, bar);
if (advanced) this.syncRthLines(); if (advanced) this.syncRthLines();
if (advanced) this.scheduleOverlays(true); if (advanced) this.scheduleOverlays(true);
} }
setSessionRangeEnabled(enabled) {
this.sessionRangeEnabled = Boolean(enabled);
this.pushSessionRange();
}
setSessionRange(range) {
if (!range || range.t == null || range.high == null || range.low == null) return;
const rolled = this.sessionT == null || range.t !== this.sessionT;
this.sessionT = range.t;
if (rolled) {
this.sessionHigh = range.high;
this.sessionLow = range.low;
} else {
this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high);
this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low);
}
this.pushSessionRange();
}
expandSessionRange(bar) {
if (!this.sessionRangeEnabled || this.bars[0]?.tf === '1d') return;
if (this.sessionT == null || bar.t < this.sessionT) return;
let changed = false;
if (bar.h != null && (this.sessionHigh == null || bar.h > this.sessionHigh)) {
this.sessionHigh = bar.h;
changed = true;
}
if (bar.l != null && (this.sessionLow == null || bar.l < this.sessionLow)) {
this.sessionLow = bar.l;
changed = true;
}
if (changed) this.pushSessionRange();
}
pushSessionRange() {
this.sessionRangePrimitive?.setRange(
this.sessionHigh,
this.sessionLow,
this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d',
);
}
extendManualLineSeries(previous, bar) { extendManualLineSeries(previous, bar) {
const step = ConfluenceChart.barInterval(this.bars); const step = ConfluenceChart.barInterval(this.bars);
if (!(step > 0)) return; if (!(step > 0)) return;
@ -884,11 +1066,15 @@ class ConfluenceChart {
updateCurrentPrice(price) { updateCurrentPrice(price) {
if (price == null) return; if (price == null) return;
if (this.currentPriceLine && price === this.lastCurrentPrice) {
this.syncCurrentPricePulse();
return;
}
const options = { const options = {
price, price,
color: '#b7771d', color: '#b7771d',
lineWidth: 1, lineWidth: 2,
lineStyle: LightweightCharts.LineStyle.Dotted, lineStyle: LightweightCharts.LineStyle.LargeDashed,
axisLabelVisible: true, axisLabelVisible: true,
title: '', title: '',
}; };
@ -900,13 +1086,15 @@ class ConfluenceChart {
} }
syncCurrentPricePulse() { syncCurrentPricePulse() {
if (!this.currentPricePulse) return;
const show = this.animateCurrentPrice && this.lastCurrentPrice != null; const show = this.animateCurrentPrice && this.lastCurrentPrice != null;
this.currentPricePulse.hidden = !show; if (this.currentPricePulse) {
this.currentPricePulse.classList.toggle('active', show); this.currentPricePulse.hidden = !show;
this.currentPricePulse.classList.toggle('active', show);
}
if (!show || !this.candles || this.lastCurrentPrice == null) return; if (!show || !this.candles || this.lastCurrentPrice == null) return;
const y = this.candles.priceToCoordinate(this.lastCurrentPrice); const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
if (y != null) this.currentPricePulse.style.top = `${Math.round(y)}px`; if (y == null || !this.currentPricePulse) return;
this.currentPricePulse.style.top = `${Math.round(y - 9)}px`;
} }
setCommentHandlers({ onToggle, onMove, onSelect }) { setCommentHandlers({ onToggle, onMove, onSelect }) {
@ -1237,7 +1425,7 @@ class ConfluenceChart {
if (point && this.withinPlot(point) && this.onCommentMove) { if (point && this.withinPlot(point) && this.onCommentMove) {
this.onCommentMove(node.comment, { this.onCommentMove(node.comment, {
anchor_t: Math.round(point.t), anchor_t: Math.round(point.t),
anchor_p: ConfluenceChart.snapToTick(point.p), anchor_p: this.snapPrice(point.p),
}); });
} else { } else {
this.renderComments(); this.renderComments();
@ -1441,6 +1629,7 @@ class ConfluenceChart {
this.pendingView = null; this.pendingView = null;
} }
this.scheduleOverlays(true); this.scheduleOverlays(true);
this.scheduleGeometryDiagnostic();
} }
renderContextLabels() { renderContextLabels() {
@ -1651,7 +1840,7 @@ class ConfluenceChart {
if (!point || !this.withinPlot(point) || point.t == null) return null; if (!point || !this.withinPlot(point) || point.t == null) return null;
return { return {
time: point.t, time: point.t,
price: ConfluenceChart.snapToTick(point.p), price: this.snapPrice(point.p),
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1), y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
}; };
@ -1738,6 +1927,90 @@ class ConfluenceChart {
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point)); if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
} }
postDebugSnap(body) {
fetch('/api/debug/snap', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
body: JSON.stringify(body),
}).catch(() => {});
}
scheduleGeometryDiagnostic() {
if (!ConfluenceChart.diagnosticMode()) return;
clearTimeout(this._geometryDiagTimer);
this._geometryDiagTimer = setTimeout(() => this.reportGeometryDiagnostic(), 400);
}
reportGeometryDiagnostic() {
if (!ConfluenceChart.diagnosticMode() || !this.bars.length) return;
const now = Date.now();
if (this._lastGeometryReport && now - this._lastGeometryReport < 2000) return;
this._lastGeometryReport = now;
const step = ConfluenceChart.barInterval(this.bars) || 60;
const space = new Set((this.futureSpace?.data() || []).map(point => Number(point.time)));
const gaps = [];
for (let index = 1; index < this.bars.length; index += 1) {
const delta = this.bars[index].t - this.bars[index - 1].t;
if (delta <= step) continue;
let owned = 0;
for (let time = this.bars[index - 1].t + step; time < this.bars[index].t; time += step) {
if (space.has(time)) owned += 1;
}
gaps.push({
from: this.bars[index - 1].t,
to: this.bars[index].t,
missing: delta / step - 1,
owned,
});
}
const lines = this.levels
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
.slice(0, 24)
.map(level => {
const pts = this.levelSeries.get(level.id)?.series.data() || [];
const segs = [];
for (let index = 1; index < pts.length; index += 1) {
const first = pts[index - 1];
const second = pts[index];
const x1 = this.chart.timeScale().timeToCoordinate(first.time);
const x2 = this.chart.timeScale().timeToCoordinate(second.time);
const y1 = this.candles.priceToCoordinate(first.value);
const y2 = this.candles.priceToCoordinate(second.value);
if ([x1, x2, y1, y2].some(value => value == null) || x2 === x1) continue;
segs.push({
t1: Number(first.time),
t2: Number(second.time),
dtMin: (Number(second.time) - Number(first.time)) / 60,
dx: +(x2 - x1).toFixed(2),
slope: +((y2 - y1) / (x2 - x1)).toFixed(4),
});
}
const slopes = segs.map(seg => seg.slope).sort((a, b) => a - b);
const med = slopes.length ? slopes[Math.floor(slopes.length / 2)] : null;
const thresh = Math.abs(med || 1) * 0.08;
return {
n: level.number,
tf: level.tf,
pts: pts.length,
med,
bad: segs.filter(seg => med != null && Math.abs(seg.slope - med) > thresh).slice(0, 12),
};
});
this.postDebugSnap({
kind: 'geometry',
tf: this.bars[0].tf,
bars_held: this.bars.length,
first_bar_t: this.bars[0].t,
last_bar_t: this.bars[this.bars.length - 1].t,
chart_w: this.chartEl?.clientWidth,
chart_h: this.chartEl?.clientHeight,
note: `gaps ${gaps.length} lines ${lines.length}`,
gaps: gaps.slice(0, 40),
lines,
});
}
showSnapDot(snapped, point = {}) { showSnapDot(snapped, point = {}) {
if (snapped.t == null) return this.hideSnapDot(); if (snapped.t == null) return this.hideSnapDot();
const x = this.coordinateAtTime(snapped.t); const x = this.coordinateAtTime(snapped.t);
@ -1789,12 +2062,7 @@ class ConfluenceChart {
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`, : `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
}; };
this.lastSnapDiagnostic = body; this.lastSnapDiagnostic = body;
fetch('/api/debug/snap', { this.postDebugSnap(body);
method: 'POST',
headers: { 'Content-Type': 'application/json',
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
body: JSON.stringify(body),
}).catch(() => {});
} }
} }
} }
@ -1900,7 +2168,7 @@ class ConfluenceChart {
this.onToolComplete?.({ this.onToolComplete?.({
tool, tool,
time: end.t, time: end.t,
price: ConfluenceChart.snapToTick(end.p), price: this.snapPrice(end.p),
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1), x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1), y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
}); });
@ -1910,7 +2178,7 @@ class ConfluenceChart {
if (tool === 'level') { if (tool === 'level') {
// A click with no drag is a valid placement; the drag is only there to // A click with no drag is a valid placement; the drag is only there to
// let you fine-tune the price before committing. // let you fine-tune the price before committing.
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) }); this.onToolComplete?.({ tool, price: this.snapPrice(end.p) });
return; return;
} }
@ -1993,7 +2261,7 @@ class ConfluenceChart {
const last = this.bars[this.bars.length - 1]; const last = this.bars[this.bars.length - 1];
if (fallbackT > last.t) { if (fallbackT > last.t) {
const index = Math.round(this.indexAt(fallbackT)); const index = Math.round(this.indexAt(fallbackT));
return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null }; return { t: this.timeAtIndex(index), p: this.snapPrice(point.p), snappedSide: null };
} }
// An already-snapped point carries no cursor position; return it untouched // An already-snapped point carries no cursor position; return it untouched
// rather than measuring against undefined. // rather than measuring against undefined.
@ -2022,7 +2290,7 @@ class ConfluenceChart {
if (!this.gesture) return; if (!this.gesture) return;
const { start, end } = this.gesture; const { start, end } = this.gesture;
if (this.armedTool === 'level') { if (this.armedTool === 'level') {
const price = ConfluenceChart.snapToTick(end.p); const price = this.snapPrice(end.p);
const y = this.candles.priceToCoordinate(price); const y = this.candles.priceToCoordinate(price);
if (y == null) return; if (y == null) return;
this.previewLine.removeAttribute('hidden'); this.previewLine.removeAttribute('hidden');
@ -2396,8 +2664,29 @@ class ConfluenceChart {
?? source.futureDurations[source.futureDurations.length - 1] ?? null; ?? source.futureDurations[source.futureDurations.length - 1] ?? null;
} }
static fillShortGaps(source) {
const times = source?.allTimes || source?.times || [];
const step = source?.duration;
if (!(step > 0) || times.length < 2) return times;
const filled = [times[0]];
for (let index = 1; index < times.length; index += 1) {
const prev = filled[filled.length - 1];
const next = times[index];
const gap = next - prev;
if (gap > step && gap <= ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) {
for (let time = prev + step; time < next; time += step) filled.push(time);
}
filled.push(next);
}
return filled;
}
static sourceTimes(source) { static sourceTimes(source) {
return source?.allTimes || source?.times || []; if (!source) return [];
if (source._filled) return source._filled;
const filled = ConfluenceChart.fillShortGaps(source);
source._filled = filled;
return filled;
} }
static timeframeIndexAt(source, time, allowFuture = true) { static timeframeIndexAt(source, time, allowFuture = true) {
@ -2442,11 +2731,40 @@ class ConfluenceChart {
return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start); return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start);
} }
sourceIndexAt(level, time) {
const source = this.sourceSeries(level);
if (!source) return null;
const exact = ConfluenceChart.timeframeIndexAt(source, time);
if (exact != null) return exact;
const times = ConfluenceChart.sourceTimes(source);
if (!times.length) return null;
let lo = 0;
let hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (times[mid] <= time) lo = mid + 1;
else hi = mid;
}
return lo === 0 ? null : lo - 1;
}
shiftLineTime(level, time, bars) { shiftLineTime(level, time, bars) {
const source = this.sourceSeries(level); const source = this.sourceSeries(level);
if (!source) return this.shiftDisplayedTime(time, bars); if (!source) return this.shiftDisplayedTime(time, bars);
const index = ConfluenceChart.timeframeIndexAt(source, time); const index = this.sourceIndexAt(level, time);
return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars); if (index == null) return null;
const times = ConfluenceChart.sourceTimes(source);
const target = Math.min(index + bars, times.length - 1);
if (target < 0) return times[0] ?? null;
return ConfluenceChart.timeframeTimeAt(source, target);
}
maxLineShift(level, time, bars) {
const index = this.sourceIndexAt(level, time);
if (index == null) return null;
const source = this.sourceSeries(level);
const last = ConfluenceChart.sourceTimes(source).length - 1;
return Math.max(0, Math.min(bars, last - index));
} }
shiftDisplayedTime(time, slots) { shiftDisplayedTime(time, slots) {
@ -2514,10 +2832,18 @@ class ConfluenceChart {
if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t); if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t);
} }
} else { } else {
const displayed = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff);
for (const time of ConfluenceChart.sourceTimes(source) || []) { for (const time of ConfluenceChart.sourceTimes(source) || []) {
if (time >= first && time <= lastBar && time >= level.anchor_t && time <= cutoff) { if (time < first || time > lastBar || time < level.anchor_t || time > cutoff) continue;
stamps.add(time); let lo = 0;
let hi = displayed.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (displayed[mid].t < time) lo = mid + 1;
else hi = mid;
} }
const bar = displayed[lo] || displayed[displayed.length - 1];
if (bar) stamps.add(bar.t);
} }
if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar); if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar);
} }
@ -2863,7 +3189,7 @@ class ConfluenceChart {
? sourcePoint - sourceStart ? sourcePoint - sourceStart
: this.indexAt(point.t) - this.indexAt(start.t), : this.indexAt(point.t) - this.indexAt(start.t),
); );
const priceShift = ConfluenceChart.snapToTick(point.p - start.p); const priceShift = this.snapPrice(point.p - start.p);
const anchorT = source const anchorT = source
? this.shiftLineTime(original, original.anchor_t, indexShift) ? this.shiftLineTime(original, original.anchor_t, indexShift)
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); : this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
@ -2929,7 +3255,7 @@ class ConfluenceChart {
} }
const snapped = this.snapToBars const snapped = this.snapToBars
? this.snapPoint(point) ? this.snapPoint(point)
: { ...point, p: ConfluenceChart.snapToTick(point.p) }; : { ...point, p: this.snapPrice(point.p) };
if (snapped.t == null || snapped.p == null) return; if (snapped.t == null || snapped.p == null) return;
const time = snapped.t; const time = snapped.t;
const price = snapped.p; const price = snapped.p;
@ -2987,15 +3313,16 @@ class ConfluenceChart {
if (!level || !this.bars.length) return; if (!level || !this.bars.length) return;
event.preventDefault(); event.preventDefault();
const point = this.eventPoint(event); const point = this.eventPoint(event);
if (!point || !this.withinPlot(point) || point.t == null) return; if (!point || !this.withinPlot(point)) return;
const lastBar = this.bars[this.bars.length - 1]; const lastBar = this.bars[this.bars.length - 1];
const cutoff = point.t > lastBar.t const cutoff = point.t == null ? null : point.t > lastBar.t
? Math.round(point.t) ? Math.round(point.t)
: this.bars.reduce((nearest, bar) => : this.bars.reduce((nearest, bar) =>
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
).t; ).t;
this.contextLineId = level.id; this.contextLineId = level.id;
this.contextCutoff = cutoff > level.last_t ? cutoff : null; const origin = Math.min(level.anchor_t, level.last_t);
this.contextCutoff = cutoff != null && cutoff > origin ? cutoff : null;
this.contextEndButton.hidden = this.contextCutoff == null; this.contextEndButton.hidden = this.contextCutoff == null;
this.contextExtendButton.hidden = level.cutoff_t == null; this.contextExtendButton.hidden = level.cutoff_t == null;
this.contextMenu.hidden = false; this.contextMenu.hidden = false;
@ -3056,7 +3383,8 @@ class ConfluenceChart {
syncRthLines() { syncRthLines() {
if (!this.rthPrimitive) return; if (!this.rthPrimitive) return;
if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') { if (!this.rthEnabled || this.rthMode !== 'spy_rth'
|| !this.bars.length || this.bars[0]?.tf === '1d') {
this.rthPrimitive.setMarks([]); this.rthPrimitive.setMarks([]);
return; return;
} }

View file

@ -74,6 +74,8 @@
</details> </details>
<details class="sidebar-section config-section"> <details class="sidebar-section config-section">
<summary>Config</summary> <summary>Config</summary>
<label title="Today's session high and low as short ticks at the live edge"><input type="checkbox" v-model="sessionRange"> Session high / low</label>
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label> <label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label> <label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label> <label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
@ -136,9 +138,9 @@
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label> <label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label> <label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
</div> </div>
<label>Alert early (pts)<input type="number" min="0" step="0.25" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label> <label>Alert early (pts)<input type="number" min="0" :step="tick" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
<form class="row price-row" @submit.prevent="addPriceAlert"> <form class="row price-row" @submit.prevent="addPriceAlert">
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label> <label>Price<input type="number" :step="tick" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
<button type="submit" :disabled="!alertPrice">Add</button> <button type="submit" :disabled="!alertPrice">Add</button>
</form> </form>
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p> <p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
@ -281,6 +283,7 @@
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i> <i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
{{ selectedAreHidden ? 'Show' : 'Hide' }} {{ selectedAreHidden ? 'Show' : 'Hide' }}
</button> </button>
<button @click="duplicateSelected" :disabled="!selectedTrendline" aria-label="Duplicate selected trendline">Duplicate</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button> <button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div> </div>
<div class="drawing-list-shell"> <div class="drawing-list-shell">
@ -322,11 +325,11 @@
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span> <span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
<span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span> <span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span>
<span v-else-if="item.kind === 'level'" class="level-editors"> <span v-else-if="item.kind === 'level'" class="level-editors">
<label>Price<input type="number" min="0.25" step="0.25" :value="item.line.anchor_p" <label>Price<input type="number" :min="tick" :step="tick" :value="item.line.anchor_p"
aria-label="Level price in drawing list" aria-label="Level price in drawing list"
@keydown.enter="$event.target.blur()" @keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label> @change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
<label>Early<input type="number" min="0" step="0.25" :value="item.line.alert_early_points ?? ''" <label>Early<input type="number" min="0" :step="tick" :value="item.line.alert_early_points ?? ''"
placeholder="ATR" aria-label="Level alert early points" placeholder="ATR" aria-label="Level alert early points"
@keydown.enter="$event.target.blur()" @keydown.enter="$event.target.blur()"
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label> @change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>

View file

@ -14,10 +14,10 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.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; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-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; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-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:4px; right:4px; z-index:8; display:flex; justify-content:space-between; align-items:center; opacity:0; pointer-events:none; }.current-price-pulse.active { opacity:1; animation:current-price-frame 1.8s ease-in-out infinite; }.current-price-tri { width:0; height:0; border-style:solid; filter:drop-shadow(0 0 1px var(--chart-bg)); }.current-price-tri-right { border-width:9px 0 9px 14px; border-color:transparent transparent transparent var(--accent); }.current-price-tri-left { border-width:9px 14px 9px 0; border-color:transparent var(--accent) transparent transparent; }@keyframes current-price-frame { 0%,100% { opacity:1; } 50% { opacity:.35; } }
.chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; } .chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; }
.projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; } .projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; }
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } } @media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; opacity:.85; } }
@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); } }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }

View file

@ -94,9 +94,10 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
'the rollback path no longer restores displayed-grid geometry'); 'the rollback path no longer restores displayed-grid geometry');
assert.equal(result.shifted, result.sourceSecondBar, assert.equal(result.shifted, result.sourceSecondBar,
'a source-timeframe nudge did not move by one 30m bar'); 'a source-timeframe nudge did not move by one 30m bar');
assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes, const historical = result.sampledTimes.filter(time => time <= result.displayedTimes.at(-1));
'manual line samples no longer match the displayed candle grid'); assert.deepEqual(historical, [result.displayedTimes[0], result.displayedTimes.at(-1)],
assert.ok(result.sampledTimes.slice(result.displayedTimes.length) 'a 30m line on 1m should sample source-bar times, not every 1m candle');
assert.ok(result.sampledTimes.slice(historical.length)
.every(time => time > result.displayedTimes.at(-1)), .every(time => time > result.displayedTimes.at(-1)),
'manual-line extension mixed future points into candle history'); 'manual-line extension mixed future points into candle history');
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
@ -233,6 +234,8 @@ test('daily future slots and live duration follow the server session calendar',
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1), beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
shifted: c.shiftDisplayedTime(thursday, 1), shifted: c.shiftDisplayedTime(thursday, 1),
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1), shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
horizonShift: c.maxLineShift(horizonLine, monday, 10),
thursdayShift: c.maxLineShift(horizonLine, thursday, 10),
}; };
}); });
@ -240,7 +243,9 @@ test('daily future slots and live duration follow the server session calendar',
assert.equal(result.duration, 23 * 3600); assert.equal(result.duration, 23 * 3600);
assert.equal(result.beyondHorizon, null); assert.equal(result.beyondHorizon, null);
assert.equal(result.shifted, 1786917600); assert.equal(result.shifted, 1786917600);
assert.equal(result.shiftBeyondHorizon, null); assert.equal(result.shiftBeyondHorizon, 1787004000);
assert.equal(result.horizonShift, 0);
assert.ok(result.thursdayShift > 0);
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
@ -353,6 +358,174 @@ test('a short final data gap does not kink a future trendline',
}); });
}); });
test('a 30m line stays straight on 1m when the bucket is missing its first minutes',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const minute = 60;
const half = 30 * minute;
const open = 400000;
const bars = [];
for (let t = open - 2 * half; t < open; t += minute) {
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
}
for (let t = open + 9 * minute; t < open + 2 * half; t += minute) {
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
}
const line = {
id: 'late-open', kind: 'manual', tf: '30m', side: 'support', label: 'late',
anchor_t: open - 2 * half, anchor_p: 100, last_t: open,
slope: 3 / half, cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 2, provisional: false,
};
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
));
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf',
series: { '30m': { times: [open - 2 * half, open - half, open, open + half], duration: half } },
});
c.syncLevels([line]);
const data = c.levelSeries.get(line.id).series.data();
const point = time => {
const row = data.find(value => Number(value.time) === time);
return {
x: c.chart.timeScale().timeToCoordinate(time),
y: row ? c.candles.priceToCoordinate(row.value) : null,
};
};
const a = point(open - half);
const b = point(open + 9 * minute);
const d = point(open + half);
return {
snappedOpen: data.some(value => Number(value.time) === open + 9 * minute),
injectedOpen: data.some(value => Number(value.time) === open),
lateSlope: (b.y - a.y) / (b.x - a.x),
fullSlope: (d.y - b.y) / (d.x - b.x),
};
});
assert.equal(result.injectedOpen, false, '30m open that is not a 1m bar was placed on the scale');
assert.equal(result.snappedOpen, true, 'missing 30m open was not snapped to the first 1m bar');
assert.ok(Math.abs(result.lateSlope - result.fullSlope) < 1e-6,
`late-open 30m segment kinked from ${result.lateSlope} to ${result.fullSlope}`);
assertNoPageErrors(page, assert);
});
});
test('a 1m line stays straight across a slotted mid-session hole',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const minute = 60;
const start = 500000;
const bars = [];
for (let t = start; t < start + 20 * minute; t += minute) {
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
}
for (let t = start + 29 * minute; t < start + 50 * minute; t += minute) {
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
}
const line = {
id: '1m-hole', kind: 'manual', tf: '1m', side: 'support', label: 'hole',
anchor_t: start, anchor_p: 100, last_t: start + 10 * minute,
slope: 1 / minute, cutoff_t: null, hidden: false, color: '#65b7cf',
line_width: 2, provisional: false,
};
c.setDisplayFutureTimes(Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
));
c.setBars(bars);
c.setTrendlineGeometry({
mode: 'source_tf',
series: { '1m': { times: bars.map(bar => bar.t), duration: minute } },
});
c.syncLevels([line]);
const data = c.levelSeries.get(line.id).series.data();
const point = time => {
const row = data.find(value => Number(value.time) === time);
return {
x: c.chart.timeScale().timeToCoordinate(time),
y: row ? c.candles.priceToCoordinate(row.value) : null,
};
};
const a = point(start + 18 * minute);
const b = point(start + 19 * minute);
const d = point(start + 29 * minute);
const e = point(start + 30 * minute);
return {
before: (b.y - a.y) / (b.x - a.x),
across: (d.y - b.y) / (d.x - b.x),
after: (e.y - d.y) / (e.x - d.x),
filled: ConfluenceChart.sourceTimes(c.trendlineSourceSeries.get('1m'))
.includes(start + 20 * minute),
};
});
assert.equal(result.filled, true, 'short 1m hole was not inserted into source times');
assert.ok(Math.abs(result.across - result.before) < 1e-6,
`1m line kinked across hole from ${result.before} to ${result.across}`);
assert.ok(Math.abs(result.after - result.before) < 1e-6,
`1m line kinked after hole from ${result.before} to ${result.after}`);
assertNoPageErrors(page, assert);
});
});
test('repeating the same future times does not rebuild the time scale',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(() => {
const c = window.__chart;
const minute = 60;
const bars = Array.from({ length: 20 }, (_, index) => ({
tf: '1m', t: 600000 + index * minute, o: 100, h: 101, l: 99, c: 100,
v: 1, closed: true,
}));
const future = Array.from(
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
);
c.setDisplayFutureTimes(future);
c.setBars(bars);
let writes = 0;
const original = c.futureSpace.setData.bind(c.futureSpace);
c.futureSpace.setData = data => {
writes += 1;
return original(data);
};
c.setDisplayFutureTimes(future.slice());
c.setDisplayFutureTimes(future.map(Number));
return { writes };
});
assert.equal(result.writes, 0, 'identical future times rebuilt futureSpace');
assertNoPageErrors(page, assert);
});
});
test('forming ticks coalesce to one candle update per frame',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const result = await page.evaluate(async () => {
const c = window.__chart;
const last = c.bars.at(-1);
let updates = 0;
const original = c.candles.update.bind(c.candles);
c.candles.update = data => {
updates += 1;
return original(data);
};
for (let index = 0; index < 12; index += 1) {
c.updateBar({ ...last, c: last.c + index * 0.25, closed: false });
}
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return { updates };
});
assert.ok(result.updates <= 2,
`twelve forming ticks painted ${result.updates} candle updates`);
assertNoPageErrors(page, assert);
});
});
test('painted canvas pixels keep the same slope through the live edge', test('painted canvas pixels keep the same slope through the live edge',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {

View file

@ -58,6 +58,24 @@ def test_corrupt_state_does_not_prevent_alerting(tmp_path):
assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1 assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1
def test_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path):
one = engine(tmp_path)
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1
assert one.evaluate(zone(), 100, 1, 90, "ES=F") == []
def test_legacy_fired_rows_without_symbol_are_es(tmp_path):
path = tmp_path / "alert_state.json"
path.write_text(
'{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n',
encoding="utf-8",
)
two = engine(tmp_path)
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 1
def test_state_file_records_centre_and_time(tmp_path): def test_state_file_records_centre_and_time(tmp_path):
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES") engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8")) payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
@ -65,4 +83,5 @@ def test_state_file_records_centre_and_time(tmp_path):
# do not restart from 1 after a deploy and collide with a phone's history. # do not restart from 1 after a deploy and collide with a phone's history.
assert len(payload["fired"]) == 1 assert len(payload["fired"]) == 1
assert payload["fired"][0]["at"] == 42 assert payload["fired"][0]["at"] == 42
assert payload["fired"][0]["symbol"] == "/ES"
assert payload["next_number"] == 2 assert payload["next_number"] == 2

View file

@ -26,6 +26,14 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1 assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
engine = AlertEngine(min_score=6, cooldown_seconds=900)
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 1
def test_score_threshold_blocks_two_daily_mas_at_default_calibration(): def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
engine = AlertEngine(min_score=28) engine = AlertEngine(min_score=28)
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1) cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)

View file

@ -40,6 +40,16 @@ def test_open_when_no_token_configured(client):
assert client("").get("/api/bars").status_code == 200 assert client("").get("/api/bars").status_code == 200
def test_debug_snap_keeps_geometry_fields(client):
response = client("").post("/api/debug/snap", json={
"kind": "geometry",
"tf": "1m",
"gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}],
"lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}],
})
assert response.status_code == 204
def test_oauth_callback_stays_open_when_a_token_is_set(): def test_oauth_callback_stays_open_when_a_token_is_set():
# The provider redirects a browser here and cannot attach the chart token. # The provider redirects a browser here and cannot attach the chart token.
# A 401 would break the login flow at its last step, on production only, # A 401 would break the login flow at its last step, on production only,

View file

@ -1,4 +1,7 @@
from app.analysis.bar_space import index_at, price_in_bar_space, price_in_timeframe_space from app.analysis.bar_space import (
fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
timeframe_index_at,
)
from app.analysis.levels import Level, LevelKind, Side from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe from app.bars.models import Timeframe
@ -110,3 +113,25 @@ def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser
assert price_in_timeframe_space( assert price_in_timeframe_space(
line, source_times, Timeframe.M30, 2 * half_hour, line, source_times, Timeframe.M30, 2 * half_hour,
) == 102.0 ) == 102.0
def test_a_short_1m_hole_still_advances_one_index_per_minute():
times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE))
filled = fill_short_gaps(times, Timeframe.M1)
assert 10 * MINUTE in filled
assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19
line = sloped(0, 100.0, 30 * MINUTE, 130.0)
assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0
def test_a_weekend_is_not_filled_as_short_gaps():
weekend = 49 * 3600
times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE]
assert fill_short_gaps(times, Timeframe.M1) == times
def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
five = 5 * MINUTE
times = [0, five, 3 * five]
filled = fill_short_gaps(times, Timeframe.M5)
assert filled == [0, five, 2 * five, 3 * five]

View file

@ -21,6 +21,7 @@ def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path):
assert [entry["message"] for entry in day] == ["dropped", "yesterday"] assert [entry["message"] for entry in day] == ["dropped", "yesterday"]
assert more is True assert more is True
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old" assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
assert EventLog(tmp_path / "events.json")._entries[0]["symbol"] == "/ES"
def test_more_pages_older_than_the_cutoff(tmp_path): def test_more_pages_older_than_the_cutoff(tmp_path):
@ -56,3 +57,6 @@ def test_dispatched_alerts_land_in_the_event_log(tmp_path):
assert "#27" in events[0]["message"] assert "#27" in events[0]["message"]
assert "confluence" not in events[0]["message"] assert "confluence" not in events[0]["message"]
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
assert snapshot(runtime, Timeframe.M1)["events"][0]["symbol"] == "/ES"
assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es"
assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25

View file

@ -0,0 +1,99 @@
import asyncio
import time
from app.bars.models import Bar, Timeframe
from app.bars.session import bucket_start
from app.config import Settings
from app.market.stream import StreamService
from app.runtime import Runtime
def minute(t, price, closed=True, source="schwab"):
return Bar(Timeframe.M1, t, price, price + 1, price - 1, price, 10, closed, "/ES", source)
class History:
"""A seed source holding only 1m history, like Yahoo's recent reach."""
name = "yahoo"
delay_minutes = 0
def __init__(self, bars):
self.bars = bars
def supports_history(self):
return True
async def history(self, symbol, tf, start, end, *, range_=None):
if tf is not Timeframe.M1:
return []
return [bar for bar in self.bars if start <= bar.t < end]
def runtime(tmp_path) -> Runtime:
return Runtime(
Settings(
manual_lines_path=tmp_path / "manual_lines.json",
alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
events_path=tmp_path / "events.json",
)
)
def test_an_outage_is_backfilled_behind_the_live_bars(tmp_path, monkeypatch):
# The seed ran only at startup, so a stream that was down for days came
# back to live bars with the whole outage still missing.
day = bucket_start(int(time.time()) - 86400, Timeframe.D1)
instance = runtime(tmp_path)
missed = [minute(day + 60 * i, 5000 + i, source="yahoo") for i in range(1, 60)]
monkeypatch.setattr("app.runtime.seed_source", lambda settings: History(missed))
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
instance.subscribers.add(queue)
async def scenario():
await instance.on_bar(minute(day, 4990))
# The stream comes back an hour later, into the same day.
await instance.on_bar(minute(day + 3600, 6000))
await instance.on_bar(minute(day + 3660, 6001))
return await instance.fill_gap(day, day + 3600)
added = asyncio.run(scenario())
held = [bar.t for bar in instance.store.get(Timeframe.M1)]
assert held == [day] + [bar.t for bar in missed] + [day + 3600, day + 3660]
assert added > len(missed), "higher timeframes are rebuilt from the recovered minutes"
assert day + 300 in [bar.t for bar in instance.store.get(Timeframe.M5)]
daily = instance.store.get(Timeframe.D1)[-1]
assert daily.t == day
assert daily.l == 4989, "the live daily bar must include the pre-reconnect low"
assert daily.o == 4990
events = []
while not queue.empty():
events.append(queue.get_nowait()["type"])
assert "resync" in events
assert "alert" not in events
def test_a_reconnect_past_a_gap_asks_for_a_backfill():
class Flaky:
name = "schwab"
def __init__(self):
self.connections = [[minute(60, 1)], [minute(60 + 86400, 2)]]
async def stream(self, symbol):
for bar in self.connections.pop(0):
yield bar
if not self.connections:
service.stop()
raise RuntimeError("socket closed")
service = StreamService(Flaky(), "/ES")
service.reconnect_seconds = 0
resumed = []
service.on_resume = lambda after, before: resumed.append((after, before))
asyncio.run(service.run())
assert resumed == [(60, 60 + 86400)]

60
tests/test_instrument.py Normal file
View file

@ -0,0 +1,60 @@
from app.config import Settings
from app.instrument import (
DEFAULT_SYMBOL, get_instrument, instrument_for_symbol, INSTRUMENTS,
)
def test_settings_default_to_the_es_profile():
settings = Settings()
assert settings.instrument == "es"
assert settings.profile.tick == 0.25
assert Settings(instrument="gc").profile.schwab_symbol == "/GC"
def test_es_is_the_default_profile():
es = get_instrument("es")
assert es.schwab_symbol == DEFAULT_SYMBOL
assert es.tick == 0.25
assert es.rth == "spy_rth"
def test_nq_shares_es_tick_and_rth():
nq = get_instrument("nq")
es = get_instrument("es")
assert nq.tick == es.tick
assert nq.rth == es.rth
assert nq.schwab_symbol == "/NQ"
def test_gold_and_oil_are_not_quarter_ticks():
assert get_instrument("gc").tick == 0.10
assert get_instrument("gc").rth == "none"
assert get_instrument("cl").tick == 0.01
assert get_instrument("cl").rth == "nymex_day"
def test_yahoo_and_schwab_names_map_to_the_same_root():
assert instrument_for_symbol("ES=F").schwab_symbol == "/ES"
assert instrument_for_symbol("/ES").id == "es"
assert instrument_for_symbol("GC=F").id == "gc"
assert instrument_for_symbol("unknown").id == "es"
def test_unknown_instrument_id_is_rejected():
try:
get_instrument("btc")
except ValueError as error:
assert "btc" in str(error)
else:
raise AssertionError("unknown id was accepted")
def test_gold_snap_is_not_a_quarter_point():
gc = get_instrument("gc")
assert gc.snap(3450.07) == 3450.10
assert get_instrument("es").snap(6400.10) == 6400.00
assert get_instrument("cl").snap(70.014) == 70.01
def test_every_planned_root_is_in_the_table():
assert set(INSTRUMENTS) == {"es", "nq", "gc", "cl"}

View file

@ -9,6 +9,25 @@ def sample_line():
return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1) return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
def test_a_drawing_without_symbol_loads_as_es(tmp_path):
path = tmp_path / "manual_lines.json"
path.write_text(
'[{"id":"ml_old","tf":"1h","side":"resistance","anchor_t":100,'
'"anchor_p":5000,"slope":-0.01,"last_t":200,"created_at":300,"number":1}]\n',
encoding="utf-8",
)
loaded = ManualLineStore(path).lines["ml_old"]
assert loaded.symbol == "/ES"
def test_a_gold_drawing_keeps_its_symbol_through_json(tmp_path):
path = tmp_path / "manual_lines.json"
line = sample_line()
line.symbol = "/GC"
ManualLineStore(path).add(line)
assert ManualLineStore(path).lines["ml_test"].symbol == "/GC"
def test_json_persistence_round_trip(tmp_path): def test_json_persistence_round_trip(tmp_path):
path = tmp_path / "manual_lines.json" path = tmp_path / "manual_lines.json"
store = ManualLineStore(path) store = ManualLineStore(path)
@ -159,6 +178,7 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path):
"cutoff_t": 150, "cutoff_t": 150,
}).json() }).json()
assert created["cutoff_t"] == 150 assert created["cutoff_t"] == 150
assert created["symbol"] == "/ES"
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json() cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
assert cleared["cutoff_t"] is None assert cleared["cutoff_t"] is None
@ -198,6 +218,7 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
}).json() }).json()
assert restored["id"] == line_id assert restored["id"] == line_id
assert restored["number"] == number assert restored["number"] == number
assert restored["symbol"] == "/ES"
assert client.post("/api/lines/restore", json={ assert client.post("/api/lines/restore", json={
"id": line_id, "id": line_id,
"tf": "1m", "tf": "1m",
@ -208,3 +229,30 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
"last_t": 200, "last_t": 200,
"number": number, "number": number,
}).status_code == 409 }).status_code == 409
def test_restoring_keeps_a_non_es_symbol(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
restored = client.post("/api/lines/restore", json={
"id": "ml_gold",
"tf": "1m",
"side": "support",
"anchor_t": 100,
"anchor_p": 1.0,
"slope": 0.01,
"last_t": 200,
"number": 9,
"symbol": "/GC",
}).json()
assert restored["symbol"] == "/GC"
assert ManualLineStore(tmp_path / "manual_lines.json").lines["ml_gold"].symbol == "/GC"

View file

@ -122,7 +122,7 @@ def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
)) ))
seen = {} seen = {}
def evaluate(clusters, price, atr15, at, symbol, watched): def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters]) seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
return [] return []
@ -158,7 +158,7 @@ def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
)) ))
seen_members = [] seen_members = []
def evaluate(clusters, price, atr15, at, symbol, watched): def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen_members.extend(member.id for cluster in clusters for member in cluster.members) seen_members.extend(member.id for cluster in clusters for member in cluster.members)
return [] return []

View file

@ -48,3 +48,22 @@ def test_a_tick_cannot_overwrite_a_settled_bar():
held = store.get(Timeframe.M1)[0] held = store.get(Timeframe.M1)[0]
assert held.closed is True and held.v == 400 assert held.closed is True and held.v == 400
def test_a_backfilled_hole_lands_behind_live_bars_without_replacing_them():
# After an outage the live stream is already newer than the hole, so put()
# dropped every recovered bar and fifteen missed days stayed missing.
store = InMemoryBarStore(4)
store.put(bar(60))
store.put(bar(600, close=7))
added = store.fill([bar(120), bar(180), bar(600, close=99)])
assert added == 2
assert [value.t for value in store.get(Timeframe.M1)] == [60, 120, 180, 600]
assert store.get(Timeframe.M1)[-1].c == 7, "the live source's bar must win"
store.fill([bar(240)])
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180, 240, 600], (
"the cap keeps the newest history"
)

View file

@ -37,3 +37,21 @@ def test_ma_alert_endpoints_persist(tmp_path):
assert client.get("/api/prefs/ma-alerts").json() == {"1d": [10, 200]} assert client.get("/api/prefs/ma-alerts").json() == {"1d": [10, 200]}
assert client.put("/api/prefs/ma-alerts", json={"1d": [7]}).status_code == 400 assert client.put("/api/prefs/ma-alerts", json={"1d": [7]}).status_code == 400
assert SHARED_USER in (tmp_path / "user_prefs.json").read_text(encoding="utf-8") assert SHARED_USER in (tmp_path / "user_prefs.json").read_text(encoding="utf-8")
def test_confluence_alerts_default_off_and_persist(tmp_path):
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
))
client = TestClient(app)
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": False}
assert client.put("/api/prefs/confluence-alerts", json={"enabled": True}).json() == {
"enabled": True,
}
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": True}
assert client.put("/api/prefs/confluence-alerts", json={"enabled": "yes"}).status_code == 400

View file

@ -82,6 +82,20 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path):
message = snapshot(runtime, Timeframe.H1) message = snapshot(runtime, Timeframe.H1)
assert message["session_open"] == 6123.25 assert message["session_open"] == 6123.25
assert message["instrument"]["schwab_symbol"] == "/ES"
assert message["instrument"]["tick"] == 0.25
def test_snapshot_session_range_uses_the_forming_daily_bar(tmp_path):
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
runtime.store.put(Bar(Timeframe.D1, 1000, 100, 110, 90, 105, 1, True, "/ES", "test"))
runtime.store.put(Bar(Timeframe.D1, 2000, 105, 125, 102, 120, 1, False, "/ES", "test"))
message = snapshot(runtime, Timeframe.M1)
assert message["session_t"] == 2000
assert message["session_high"] == 125
assert message["session_low"] == 102
def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path): def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
@ -130,6 +144,44 @@ def test_snapshot_rollback_gate_omits_source_geometry(tmp_path):
} }
def test_a_forming_tick_does_not_resend_future_times(tmp_path):
app = FastAPI()
app.include_router(ws_router)
runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
trendline_source_geometry=True,
))
runtime.store.put(Bar(Timeframe.M1, 1000, 1, 1, 1, 1, 1, True, "/ES", "test"))
runtime.store.put(Bar(Timeframe.M30, 0, 1, 1, 1, 1, 1, True, "/ES", "test"))
runtime.manual_lines.add(ManualLine(
"ml_30m", Timeframe.M30, Side.SUPPORT, 0, 100, 1 / 1800, 1800, 1800,
))
runtime.rebuild_levels()
app.state.runtime = runtime
with TestClient(app).websocket_connect("/ws") as websocket:
websocket.receive_json()
first = Bar(Timeframe.M1, 1060, 1, 2, 1, 1.5, 1, False, "/ES", "test")
runtime.broadcast({"type": "bar", "bar": first})
opened = websocket.receive_json()
assert opened["type"] == "bar"
assert opened["future_times"]
runtime.broadcast({"type": "bar", "bar": first})
tick = websocket.receive_json()
assert tick["type"] == "bar"
assert "future_times" not in tick
assert "trendline_future_times" not in tick
assert "session_high" not in tick
higher = Bar(Timeframe.M30, 1800, 1, 2, 1, 1.5, 1, False, "/ES", "test")
runtime.broadcast({"type": "bar", "bar": higher})
assert websocket.receive_json()["type"] == "trendline_bar"
runtime.broadcast({"type": "bar", "bar": higher})
runtime.broadcast({"type": "bar", "bar": first})
assert websocket.receive_json()["type"] == "bar"
def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path): def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path):
app = FastAPI() app = FastAPI()
app.include_router(ws_router) app.include_router(ws_router)