Compare commits
No commits in common. "main" and "before-1m-source-snap" have entirely different histories.
main
...
before-1m-
34 changed files with 136 additions and 1810 deletions
34
AGENTS.md
34
AGENTS.md
|
|
@ -14,7 +14,6 @@ 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/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).
|
||||
Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md).
|
||||
|
||||
## Tests earn their place by catching a real bug
|
||||
|
||||
|
|
@ -90,11 +89,8 @@ docker compose logs api | grep SNAPDBG
|
|||
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
|
||||
snapped time and price, how many bars were held, the first and last bar, and the
|
||||
chart's width. After bars and levels load it also posts one `kind=geometry`
|
||||
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.
|
||||
chart's width. 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.
|
||||
|
||||
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||
fields `SnapReport` declares.
|
||||
|
|
@ -153,10 +149,6 @@ geometry change that needs explicit browser verification.
|
|||
- The displayed time scale, a drawing's source-timeframe bar space, and the
|
||||
server session calendar are different coordinate systems. Never substitute
|
||||
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
|
||||
looks valid before a settlement/weekend break must remain resolvable when real
|
||||
history arrives.
|
||||
|
|
@ -251,19 +243,6 @@ 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
|
||||
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
|
||||
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see
|
||||
`docs/async_refactor.md`.
|
||||
|
|
@ -272,8 +251,10 @@ actually advanced.
|
|||
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
|
||||
rebuild overlays that only depend on the viewport.
|
||||
- Overlay redraws go through `scheduleOverlays`. Force when bars are
|
||||
replaced, a new bar opens, or size changed. A forming-bar tick is
|
||||
not that.
|
||||
replaced, a new bar opens, or size changed. **A forming-bar tick is
|
||||
not that.** `updateBar` must not `scheduleOverlays(true)` on every
|
||||
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
|
||||
live overlay path. `?diag=1` is not a license to sync the GPU every
|
||||
frame.
|
||||
|
|
@ -283,6 +264,3 @@ actually advanced.
|
|||
layout actually changed.
|
||||
- A feature that needs a per-frame or per-tick loop needs a reason,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from pathlib import Path
|
|||
|
||||
from app.analysis.confluence import Cluster
|
||||
from app.analysis.levels import Level, LevelKind, Side
|
||||
from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -44,7 +43,6 @@ class Alert:
|
|||
class _Fired:
|
||||
center: float
|
||||
at: int
|
||||
symbol: str = DEFAULT_SYMBOL
|
||||
|
||||
|
||||
class AlertEngine:
|
||||
|
|
@ -85,14 +83,7 @@ class AlertEngine:
|
|||
if isinstance(payload, dict):
|
||||
self._next_number = int(payload.get("next_number", 1))
|
||||
payload = payload.get("fired", [])
|
||||
return [
|
||||
_Fired(
|
||||
float(item["center"]),
|
||||
int(item["at"]),
|
||||
str(item.get("symbol") or DEFAULT_SYMBOL),
|
||||
)
|
||||
for item in payload
|
||||
]
|
||||
return [_Fired(float(item["center"]), int(item["at"])) for item in payload]
|
||||
except Exception:
|
||||
# Corrupt state costs one burst of duplicate alerts, which is a far
|
||||
# better failure than refusing to start the stream.
|
||||
|
|
@ -124,12 +115,7 @@ class AlertEngine:
|
|||
{
|
||||
"next_number": self._next_number,
|
||||
"fired": [
|
||||
{
|
||||
"center": entry.center,
|
||||
"at": entry.at,
|
||||
"symbol": entry.symbol,
|
||||
}
|
||||
for entry in self._fired
|
||||
{"center": entry.center, "at": entry.at} for entry in self._fired
|
||||
],
|
||||
},
|
||||
indent=2,
|
||||
|
|
@ -151,12 +137,10 @@ class AlertEngine:
|
|||
now: int,
|
||||
symbol: str,
|
||||
watched: list[Level] | None = None,
|
||||
confluence: bool = True,
|
||||
) -> list[Alert]:
|
||||
tolerance = 0.5 * atr15
|
||||
if tolerance <= 0:
|
||||
return []
|
||||
root = instrument_for_symbol(symbol).schwab_symbol
|
||||
# Two zones within an ATR of each other are the same zone as far as
|
||||
# being told about them goes.
|
||||
merge_distance = 2 * tolerance
|
||||
|
|
@ -200,7 +184,7 @@ class AlertEngine:
|
|||
# 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.
|
||||
# A disarmed one has already had its say and no longer qualifies.
|
||||
if not drawn and (not confluence or cluster.score < self.min_score):
|
||||
if not drawn and cluster.score < self.min_score:
|
||||
continue
|
||||
if abs(cluster.center - current_price) > tolerance and not early_drawn:
|
||||
continue
|
||||
|
|
@ -210,12 +194,10 @@ class AlertEngine:
|
|||
# was oscillating on re-alerted on every crossing — which is exactly
|
||||
# when a level is least newsworthy, not most.
|
||||
if any(
|
||||
entry.symbol == root
|
||||
and abs(entry.center - cluster.center) <= merge_distance
|
||||
for entry in self._fired
|
||||
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
|
||||
):
|
||||
continue
|
||||
self._fired.append(_Fired(cluster.center, now, root))
|
||||
self._fired.append(_Fired(cluster.center, now))
|
||||
changed = True
|
||||
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||
|
|
@ -256,13 +238,9 @@ class AlertEngine:
|
|||
price = level.current_p if level.current_p is not None else level.anchor_p
|
||||
if abs(price - current_price) > tolerance:
|
||||
continue
|
||||
if any(
|
||||
entry.symbol == root
|
||||
and abs(entry.center - price) <= merge_distance
|
||||
for entry in self._fired
|
||||
):
|
||||
if any(abs(entry.center - price) <= merge_distance for entry in self._fired):
|
||||
continue
|
||||
self._fired.append(_Fired(price, now, root))
|
||||
self._fired.append(_Fired(price, now))
|
||||
changed = True
|
||||
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
|
||||
name = f"{level.period} DMA" if level.period else level.label
|
||||
|
|
|
|||
|
|
@ -12,32 +12,6 @@ from bisect import bisect_right
|
|||
from app.bars.models import Timeframe
|
||||
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:
|
||||
"""Fractional index of a timestamp within an ascending bar-time series."""
|
||||
|
|
@ -117,7 +91,6 @@ def price_in_timeframe_space(
|
|||
# Endpoints may deliberately sit in the projection area. They use the same
|
||||
# repeated-source-bucket approximation as the browser; the live evaluation
|
||||
# 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)
|
||||
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
||||
target_index = timeframe_index_at(times, t, tf)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import logging
|
|||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.instrument import DEFAULT_SYMBOL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAY_SECONDS = 86400
|
||||
|
|
@ -41,16 +39,12 @@ class EventLog:
|
|||
except Exception:
|
||||
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, symbol: str | None = None,
|
||||
) -> dict:
|
||||
def add(self, kind: str, message: str, *, number: int | None = None, at: int | None = None) -> dict:
|
||||
entry = {
|
||||
"kind": kind,
|
||||
"message": message,
|
||||
"number": number,
|
||||
"at": int(at if at is not None else time.time()),
|
||||
"symbol": symbol or DEFAULT_SYMBOL,
|
||||
}
|
||||
self._entries.append(entry)
|
||||
self._save()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from enum import Enum
|
|||
from typing import Any
|
||||
|
||||
from app.bars.models import Timeframe
|
||||
from app.instrument import DEFAULT_SYMBOL
|
||||
|
||||
|
||||
class LevelKind(str, Enum):
|
||||
|
|
@ -56,7 +55,6 @@ class Level:
|
|||
# price this line safely. Such a line remains visible but cannot cluster or
|
||||
# alert using the absolute-time fallback.
|
||||
geometry_resolved: bool = True
|
||||
symbol: str = DEFAULT_SYMBOL
|
||||
|
||||
def price_at(self, t: int) -> float:
|
||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from threading import RLock
|
|||
from app.analysis.levels import Level, LevelKind, Side
|
||||
from app.bars.models import Timeframe
|
||||
from app.config import TIMEFRAME_WEIGHT
|
||||
from app.instrument import DEFAULT_SYMBOL
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -38,7 +37,6 @@ class ManualLine:
|
|||
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
|
||||
# on (anchor_t, anchor_p) regardless of this value.
|
||||
scale: float = 1.0
|
||||
symbol: str = DEFAULT_SYMBOL
|
||||
|
||||
@property
|
||||
def drawing_kind(self) -> str:
|
||||
|
|
@ -95,7 +93,6 @@ class ManualLine:
|
|||
cutoff_t=self.cutoff_t,
|
||||
armed=self.armed,
|
||||
alert_early_points=self.alert_early_points,
|
||||
symbol=self.symbol,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
|
@ -133,7 +130,6 @@ class ManualLine:
|
|||
if value.get("alert_early_points") is not None else None
|
||||
),
|
||||
scale=float(value.get("scale", 1.0) or 1.0),
|
||||
symbol=str(value.get("symbol") or DEFAULT_SYMBOL),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.bars.models import Timeframe
|
||||
from app.config import DEFAULT_MAX_BARS_PER_TF
|
||||
|
|
@ -132,7 +132,6 @@ class LineRestore(BaseModel):
|
|||
icon: str = ""
|
||||
alert_early_points: float | None = None
|
||||
scale: float = Field(1.0, ge=0.5, le=3)
|
||||
symbol: str = ""
|
||||
|
||||
|
||||
class LinePatch(BaseModel):
|
||||
|
|
@ -176,7 +175,6 @@ def status(request: Request):
|
|||
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
||||
},
|
||||
"needs_login": runtime.needs_login(),
|
||||
"instrument": runtime.settings.profile.payload(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -233,18 +231,6 @@ 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")
|
||||
def get_ma_alerts(request: Request):
|
||||
return request.app.state.runtime.ma_alerts()
|
||||
|
|
@ -281,7 +267,6 @@ def create_line(request: Request, payload: LineCreate):
|
|||
cutoff_t=payload.cutoff_t,
|
||||
armed=payload.armed,
|
||||
kind=payload.kind,
|
||||
symbol=request.app.state.runtime.settings.profile.schwab_symbol,
|
||||
)
|
||||
runtime = request.app.state.runtime
|
||||
line = runtime.manual_lines.add(line)
|
||||
|
|
@ -322,7 +307,6 @@ def restore_line(request: Request, payload: LineRestore):
|
|||
icon=payload.icon,
|
||||
alert_early_points=payload.alert_early_points,
|
||||
scale=payload.scale,
|
||||
symbol=payload.symbol or runtime.settings.profile.schwab_symbol,
|
||||
)
|
||||
line = runtime.manual_lines.add(line)
|
||||
runtime.rebuild_levels()
|
||||
|
|
@ -352,7 +336,6 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
|
|||
color=payload.color,
|
||||
line_width=payload.line_width,
|
||||
alert_early_points=payload.alert_early_points,
|
||||
symbol=runtime.settings.profile.schwab_symbol,
|
||||
)
|
||||
line = runtime.manual_lines.add(line)
|
||||
runtime.rebuild_levels()
|
||||
|
|
@ -405,7 +388,6 @@ def create_comment(request: Request, payload: CommentCreate):
|
|||
y=payload.y,
|
||||
# A comment must never alert, whatever else changes around it.
|
||||
armed=False,
|
||||
symbol=runtime.settings.profile.schwab_symbol,
|
||||
)
|
||||
line = runtime.manual_lines.add(line)
|
||||
return line.to_dict()
|
||||
|
|
@ -413,7 +395,6 @@ def create_comment(request: Request, payload: CommentCreate):
|
|||
|
||||
class SnapReport(BaseModel):
|
||||
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
cursor_t: int | None = None
|
||||
cursor_p: float | None = None
|
||||
cursor_x: float | None = None
|
||||
|
|
|
|||
152
app/api/ws.py
152
app/api/ws.py
|
|
@ -6,7 +6,6 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|||
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
|
||||
from app.analysis.levels import LevelKind
|
||||
from app.bars.models import Timeframe
|
||||
from app.analysis.bar_space import fill_short_gaps
|
||||
from app.bars.session import bucket_duration, future_bucket_starts
|
||||
from app.analysis.confluence import cluster_levels
|
||||
|
||||
|
|
@ -54,15 +53,6 @@ def session_open(runtime) -> float | 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]:
|
||||
return {
|
||||
level.tf for level in runtime.levels
|
||||
|
|
@ -80,7 +70,7 @@ def trendline_geometry(runtime) -> dict:
|
|||
|
||||
|
||||
def trendline_series(runtime, tf: Timeframe) -> dict:
|
||||
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
|
||||
times = [bar.t for bar in runtime.store.get(tf)]
|
||||
value = {"times": times}
|
||||
if tf is Timeframe.D1:
|
||||
value["durations"] = [bucket_duration(t, tf) for t in times]
|
||||
|
|
@ -97,87 +87,8 @@ def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
|
|||
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:
|
||||
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 {
|
||||
"type": "snapshot",
|
||||
"tf": tf.value,
|
||||
|
|
@ -188,12 +99,10 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
|||
if runtime.store.get(Timeframe.M1, 1)
|
||||
else None,
|
||||
"session_open": session_open(runtime),
|
||||
**extra,
|
||||
"trendline_geometry": trendline_geometry(runtime),
|
||||
"future_times": displayed_future_times(runtime, tf),
|
||||
"events": events,
|
||||
"events_more": events_more,
|
||||
"instrument": runtime.settings.profile.payload(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -217,7 +126,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
prefs = None
|
||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||
geometry_tfs = trendline_timeframes(runtime)
|
||||
last_bar_t: dict[Timeframe, int] = {}
|
||||
|
||||
async def receive():
|
||||
nonlocal tf, prefs
|
||||
|
|
@ -251,19 +159,53 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
event = await queue.get()
|
||||
if event["type"] == "disconnect":
|
||||
break
|
||||
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":
|
||||
if event["type"] == "bar":
|
||||
bar = event["bar"]
|
||||
full = last_bar_t.get(bar.tf) != bar.t
|
||||
if full:
|
||||
last_bar_t[bar.tf] = bar.t
|
||||
payload = bar_client_message(runtime, bar, tf, full=full)
|
||||
if payload is not None:
|
||||
await websocket.send_json(payload)
|
||||
source_tfs = trendline_timeframes(runtime)
|
||||
source_bars = runtime.store.get(bar.tf)
|
||||
first_source_t = source_bars[0].t if source_bars else None
|
||||
source_index = next(
|
||||
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
||||
)
|
||||
previous_source_t = (
|
||||
source_bars[source_index - 1].t
|
||||
if source_index is not None and source_index > 0 else None
|
||||
)
|
||||
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
||||
if bar.tf is tf:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"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":
|
||||
message = {
|
||||
"type": "levels", "changed": event["changed"], "removed": event["removed"],
|
||||
|
|
|
|||
|
|
@ -46,35 +46,6 @@ class InMemoryBarStore:
|
|||
# Buckets are ordered, so nothing further back can match.
|
||||
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]:
|
||||
bars = list(self._bars[tf])
|
||||
return bars[-limit:] if limit is not None else bars
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from pathlib import Path
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from app.bars.models import Timeframe
|
||||
from app.instrument import get_instrument
|
||||
|
||||
|
||||
DEFAULT_MAX_BARS_PER_TF = 5000
|
||||
|
|
@ -25,7 +24,6 @@ class Settings(BaseSettings):
|
|||
|
||||
live_source: str = "yahoo"
|
||||
seed_source: str = "yahoo"
|
||||
instrument: str = "es"
|
||||
yahoo_symbol: str = "ES=F"
|
||||
yahoo_poll_seconds: float = 20
|
||||
seed_1h_range: str = "730d"
|
||||
|
|
@ -75,10 +73,6 @@ class Settings(BaseSettings):
|
|||
alert_timezone: str = "America/Chicago"
|
||||
replay_file: Path | None = None
|
||||
|
||||
@property
|
||||
def profile(self):
|
||||
return get_instrument(self.instrument)
|
||||
|
||||
@property
|
||||
def live_symbol(self) -> str:
|
||||
"""What the live source calls the instrument.
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -19,12 +19,6 @@ class StreamService:
|
|||
self._handlers: list[BarHandler] = []
|
||||
self._stop = asyncio.Event()
|
||||
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:
|
||||
self._handlers.append(handler)
|
||||
|
|
@ -51,21 +45,11 @@ class StreamService:
|
|||
|
||||
async def run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
first = True
|
||||
try:
|
||||
async for bar in self.source.stream(self.symbol):
|
||||
self.status = "replay" if self.source.name == "replay" else "connected"
|
||||
self.last_error = None
|
||||
before = self.last_bar_t
|
||||
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():
|
||||
break
|
||||
if self.source.name == "replay":
|
||||
|
|
@ -80,7 +64,7 @@ class StreamService:
|
|||
self.on_drop(str(exc))
|
||||
self.status = "disconnected"
|
||||
try:
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=self.reconnect_seconds)
|
||||
await asyncio.wait_for(self._stop.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
|
|
|
|||
119
app/runtime.py
119
app/runtime.py
|
|
@ -27,15 +27,6 @@ from app.notify.ntfy import send_ntfy
|
|||
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
|
||||
class Runtime:
|
||||
settings: Settings
|
||||
|
|
@ -53,7 +44,6 @@ class Runtime:
|
|||
alert_engine: AlertEngine = field(init=False)
|
||||
_sent_levels: dict[str, dict] = field(default_factory=dict)
|
||||
_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;
|
||||
# None while a test drives the runtime directly.
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
|
@ -91,7 +81,6 @@ class Runtime:
|
|||
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
||||
self.stream.add_handler(self.on_bar)
|
||||
self.stream.on_drop = self._on_stream_drop
|
||||
self.stream.on_resume = self._on_stream_resume
|
||||
|
||||
async def on_bar(self, bar: Bar) -> None:
|
||||
# A tick-built bar is provisional and arrives many times a minute. It
|
||||
|
|
@ -298,17 +287,9 @@ class Runtime:
|
|||
evaluation_t,
|
||||
self.stream.symbol,
|
||||
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:
|
||||
stored = self.user_prefs.get("ma_alerts") or {}
|
||||
return {"1d": list(stored.get("1d") or [])}
|
||||
|
|
@ -328,108 +309,12 @@ class Runtime:
|
|||
|
||||
def _on_stream_drop(self, error: str) -> None:
|
||||
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
|
||||
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))
|
||||
self.events.add(kind, error.split("\n", 1)[0][:200])
|
||||
|
||||
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||
tripped: set[str] = set()
|
||||
for alert in alerts:
|
||||
self.events.add(
|
||||
"alert", alert.message, number=alert.number, at=alert.at,
|
||||
symbol=self.settings.profile.schwab_symbol,
|
||||
)
|
||||
self.events.add("alert", alert.message, number=alert.number, at=alert.at)
|
||||
self.broadcast({
|
||||
"type": "alert",
|
||||
"cluster": alert.cluster,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ decisions behind them in `docs/plan.md`.
|
|||
|
||||
### Revisit the trendline live-edge bend with painted-pixel evidence
|
||||
|
||||
Mid-session 1m tape holes are now counted in source index as well as on the
|
||||
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
|
||||
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
|
||||
source-space prices. Diagnostic values can report unequal historical/future
|
||||
screen slopes when the final candles are sparse, but synthetic browser coverage
|
||||
|
|
|
|||
|
|
@ -1336,123 +1336,3 @@ alert before the deferred global rebuild.
|
|||
Finally, source-dependent shift operations no longer silently keep the old time
|
||||
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
# 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?
|
||||
17
docs/plan.md
17
docs/plan.md
|
|
@ -176,16 +176,6 @@ 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
|
||||
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
|
||||
|
||||
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
|
||||
|
|
@ -826,8 +816,8 @@ Server → client:
|
|||
Rules:
|
||||
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch
|
||||
`levels` — they only change on higher-TF closes.
|
||||
- Always send a full `snapshot` on connect and after any reconnect, and after the
|
||||
server backfills a gap (`resync`). The client must never try to reconcile a gap.
|
||||
- Always send a full `snapshot` on connect and after any reconnect. The client must
|
||||
never try to reconcile a gap.
|
||||
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
|
||||
the entire point: a 4h line drawn through a 1m chart.
|
||||
|
||||
|
|
@ -1153,9 +1143,6 @@ 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
|
||||
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
|
||||
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
|
||||
Hand-drawn lines are authoritative — full weight, no quality discount.
|
||||
|
|
|
|||
171
static/app.js
171
static/app.js
|
|
@ -135,7 +135,6 @@ createApp({
|
|||
const drawingFilter = ref('');
|
||||
const drawingKind = ref('all');
|
||||
const maAlerts = ref({ '1d': [] });
|
||||
const confluenceAlerts = ref(false);
|
||||
const drawingTf = ref('all');
|
||||
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
||||
const captureBusy = ref(false);
|
||||
|
|
@ -154,8 +153,6 @@ createApp({
|
|||
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
|
||||
const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false');
|
||||
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 optionPrefs = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; }
|
||||
|
|
@ -366,13 +363,7 @@ createApp({
|
|||
|
||||
async function refreshStatus() {
|
||||
const response = await apiFetch('/api/status');
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (response.ok) status.value = await response.json();
|
||||
}
|
||||
|
||||
function selectedExpiration() {
|
||||
|
|
@ -490,34 +481,6 @@ createApp({
|
|||
});
|
||||
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() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
||||
|
|
@ -532,10 +495,6 @@ createApp({
|
|||
if (message.type === 'snapshot') {
|
||||
dataReceivedAt.value = Date.now();
|
||||
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.setBars(message.bars);
|
||||
levels.value = message.levels || [];
|
||||
|
|
@ -547,9 +506,6 @@ createApp({
|
|||
drawColorInitialized = true;
|
||||
}
|
||||
syncVisibleLevels();
|
||||
chartApi.setSessionRange({
|
||||
t: message.session_t, high: message.session_high, low: message.session_low,
|
||||
});
|
||||
price.value = message.price;
|
||||
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||
hydrateEvents(message.events, message.events_more);
|
||||
|
|
@ -562,7 +518,6 @@ createApp({
|
|||
if (message.tf && message.tf !== timeframe.value) return;
|
||||
dataReceivedAt.value = Date.now();
|
||||
if (message.trendline_first_t != null) {
|
||||
const lastSource = chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1);
|
||||
const geometryCurrent = chartApi.updateTrendlineTime(
|
||||
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
|
||||
message.trendline_previous_t,
|
||||
|
|
@ -570,34 +525,22 @@ createApp({
|
|||
);
|
||||
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
||||
} else if (chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1) !== lastSource) {
|
||||
syncVisibleLevels();
|
||||
}
|
||||
}
|
||||
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.setDisplayFutureTimes(message.future_times);
|
||||
chartApi.updateBar(message.bar);
|
||||
noteLivePrice(message.bar.c);
|
||||
if (message.session_open != null) sessionOpen.value = message.session_open;
|
||||
if (status.value.last_bar_t !== message.bar.t) {
|
||||
status.value.last_bar_t = message.bar.t;
|
||||
dataReceivedAt.value = Date.now();
|
||||
}
|
||||
price.value = message.bar.c;
|
||||
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||
status.value.last_bar_t = message.bar.t;
|
||||
} else if (message.type === 'trendline_bar') {
|
||||
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
|
||||
const geometryCurrent = chartApi.updateTrendlineTime(
|
||||
message.tf, message.t, message.duration, message.first_t, message.previous_t,
|
||||
message.future_times, message.future_durations,
|
||||
);
|
||||
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
||||
} else if (chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1) !== lastSource) {
|
||||
syncVisibleLevels();
|
||||
}
|
||||
syncVisibleLevels();
|
||||
} else if (message.type === 'trendline_geometry') {
|
||||
chartApi.setTrendlineGeometry(message.geometry);
|
||||
syncVisibleLevels();
|
||||
|
|
@ -1115,14 +1058,6 @@ createApp({
|
|||
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) {
|
||||
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
|
||||
const base = match[1];
|
||||
|
|
@ -1132,64 +1067,12 @@ createApp({
|
|||
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) {
|
||||
const fail = reason => {
|
||||
addEvent(`DUPLICATE #${line.number} FAILED · ${reason}`, 'error');
|
||||
fetch('/api/debug/snap', {
|
||||
method: 'POST',
|
||||
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;
|
||||
}
|
||||
if (line.geometry_resolved === false) return;
|
||||
const anchorT = shiftTimeByBars(line, line.anchor_t);
|
||||
const endT = shiftTimeByBars(line, line.last_t);
|
||||
const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t);
|
||||
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) return;
|
||||
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
|
||||
const response = await apiFetch('/api/lines', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -1204,7 +1087,7 @@ createApp({
|
|||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
addEvent(`DUPLICATE #${line.number} FAILED · HTTP ${response.status}`, 'error');
|
||||
console.error(`Unable to duplicate line: HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const saved = await response.json();
|
||||
|
|
@ -1365,14 +1248,14 @@ createApp({
|
|||
if (item.line) {
|
||||
const line = { ...item.line };
|
||||
const previous = { ...item.line };
|
||||
if (vertical) line.anchor_p = chartApi.snapPrice(line.anchor_p + vertical * chartApi.tick);
|
||||
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK);
|
||||
if (line.slope === 0) {
|
||||
if (vertical) await updateLineGeometry(line, previous);
|
||||
continue;
|
||||
}
|
||||
const endPrice = item.line.anchor_p
|
||||
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
|
||||
+ vertical * chartApi.tick;
|
||||
+ vertical * ConfluenceChart.TICK;
|
||||
if (horizontal) {
|
||||
if (line.geometry_resolved === false) continue;
|
||||
const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
|
||||
|
|
@ -1394,8 +1277,8 @@ createApp({
|
|||
if (comment.pinned) {
|
||||
const changes = {};
|
||||
if (vertical) {
|
||||
changes.anchor_p = chartApi.snapPrice(
|
||||
comment.anchor_p + vertical * chartApi.tick,
|
||||
changes.anchor_p = ConfluenceChart.snapToTick(
|
||||
comment.anchor_p + vertical * ConfluenceChart.TICK,
|
||||
);
|
||||
}
|
||||
if (horizontal) {
|
||||
|
|
@ -1549,11 +1432,6 @@ createApp({
|
|||
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) {
|
||||
const armed = new Set(maAlerts.value['1d'] || []);
|
||||
if (armed.has(period)) armed.delete(period);
|
||||
|
|
@ -1629,14 +1507,6 @@ createApp({
|
|||
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
||||
}, { 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 => {
|
||||
localStorage.setItem('chart-animate-current-price', String(value));
|
||||
if (chartApi) chartApi.setCurrentPriceAnimation(value);
|
||||
|
|
@ -1647,11 +1517,6 @@ createApp({
|
|||
if (chartApi) chartApi.setAutoScrollLivePrice(value);
|
||||
});
|
||||
|
||||
watch(sessionRange, value => {
|
||||
localStorage.setItem('chart-session-range', String(value));
|
||||
if (chartApi) chartApi.setSessionRangeEnabled(value);
|
||||
});
|
||||
|
||||
watch(extraDetail, value => {
|
||||
localStorage.setItem('chart-extra-detail', String(value));
|
||||
});
|
||||
|
|
@ -1683,7 +1548,6 @@ createApp({
|
|||
chartApi.setSnap(snap.value);
|
||||
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
|
||||
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
|
||||
chartApi.setSessionRangeEnabled(sessionRange.value);
|
||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
nextTick(() => {
|
||||
|
|
@ -1695,7 +1559,6 @@ createApp({
|
|||
await refreshStatus();
|
||||
await refreshComments();
|
||||
await refreshMaAlerts();
|
||||
await refreshConfluenceAlerts();
|
||||
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
||||
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
|
||||
connect();
|
||||
|
|
@ -1708,6 +1571,6 @@ createApp({
|
|||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
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 };
|
||||
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 };
|
||||
},
|
||||
}).mount('#app');
|
||||
|
|
|
|||
402
static/chart.js
402
static/chart.js
|
|
@ -195,87 +195,6 @@ 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 {
|
||||
constructor() {
|
||||
this.chart = null;
|
||||
|
|
@ -292,10 +211,6 @@ class ConfluenceChart {
|
|||
this.animateCurrentPrice = true;
|
||||
this.autoScrollLivePrice = true;
|
||||
this.lastCurrentPrice = null;
|
||||
this.pendingBar = null;
|
||||
this.pendingBarFrame = null;
|
||||
this.tick = 0.25;
|
||||
this.rthMode = 'spy_rth';
|
||||
this.previewLine = null;
|
||||
this.lineBridgeLayer = null;
|
||||
this.bars = [];
|
||||
|
|
@ -357,11 +272,6 @@ class ConfluenceChart {
|
|||
this.commentNodes = new Map();
|
||||
this.commentLayer = null;
|
||||
this.contextLabelLayer = null;
|
||||
this.sessionRangePrimitive = null;
|
||||
this.sessionRangeEnabled = true;
|
||||
this.sessionT = null;
|
||||
this.sessionHigh = null;
|
||||
this.sessionLow = null;
|
||||
this.overlayFrame = null;
|
||||
this.overlayForce = false;
|
||||
this.overlayRangeKey = '';
|
||||
|
|
@ -386,6 +296,8 @@ class ConfluenceChart {
|
|||
};
|
||||
}
|
||||
|
||||
static TICK = 0.25;
|
||||
|
||||
// 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.
|
||||
static DRAG_THRESHOLD = 12;
|
||||
|
|
@ -432,20 +344,8 @@ class ConfluenceChart {
|
|||
return localStorage.getItem('chart-diag') === '1';
|
||||
}
|
||||
|
||||
static snapToTick(price, tick = 0.25) {
|
||||
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();
|
||||
static snapToTick(price) {
|
||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||
}
|
||||
|
||||
create(el) {
|
||||
|
|
@ -555,8 +455,6 @@ class ConfluenceChart {
|
|||
this.candles.attachPrimitive(this.rthPrimitive);
|
||||
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
|
||||
this.candles.attachPrimitive(this.fibPrimitive);
|
||||
this.sessionRangePrimitive = new SessionRangePrimitive();
|
||||
this.candles.attachPrimitive(this.sessionRangePrimitive);
|
||||
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||
priceScaleId: 'right',
|
||||
color: 'rgba(0,0,0,0)',
|
||||
|
|
@ -594,7 +492,6 @@ class ConfluenceChart {
|
|||
this.overlayLayer.appendChild(this.ohlcReadout);
|
||||
this.currentPricePulse = document.createElement('div');
|
||||
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.overlayLayer.appendChild(this.currentPricePulse);
|
||||
this.projectionDiagnostic = document.createElement('pre');
|
||||
|
|
@ -667,11 +564,9 @@ class ConfluenceChart {
|
|||
duplicate.type = 'button';
|
||||
duplicate.dataset.action = 'duplicate';
|
||||
duplicate.textContent = 'Duplicate';
|
||||
duplicate.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
duplicate.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const id = this.contextLineId || this.selectedLineId;
|
||||
const level = this.levels.find(value => value.id === id);
|
||||
const level = this.levels.find(value => value.id === this.contextLineId);
|
||||
this.hideContextMenu();
|
||||
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
|
||||
});
|
||||
|
|
@ -685,7 +580,6 @@ class ConfluenceChart {
|
|||
this.hideContextMenu();
|
||||
if (id && this.onLineDelete) this.onLineDelete(id);
|
||||
});
|
||||
this.contextMenu.addEventListener('pointerdown', event => event.stopPropagation());
|
||||
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
||||
this.contextMenu.append(endHere, extend, duplicate, deleteLine);
|
||||
this.overlayLayer.appendChild(this.contextMenu);
|
||||
|
|
@ -789,7 +683,6 @@ class ConfluenceChart {
|
|||
}
|
||||
this.syncRthLines();
|
||||
this.scheduleOverlays(true);
|
||||
this.scheduleGeometryDiagnostic();
|
||||
}
|
||||
|
||||
setTrendlineGeometry(geometry) {
|
||||
|
|
@ -811,10 +704,7 @@ class ConfluenceChart {
|
|||
}
|
||||
|
||||
setDisplayFutureTimes(times) {
|
||||
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;
|
||||
this.displayFutureTimes = (times || []).map(Number);
|
||||
if (this.bars.length && this.futureSpace) {
|
||||
this.futureSpace.setData(this.timeScaleSlots(this.bars));
|
||||
}
|
||||
|
|
@ -857,14 +747,9 @@ class ConfluenceChart {
|
|||
} else if (t === source.times[last] && source.duration == null && duration != null) {
|
||||
source.durations[last] = Number(duration);
|
||||
}
|
||||
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.futureTimes = futureTimes.map(Number);
|
||||
source.futureDurations = futureDurations.map(Number);
|
||||
source.allTimes = [...source.times, ...source.futureTimes];
|
||||
source._filled = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -911,30 +796,6 @@ class ConfluenceChart {
|
|||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
|
|
@ -948,54 +809,11 @@ class ConfluenceChart {
|
|||
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;
|
||||
else this.bars.push(bar);
|
||||
this.expandSessionRange(bar);
|
||||
if (advanced) this.extendManualLineSeries(last, bar);
|
||||
if (advanced) this.syncRthLines();
|
||||
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) {
|
||||
const step = ConfluenceChart.barInterval(this.bars);
|
||||
if (!(step > 0)) return;
|
||||
|
|
@ -1066,15 +884,11 @@ class ConfluenceChart {
|
|||
|
||||
updateCurrentPrice(price) {
|
||||
if (price == null) return;
|
||||
if (this.currentPriceLine && price === this.lastCurrentPrice) {
|
||||
this.syncCurrentPricePulse();
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
price,
|
||||
color: '#b7771d',
|
||||
lineWidth: 2,
|
||||
lineStyle: LightweightCharts.LineStyle.LargeDashed,
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Dotted,
|
||||
axisLabelVisible: true,
|
||||
title: '',
|
||||
};
|
||||
|
|
@ -1086,15 +900,13 @@ class ConfluenceChart {
|
|||
}
|
||||
|
||||
syncCurrentPricePulse() {
|
||||
if (!this.currentPricePulse) return;
|
||||
const show = this.animateCurrentPrice && this.lastCurrentPrice != null;
|
||||
if (this.currentPricePulse) {
|
||||
this.currentPricePulse.hidden = !show;
|
||||
this.currentPricePulse.classList.toggle('active', show);
|
||||
}
|
||||
this.currentPricePulse.hidden = !show;
|
||||
this.currentPricePulse.classList.toggle('active', show);
|
||||
if (!show || !this.candles || this.lastCurrentPrice == null) return;
|
||||
const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
|
||||
if (y == null || !this.currentPricePulse) return;
|
||||
this.currentPricePulse.style.top = `${Math.round(y - 9)}px`;
|
||||
if (y != null) this.currentPricePulse.style.top = `${Math.round(y)}px`;
|
||||
}
|
||||
|
||||
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
||||
|
|
@ -1425,7 +1237,7 @@ class ConfluenceChart {
|
|||
if (point && this.withinPlot(point) && this.onCommentMove) {
|
||||
this.onCommentMove(node.comment, {
|
||||
anchor_t: Math.round(point.t),
|
||||
anchor_p: this.snapPrice(point.p),
|
||||
anchor_p: ConfluenceChart.snapToTick(point.p),
|
||||
});
|
||||
} else {
|
||||
this.renderComments();
|
||||
|
|
@ -1629,7 +1441,6 @@ class ConfluenceChart {
|
|||
this.pendingView = null;
|
||||
}
|
||||
this.scheduleOverlays(true);
|
||||
this.scheduleGeometryDiagnostic();
|
||||
}
|
||||
|
||||
renderContextLabels() {
|
||||
|
|
@ -1840,7 +1651,7 @@ class ConfluenceChart {
|
|||
if (!point || !this.withinPlot(point) || point.t == null) return null;
|
||||
return {
|
||||
time: point.t,
|
||||
price: this.snapPrice(point.p),
|
||||
price: ConfluenceChart.snapToTick(point.p),
|
||||
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
||||
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
||||
};
|
||||
|
|
@ -1927,90 +1738,6 @@ class ConfluenceChart {
|
|||
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 = {}) {
|
||||
if (snapped.t == null) return this.hideSnapDot();
|
||||
const x = this.coordinateAtTime(snapped.t);
|
||||
|
|
@ -2062,7 +1789,12 @@ class ConfluenceChart {
|
|||
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
||||
};
|
||||
this.lastSnapDiagnostic = body;
|
||||
this.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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2168,7 +1900,7 @@ class ConfluenceChart {
|
|||
this.onToolComplete?.({
|
||||
tool,
|
||||
time: end.t,
|
||||
price: this.snapPrice(end.p),
|
||||
price: ConfluenceChart.snapToTick(end.p),
|
||||
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
|
||||
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
|
||||
});
|
||||
|
|
@ -2178,7 +1910,7 @@ class ConfluenceChart {
|
|||
if (tool === 'level') {
|
||||
// A click with no drag is a valid placement; the drag is only there to
|
||||
// let you fine-tune the price before committing.
|
||||
this.onToolComplete?.({ tool, price: this.snapPrice(end.p) });
|
||||
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2261,7 +1993,7 @@ class ConfluenceChart {
|
|||
const last = this.bars[this.bars.length - 1];
|
||||
if (fallbackT > last.t) {
|
||||
const index = Math.round(this.indexAt(fallbackT));
|
||||
return { t: this.timeAtIndex(index), p: this.snapPrice(point.p), snappedSide: null };
|
||||
return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null };
|
||||
}
|
||||
// An already-snapped point carries no cursor position; return it untouched
|
||||
// rather than measuring against undefined.
|
||||
|
|
@ -2290,7 +2022,7 @@ class ConfluenceChart {
|
|||
if (!this.gesture) return;
|
||||
const { start, end } = this.gesture;
|
||||
if (this.armedTool === 'level') {
|
||||
const price = this.snapPrice(end.p);
|
||||
const price = ConfluenceChart.snapToTick(end.p);
|
||||
const y = this.candles.priceToCoordinate(price);
|
||||
if (y == null) return;
|
||||
this.previewLine.removeAttribute('hidden');
|
||||
|
|
@ -2664,29 +2396,8 @@ class ConfluenceChart {
|
|||
?? 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) {
|
||||
if (!source) return [];
|
||||
if (source._filled) return source._filled;
|
||||
const filled = ConfluenceChart.fillShortGaps(source);
|
||||
source._filled = filled;
|
||||
return filled;
|
||||
return source?.allTimes || source?.times || [];
|
||||
}
|
||||
|
||||
static timeframeIndexAt(source, time, allowFuture = true) {
|
||||
|
|
@ -2731,40 +2442,11 @@ class ConfluenceChart {
|
|||
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) {
|
||||
const source = this.sourceSeries(level);
|
||||
if (!source) return this.shiftDisplayedTime(time, bars);
|
||||
const index = this.sourceIndexAt(level, time);
|
||||
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));
|
||||
const index = ConfluenceChart.timeframeIndexAt(source, time);
|
||||
return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars);
|
||||
}
|
||||
|
||||
shiftDisplayedTime(time, slots) {
|
||||
|
|
@ -2832,18 +2514,10 @@ class ConfluenceChart {
|
|||
if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t);
|
||||
}
|
||||
} else {
|
||||
const displayed = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff);
|
||||
for (const time of ConfluenceChart.sourceTimes(source) || []) {
|
||||
if (time < first || time > lastBar || time < level.anchor_t || time > cutoff) continue;
|
||||
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;
|
||||
if (time >= first && time <= lastBar && time >= level.anchor_t && time <= cutoff) {
|
||||
stamps.add(time);
|
||||
}
|
||||
const bar = displayed[lo] || displayed[displayed.length - 1];
|
||||
if (bar) stamps.add(bar.t);
|
||||
}
|
||||
if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar);
|
||||
}
|
||||
|
|
@ -3189,7 +2863,7 @@ class ConfluenceChart {
|
|||
? sourcePoint - sourceStart
|
||||
: this.indexAt(point.t) - this.indexAt(start.t),
|
||||
);
|
||||
const priceShift = this.snapPrice(point.p - start.p);
|
||||
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
|
||||
const anchorT = source
|
||||
? this.shiftLineTime(original, original.anchor_t, indexShift)
|
||||
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
||||
|
|
@ -3255,7 +2929,7 @@ class ConfluenceChart {
|
|||
}
|
||||
const snapped = this.snapToBars
|
||||
? this.snapPoint(point)
|
||||
: { ...point, p: this.snapPrice(point.p) };
|
||||
: { ...point, p: ConfluenceChart.snapToTick(point.p) };
|
||||
if (snapped.t == null || snapped.p == null) return;
|
||||
const time = snapped.t;
|
||||
const price = snapped.p;
|
||||
|
|
@ -3313,16 +2987,15 @@ class ConfluenceChart {
|
|||
if (!level || !this.bars.length) return;
|
||||
event.preventDefault();
|
||||
const point = this.eventPoint(event);
|
||||
if (!point || !this.withinPlot(point)) return;
|
||||
if (!point || !this.withinPlot(point) || point.t == null) return;
|
||||
const lastBar = this.bars[this.bars.length - 1];
|
||||
const cutoff = point.t == null ? null : point.t > lastBar.t
|
||||
const cutoff = point.t > lastBar.t
|
||||
? Math.round(point.t)
|
||||
: this.bars.reduce((nearest, bar) =>
|
||||
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
||||
).t;
|
||||
this.contextLineId = level.id;
|
||||
const origin = Math.min(level.anchor_t, level.last_t);
|
||||
this.contextCutoff = cutoff != null && cutoff > origin ? cutoff : null;
|
||||
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
||||
this.contextEndButton.hidden = this.contextCutoff == null;
|
||||
this.contextExtendButton.hidden = level.cutoff_t == null;
|
||||
this.contextMenu.hidden = false;
|
||||
|
|
@ -3383,8 +3056,7 @@ class ConfluenceChart {
|
|||
|
||||
syncRthLines() {
|
||||
if (!this.rthPrimitive) return;
|
||||
if (!this.rthEnabled || this.rthMode !== 'spy_rth'
|
||||
|| !this.bars.length || this.bars[0]?.tf === '1d') {
|
||||
if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') {
|
||||
this.rthPrimitive.setMarks([]);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,6 @@
|
|||
</details>
|
||||
<details class="sidebar-section config-section">
|
||||
<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="autoScrollLivePrice"> Autoscroll to live price</label>
|
||||
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
|
||||
|
|
@ -138,9 +136,9 @@
|
|||
<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>
|
||||
</div>
|
||||
<label>Alert early (pts)<input type="number" min="0" :step="tick" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
|
||||
<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>
|
||||
<form class="row price-row" @submit.prevent="addPriceAlert">
|
||||
<label>Price<input type="number" :step="tick" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
|
||||
<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>
|
||||
<button type="submit" :disabled="!alertPrice">Add</button>
|
||||
</form>
|
||||
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
||||
|
|
@ -283,7 +281,6 @@
|
|||
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
|
||||
{{ selectedAreHidden ? 'Show' : 'Hide' }}
|
||||
</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>
|
||||
</div>
|
||||
<div class="drawing-list-shell">
|
||||
|
|
@ -325,11 +322,11 @@
|
|||
<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 === 'level'" class="level-editors">
|
||||
<label>Price<input type="number" :min="tick" :step="tick" :value="item.line.anchor_p"
|
||||
<label>Price<input type="number" min="0.25" step="0.25" :value="item.line.anchor_p"
|
||||
aria-label="Level price in drawing list"
|
||||
@keydown.enter="$event.target.blur()"
|
||||
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
|
||||
<label>Early<input type="number" min="0" :step="tick" :value="item.line.alert_early_points ?? ''"
|
||||
<label>Early<input type="number" min="0" step="0.25" :value="item.line.alert_early_points ?? ''"
|
||||
placeholder="ATR" aria-label="Level alert early points"
|
||||
@keydown.enter="$event.target.blur()"
|
||||
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
.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; }
|
||||
.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; } }
|
||||
.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%); } }
|
||||
.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; }
|
||||
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; opacity:.85; } }
|
||||
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } }
|
||||
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -94,10 +94,9 @@ test('a 30m line keeps source geometry when the 1m window starts after its ancho
|
|||
'the rollback path no longer restores displayed-grid geometry');
|
||||
assert.equal(result.shifted, result.sourceSecondBar,
|
||||
'a source-timeframe nudge did not move by one 30m bar');
|
||||
const historical = result.sampledTimes.filter(time => time <= result.displayedTimes.at(-1));
|
||||
assert.deepEqual(historical, [result.displayedTimes[0], result.displayedTimes.at(-1)],
|
||||
'a 30m line on 1m should sample source-bar times, not every 1m candle');
|
||||
assert.ok(result.sampledTimes.slice(historical.length)
|
||||
assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes,
|
||||
'manual line samples no longer match the displayed candle grid');
|
||||
assert.ok(result.sampledTimes.slice(result.displayedTimes.length)
|
||||
.every(time => time > result.displayedTimes.at(-1)),
|
||||
'manual-line extension mixed future points into candle history');
|
||||
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
|
||||
|
|
@ -234,8 +233,6 @@ test('daily future slots and live duration follow the server session calendar',
|
|||
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
|
||||
shifted: c.shiftDisplayedTime(thursday, 1),
|
||||
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
|
||||
horizonShift: c.maxLineShift(horizonLine, monday, 10),
|
||||
thursdayShift: c.maxLineShift(horizonLine, thursday, 10),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -243,9 +240,7 @@ test('daily future slots and live duration follow the server session calendar',
|
|||
assert.equal(result.duration, 23 * 3600);
|
||||
assert.equal(result.beyondHorizon, null);
|
||||
assert.equal(result.shifted, 1786917600);
|
||||
assert.equal(result.shiftBeyondHorizon, 1787004000);
|
||||
assert.equal(result.horizonShift, 0);
|
||||
assert.ok(result.thursdayShift > 0);
|
||||
assert.equal(result.shiftBeyondHorizon, null);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
|
@ -358,174 +353,6 @@ 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',
|
||||
{ timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
|
|
|
|||
|
|
@ -58,24 +58,6 @@ def test_corrupt_state_does_not_prevent_alerting(tmp_path):
|
|||
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):
|
||||
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
||||
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
||||
|
|
@ -83,5 +65,4 @@ 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.
|
||||
assert len(payload["fired"]) == 1
|
||||
assert payload["fired"][0]["at"] == 42
|
||||
assert payload["fired"][0]["symbol"] == "/ES"
|
||||
assert payload["next_number"] == 2
|
||||
|
|
|
|||
|
|
@ -26,14 +26,6 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
|
|||
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():
|
||||
engine = AlertEngine(min_score=28)
|
||||
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
||||
|
|
|
|||
|
|
@ -40,16 +40,6 @@ def test_open_when_no_token_configured(client):
|
|||
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():
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
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.bar_space import index_at, price_in_bar_space, price_in_timeframe_space
|
||||
from app.analysis.levels import Level, LevelKind, Side
|
||||
from app.bars.models import Timeframe
|
||||
|
||||
|
|
@ -113,25 +110,3 @@ def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser
|
|||
assert price_in_timeframe_space(
|
||||
line, source_times, Timeframe.M30, 2 * half_hour,
|
||||
) == 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]
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path):
|
|||
assert [entry["message"] for entry in day] == ["dropped", "yesterday"]
|
||||
assert more is True
|
||||
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):
|
||||
|
|
@ -57,6 +56,3 @@ def test_dispatched_alerts_land_in_the_event_log(tmp_path):
|
|||
assert "#27" 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]["symbol"] == "/ES"
|
||||
assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es"
|
||||
assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
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)]
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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"}
|
||||
|
|
@ -9,25 +9,6 @@ def sample_line():
|
|||
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):
|
||||
path = tmp_path / "manual_lines.json"
|
||||
store = ManualLineStore(path)
|
||||
|
|
@ -178,7 +159,6 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path):
|
|||
"cutoff_t": 150,
|
||||
}).json()
|
||||
assert created["cutoff_t"] == 150
|
||||
assert created["symbol"] == "/ES"
|
||||
|
||||
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
|
||||
assert cleared["cutoff_t"] is None
|
||||
|
|
@ -218,7 +198,6 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
|||
}).json()
|
||||
assert restored["id"] == line_id
|
||||
assert restored["number"] == number
|
||||
assert restored["symbol"] == "/ES"
|
||||
assert client.post("/api/lines/restore", json={
|
||||
"id": line_id,
|
||||
"tf": "1m",
|
||||
|
|
@ -229,30 +208,3 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
|||
"last_t": 200,
|
||||
"number": number,
|
||||
}).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"
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
|
|||
))
|
||||
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])
|
||||
return []
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
|
|||
))
|
||||
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)
|
||||
return []
|
||||
|
||||
|
|
|
|||
|
|
@ -48,22 +48,3 @@ def test_a_tick_cannot_overwrite_a_settled_bar():
|
|||
|
||||
held = store.get(Timeframe.M1)[0]
|
||||
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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,21 +37,3 @@ def test_ma_alert_endpoints_persist(tmp_path):
|
|||
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 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
|
||||
|
|
|
|||
|
|
@ -82,20 +82,6 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path):
|
|||
message = snapshot(runtime, Timeframe.H1)
|
||||
|
||||
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):
|
||||
|
|
@ -144,44 +130,6 @@ 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):
|
||||
app = FastAPI()
|
||||
app.include_router(ws_router)
|
||||
|
|
|
|||
Loading…
Reference in a new issue