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/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are
|
||||||
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md).
|
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md).
|
||||||
Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md).
|
Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md).
|
||||||
Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md).
|
|
||||||
|
|
||||||
## Tests earn their place by catching a real bug
|
## Tests earn their place by catching a real bug
|
||||||
|
|
||||||
|
|
@ -90,11 +89,8 @@ docker compose logs api | grep SNAPDBG
|
||||||
With it on, every snap the trendline tool computes is posted to
|
With it on, every snap the trendline tool computes is posted to
|
||||||
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
||||||
snapped time and price, how many bars were held, the first and last bar, and the
|
snapped time and price, how many bars were held, the first and last bar, and the
|
||||||
chart's width. After bars and levels load it also posts one `kind=geometry`
|
chart's width. Throttled to about one a second. It reads the client's own
|
||||||
report: 1m holes, whether `futureSpace` owns them, and each visible manual
|
numbers, which is exactly what "works in my headless run" cannot tell you.
|
||||||
line's off-median screen segments. Throttled to about one a second. It reads
|
|
||||||
the client's own numbers, which is exactly what "works in my headless run"
|
|
||||||
cannot tell you. Do not ask the user to paste that from the console.
|
|
||||||
|
|
||||||
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||||
fields `SnapReport` declares.
|
fields `SnapReport` declares.
|
||||||
|
|
@ -153,10 +149,6 @@ geometry change that needs explicit browser verification.
|
||||||
- The displayed time scale, a drawing's source-timeframe bar space, and the
|
- The displayed time scale, a drawing's source-timeframe bar space, and the
|
||||||
server session calendar are different coordinate systems. Never substitute
|
server session calendar are different coordinate systems. Never substitute
|
||||||
wall-clock seconds or the displayed grid for canonical source geometry.
|
wall-clock seconds or the displayed grid for canonical source geometry.
|
||||||
- A short 1m hole that already owns empty `futureSpace` slots still kinks a
|
|
||||||
line if source index treats the surrounding bars as adjacent. Count both
|
|
||||||
before changing geometry — see the 2026-08-26 entry in
|
|
||||||
`docs/implementation.md`. Settlement and weekend compression are intended.
|
|
||||||
- Daily and intraday future slots must skip non-session time. An endpoint that
|
- Daily and intraday future slots must skip non-session time. An endpoint that
|
||||||
looks valid before a settlement/weekend break must remain resolvable when real
|
looks valid before a settlement/weekend break must remain resolvable when real
|
||||||
history arrives.
|
history arrives.
|
||||||
|
|
@ -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
|
pays for a live stream, 5k bars, and a canvas. New work must not add
|
||||||
cost on the hot path unless the screen or an alert has to change.
|
cost on the hot path unless the screen or an alert has to change.
|
||||||
|
|
||||||
**A forming tick may update the live candle and the price label. That
|
|
||||||
is all.** It must not rebuild, `setData`, walk bars, walk drawings, or
|
|
||||||
recompute geometry. If the work cannot be an `update()` of one point,
|
|
||||||
it does not belong on the tick. Naming a different function does not
|
|
||||||
make it cheap — we banned `scheduleOverlays(true)` on ticks and then
|
|
||||||
shipped `futureSpace.setData` plus `syncLevels` on the same path.
|
|
||||||
|
|
||||||
Before you finish any change that touches bars, sockets, overlays,
|
|
||||||
drawings, or the time scale, answer: *what happens on a 1m forming
|
|
||||||
tick at 4 Hz with 5k bars?* If the answer is more than `candles.update`
|
|
||||||
+ price line, stop and make the rest a no-op unless a timestamp
|
|
||||||
actually advanced.
|
|
||||||
|
|
||||||
- Nothing extra on the event loop each closed bar or tick. Watch
|
- Nothing extra on the event loop each closed bar or tick. Watch
|
||||||
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see
|
`loop_lag_ms`. CPU stays in the threadpool or off the loop — see
|
||||||
`docs/async_refactor.md`.
|
`docs/async_refactor.md`.
|
||||||
|
|
@ -272,8 +251,10 @@ actually advanced.
|
||||||
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
|
- Crosshair move is for the cursor (OHLC, drawing tooltip). Do not
|
||||||
rebuild overlays that only depend on the viewport.
|
rebuild overlays that only depend on the viewport.
|
||||||
- Overlay redraws go through `scheduleOverlays`. Force when bars are
|
- Overlay redraws go through `scheduleOverlays`. Force when bars are
|
||||||
replaced, a new bar opens, or size changed. A forming-bar tick is
|
replaced, a new bar opens, or size changed. **A forming-bar tick is
|
||||||
not that.
|
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
|
- Diagnostic `getImageData` / painted-pixel sampling is never on the
|
||||||
live overlay path. `?diag=1` is not a license to sync the GPU every
|
live overlay path. `?diag=1` is not a license to sync the GPU every
|
||||||
frame.
|
frame.
|
||||||
|
|
@ -283,6 +264,3 @@ actually advanced.
|
||||||
layout actually changed.
|
layout actually changed.
|
||||||
- A feature that needs a per-frame or per-tick loop needs a reason,
|
- A feature that needs a per-frame or per-tick loop needs a reason,
|
||||||
and an off switch.
|
and an off switch.
|
||||||
- Chart overlays may animate only `transform` or `opacity`. Animating
|
|
||||||
`background-position` on a full-width gradient cost ~15% CPU; the
|
|
||||||
compositor will not save you from a paint property.
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from pathlib import Path
|
||||||
|
|
||||||
from app.analysis.confluence import Cluster
|
from app.analysis.confluence import Cluster
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -44,7 +43,6 @@ class Alert:
|
||||||
class _Fired:
|
class _Fired:
|
||||||
center: float
|
center: float
|
||||||
at: int
|
at: int
|
||||||
symbol: str = DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
|
|
||||||
class AlertEngine:
|
class AlertEngine:
|
||||||
|
|
@ -85,14 +83,7 @@ class AlertEngine:
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
self._next_number = int(payload.get("next_number", 1))
|
self._next_number = int(payload.get("next_number", 1))
|
||||||
payload = payload.get("fired", [])
|
payload = payload.get("fired", [])
|
||||||
return [
|
return [_Fired(float(item["center"]), int(item["at"])) for item in payload]
|
||||||
_Fired(
|
|
||||||
float(item["center"]),
|
|
||||||
int(item["at"]),
|
|
||||||
str(item.get("symbol") or DEFAULT_SYMBOL),
|
|
||||||
)
|
|
||||||
for item in payload
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Corrupt state costs one burst of duplicate alerts, which is a far
|
# Corrupt state costs one burst of duplicate alerts, which is a far
|
||||||
# better failure than refusing to start the stream.
|
# better failure than refusing to start the stream.
|
||||||
|
|
@ -124,12 +115,7 @@ class AlertEngine:
|
||||||
{
|
{
|
||||||
"next_number": self._next_number,
|
"next_number": self._next_number,
|
||||||
"fired": [
|
"fired": [
|
||||||
{
|
{"center": entry.center, "at": entry.at} for entry in self._fired
|
||||||
"center": entry.center,
|
|
||||||
"at": entry.at,
|
|
||||||
"symbol": entry.symbol,
|
|
||||||
}
|
|
||||||
for entry in self._fired
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
|
|
@ -151,12 +137,10 @@ class AlertEngine:
|
||||||
now: int,
|
now: int,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
watched: list[Level] | None = None,
|
watched: list[Level] | None = None,
|
||||||
confluence: bool = True,
|
|
||||||
) -> list[Alert]:
|
) -> list[Alert]:
|
||||||
tolerance = 0.5 * atr15
|
tolerance = 0.5 * atr15
|
||||||
if tolerance <= 0:
|
if tolerance <= 0:
|
||||||
return []
|
return []
|
||||||
root = instrument_for_symbol(symbol).schwab_symbol
|
|
||||||
# Two zones within an ATR of each other are the same zone as far as
|
# Two zones within an ATR of each other are the same zone as far as
|
||||||
# being told about them goes.
|
# being told about them goes.
|
||||||
merge_distance = 2 * tolerance
|
merge_distance = 2 * tolerance
|
||||||
|
|
@ -200,7 +184,7 @@ class AlertEngine:
|
||||||
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
|
||||||
# score would mean a line you deliberately drew could never alert.
|
# score would mean a line you deliberately drew could never alert.
|
||||||
# A disarmed one has already had its say and no longer qualifies.
|
# A disarmed one has already had its say and no longer qualifies.
|
||||||
if not drawn and (not confluence or cluster.score < self.min_score):
|
if not drawn and cluster.score < self.min_score:
|
||||||
continue
|
continue
|
||||||
if abs(cluster.center - current_price) > tolerance and not early_drawn:
|
if abs(cluster.center - current_price) > tolerance and not early_drawn:
|
||||||
continue
|
continue
|
||||||
|
|
@ -210,12 +194,10 @@ class AlertEngine:
|
||||||
# was oscillating on re-alerted on every crossing — which is exactly
|
# was oscillating on re-alerted on every crossing — which is exactly
|
||||||
# when a level is least newsworthy, not most.
|
# when a level is least newsworthy, not most.
|
||||||
if any(
|
if any(
|
||||||
entry.symbol == root
|
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
|
||||||
and abs(entry.center - cluster.center) <= merge_distance
|
|
||||||
for entry in self._fired
|
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
self._fired.append(_Fired(cluster.center, now, root))
|
self._fired.append(_Fired(cluster.center, now))
|
||||||
changed = True
|
changed = True
|
||||||
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||||
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||||
|
|
@ -256,13 +238,9 @@ class AlertEngine:
|
||||||
price = level.current_p if level.current_p is not None else level.anchor_p
|
price = level.current_p if level.current_p is not None else level.anchor_p
|
||||||
if abs(price - current_price) > tolerance:
|
if abs(price - current_price) > tolerance:
|
||||||
continue
|
continue
|
||||||
if any(
|
if any(abs(entry.center - price) <= merge_distance for entry in self._fired):
|
||||||
entry.symbol == root
|
|
||||||
and abs(entry.center - price) <= merge_distance
|
|
||||||
for entry in self._fired
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
self._fired.append(_Fired(price, now, root))
|
self._fired.append(_Fired(price, now))
|
||||||
changed = True
|
changed = True
|
||||||
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
|
direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH"
|
||||||
name = f"{level.period} DMA" if level.period else level.label
|
name = f"{level.period} DMA" if level.period else level.label
|
||||||
|
|
|
||||||
|
|
@ -12,32 +12,6 @@ from bisect import bisect_right
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.bars.session import bucket_duration, next_bucket_start
|
from app.bars.session import bucket_duration, next_bucket_start
|
||||||
|
|
||||||
# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes
|
|
||||||
# occupy empty slots on the chart, so source index must count them too.
|
|
||||||
MAX_INTRADAY_GAP_SECONDS = 30 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
|
|
||||||
"""Insert missing bucket opens inside short intraday holes.
|
|
||||||
|
|
||||||
A 9-minute 1m hole is eight empty columns on screen. Without these times,
|
|
||||||
source index treats the two surrounding bars as adjacent and the line
|
|
||||||
goes flat across the hole. Settlement and weekends stay compressed.
|
|
||||||
"""
|
|
||||||
if tf is Timeframe.D1 or len(times) < 2:
|
|
||||||
return times
|
|
||||||
step = tf.seconds
|
|
||||||
if step <= 0:
|
|
||||||
return times
|
|
||||||
filled = [times[0]]
|
|
||||||
for time in times[1:]:
|
|
||||||
prev = filled[-1]
|
|
||||||
gap = time - prev
|
|
||||||
if step < gap <= MAX_INTRADAY_GAP_SECONDS:
|
|
||||||
filled.extend(range(prev + step, time, step))
|
|
||||||
filled.append(time)
|
|
||||||
return filled
|
|
||||||
|
|
||||||
|
|
||||||
def index_at(times: list[int], t: int) -> float:
|
def index_at(times: list[int], t: int) -> float:
|
||||||
"""Fractional index of a timestamp within an ascending bar-time series."""
|
"""Fractional index of a timestamp within an ascending bar-time series."""
|
||||||
|
|
@ -117,7 +91,6 @@ def price_in_timeframe_space(
|
||||||
# Endpoints may deliberately sit in the projection area. They use the same
|
# Endpoints may deliberately sit in the projection area. They use the same
|
||||||
# repeated-source-bucket approximation as the browser; the live evaluation
|
# repeated-source-bucket approximation as the browser; the live evaluation
|
||||||
# instant itself must still belong to held source history.
|
# instant itself must still belong to held source history.
|
||||||
times = fill_short_gaps(times, tf)
|
|
||||||
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
|
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
|
||||||
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
||||||
target_index = timeframe_index_at(times, t, tf)
|
target_index = timeframe_index_at(times, t, tf)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.instrument import DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DAY_SECONDS = 86400
|
DAY_SECONDS = 86400
|
||||||
|
|
@ -41,16 +39,12 @@ class EventLog:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not persist event log", exc_info=True)
|
logger.warning("Could not persist event log", exc_info=True)
|
||||||
|
|
||||||
def add(
|
def add(self, kind: str, message: str, *, number: int | None = None, at: int | None = None) -> dict:
|
||||||
self, kind: str, message: str, *, number: int | None = None,
|
|
||||||
at: int | None = None, symbol: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
entry = {
|
entry = {
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"message": message,
|
"message": message,
|
||||||
"number": number,
|
"number": number,
|
||||||
"at": int(at if at is not None else time.time()),
|
"at": int(at if at is not None else time.time()),
|
||||||
"symbol": symbol or DEFAULT_SYMBOL,
|
|
||||||
}
|
}
|
||||||
self._entries.append(entry)
|
self._entries.append(entry)
|
||||||
self._save()
|
self._save()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.instrument import DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
|
|
||||||
class LevelKind(str, Enum):
|
class LevelKind(str, Enum):
|
||||||
|
|
@ -56,7 +55,6 @@ class Level:
|
||||||
# price this line safely. Such a line remains visible but cannot cluster or
|
# price this line safely. Such a line remains visible but cannot cluster or
|
||||||
# alert using the absolute-time fallback.
|
# alert using the absolute-time fallback.
|
||||||
geometry_resolved: bool = True
|
geometry_resolved: bool = True
|
||||||
symbol: str = DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
def price_at(self, t: int) -> float:
|
def price_at(self, t: int) -> float:
|
||||||
return self.anchor_p + self.slope * (t - self.anchor_t)
|
return self.anchor_p + self.slope * (t - self.anchor_t)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from threading import RLock
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.config import TIMEFRAME_WEIGHT
|
from app.config import TIMEFRAME_WEIGHT
|
||||||
from app.instrument import DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -38,7 +37,6 @@ class ManualLine:
|
||||||
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
|
# Visual multiplier for marks. 1 is the original 30px glyph; the pin stays
|
||||||
# on (anchor_t, anchor_p) regardless of this value.
|
# on (anchor_t, anchor_p) regardless of this value.
|
||||||
scale: float = 1.0
|
scale: float = 1.0
|
||||||
symbol: str = DEFAULT_SYMBOL
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def drawing_kind(self) -> str:
|
def drawing_kind(self) -> str:
|
||||||
|
|
@ -95,7 +93,6 @@ class ManualLine:
|
||||||
cutoff_t=self.cutoff_t,
|
cutoff_t=self.cutoff_t,
|
||||||
armed=self.armed,
|
armed=self.armed,
|
||||||
alert_early_points=self.alert_early_points,
|
alert_early_points=self.alert_early_points,
|
||||||
symbol=self.symbol,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
|
|
@ -133,7 +130,6 @@ class ManualLine:
|
||||||
if value.get("alert_early_points") is not None else None
|
if value.get("alert_early_points") is not None else None
|
||||||
),
|
),
|
||||||
scale=float(value.get("scale", 1.0) or 1.0),
|
scale=float(value.get("scale", 1.0) or 1.0),
|
||||||
symbol=str(value.get("symbol") or DEFAULT_SYMBOL),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.config import DEFAULT_MAX_BARS_PER_TF
|
from app.config import DEFAULT_MAX_BARS_PER_TF
|
||||||
|
|
@ -132,7 +132,6 @@ class LineRestore(BaseModel):
|
||||||
icon: str = ""
|
icon: str = ""
|
||||||
alert_early_points: float | None = None
|
alert_early_points: float | None = None
|
||||||
scale: float = Field(1.0, ge=0.5, le=3)
|
scale: float = Field(1.0, ge=0.5, le=3)
|
||||||
symbol: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class LinePatch(BaseModel):
|
class LinePatch(BaseModel):
|
||||||
|
|
@ -176,7 +175,6 @@ def status(request: Request):
|
||||||
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
||||||
},
|
},
|
||||||
"needs_login": runtime.needs_login(),
|
"needs_login": runtime.needs_login(),
|
||||||
"instrument": runtime.settings.profile.payload(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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")
|
@router.get("/prefs/ma-alerts")
|
||||||
def get_ma_alerts(request: Request):
|
def get_ma_alerts(request: Request):
|
||||||
return request.app.state.runtime.ma_alerts()
|
return request.app.state.runtime.ma_alerts()
|
||||||
|
|
@ -281,7 +267,6 @@ def create_line(request: Request, payload: LineCreate):
|
||||||
cutoff_t=payload.cutoff_t,
|
cutoff_t=payload.cutoff_t,
|
||||||
armed=payload.armed,
|
armed=payload.armed,
|
||||||
kind=payload.kind,
|
kind=payload.kind,
|
||||||
symbol=request.app.state.runtime.settings.profile.schwab_symbol,
|
|
||||||
)
|
)
|
||||||
runtime = request.app.state.runtime
|
runtime = request.app.state.runtime
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
|
|
@ -322,7 +307,6 @@ def restore_line(request: Request, payload: LineRestore):
|
||||||
icon=payload.icon,
|
icon=payload.icon,
|
||||||
alert_early_points=payload.alert_early_points,
|
alert_early_points=payload.alert_early_points,
|
||||||
scale=payload.scale,
|
scale=payload.scale,
|
||||||
symbol=payload.symbol or runtime.settings.profile.schwab_symbol,
|
|
||||||
)
|
)
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
runtime.rebuild_levels()
|
runtime.rebuild_levels()
|
||||||
|
|
@ -352,7 +336,6 @@ def create_price_alert(request: Request, payload: PriceAlertCreate):
|
||||||
color=payload.color,
|
color=payload.color,
|
||||||
line_width=payload.line_width,
|
line_width=payload.line_width,
|
||||||
alert_early_points=payload.alert_early_points,
|
alert_early_points=payload.alert_early_points,
|
||||||
symbol=runtime.settings.profile.schwab_symbol,
|
|
||||||
)
|
)
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
runtime.rebuild_levels()
|
runtime.rebuild_levels()
|
||||||
|
|
@ -405,7 +388,6 @@ def create_comment(request: Request, payload: CommentCreate):
|
||||||
y=payload.y,
|
y=payload.y,
|
||||||
# A comment must never alert, whatever else changes around it.
|
# A comment must never alert, whatever else changes around it.
|
||||||
armed=False,
|
armed=False,
|
||||||
symbol=runtime.settings.profile.schwab_symbol,
|
|
||||||
)
|
)
|
||||||
line = runtime.manual_lines.add(line)
|
line = runtime.manual_lines.add(line)
|
||||||
return line.to_dict()
|
return line.to_dict()
|
||||||
|
|
@ -413,7 +395,6 @@ def create_comment(request: Request, payload: CommentCreate):
|
||||||
|
|
||||||
class SnapReport(BaseModel):
|
class SnapReport(BaseModel):
|
||||||
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
||||||
model_config = ConfigDict(extra="allow")
|
|
||||||
cursor_t: int | None = None
|
cursor_t: int | None = None
|
||||||
cursor_p: float | None = None
|
cursor_p: float | None = None
|
||||||
cursor_x: float | None = None
|
cursor_x: float | None = None
|
||||||
|
|
|
||||||
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.api.deps import SESSION_COOKIE, session_matches, token_matches
|
||||||
from app.analysis.levels import LevelKind
|
from app.analysis.levels import LevelKind
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.analysis.bar_space import fill_short_gaps
|
|
||||||
from app.bars.session import bucket_duration, future_bucket_starts
|
from app.bars.session import bucket_duration, future_bucket_starts
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
|
|
||||||
|
|
@ -54,15 +53,6 @@ def session_open(runtime) -> float | None:
|
||||||
return bars[-1].o if bars else None
|
return bars[-1].o if bars else None
|
||||||
|
|
||||||
|
|
||||||
def session_range(runtime) -> dict | None:
|
|
||||||
"""High/low of the forming CME session — today's range, not prior day."""
|
|
||||||
bars = runtime.store.get(Timeframe.D1, 1)
|
|
||||||
if not bars:
|
|
||||||
return None
|
|
||||||
bar = bars[-1]
|
|
||||||
return {"t": bar.t, "high": bar.h, "low": bar.l}
|
|
||||||
|
|
||||||
|
|
||||||
def trendline_timeframes(runtime) -> set[Timeframe]:
|
def trendline_timeframes(runtime) -> set[Timeframe]:
|
||||||
return {
|
return {
|
||||||
level.tf for level in runtime.levels
|
level.tf for level in runtime.levels
|
||||||
|
|
@ -80,7 +70,7 @@ def trendline_geometry(runtime) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def trendline_series(runtime, tf: Timeframe) -> 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}
|
value = {"times": times}
|
||||||
if tf is Timeframe.D1:
|
if tf is Timeframe.D1:
|
||||||
value["durations"] = [bucket_duration(t, tf) for t in times]
|
value["durations"] = [bucket_duration(t, tf) for t in times]
|
||||||
|
|
@ -97,87 +87,8 @@ def displayed_future_times(runtime, tf: Timeframe) -> list[int]:
|
||||||
return future_bucket_starts(bars[-1].t, tf) if bars else []
|
return future_bucket_starts(bars[-1].t, tf) if bars else []
|
||||||
|
|
||||||
|
|
||||||
def bar_client_message(runtime, bar, displayed_tf: Timeframe, *, full: bool) -> dict | None:
|
|
||||||
"""What one socket should hear about a bar.
|
|
||||||
|
|
||||||
A forming tick of an already-seen minute is just the live candle. Future
|
|
||||||
calendars and HTF geometry go out only when that timeframe's timestamp
|
|
||||||
advances — otherwise 1m at 4 Hz resends 180 slots and walks the store.
|
|
||||||
"""
|
|
||||||
if bar.tf is displayed_tf:
|
|
||||||
payload = {"type": "bar", "tf": displayed_tf.value, "bar": bar.to_dict()}
|
|
||||||
if not full:
|
|
||||||
return payload
|
|
||||||
source_tfs = trendline_timeframes(runtime)
|
|
||||||
source_bars = runtime.store.get(bar.tf)
|
|
||||||
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
|
||||||
first_source_t = source_bars[0].t if source_bars else None
|
|
||||||
source_index = next(
|
|
||||||
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
|
||||||
)
|
|
||||||
previous_source_t = (
|
|
||||||
source_bars[source_index - 1].t
|
|
||||||
if source_index is not None and source_index > 0 else None
|
|
||||||
)
|
|
||||||
rng = session_range(runtime)
|
|
||||||
extra = {
|
|
||||||
"session_t": rng["t"],
|
|
||||||
"session_high": rng["high"],
|
|
||||||
"session_low": rng["low"],
|
|
||||||
} if rng else {}
|
|
||||||
payload.update(
|
|
||||||
{
|
|
||||||
"duration": bucket_duration(bar.t, bar.tf),
|
|
||||||
"session_open": session_open(runtime),
|
|
||||||
**extra,
|
|
||||||
"trendline_first_t": (
|
|
||||||
first_source_t
|
|
||||||
if runtime.settings.trendline_source_geometry
|
|
||||||
and bar.tf in source_tfs else None
|
|
||||||
),
|
|
||||||
"trendline_previous_t": previous_source_t,
|
|
||||||
"trendline_future_times": source_geometry.get("future_times", []),
|
|
||||||
"trendline_future_durations": source_geometry.get("future_durations", []),
|
|
||||||
"future_times": source_geometry.get("future_times", []),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return payload
|
|
||||||
if (
|
|
||||||
full
|
|
||||||
and runtime.settings.trendline_source_geometry
|
|
||||||
and bar.tf in trendline_timeframes(runtime)
|
|
||||||
):
|
|
||||||
source_bars = runtime.store.get(bar.tf)
|
|
||||||
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
|
||||||
first_source_t = source_bars[0].t if source_bars else None
|
|
||||||
source_index = next(
|
|
||||||
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
|
||||||
)
|
|
||||||
previous_source_t = (
|
|
||||||
source_bars[source_index - 1].t
|
|
||||||
if source_index is not None and source_index > 0 else None
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"type": "trendline_bar",
|
|
||||||
"tf": bar.tf.value,
|
|
||||||
"t": bar.t,
|
|
||||||
"duration": bucket_duration(bar.t, bar.tf),
|
|
||||||
"first_t": first_source_t,
|
|
||||||
"previous_t": previous_source_t,
|
|
||||||
"future_times": source_geometry.get("future_times", []),
|
|
||||||
"future_durations": source_geometry.get("future_durations", []),
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
events, events_more = runtime.events.recent()
|
events, events_more = runtime.events.recent()
|
||||||
rng = session_range(runtime)
|
|
||||||
extra = {
|
|
||||||
"session_t": rng["t"],
|
|
||||||
"session_high": rng["high"],
|
|
||||||
"session_low": rng["low"],
|
|
||||||
} if rng else {}
|
|
||||||
return {
|
return {
|
||||||
"type": "snapshot",
|
"type": "snapshot",
|
||||||
"tf": tf.value,
|
"tf": tf.value,
|
||||||
|
|
@ -188,12 +99,10 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
if runtime.store.get(Timeframe.M1, 1)
|
if runtime.store.get(Timeframe.M1, 1)
|
||||||
else None,
|
else None,
|
||||||
"session_open": session_open(runtime),
|
"session_open": session_open(runtime),
|
||||||
**extra,
|
|
||||||
"trendline_geometry": trendline_geometry(runtime),
|
"trendline_geometry": trendline_geometry(runtime),
|
||||||
"future_times": displayed_future_times(runtime, tf),
|
"future_times": displayed_future_times(runtime, tf),
|
||||||
"events": events,
|
"events": events,
|
||||||
"events_more": events_more,
|
"events_more": events_more,
|
||||||
"instrument": runtime.settings.profile.payload(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -217,7 +126,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
prefs = None
|
prefs = None
|
||||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
geometry_tfs = trendline_timeframes(runtime)
|
geometry_tfs = trendline_timeframes(runtime)
|
||||||
last_bar_t: dict[Timeframe, int] = {}
|
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
nonlocal tf, prefs
|
nonlocal tf, prefs
|
||||||
|
|
@ -251,19 +159,53 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
event = await queue.get()
|
event = await queue.get()
|
||||||
if event["type"] == "disconnect":
|
if event["type"] == "disconnect":
|
||||||
break
|
break
|
||||||
if event["type"] == "resync":
|
if event["type"] == "bar":
|
||||||
# History changed behind the live edge (a backfilled outage).
|
|
||||||
# Bar deltas only move the tail, so the whole series is resent.
|
|
||||||
last_bar_t.clear()
|
|
||||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
|
||||||
elif event["type"] == "bar":
|
|
||||||
bar = event["bar"]
|
bar = event["bar"]
|
||||||
full = last_bar_t.get(bar.tf) != bar.t
|
source_tfs = trendline_timeframes(runtime)
|
||||||
if full:
|
source_bars = runtime.store.get(bar.tf)
|
||||||
last_bar_t[bar.tf] = bar.t
|
first_source_t = source_bars[0].t if source_bars else None
|
||||||
payload = bar_client_message(runtime, bar, tf, full=full)
|
source_index = next(
|
||||||
if payload is not None:
|
(index for index, value in enumerate(source_bars) if value.t == bar.t), None,
|
||||||
await websocket.send_json(payload)
|
)
|
||||||
|
previous_source_t = (
|
||||||
|
source_bars[source_index - 1].t
|
||||||
|
if source_index is not None and source_index > 0 else None
|
||||||
|
)
|
||||||
|
source_geometry = trendline_series(runtime, bar.tf) if source_bars else {}
|
||||||
|
if bar.tf is tf:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "bar", "tf": tf.value,
|
||||||
|
"bar": bar.to_dict(),
|
||||||
|
"duration": bucket_duration(bar.t, bar.tf),
|
||||||
|
"session_open": session_open(runtime),
|
||||||
|
"trendline_first_t": (
|
||||||
|
first_source_t
|
||||||
|
if runtime.settings.trendline_source_geometry
|
||||||
|
and bar.tf in source_tfs else None
|
||||||
|
),
|
||||||
|
"trendline_previous_t": previous_source_t,
|
||||||
|
"trendline_future_times": source_geometry.get("future_times", []),
|
||||||
|
"trendline_future_durations": source_geometry.get("future_durations", []),
|
||||||
|
"future_times": source_geometry.get("future_times", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
runtime.settings.trendline_source_geometry
|
||||||
|
and bar.tf in source_tfs
|
||||||
|
):
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "trendline_bar",
|
||||||
|
"tf": bar.tf.value,
|
||||||
|
"t": bar.t,
|
||||||
|
"duration": bucket_duration(bar.t, bar.tf),
|
||||||
|
"first_t": first_source_t,
|
||||||
|
"previous_t": previous_source_t,
|
||||||
|
"future_times": source_geometry.get("future_times", []),
|
||||||
|
"future_durations": source_geometry.get("future_durations", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
elif event["type"] == "levels":
|
elif event["type"] == "levels":
|
||||||
message = {
|
message = {
|
||||||
"type": "levels", "changed": event["changed"], "removed": event["removed"],
|
"type": "levels", "changed": event["changed"], "removed": event["removed"],
|
||||||
|
|
|
||||||
|
|
@ -46,35 +46,6 @@ class InMemoryBarStore:
|
||||||
# Buckets are ordered, so nothing further back can match.
|
# Buckets are ordered, so nothing further back can match.
|
||||||
return
|
return
|
||||||
|
|
||||||
def fill(self, bars: list[Bar]) -> int:
|
|
||||||
"""Insert history into buckets the store has no bar for.
|
|
||||||
|
|
||||||
``put`` only lands a bar at the tail or a few buckets behind it, so a
|
|
||||||
stretch missed while the stream was down cannot reach it — the live
|
|
||||||
bars that arrived on reconnect are already newer. Existing bars always
|
|
||||||
win: they are the live source's own figures, and the bucket either side
|
|
||||||
of the hole is the live aggregator's to finish. Returns how many bars
|
|
||||||
were inserted.
|
|
||||||
"""
|
|
||||||
added = 0
|
|
||||||
by_tf: dict[Timeframe, list[Bar]] = defaultdict(list)
|
|
||||||
for bar in bars:
|
|
||||||
by_tf[bar.tf].append(bar)
|
|
||||||
for tf, incoming in by_tf.items():
|
|
||||||
held = self._bars[tf]
|
|
||||||
merged = {bar.t: bar for bar in held}
|
|
||||||
for bar in incoming:
|
|
||||||
if bar.t not in merged:
|
|
||||||
merged[bar.t] = bar
|
|
||||||
added += 1
|
|
||||||
if len(merged) == len(held):
|
|
||||||
continue
|
|
||||||
ordered = [merged[t] for t in sorted(merged)]
|
|
||||||
held.clear()
|
|
||||||
# maxlen keeps the newest, which is the history the chart shows.
|
|
||||||
held.extend(ordered)
|
|
||||||
return added
|
|
||||||
|
|
||||||
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
|
def get(self, tf: Timeframe, limit: int | None = None) -> list[Bar]:
|
||||||
bars = list(self._bars[tf])
|
bars = list(self._bars[tf])
|
||||||
return bars[-limit:] if limit is not None else bars
|
return bars[-limit:] if limit is not None else bars
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from pathlib import Path
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.instrument import get_instrument
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_BARS_PER_TF = 5000
|
DEFAULT_MAX_BARS_PER_TF = 5000
|
||||||
|
|
@ -25,7 +24,6 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
live_source: str = "yahoo"
|
live_source: str = "yahoo"
|
||||||
seed_source: str = "yahoo"
|
seed_source: str = "yahoo"
|
||||||
instrument: str = "es"
|
|
||||||
yahoo_symbol: str = "ES=F"
|
yahoo_symbol: str = "ES=F"
|
||||||
yahoo_poll_seconds: float = 20
|
yahoo_poll_seconds: float = 20
|
||||||
seed_1h_range: str = "730d"
|
seed_1h_range: str = "730d"
|
||||||
|
|
@ -75,10 +73,6 @@ class Settings(BaseSettings):
|
||||||
alert_timezone: str = "America/Chicago"
|
alert_timezone: str = "America/Chicago"
|
||||||
replay_file: Path | None = None
|
replay_file: Path | None = None
|
||||||
|
|
||||||
@property
|
|
||||||
def profile(self):
|
|
||||||
return get_instrument(self.instrument)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def live_symbol(self) -> str:
|
def live_symbol(self) -> str:
|
||||||
"""What the live source calls the instrument.
|
"""What the live source calls the instrument.
|
||||||
|
|
|
||||||
|
|
@ -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._handlers: list[BarHandler] = []
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self.on_drop = None
|
self.on_drop = None
|
||||||
# Called with (last bar before the outage, first bar after it) when a
|
|
||||||
# new connection opens further past the last bar than this. Reconnect
|
|
||||||
# alone only resumes the present; nothing else fetches what was missed.
|
|
||||||
self.on_resume: Callable[[int, int], None] | None = None
|
|
||||||
self.resume_gap_seconds = 120
|
|
||||||
self.reconnect_seconds = 5.0
|
|
||||||
|
|
||||||
def add_handler(self, handler: BarHandler) -> None:
|
def add_handler(self, handler: BarHandler) -> None:
|
||||||
self._handlers.append(handler)
|
self._handlers.append(handler)
|
||||||
|
|
@ -51,21 +45,11 @@ class StreamService:
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
first = True
|
|
||||||
try:
|
try:
|
||||||
async for bar in self.source.stream(self.symbol):
|
async for bar in self.source.stream(self.symbol):
|
||||||
self.status = "replay" if self.source.name == "replay" else "connected"
|
self.status = "replay" if self.source.name == "replay" else "connected"
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
before = self.last_bar_t
|
|
||||||
await self._emit(bar)
|
await self._emit(bar)
|
||||||
if first:
|
|
||||||
first = False
|
|
||||||
if (
|
|
||||||
self.on_resume is not None
|
|
||||||
and before is not None
|
|
||||||
and bar.t - before > self.resume_gap_seconds
|
|
||||||
):
|
|
||||||
self.on_resume(before, bar.t)
|
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
break
|
break
|
||||||
if self.source.name == "replay":
|
if self.source.name == "replay":
|
||||||
|
|
@ -80,7 +64,7 @@ class StreamService:
|
||||||
self.on_drop(str(exc))
|
self.on_drop(str(exc))
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=self.reconnect_seconds)
|
await asyncio.wait_for(self._stop.wait(), timeout=5)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
119
app/runtime.py
119
app/runtime.py
|
|
@ -27,15 +27,6 @@ from app.notify.ntfy import send_ntfy
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _range_seconds(range_: str) -> int:
|
|
||||||
"""How far back a Yahoo range reaches: "8d" is eight days."""
|
|
||||||
units = {"d": 86400, "wk": 7 * 86400, "mo": 31 * 86400, "y": 366 * 86400}
|
|
||||||
for suffix, seconds in units.items():
|
|
||||||
if range_.endswith(suffix) and range_[: -len(suffix)].isdigit():
|
|
||||||
return int(range_[: -len(suffix)]) * seconds
|
|
||||||
return 8 * 86400
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Runtime:
|
class Runtime:
|
||||||
settings: Settings
|
settings: Settings
|
||||||
|
|
@ -53,7 +44,6 @@ class Runtime:
|
||||||
alert_engine: AlertEngine = field(init=False)
|
alert_engine: AlertEngine = field(init=False)
|
||||||
_sent_levels: dict[str, dict] = field(default_factory=dict)
|
_sent_levels: dict[str, dict] = field(default_factory=dict)
|
||||||
_notify_tasks: set[asyncio.Task] = field(default_factory=set)
|
_notify_tasks: set[asyncio.Task] = field(default_factory=set)
|
||||||
_backfill_tasks: set[asyncio.Task] = field(default_factory=set)
|
|
||||||
# The loop that owns the subscriber queues. Set once the app is running;
|
# The loop that owns the subscriber queues. Set once the app is running;
|
||||||
# None while a test drives the runtime directly.
|
# None while a test drives the runtime directly.
|
||||||
_loop: asyncio.AbstractEventLoop | None = None
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
@ -91,7 +81,6 @@ class Runtime:
|
||||||
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
||||||
self.stream.add_handler(self.on_bar)
|
self.stream.add_handler(self.on_bar)
|
||||||
self.stream.on_drop = self._on_stream_drop
|
self.stream.on_drop = self._on_stream_drop
|
||||||
self.stream.on_resume = self._on_stream_resume
|
|
||||||
|
|
||||||
async def on_bar(self, bar: Bar) -> None:
|
async def on_bar(self, bar: Bar) -> None:
|
||||||
# A tick-built bar is provisional and arrives many times a minute. It
|
# A tick-built bar is provisional and arrives many times a minute. It
|
||||||
|
|
@ -298,17 +287,9 @@ class Runtime:
|
||||||
evaluation_t,
|
evaluation_t,
|
||||||
self.stream.symbol,
|
self.stream.symbol,
|
||||||
self.watched_ma_levels(),
|
self.watched_ma_levels(),
|
||||||
confluence=self.confluence_alerts_enabled(),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def confluence_alerts_enabled(self) -> bool:
|
|
||||||
return bool(self.user_prefs.get("confluence_alerts"))
|
|
||||||
|
|
||||||
def set_confluence_alerts(self, enabled: bool) -> dict:
|
|
||||||
self.user_prefs.put("confluence_alerts", bool(enabled))
|
|
||||||
return {"enabled": self.confluence_alerts_enabled()}
|
|
||||||
|
|
||||||
def ma_alerts(self) -> dict:
|
def ma_alerts(self) -> dict:
|
||||||
stored = self.user_prefs.get("ma_alerts") or {}
|
stored = self.user_prefs.get("ma_alerts") or {}
|
||||||
return {"1d": list(stored.get("1d") or [])}
|
return {"1d": list(stored.get("1d") or [])}
|
||||||
|
|
@ -328,108 +309,12 @@ class Runtime:
|
||||||
|
|
||||||
def _on_stream_drop(self, error: str) -> None:
|
def _on_stream_drop(self, error: str) -> None:
|
||||||
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
|
kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream"
|
||||||
self.events.add(
|
self.events.add(kind, error.split("\n", 1)[0][:200])
|
||||||
kind, error.split("\n", 1)[0][:200],
|
|
||||||
symbol=self.settings.profile.schwab_symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_stream_resume(self, after: int, before: int) -> None:
|
|
||||||
task = asyncio.create_task(self.backfill_gap(after, before), name="gap-backfill")
|
|
||||||
self._backfill_tasks.add(task)
|
|
||||||
task.add_done_callback(self._backfill_tasks.discard)
|
|
||||||
|
|
||||||
async def backfill_gap(self, after: int, before: int) -> None:
|
|
||||||
"""Fill the history missed while the stream was down.
|
|
||||||
|
|
||||||
The seed runs once, at startup, so an outage between deploys used to
|
|
||||||
stay a hole until the next one — fifteen days of it after a Schwab
|
|
||||||
refresh token expired. Yahoo serves futures about ten minutes late, so
|
|
||||||
the minutes just before reconnect are fetched again once they exist.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await self.fill_gap(after, before)
|
|
||||||
delay = getattr(seed_source(self.settings), "delay_minutes", 0) or 0
|
|
||||||
if delay:
|
|
||||||
await asyncio.sleep(delay * 60 + 60)
|
|
||||||
await self.fill_gap(max(after, before - (delay + 5) * 60), before)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
# A failed backfill leaves the hole it found; the live stream is fine.
|
|
||||||
logger.exception("Gap backfill failed")
|
|
||||||
|
|
||||||
async def fill_gap(self, after: int, before: int) -> int:
|
|
||||||
source = seed_source(self.settings)
|
|
||||||
if source is None or not source.supports_history():
|
|
||||||
return 0
|
|
||||||
symbol = self.settings.yahoo_symbol
|
|
||||||
now = int(time.time())
|
|
||||||
# Native coarse history first: those buckets are complete, where one
|
|
||||||
# rebuilt from 1m is only as old as Yahoo's 1m reach.
|
|
||||||
passes = (
|
|
||||||
(Timeframe.H1, self.settings.seed_1h_range),
|
|
||||||
(Timeframe.M30, self.settings.seed_30m_range),
|
|
||||||
(Timeframe.M1, self.settings.seed_1m_range),
|
|
||||||
)
|
|
||||||
added = 0
|
|
||||||
for tf, range_ in passes:
|
|
||||||
start = max(bucket_start(after, tf), now - _range_seconds(range_))
|
|
||||||
if start >= before:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
bars = await source.history(symbol, tf, start, before)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Gap backfill: %s history failed", tf.value)
|
|
||||||
continue
|
|
||||||
# A fresh aggregator: the live one is already past the hole, and
|
|
||||||
# feeding it history would reopen buckets it has closed.
|
|
||||||
aggregator = Aggregator(self.settings.enabled_timeframes)
|
|
||||||
derived: dict[tuple[Timeframe, int], Bar] = {}
|
|
||||||
for bar in bars:
|
|
||||||
if not start <= bar.t < before:
|
|
||||||
continue
|
|
||||||
for aggregated in aggregator.update(replace(bar, closed=True)):
|
|
||||||
derived[(aggregated.tf, aggregated.t)] = aggregated
|
|
||||||
added += self.store.fill(list(derived.values()))
|
|
||||||
if added:
|
|
||||||
logger.info("Gap backfill: %d bars between %d and %d", added, after, before)
|
|
||||||
self.refold_forming(before)
|
|
||||||
values = atr(self.store.get(Timeframe.M15), 14)
|
|
||||||
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
|
||||||
# Levels only, never alerts: a touch during the outage is not news.
|
|
||||||
self.rebuild_levels()
|
|
||||||
self.broadcast({"type": "resync"})
|
|
||||||
return added
|
|
||||||
|
|
||||||
def refold_forming(self, before: int) -> None:
|
|
||||||
"""Rebuild the live buckets that opened before the stream came back.
|
|
||||||
|
|
||||||
The live aggregator started today's daily bar — and the current hour —
|
|
||||||
from the first minute after reconnect, so its open, high and low
|
|
||||||
ignore everything the backfill just recovered. Refolding from the stored
|
|
||||||
minutes is idempotent, so the delayed second pass can run it again.
|
|
||||||
"""
|
|
||||||
minutes = [bar for bar in self.store.get(Timeframe.M1) if bar.closed]
|
|
||||||
for tf, forming in self.aggregator.forming.items():
|
|
||||||
if forming.t >= before:
|
|
||||||
continue
|
|
||||||
inside = [bar for bar in minutes if bucket_start(bar.t, tf) == forming.t]
|
|
||||||
if not inside or inside[0].t >= before:
|
|
||||||
continue
|
|
||||||
forming.o = inside[0].o
|
|
||||||
forming.h = max(bar.h for bar in inside)
|
|
||||||
forming.l = min(bar.l for bar in inside)
|
|
||||||
forming.c = inside[-1].c
|
|
||||||
forming.v = sum(bar.v for bar in inside)
|
|
||||||
self.store.put(replace(forming))
|
|
||||||
|
|
||||||
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||||
tripped: set[str] = set()
|
tripped: set[str] = set()
|
||||||
for alert in alerts:
|
for alert in alerts:
|
||||||
self.events.add(
|
self.events.add("alert", alert.message, number=alert.number, at=alert.at)
|
||||||
"alert", alert.message, number=alert.number, at=alert.at,
|
|
||||||
symbol=self.settings.profile.schwab_symbol,
|
|
||||||
)
|
|
||||||
self.broadcast({
|
self.broadcast({
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"cluster": alert.cluster,
|
"cluster": alert.cluster,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,8 @@ decisions behind them in `docs/plan.md`.
|
||||||
|
|
||||||
### Revisit the trendline live-edge bend with painted-pixel evidence
|
### Revisit the trendline live-edge bend with painted-pixel evidence
|
||||||
|
|
||||||
Mid-session 1m tape holes are now counted in source index as well as on the
|
Production still appears to show a bend where 30m manual lines cross from the
|
||||||
time scale. Production still appears to show a bend where 30m manual lines
|
last real candle into future whitespace. The deployed code is verified to use
|
||||||
cross from the last real candle into future whitespace. The deployed code is verified to use
|
|
||||||
one canvas `LineSeries`, retained transparent time-scale points, and canonical
|
one canvas `LineSeries`, retained transparent time-scale points, and canonical
|
||||||
source-space prices. Diagnostic values can report unequal historical/future
|
source-space prices. Diagnostic values can report unequal historical/future
|
||||||
screen slopes when the final candles are sparse, but synthetic browser coverage
|
screen slopes when the final candles are sparse, but synthetic browser coverage
|
||||||
|
|
|
||||||
|
|
@ -1336,123 +1336,3 @@ alert before the deferred global rebuild.
|
||||||
Finally, source-dependent shift operations no longer silently keep the old time
|
Finally, source-dependent shift operations no longer silently keep the old time
|
||||||
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
|
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
|
||||||
and nudge abort cleanly when the canonical shift cannot be represented.
|
and nudge abort cleanly when the canonical shift cannot be represented.
|
||||||
|
|
||||||
### 2026-08-26 — bent 1m trendlines: slotted holes, uncounted source index
|
|
||||||
|
|
||||||
This looked like a live-edge renderer bug, then like a Yahoo→Schwab join hole,
|
|
||||||
then like missing `futureSpace` slots. It was none of those. Write this down
|
|
||||||
before "fixing" the next kink.
|
|
||||||
|
|
||||||
**What it looked like.** 30m/1m manual lines on the production 1m chart bent
|
|
||||||
mid-session. Local Yahoo-only did not. The join after each Schwab deploy is a
|
|
||||||
~9-minute 1m hole (today 02:18→02:27 CDT). That was the obvious suspect.
|
|
||||||
|
|
||||||
**What we measured.** Last-80 1m times had that one hole. The full tape had 23
|
|
||||||
holes. For each hole, count missing minutes vs timestamps already owned by
|
|
||||||
`futureSpace`:
|
|
||||||
|
|
||||||
- 1–10 minute Yahoo holes, including the 02:18 join and the nightly
|
|
||||||
22:58–23:09 gap: `owned === missing`. Slots were already there.
|
|
||||||
- Daily 15:59→17:00 (60 missing) and Fri 15:59→Sun 17:10: `owned === 0`.
|
|
||||||
Compressed on purpose (`MAX_INTRADAY_GAP_SECONDS = 30m`). Local Yahoo has
|
|
||||||
those too.
|
|
||||||
|
|
||||||
So the join-hole theory was dead. Do not insert more slots for a hole that
|
|
||||||
already owns them. Do not "fix" settlement or the weekend unless the product
|
|
||||||
decision changes.
|
|
||||||
|
|
||||||
**What was actually bent.** The time scale and the line's source index
|
|
||||||
disagreed. `futureSpace` gave the hole eight empty columns. Source times were
|
|
||||||
still the real bars only, so `timeframeIndexAt` treated 02:18 and 02:27 as
|
|
||||||
neighbours: one index step, nine slots of x, the segment went flat, then
|
|
||||||
resumed. Worst on 1m-native and 5m (aggregator does not synthesise empty
|
|
||||||
buckets). 30m often survived because Yahoo seeds 30m separately, so those
|
|
||||||
opens still existed.
|
|
||||||
|
|
||||||
**The check, in order, next time a line kinks:**
|
|
||||||
|
|
||||||
1. List 1m holes and `futureSpace` ownership (`kind=geometry` SNAPDBG).
|
|
||||||
2. If `owned === missing`, the scale is not the bug. Compare source index
|
|
||||||
across the hole to slot count. They must match.
|
|
||||||
3. If `owned === 0` and the hole is ≤30m, slots were dropped — that is a
|
|
||||||
scale bug. If the hole is settlement/weekend, compression is intended.
|
|
||||||
4. Do not patch live-edge geometry from a mid-session tape hole. Do not
|
|
||||||
ask for another console paste; `?diag=1` posts the report.
|
|
||||||
|
|
||||||
**The fix.** `fill_short_gaps` inserts missing bucket opens inside the same
|
|
||||||
30-minute bound, in `trendline_series`, in `price_in_timeframe_space`, and in
|
|
||||||
the browser's `sourceTimes`. A 9-minute hole becomes nine index steps.
|
|
||||||
Weekends and settlement stay one step.
|
|
||||||
|
|
||||||
`?diag=1` posts one `kind=geometry` SNAPDBG after bars load (gap ownership
|
|
||||||
and off-median segments). Read it with `logs --since 20m`.
|
|
||||||
|
|
||||||
### 2026-08-26 — 1m tab CPU after the overlay ban
|
|
||||||
|
|
||||||
Banning `scheduleOverlays(true)` on ticks was not enough. Forming 1m ticks
|
|
||||||
still `futureSpace.setData`'d 5k-gap + 180 future points, `syncLevels`'d every
|
|
||||||
HTF line, and the socket still attached 180 `future_times` (twice) plus a
|
|
||||||
`trendline_bar` per sloped HTF. Vue then re-rendered the whole page because
|
|
||||||
`price` and `dataReceivedAt` are refs on the one root component.
|
|
||||||
|
|
||||||
A forming tick of an already-seen minute is now `{type, tf, bar}` only. HTF
|
|
||||||
geometry and future calendars go out when that timeframe's timestamp advances.
|
|
||||||
The header quote paints the DOM directly; Vue's `price` flushes at 1 Hz.
|
|
||||||
|
|
||||||
Schwab still prints many forming ticks per frame. Those now coalesce to one
|
|
||||||
`candles.update` on the next animation frame. A new minute flushes immediately.
|
|
||||||
|
|
||||||
**Animate current price** was two infinite CSS animations across the full plot:
|
|
||||||
`background-position` on a masked `repeating-linear-gradient`, plus a
|
|
||||||
`color-mix` shine. That cannot run on the compositor. Turning it off dropped
|
|
||||||
tab CPU from ~20% to ~5%. The cue is now a 20px frame around the right-hand
|
|
||||||
price label, opacity only.
|
|
||||||
|
|
||||||
### 2026-08-31 — End trendline here was hidden unless the click was past last_t
|
|
||||||
|
|
||||||
The context item only appeared when `cutoff > last_t`. A line whose second
|
|
||||||
anchor already sits at or past the live edge never offered it; Duplicate and
|
|
||||||
Delete still showed. The click does not move `last_t`. It sets `cutoff_t`,
|
|
||||||
which clips drawing. Slope and both handles stay. Extend clears the cutoff.
|
|
||||||
|
|
||||||
The menu now offers End here for any click after the earlier of the two
|
|
||||||
anchors, including on the body between them and at `last_t` itself (that
|
|
||||||
stops the projection without moving the end handle).
|
|
||||||
|
|
||||||
### 2026-08-31 — instrument profile and symbol stamps
|
|
||||||
|
|
||||||
Drawings, alert-state rows, and events now carry `symbol`, default `/ES`.
|
|
||||||
A missing field loads as ES so production JSON does not need a rewrite
|
|
||||||
before the first save. Fired zones for `/ES` and `ES=F` are the same
|
|
||||||
root; `/GC` at the same price is not. The browser snap grid reads `tick`
|
|
||||||
from the snapshot/status profile instead of a chart constant. No
|
|
||||||
switcher and no second stream — see `docs/investigate_added_symbols.md`.
|
|
||||||
|
|
||||||
### 2026-09-24 — a Schwab outage stayed a hole until the next deploy
|
|
||||||
|
|
||||||
The chart was missing Sept 9 → 24. The Schwab refresh token was dead
|
|
||||||
(`invalid_grant` every 5 s). After reauth, live bars resumed but the gap did
|
|
||||||
not fill. History was only ever fetched by the startup seed; the stream's
|
|
||||||
reconnect loop just resumes the present. The container had been up since the
|
|
||||||
Sept 4 deploy.
|
|
||||||
|
|
||||||
`put()` could not have taken the recovered bars anyway: it only lands a bar at
|
|
||||||
the tail or within 8 buckets of it, and the live bars were already newer. Hence
|
|
||||||
`InMemoryBarStore.fill`, which inserts into empty buckets only and never
|
|
||||||
replaces a live bar. A `deque` with `maxlen` also raises on `insert` when full,
|
|
||||||
so it rebuilds the sorted deque and lets `maxlen` drop the oldest.
|
|
||||||
|
|
||||||
A reconnect more than 120 s past the last bar now triggers a backfill (`docs/plan.md`, after
|
|
||||||
"In production both run at once"). Measured against real Yahoo for this outage: 11,173 bars in about
|
|
||||||
1 s including network, 3 ms of that in `fill`. 1h/30m/1d cover all 15 days. 1m
|
|
||||||
only reaches back 8 days, so 1m/5m/15m start Sept 17, and the 5,000-bar 1m cap
|
|
||||||
keeps about 3.5 days of that.
|
|
||||||
|
|
||||||
Two things that looked fine and were not:
|
|
||||||
- Yahoo `ES=F` is ~10 minutes late. The first pass always leaves the last
|
|
||||||
~10 minutes before reconnect empty, so a second pass runs after
|
|
||||||
`delay_minutes`.
|
|
||||||
- The live aggregator opened today's daily bar (and the current hour) at
|
|
||||||
the reconnect minute, so filling holes alone left the day's open/high/low
|
|
||||||
wrong, and tomorrow's prior-day levels would inherit that. `refold_forming`
|
|
||||||
rebuilds those buckets from the stored minutes, idempotently.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
var. **In production both run at once:** Yahoo seeds history at startup, Schwab
|
||||||
provides the live tail.
|
provides the live tail.
|
||||||
|
|
||||||
When the live stream reconnects more than two minutes past its last bar,
|
|
||||||
`Runtime.backfill_gap` fetches the missed stretch from the same seed source
|
|
||||||
(1h, 30m, 1m, each limited to its seed range), rebuilds higher timeframes with a
|
|
||||||
fresh aggregator, and inserts only into empty buckets (`InMemoryBarStore.fill`).
|
|
||||||
Live bars always win. The live aggregator's still-forming buckets are then
|
|
||||||
refolded from the stored minutes, levels are rebuilt without evaluating alerts,
|
|
||||||
and every socket gets a `resync` → full `snapshot`. Yahoo is ~10 minutes late,
|
|
||||||
so a second pass runs after that delay for the minutes just before reconnect.
|
|
||||||
The bucket that was forming when the stream *died* keeps only what it had.
|
|
||||||
|
|
||||||
### Do not use Yahoo's daily bars
|
### Do not use Yahoo's daily bars
|
||||||
|
|
||||||
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
|
Yahoo anchors `ES=F` daily bars to **midnight ET**, but the CME futures session runs
|
||||||
|
|
@ -826,8 +816,8 @@ Server → client:
|
||||||
Rules:
|
Rules:
|
||||||
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch
|
- Send `bar` on **every** update of the forming bar (that is the live chart) but batch
|
||||||
`levels` — they only change on higher-TF closes.
|
`levels` — they only change on higher-TF closes.
|
||||||
- Always send a full `snapshot` on connect and after any reconnect, and after the
|
- Always send a full `snapshot` on connect and after any reconnect. The client must
|
||||||
server backfills a gap (`resync`). The client must never try to reconcile a gap.
|
never try to reconcile a gap.
|
||||||
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
|
- Levels are sent for **all** timeframes regardless of the displayed timeframe. That is
|
||||||
the entire point: a 4h line drawn through a 1m chart.
|
the entire point: a 4h line drawn through a 1m chart.
|
||||||
|
|
||||||
|
|
@ -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
|
**Done when:** a replayed tape produces a sane number of alerts (single digits per
|
||||||
session), each corresponding to a real multi-timeframe convergence.
|
session), each corresponding to a real multi-timeframe convergence.
|
||||||
|
|
||||||
**Update 2026-08-27:** Auto confluence (ZONE) alerts are off by default. Config →
|
|
||||||
Confluence alerts. Armed drawings and DMA bells still fire.
|
|
||||||
|
|
||||||
### M5 — Manual trendlines
|
### M5 — Manual trendlines
|
||||||
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
|
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
|
||||||
Hand-drawn lines are authoritative — full weight, no quality discount.
|
Hand-drawn lines are authoritative — full weight, no quality discount.
|
||||||
|
|
|
||||||
169
static/app.js
169
static/app.js
|
|
@ -135,7 +135,6 @@ createApp({
|
||||||
const drawingFilter = ref('');
|
const drawingFilter = ref('');
|
||||||
const drawingKind = ref('all');
|
const drawingKind = ref('all');
|
||||||
const maAlerts = ref({ '1d': [] });
|
const maAlerts = ref({ '1d': [] });
|
||||||
const confluenceAlerts = ref(false);
|
|
||||||
const drawingTf = ref('all');
|
const drawingTf = ref('all');
|
||||||
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
|
||||||
const captureBusy = ref(false);
|
const captureBusy = ref(false);
|
||||||
|
|
@ -154,8 +153,6 @@ createApp({
|
||||||
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
|
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
|
||||||
const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false');
|
const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false');
|
||||||
const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true');
|
const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true');
|
||||||
const tick = ref(0.25);
|
|
||||||
const sessionRange = ref(localStorage.getItem('chart-session-range') !== 'false');
|
|
||||||
const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false');
|
const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false');
|
||||||
const optionPrefs = (() => {
|
const optionPrefs = (() => {
|
||||||
try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; }
|
try { return JSON.parse(localStorage.getItem('chart-es-options')) || {}; }
|
||||||
|
|
@ -366,13 +363,7 @@ createApp({
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
const response = await apiFetch('/api/status');
|
const response = await apiFetch('/api/status');
|
||||||
if (response.ok) {
|
if (response.ok) status.value = await response.json();
|
||||||
status.value = await response.json();
|
|
||||||
if (status.value.instrument && chartApi) {
|
|
||||||
chartApi.setInstrument(status.value.instrument);
|
|
||||||
tick.value = status.value.instrument.tick || tick.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedExpiration() {
|
function selectedExpiration() {
|
||||||
|
|
@ -490,34 +481,6 @@ createApp({
|
||||||
});
|
});
|
||||||
watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs);
|
watch([optionExpiryId, optionSide, optionMode, optionMin, optionMax], persistOptionPrefs);
|
||||||
|
|
||||||
let livePriceFlush = null;
|
|
||||||
let pendingLivePrice = null;
|
|
||||||
function paintQuote(next) {
|
|
||||||
if (next == null) return;
|
|
||||||
const priceEl = document.querySelector('.quote .price');
|
|
||||||
if (priceEl) priceEl.textContent = next.toFixed(2);
|
|
||||||
const open = sessionOpen.value;
|
|
||||||
const changeEl = document.querySelector('.quote .quote-change');
|
|
||||||
if (!changeEl || !(open > 0)) return;
|
|
||||||
const points = next - open;
|
|
||||||
const percent = points / open * 100;
|
|
||||||
changeEl.textContent =
|
|
||||||
`${points >= 0 ? '+' : ''}${points.toFixed(2)} (${percent >= 0 ? '+' : ''}${percent.toFixed(2)}%)`;
|
|
||||||
changeEl.classList.toggle('positive', points > 0);
|
|
||||||
changeEl.classList.toggle('negative', points < 0);
|
|
||||||
changeEl.classList.toggle('flat', points === 0);
|
|
||||||
}
|
|
||||||
function noteLivePrice(next) {
|
|
||||||
if (next == null) return;
|
|
||||||
paintQuote(next);
|
|
||||||
pendingLivePrice = next;
|
|
||||||
if (next === price.value || livePriceFlush) return;
|
|
||||||
livePriceFlush = setTimeout(() => {
|
|
||||||
livePriceFlush = null;
|
|
||||||
if (pendingLivePrice != null) price.value = pendingLivePrice;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
|
||||||
|
|
@ -532,10 +495,6 @@ createApp({
|
||||||
if (message.type === 'snapshot') {
|
if (message.type === 'snapshot') {
|
||||||
dataReceivedAt.value = Date.now();
|
dataReceivedAt.value = Date.now();
|
||||||
chartApi.setTrendlineGeometry(message.trendline_geometry);
|
chartApi.setTrendlineGeometry(message.trendline_geometry);
|
||||||
if (message.instrument) {
|
|
||||||
chartApi.setInstrument(message.instrument);
|
|
||||||
tick.value = message.instrument.tick || tick.value;
|
|
||||||
}
|
|
||||||
chartApi.setDisplayFutureTimes(message.future_times);
|
chartApi.setDisplayFutureTimes(message.future_times);
|
||||||
chartApi.setBars(message.bars);
|
chartApi.setBars(message.bars);
|
||||||
levels.value = message.levels || [];
|
levels.value = message.levels || [];
|
||||||
|
|
@ -547,9 +506,6 @@ createApp({
|
||||||
drawColorInitialized = true;
|
drawColorInitialized = true;
|
||||||
}
|
}
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
chartApi.setSessionRange({
|
|
||||||
t: message.session_t, high: message.session_high, low: message.session_low,
|
|
||||||
});
|
|
||||||
price.value = message.price;
|
price.value = message.price;
|
||||||
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
hydrateEvents(message.events, message.events_more);
|
hydrateEvents(message.events, message.events_more);
|
||||||
|
|
@ -562,7 +518,6 @@ createApp({
|
||||||
if (message.tf && message.tf !== timeframe.value) return;
|
if (message.tf && message.tf !== timeframe.value) return;
|
||||||
dataReceivedAt.value = Date.now();
|
dataReceivedAt.value = Date.now();
|
||||||
if (message.trendline_first_t != null) {
|
if (message.trendline_first_t != null) {
|
||||||
const lastSource = chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1);
|
|
||||||
const geometryCurrent = chartApi.updateTrendlineTime(
|
const geometryCurrent = chartApi.updateTrendlineTime(
|
||||||
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
|
message.bar.tf, message.bar.t, message.duration, message.trendline_first_t,
|
||||||
message.trendline_previous_t,
|
message.trendline_previous_t,
|
||||||
|
|
@ -570,34 +525,22 @@ createApp({
|
||||||
);
|
);
|
||||||
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
||||||
} else if (chartApi.trendlineSourceSeries.get(message.bar.tf)?.times.at(-1) !== lastSource) {
|
|
||||||
syncVisibleLevels();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (message.future_times) chartApi.setDisplayFutureTimes(message.future_times);
|
chartApi.setDisplayFutureTimes(message.future_times);
|
||||||
if (message.session_t != null) {
|
|
||||||
chartApi.setSessionRange({
|
|
||||||
t: message.session_t, high: message.session_high, low: message.session_low,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
chartApi.updateBar(message.bar);
|
chartApi.updateBar(message.bar);
|
||||||
noteLivePrice(message.bar.c);
|
price.value = message.bar.c;
|
||||||
if (message.session_open != null) sessionOpen.value = message.session_open;
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
if (status.value.last_bar_t !== message.bar.t) {
|
|
||||||
status.value.last_bar_t = message.bar.t;
|
status.value.last_bar_t = message.bar.t;
|
||||||
dataReceivedAt.value = Date.now();
|
|
||||||
}
|
|
||||||
} else if (message.type === 'trendline_bar') {
|
} else if (message.type === 'trendline_bar') {
|
||||||
const lastSource = chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1);
|
|
||||||
const geometryCurrent = chartApi.updateTrendlineTime(
|
const geometryCurrent = chartApi.updateTrendlineTime(
|
||||||
message.tf, message.t, message.duration, message.first_t, message.previous_t,
|
message.tf, message.t, message.duration, message.first_t, message.previous_t,
|
||||||
message.future_times, message.future_durations,
|
message.future_times, message.future_durations,
|
||||||
);
|
);
|
||||||
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
if (!geometryCurrent && socket?.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
socket.send(JSON.stringify({ type: 'trendline_geometry' }));
|
||||||
} else if (chartApi.trendlineSourceSeries.get(message.tf)?.times.at(-1) !== lastSource) {
|
|
||||||
syncVisibleLevels();
|
|
||||||
}
|
}
|
||||||
|
syncVisibleLevels();
|
||||||
} else if (message.type === 'trendline_geometry') {
|
} else if (message.type === 'trendline_geometry') {
|
||||||
chartApi.setTrendlineGeometry(message.geometry);
|
chartApi.setTrendlineGeometry(message.geometry);
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
|
|
@ -1115,14 +1058,6 @@ createApp({
|
||||||
return chartApi?.shiftLineTime(line, time, count) ?? null;
|
return chartApi?.shiftLineTime(line, time, count) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateShiftCount(line, count = 10) {
|
|
||||||
const times = [line.anchor_t, line.last_t];
|
|
||||||
if (line.cutoff_t != null) times.push(line.cutoff_t);
|
|
||||||
const allowed = times.map(time => chartApi?.maxLineShift(line, time, count));
|
|
||||||
if (allowed.some(value => value == null)) return null;
|
|
||||||
return Math.min(...allowed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function duplicateName(line) {
|
function duplicateName(line) {
|
||||||
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
|
const match = (line.label || `${line.tf} ${line.side}`).match(/^(.*?)(?: (\d+))?$/);
|
||||||
const base = match[1];
|
const base = match[1];
|
||||||
|
|
@ -1132,64 +1067,12 @@ createApp({
|
||||||
return `${base} ${number}`;
|
return `${base} ${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTrendline = computed(() => {
|
|
||||||
const id = selectedDrawing.value;
|
|
||||||
if (!id) return null;
|
|
||||||
const line = levels.value.find(level => level.id === id);
|
|
||||||
if (!line || line.kind !== 'manual' || !line.slope) return null;
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function duplicateSelected() {
|
|
||||||
const line = selectedTrendline.value;
|
|
||||||
if (!line) {
|
|
||||||
addEvent('DUPLICATE FAILED · select one trendline', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await duplicateLine(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function duplicateLine(line) {
|
async function duplicateLine(line) {
|
||||||
const fail = reason => {
|
if (line.geometry_resolved === false) return;
|
||||||
addEvent(`DUPLICATE #${line.number} FAILED · ${reason}`, 'error');
|
const anchorT = shiftTimeByBars(line, line.anchor_t);
|
||||||
fetch('/api/debug/snap', {
|
const endT = shiftTimeByBars(line, line.last_t);
|
||||||
method: 'POST',
|
const cutoffT = line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) return;
|
||||||
body: JSON.stringify({
|
|
||||||
kind: 'duplicate',
|
|
||||||
tf: line.tf,
|
|
||||||
note: `#${line.number} ${reason} last_t ${line.last_t} cutoff ${line.cutoff_t}`,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
};
|
|
||||||
fetch('/api/debug/snap', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
kind: 'duplicate',
|
|
||||||
tf: line.tf,
|
|
||||||
note: `start #${line.number} last_t ${line.last_t} cutoff ${line.cutoff_t}`,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
if (line.geometry_resolved === false) {
|
|
||||||
fail('no source geometry');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const count = duplicateShiftCount(line);
|
|
||||||
if (count == null) {
|
|
||||||
fail('cannot shift');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const shift = (time, bars) => (count > 0
|
|
||||||
? shiftTimeByBars(line, time, bars)
|
|
||||||
: chartApi.shiftDisplayedTime(time, 10));
|
|
||||||
const anchorT = shift(line.anchor_t, count);
|
|
||||||
const endT = shift(line.last_t, count);
|
|
||||||
const cutoffT = line.cutoff_t == null ? null : shift(line.cutoff_t, count);
|
|
||||||
if (anchorT == null || endT == null || (line.cutoff_t != null && cutoffT == null)) {
|
|
||||||
fail('cannot shift');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
|
const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t);
|
||||||
const response = await apiFetch('/api/lines', {
|
const response = await apiFetch('/api/lines', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -1204,7 +1087,7 @@ createApp({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
addEvent(`DUPLICATE #${line.number} FAILED · HTTP ${response.status}`, 'error');
|
console.error(`Unable to duplicate line: HTTP ${response.status}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = await response.json();
|
const saved = await response.json();
|
||||||
|
|
@ -1365,14 +1248,14 @@ createApp({
|
||||||
if (item.line) {
|
if (item.line) {
|
||||||
const line = { ...item.line };
|
const line = { ...item.line };
|
||||||
const previous = { ...item.line };
|
const previous = { ...item.line };
|
||||||
if (vertical) line.anchor_p = 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 (line.slope === 0) {
|
||||||
if (vertical) await updateLineGeometry(line, previous);
|
if (vertical) await updateLineGeometry(line, previous);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const endPrice = item.line.anchor_p
|
const endPrice = item.line.anchor_p
|
||||||
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
|
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
|
||||||
+ vertical * chartApi.tick;
|
+ vertical * ConfluenceChart.TICK;
|
||||||
if (horizontal) {
|
if (horizontal) {
|
||||||
if (line.geometry_resolved === false) continue;
|
if (line.geometry_resolved === false) continue;
|
||||||
const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
|
const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal);
|
||||||
|
|
@ -1394,8 +1277,8 @@ createApp({
|
||||||
if (comment.pinned) {
|
if (comment.pinned) {
|
||||||
const changes = {};
|
const changes = {};
|
||||||
if (vertical) {
|
if (vertical) {
|
||||||
changes.anchor_p = chartApi.snapPrice(
|
changes.anchor_p = ConfluenceChart.snapToTick(
|
||||||
comment.anchor_p + vertical * chartApi.tick,
|
comment.anchor_p + vertical * ConfluenceChart.TICK,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (horizontal) {
|
if (horizontal) {
|
||||||
|
|
@ -1549,11 +1432,6 @@ createApp({
|
||||||
if (response.ok) maAlerts.value = await response.json();
|
if (response.ok) maAlerts.value = await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshConfluenceAlerts() {
|
|
||||||
const response = await apiFetch('/api/prefs/confluence-alerts');
|
|
||||||
if (response.ok) confluenceAlerts.value = Boolean((await response.json()).enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleMaAlert(period) {
|
async function toggleMaAlert(period) {
|
||||||
const armed = new Set(maAlerts.value['1d'] || []);
|
const armed = new Set(maAlerts.value['1d'] || []);
|
||||||
if (armed.has(period)) armed.delete(period);
|
if (armed.has(period)) armed.delete(period);
|
||||||
|
|
@ -1629,14 +1507,6 @@ createApp({
|
||||||
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
watch(confluenceAlerts, value => {
|
|
||||||
apiFetch('/api/prefs/confluence-alerts', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: Boolean(value) }),
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(animateCurrentPrice, value => {
|
watch(animateCurrentPrice, value => {
|
||||||
localStorage.setItem('chart-animate-current-price', String(value));
|
localStorage.setItem('chart-animate-current-price', String(value));
|
||||||
if (chartApi) chartApi.setCurrentPriceAnimation(value);
|
if (chartApi) chartApi.setCurrentPriceAnimation(value);
|
||||||
|
|
@ -1647,11 +1517,6 @@ createApp({
|
||||||
if (chartApi) chartApi.setAutoScrollLivePrice(value);
|
if (chartApi) chartApi.setAutoScrollLivePrice(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(sessionRange, value => {
|
|
||||||
localStorage.setItem('chart-session-range', String(value));
|
|
||||||
if (chartApi) chartApi.setSessionRangeEnabled(value);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(extraDetail, value => {
|
watch(extraDetail, value => {
|
||||||
localStorage.setItem('chart-extra-detail', String(value));
|
localStorage.setItem('chart-extra-detail', String(value));
|
||||||
});
|
});
|
||||||
|
|
@ -1683,7 +1548,6 @@ createApp({
|
||||||
chartApi.setSnap(snap.value);
|
chartApi.setSnap(snap.value);
|
||||||
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
|
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
|
||||||
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
|
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
|
||||||
chartApi.setSessionRangeEnabled(sessionRange.value);
|
|
||||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|
@ -1695,7 +1559,6 @@ createApp({
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
await refreshComments();
|
await refreshComments();
|
||||||
await refreshMaAlerts();
|
await refreshMaAlerts();
|
||||||
await refreshConfluenceAlerts();
|
|
||||||
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
|
||||||
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
|
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
|
||||||
connect();
|
connect();
|
||||||
|
|
@ -1708,6 +1571,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
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');
|
}).mount('#app');
|
||||||
|
|
|
||||||
398
static/chart.js
398
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 {
|
class ConfluenceChart {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.chart = null;
|
this.chart = null;
|
||||||
|
|
@ -292,10 +211,6 @@ class ConfluenceChart {
|
||||||
this.animateCurrentPrice = true;
|
this.animateCurrentPrice = true;
|
||||||
this.autoScrollLivePrice = true;
|
this.autoScrollLivePrice = true;
|
||||||
this.lastCurrentPrice = null;
|
this.lastCurrentPrice = null;
|
||||||
this.pendingBar = null;
|
|
||||||
this.pendingBarFrame = null;
|
|
||||||
this.tick = 0.25;
|
|
||||||
this.rthMode = 'spy_rth';
|
|
||||||
this.previewLine = null;
|
this.previewLine = null;
|
||||||
this.lineBridgeLayer = null;
|
this.lineBridgeLayer = null;
|
||||||
this.bars = [];
|
this.bars = [];
|
||||||
|
|
@ -357,11 +272,6 @@ class ConfluenceChart {
|
||||||
this.commentNodes = new Map();
|
this.commentNodes = new Map();
|
||||||
this.commentLayer = null;
|
this.commentLayer = null;
|
||||||
this.contextLabelLayer = null;
|
this.contextLabelLayer = null;
|
||||||
this.sessionRangePrimitive = null;
|
|
||||||
this.sessionRangeEnabled = true;
|
|
||||||
this.sessionT = null;
|
|
||||||
this.sessionHigh = null;
|
|
||||||
this.sessionLow = null;
|
|
||||||
this.overlayFrame = null;
|
this.overlayFrame = null;
|
||||||
this.overlayForce = false;
|
this.overlayForce = false;
|
||||||
this.overlayRangeKey = '';
|
this.overlayRangeKey = '';
|
||||||
|
|
@ -386,6 +296,8 @@ class ConfluenceChart {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TICK = 0.25;
|
||||||
|
|
||||||
// Pixels of travel between press and release that make a gesture a drag
|
// Pixels of travel between press and release that make a gesture a drag
|
||||||
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
||||||
static DRAG_THRESHOLD = 12;
|
static DRAG_THRESHOLD = 12;
|
||||||
|
|
@ -432,20 +344,8 @@ class ConfluenceChart {
|
||||||
return localStorage.getItem('chart-diag') === '1';
|
return localStorage.getItem('chart-diag') === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
static snapToTick(price, tick = 0.25) {
|
static snapToTick(price) {
|
||||||
const step = tick > 0 ? tick : 0.25;
|
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||||
return Math.round(price / step) * step;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapPrice(price) {
|
|
||||||
return ConfluenceChart.snapToTick(price, this.tick);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInstrument(instrument) {
|
|
||||||
if (!instrument) return;
|
|
||||||
if (instrument.tick > 0) this.tick = Number(instrument.tick);
|
|
||||||
if (instrument.rth) this.rthMode = instrument.rth;
|
|
||||||
this.syncRthLines();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
create(el) {
|
create(el) {
|
||||||
|
|
@ -555,8 +455,6 @@ class ConfluenceChart {
|
||||||
this.candles.attachPrimitive(this.rthPrimitive);
|
this.candles.attachPrimitive(this.rthPrimitive);
|
||||||
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
|
this.fibPrimitive = new FibLevelsPrimitive(time => this.coordinateAtTime(time));
|
||||||
this.candles.attachPrimitive(this.fibPrimitive);
|
this.candles.attachPrimitive(this.fibPrimitive);
|
||||||
this.sessionRangePrimitive = new SessionRangePrimitive();
|
|
||||||
this.candles.attachPrimitive(this.sessionRangePrimitive);
|
|
||||||
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
this.futureSpace = this.chart.addSeries(LightweightCharts.LineSeries, {
|
||||||
priceScaleId: 'right',
|
priceScaleId: 'right',
|
||||||
color: 'rgba(0,0,0,0)',
|
color: 'rgba(0,0,0,0)',
|
||||||
|
|
@ -594,7 +492,6 @@ class ConfluenceChart {
|
||||||
this.overlayLayer.appendChild(this.ohlcReadout);
|
this.overlayLayer.appendChild(this.ohlcReadout);
|
||||||
this.currentPricePulse = document.createElement('div');
|
this.currentPricePulse = document.createElement('div');
|
||||||
this.currentPricePulse.className = 'current-price-pulse';
|
this.currentPricePulse.className = 'current-price-pulse';
|
||||||
this.currentPricePulse.innerHTML = '<span class="current-price-tri current-price-tri-right"></span><span class="current-price-tri current-price-tri-left"></span>';
|
|
||||||
this.currentPricePulse.hidden = true;
|
this.currentPricePulse.hidden = true;
|
||||||
this.overlayLayer.appendChild(this.currentPricePulse);
|
this.overlayLayer.appendChild(this.currentPricePulse);
|
||||||
this.projectionDiagnostic = document.createElement('pre');
|
this.projectionDiagnostic = document.createElement('pre');
|
||||||
|
|
@ -667,11 +564,9 @@ class ConfluenceChart {
|
||||||
duplicate.type = 'button';
|
duplicate.type = 'button';
|
||||||
duplicate.dataset.action = 'duplicate';
|
duplicate.dataset.action = 'duplicate';
|
||||||
duplicate.textContent = 'Duplicate';
|
duplicate.textContent = 'Duplicate';
|
||||||
duplicate.addEventListener('pointerdown', event => {
|
duplicate.addEventListener('click', event => {
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const id = this.contextLineId || this.selectedLineId;
|
const level = this.levels.find(value => value.id === this.contextLineId);
|
||||||
const level = this.levels.find(value => value.id === id);
|
|
||||||
this.hideContextMenu();
|
this.hideContextMenu();
|
||||||
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
|
if (level && this.onLineDuplicate) this.onLineDuplicate({ ...level });
|
||||||
});
|
});
|
||||||
|
|
@ -685,7 +580,6 @@ class ConfluenceChart {
|
||||||
this.hideContextMenu();
|
this.hideContextMenu();
|
||||||
if (id && this.onLineDelete) this.onLineDelete(id);
|
if (id && this.onLineDelete) this.onLineDelete(id);
|
||||||
});
|
});
|
||||||
this.contextMenu.addEventListener('pointerdown', event => event.stopPropagation());
|
|
||||||
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
||||||
this.contextMenu.append(endHere, extend, duplicate, deleteLine);
|
this.contextMenu.append(endHere, extend, duplicate, deleteLine);
|
||||||
this.overlayLayer.appendChild(this.contextMenu);
|
this.overlayLayer.appendChild(this.contextMenu);
|
||||||
|
|
@ -789,7 +683,6 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
this.syncRthLines();
|
this.syncRthLines();
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
this.scheduleGeometryDiagnostic();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrendlineGeometry(geometry) {
|
setTrendlineGeometry(geometry) {
|
||||||
|
|
@ -811,10 +704,7 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisplayFutureTimes(times) {
|
setDisplayFutureTimes(times) {
|
||||||
const next = (times || []).map(Number);
|
this.displayFutureTimes = (times || []).map(Number);
|
||||||
const prev = this.displayFutureTimes || [];
|
|
||||||
if (next.length === prev.length && next.every((time, index) => time === prev[index])) return;
|
|
||||||
this.displayFutureTimes = next;
|
|
||||||
if (this.bars.length && this.futureSpace) {
|
if (this.bars.length && this.futureSpace) {
|
||||||
this.futureSpace.setData(this.timeScaleSlots(this.bars));
|
this.futureSpace.setData(this.timeScaleSlots(this.bars));
|
||||||
}
|
}
|
||||||
|
|
@ -857,14 +747,9 @@ class ConfluenceChart {
|
||||||
} else if (t === source.times[last] && source.duration == null && duration != null) {
|
} else if (t === source.times[last] && source.duration == null && duration != null) {
|
||||||
source.durations[last] = Number(duration);
|
source.durations[last] = Number(duration);
|
||||||
}
|
}
|
||||||
const nextFuture = futureTimes.map(Number);
|
source.futureTimes = futureTimes.map(Number);
|
||||||
const sameFuture = nextFuture.length === source.futureTimes.length
|
|
||||||
&& nextFuture.every((time, index) => time === source.futureTimes[index]);
|
|
||||||
if (sameFuture && last >= 0 && t === source.times[last]) return true;
|
|
||||||
source.futureTimes = nextFuture;
|
|
||||||
source.futureDurations = futureDurations.map(Number);
|
source.futureDurations = futureDurations.map(Number);
|
||||||
source.allTimes = [...source.times, ...source.futureTimes];
|
source.allTimes = [...source.times, ...source.futureTimes];
|
||||||
source._filled = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -911,30 +796,6 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBar(bar) {
|
updateBar(bar) {
|
||||||
const last = this.bars[this.bars.length - 1];
|
|
||||||
if (last && bar.t < last.t) return;
|
|
||||||
if (!last || bar.t > last.t) {
|
|
||||||
this.flushPendingBar();
|
|
||||||
this.applyBar(bar);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.pendingBar = bar;
|
|
||||||
if (this.pendingBarFrame == null) {
|
|
||||||
this.pendingBarFrame = requestAnimationFrame(() => this.flushPendingBar());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flushPendingBar() {
|
|
||||||
if (this.pendingBarFrame != null) {
|
|
||||||
cancelAnimationFrame(this.pendingBarFrame);
|
|
||||||
this.pendingBarFrame = null;
|
|
||||||
}
|
|
||||||
const bar = this.pendingBar;
|
|
||||||
this.pendingBar = null;
|
|
||||||
if (bar) this.applyBar(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyBar(bar) {
|
|
||||||
// update() throws on anything older than the series' last point, which
|
// update() throws on anything older than the series' last point, which
|
||||||
// takes the whole app down rather than dropping one stale bar. Ticks made
|
// takes the whole app down rather than dropping one stale bar. Ticks made
|
||||||
// this reachable often enough to matter, so refuse it here as well as at
|
// this reachable often enough to matter, so refuse it here as well as at
|
||||||
|
|
@ -948,54 +809,11 @@ class ConfluenceChart {
|
||||||
this.volume.update(ConfluenceChart.toVolume(bar));
|
this.volume.update(ConfluenceChart.toVolume(bar));
|
||||||
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
|
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
|
||||||
else this.bars.push(bar);
|
else this.bars.push(bar);
|
||||||
this.expandSessionRange(bar);
|
|
||||||
if (advanced) this.extendManualLineSeries(last, bar);
|
if (advanced) this.extendManualLineSeries(last, bar);
|
||||||
if (advanced) this.syncRthLines();
|
if (advanced) this.syncRthLines();
|
||||||
if (advanced) this.scheduleOverlays(true);
|
if (advanced) this.scheduleOverlays(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionRangeEnabled(enabled) {
|
|
||||||
this.sessionRangeEnabled = Boolean(enabled);
|
|
||||||
this.pushSessionRange();
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionRange(range) {
|
|
||||||
if (!range || range.t == null || range.high == null || range.low == null) return;
|
|
||||||
const rolled = this.sessionT == null || range.t !== this.sessionT;
|
|
||||||
this.sessionT = range.t;
|
|
||||||
if (rolled) {
|
|
||||||
this.sessionHigh = range.high;
|
|
||||||
this.sessionLow = range.low;
|
|
||||||
} else {
|
|
||||||
this.sessionHigh = Math.max(this.sessionHigh ?? range.high, range.high);
|
|
||||||
this.sessionLow = Math.min(this.sessionLow ?? range.low, range.low);
|
|
||||||
}
|
|
||||||
this.pushSessionRange();
|
|
||||||
}
|
|
||||||
|
|
||||||
expandSessionRange(bar) {
|
|
||||||
if (!this.sessionRangeEnabled || this.bars[0]?.tf === '1d') return;
|
|
||||||
if (this.sessionT == null || bar.t < this.sessionT) return;
|
|
||||||
let changed = false;
|
|
||||||
if (bar.h != null && (this.sessionHigh == null || bar.h > this.sessionHigh)) {
|
|
||||||
this.sessionHigh = bar.h;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (bar.l != null && (this.sessionLow == null || bar.l < this.sessionLow)) {
|
|
||||||
this.sessionLow = bar.l;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (changed) this.pushSessionRange();
|
|
||||||
}
|
|
||||||
|
|
||||||
pushSessionRange() {
|
|
||||||
this.sessionRangePrimitive?.setRange(
|
|
||||||
this.sessionHigh,
|
|
||||||
this.sessionLow,
|
|
||||||
this.sessionRangeEnabled && this.bars[0]?.tf && this.bars[0].tf !== '1d',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
extendManualLineSeries(previous, bar) {
|
extendManualLineSeries(previous, bar) {
|
||||||
const step = ConfluenceChart.barInterval(this.bars);
|
const step = ConfluenceChart.barInterval(this.bars);
|
||||||
if (!(step > 0)) return;
|
if (!(step > 0)) return;
|
||||||
|
|
@ -1066,15 +884,11 @@ class ConfluenceChart {
|
||||||
|
|
||||||
updateCurrentPrice(price) {
|
updateCurrentPrice(price) {
|
||||||
if (price == null) return;
|
if (price == null) return;
|
||||||
if (this.currentPriceLine && price === this.lastCurrentPrice) {
|
|
||||||
this.syncCurrentPricePulse();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const options = {
|
const options = {
|
||||||
price,
|
price,
|
||||||
color: '#b7771d',
|
color: '#b7771d',
|
||||||
lineWidth: 2,
|
lineWidth: 1,
|
||||||
lineStyle: LightweightCharts.LineStyle.LargeDashed,
|
lineStyle: LightweightCharts.LineStyle.Dotted,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: '',
|
title: '',
|
||||||
};
|
};
|
||||||
|
|
@ -1086,15 +900,13 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
|
|
||||||
syncCurrentPricePulse() {
|
syncCurrentPricePulse() {
|
||||||
|
if (!this.currentPricePulse) return;
|
||||||
const show = this.animateCurrentPrice && this.lastCurrentPrice != null;
|
const show = this.animateCurrentPrice && this.lastCurrentPrice != null;
|
||||||
if (this.currentPricePulse) {
|
|
||||||
this.currentPricePulse.hidden = !show;
|
this.currentPricePulse.hidden = !show;
|
||||||
this.currentPricePulse.classList.toggle('active', show);
|
this.currentPricePulse.classList.toggle('active', show);
|
||||||
}
|
|
||||||
if (!show || !this.candles || this.lastCurrentPrice == null) return;
|
if (!show || !this.candles || this.lastCurrentPrice == null) return;
|
||||||
const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
|
const y = this.candles.priceToCoordinate(this.lastCurrentPrice);
|
||||||
if (y == null || !this.currentPricePulse) return;
|
if (y != null) this.currentPricePulse.style.top = `${Math.round(y)}px`;
|
||||||
this.currentPricePulse.style.top = `${Math.round(y - 9)}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
||||||
|
|
@ -1425,7 +1237,7 @@ class ConfluenceChart {
|
||||||
if (point && this.withinPlot(point) && this.onCommentMove) {
|
if (point && this.withinPlot(point) && this.onCommentMove) {
|
||||||
this.onCommentMove(node.comment, {
|
this.onCommentMove(node.comment, {
|
||||||
anchor_t: Math.round(point.t),
|
anchor_t: Math.round(point.t),
|
||||||
anchor_p: this.snapPrice(point.p),
|
anchor_p: ConfluenceChart.snapToTick(point.p),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.renderComments();
|
this.renderComments();
|
||||||
|
|
@ -1629,7 +1441,6 @@ class ConfluenceChart {
|
||||||
this.pendingView = null;
|
this.pendingView = null;
|
||||||
}
|
}
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
this.scheduleGeometryDiagnostic();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContextLabels() {
|
renderContextLabels() {
|
||||||
|
|
@ -1840,7 +1651,7 @@ class ConfluenceChart {
|
||||||
if (!point || !this.withinPlot(point) || point.t == null) return null;
|
if (!point || !this.withinPlot(point) || point.t == null) return null;
|
||||||
return {
|
return {
|
||||||
time: point.t,
|
time: point.t,
|
||||||
price: this.snapPrice(point.p),
|
price: ConfluenceChart.snapToTick(point.p),
|
||||||
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
||||||
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
||||||
};
|
};
|
||||||
|
|
@ -1927,90 +1738,6 @@ class ConfluenceChart {
|
||||||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
||||||
}
|
}
|
||||||
|
|
||||||
postDebugSnap(body) {
|
|
||||||
fetch('/api/debug/snap', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json',
|
|
||||||
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleGeometryDiagnostic() {
|
|
||||||
if (!ConfluenceChart.diagnosticMode()) return;
|
|
||||||
clearTimeout(this._geometryDiagTimer);
|
|
||||||
this._geometryDiagTimer = setTimeout(() => this.reportGeometryDiagnostic(), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
reportGeometryDiagnostic() {
|
|
||||||
if (!ConfluenceChart.diagnosticMode() || !this.bars.length) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (this._lastGeometryReport && now - this._lastGeometryReport < 2000) return;
|
|
||||||
this._lastGeometryReport = now;
|
|
||||||
const step = ConfluenceChart.barInterval(this.bars) || 60;
|
|
||||||
const space = new Set((this.futureSpace?.data() || []).map(point => Number(point.time)));
|
|
||||||
const gaps = [];
|
|
||||||
for (let index = 1; index < this.bars.length; index += 1) {
|
|
||||||
const delta = this.bars[index].t - this.bars[index - 1].t;
|
|
||||||
if (delta <= step) continue;
|
|
||||||
let owned = 0;
|
|
||||||
for (let time = this.bars[index - 1].t + step; time < this.bars[index].t; time += step) {
|
|
||||||
if (space.has(time)) owned += 1;
|
|
||||||
}
|
|
||||||
gaps.push({
|
|
||||||
from: this.bars[index - 1].t,
|
|
||||||
to: this.bars[index].t,
|
|
||||||
missing: delta / step - 1,
|
|
||||||
owned,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const lines = this.levels
|
|
||||||
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
|
|
||||||
.slice(0, 24)
|
|
||||||
.map(level => {
|
|
||||||
const pts = this.levelSeries.get(level.id)?.series.data() || [];
|
|
||||||
const segs = [];
|
|
||||||
for (let index = 1; index < pts.length; index += 1) {
|
|
||||||
const first = pts[index - 1];
|
|
||||||
const second = pts[index];
|
|
||||||
const x1 = this.chart.timeScale().timeToCoordinate(first.time);
|
|
||||||
const x2 = this.chart.timeScale().timeToCoordinate(second.time);
|
|
||||||
const y1 = this.candles.priceToCoordinate(first.value);
|
|
||||||
const y2 = this.candles.priceToCoordinate(second.value);
|
|
||||||
if ([x1, x2, y1, y2].some(value => value == null) || x2 === x1) continue;
|
|
||||||
segs.push({
|
|
||||||
t1: Number(first.time),
|
|
||||||
t2: Number(second.time),
|
|
||||||
dtMin: (Number(second.time) - Number(first.time)) / 60,
|
|
||||||
dx: +(x2 - x1).toFixed(2),
|
|
||||||
slope: +((y2 - y1) / (x2 - x1)).toFixed(4),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const slopes = segs.map(seg => seg.slope).sort((a, b) => a - b);
|
|
||||||
const med = slopes.length ? slopes[Math.floor(slopes.length / 2)] : null;
|
|
||||||
const thresh = Math.abs(med || 1) * 0.08;
|
|
||||||
return {
|
|
||||||
n: level.number,
|
|
||||||
tf: level.tf,
|
|
||||||
pts: pts.length,
|
|
||||||
med,
|
|
||||||
bad: segs.filter(seg => med != null && Math.abs(seg.slope - med) > thresh).slice(0, 12),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.postDebugSnap({
|
|
||||||
kind: 'geometry',
|
|
||||||
tf: this.bars[0].tf,
|
|
||||||
bars_held: this.bars.length,
|
|
||||||
first_bar_t: this.bars[0].t,
|
|
||||||
last_bar_t: this.bars[this.bars.length - 1].t,
|
|
||||||
chart_w: this.chartEl?.clientWidth,
|
|
||||||
chart_h: this.chartEl?.clientHeight,
|
|
||||||
note: `gaps ${gaps.length} lines ${lines.length}`,
|
|
||||||
gaps: gaps.slice(0, 40),
|
|
||||||
lines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showSnapDot(snapped, point = {}) {
|
showSnapDot(snapped, point = {}) {
|
||||||
if (snapped.t == null) return this.hideSnapDot();
|
if (snapped.t == null) return this.hideSnapDot();
|
||||||
const x = this.coordinateAtTime(snapped.t);
|
const x = this.coordinateAtTime(snapped.t);
|
||||||
|
|
@ -2062,7 +1789,12 @@ class ConfluenceChart {
|
||||||
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
||||||
};
|
};
|
||||||
this.lastSnapDiagnostic = body;
|
this.lastSnapDiagnostic = body;
|
||||||
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?.({
|
this.onToolComplete?.({
|
||||||
tool,
|
tool,
|
||||||
time: end.t,
|
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),
|
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
|
||||||
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
|
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
|
||||||
});
|
});
|
||||||
|
|
@ -2178,7 +1910,7 @@ class ConfluenceChart {
|
||||||
if (tool === 'level') {
|
if (tool === 'level') {
|
||||||
// A click with no drag is a valid placement; the drag is only there to
|
// A click with no drag is a valid placement; the drag is only there to
|
||||||
// let you fine-tune the price before committing.
|
// let you fine-tune the price before committing.
|
||||||
this.onToolComplete?.({ tool, price: this.snapPrice(end.p) });
|
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2261,7 +1993,7 @@ class ConfluenceChart {
|
||||||
const last = this.bars[this.bars.length - 1];
|
const last = this.bars[this.bars.length - 1];
|
||||||
if (fallbackT > last.t) {
|
if (fallbackT > last.t) {
|
||||||
const index = Math.round(this.indexAt(fallbackT));
|
const index = Math.round(this.indexAt(fallbackT));
|
||||||
return { t: this.timeAtIndex(index), p: 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
|
// An already-snapped point carries no cursor position; return it untouched
|
||||||
// rather than measuring against undefined.
|
// rather than measuring against undefined.
|
||||||
|
|
@ -2290,7 +2022,7 @@ class ConfluenceChart {
|
||||||
if (!this.gesture) return;
|
if (!this.gesture) return;
|
||||||
const { start, end } = this.gesture;
|
const { start, end } = this.gesture;
|
||||||
if (this.armedTool === 'level') {
|
if (this.armedTool === 'level') {
|
||||||
const price = this.snapPrice(end.p);
|
const price = ConfluenceChart.snapToTick(end.p);
|
||||||
const y = this.candles.priceToCoordinate(price);
|
const y = this.candles.priceToCoordinate(price);
|
||||||
if (y == null) return;
|
if (y == null) return;
|
||||||
this.previewLine.removeAttribute('hidden');
|
this.previewLine.removeAttribute('hidden');
|
||||||
|
|
@ -2664,29 +2396,8 @@ class ConfluenceChart {
|
||||||
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
|
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fillShortGaps(source) {
|
|
||||||
const times = source?.allTimes || source?.times || [];
|
|
||||||
const step = source?.duration;
|
|
||||||
if (!(step > 0) || times.length < 2) return times;
|
|
||||||
const filled = [times[0]];
|
|
||||||
for (let index = 1; index < times.length; index += 1) {
|
|
||||||
const prev = filled[filled.length - 1];
|
|
||||||
const next = times[index];
|
|
||||||
const gap = next - prev;
|
|
||||||
if (gap > step && gap <= ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) {
|
|
||||||
for (let time = prev + step; time < next; time += step) filled.push(time);
|
|
||||||
}
|
|
||||||
filled.push(next);
|
|
||||||
}
|
|
||||||
return filled;
|
|
||||||
}
|
|
||||||
|
|
||||||
static sourceTimes(source) {
|
static sourceTimes(source) {
|
||||||
if (!source) return [];
|
return source?.allTimes || source?.times || [];
|
||||||
if (source._filled) return source._filled;
|
|
||||||
const filled = ConfluenceChart.fillShortGaps(source);
|
|
||||||
source._filled = filled;
|
|
||||||
return filled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static timeframeIndexAt(source, time, allowFuture = true) {
|
static timeframeIndexAt(source, time, allowFuture = true) {
|
||||||
|
|
@ -2731,40 +2442,11 @@ class ConfluenceChart {
|
||||||
return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start);
|
return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceIndexAt(level, time) {
|
|
||||||
const source = this.sourceSeries(level);
|
|
||||||
if (!source) return null;
|
|
||||||
const exact = ConfluenceChart.timeframeIndexAt(source, time);
|
|
||||||
if (exact != null) return exact;
|
|
||||||
const times = ConfluenceChart.sourceTimes(source);
|
|
||||||
if (!times.length) return null;
|
|
||||||
let lo = 0;
|
|
||||||
let hi = times.length;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >> 1;
|
|
||||||
if (times[mid] <= time) lo = mid + 1;
|
|
||||||
else hi = mid;
|
|
||||||
}
|
|
||||||
return lo === 0 ? null : lo - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
shiftLineTime(level, time, bars) {
|
shiftLineTime(level, time, bars) {
|
||||||
const source = this.sourceSeries(level);
|
const source = this.sourceSeries(level);
|
||||||
if (!source) return this.shiftDisplayedTime(time, bars);
|
if (!source) return this.shiftDisplayedTime(time, bars);
|
||||||
const index = this.sourceIndexAt(level, time);
|
const index = ConfluenceChart.timeframeIndexAt(source, time);
|
||||||
if (index == null) return null;
|
return index == null ? null : ConfluenceChart.timeframeTimeAt(source, index + bars);
|
||||||
const times = ConfluenceChart.sourceTimes(source);
|
|
||||||
const target = Math.min(index + bars, times.length - 1);
|
|
||||||
if (target < 0) return times[0] ?? null;
|
|
||||||
return ConfluenceChart.timeframeTimeAt(source, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
maxLineShift(level, time, bars) {
|
|
||||||
const index = this.sourceIndexAt(level, time);
|
|
||||||
if (index == null) return null;
|
|
||||||
const source = this.sourceSeries(level);
|
|
||||||
const last = ConfluenceChart.sourceTimes(source).length - 1;
|
|
||||||
return Math.max(0, Math.min(bars, last - index));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shiftDisplayedTime(time, slots) {
|
shiftDisplayedTime(time, slots) {
|
||||||
|
|
@ -2832,18 +2514,10 @@ class ConfluenceChart {
|
||||||
if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t);
|
if (bar.t >= level.anchor_t && bar.t <= cutoff) stamps.add(bar.t);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const displayed = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff);
|
|
||||||
for (const time of ConfluenceChart.sourceTimes(source) || []) {
|
for (const time of ConfluenceChart.sourceTimes(source) || []) {
|
||||||
if (time < first || time > lastBar || time < level.anchor_t || time > cutoff) continue;
|
if (time >= first && time <= lastBar && time >= level.anchor_t && time <= cutoff) {
|
||||||
let lo = 0;
|
stamps.add(time);
|
||||||
let hi = displayed.length;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi) >> 1;
|
|
||||||
if (displayed[mid].t < time) lo = mid + 1;
|
|
||||||
else hi = mid;
|
|
||||||
}
|
}
|
||||||
const bar = displayed[lo] || displayed[displayed.length - 1];
|
|
||||||
if (bar) stamps.add(bar.t);
|
|
||||||
}
|
}
|
||||||
if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar);
|
if (lastBar >= level.anchor_t && lastBar <= cutoff) stamps.add(lastBar);
|
||||||
}
|
}
|
||||||
|
|
@ -3189,7 +2863,7 @@ class ConfluenceChart {
|
||||||
? sourcePoint - sourceStart
|
? sourcePoint - sourceStart
|
||||||
: this.indexAt(point.t) - this.indexAt(start.t),
|
: this.indexAt(point.t) - this.indexAt(start.t),
|
||||||
);
|
);
|
||||||
const priceShift = this.snapPrice(point.p - start.p);
|
const priceShift = ConfluenceChart.snapToTick(point.p - start.p);
|
||||||
const anchorT = source
|
const anchorT = source
|
||||||
? this.shiftLineTime(original, original.anchor_t, indexShift)
|
? this.shiftLineTime(original, original.anchor_t, indexShift)
|
||||||
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
: this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift);
|
||||||
|
|
@ -3255,7 +2929,7 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
const snapped = this.snapToBars
|
const snapped = this.snapToBars
|
||||||
? this.snapPoint(point)
|
? this.snapPoint(point)
|
||||||
: { ...point, p: this.snapPrice(point.p) };
|
: { ...point, p: ConfluenceChart.snapToTick(point.p) };
|
||||||
if (snapped.t == null || snapped.p == null) return;
|
if (snapped.t == null || snapped.p == null) return;
|
||||||
const time = snapped.t;
|
const time = snapped.t;
|
||||||
const price = snapped.p;
|
const price = snapped.p;
|
||||||
|
|
@ -3313,16 +2987,15 @@ class ConfluenceChart {
|
||||||
if (!level || !this.bars.length) return;
|
if (!level || !this.bars.length) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const point = this.eventPoint(event);
|
const point = this.eventPoint(event);
|
||||||
if (!point || !this.withinPlot(point)) return;
|
if (!point || !this.withinPlot(point) || point.t == null) return;
|
||||||
const lastBar = this.bars[this.bars.length - 1];
|
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)
|
? Math.round(point.t)
|
||||||
: this.bars.reduce((nearest, bar) =>
|
: this.bars.reduce((nearest, bar) =>
|
||||||
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest
|
||||||
).t;
|
).t;
|
||||||
this.contextLineId = level.id;
|
this.contextLineId = level.id;
|
||||||
const origin = Math.min(level.anchor_t, level.last_t);
|
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
|
||||||
this.contextCutoff = cutoff != null && cutoff > origin ? cutoff : null;
|
|
||||||
this.contextEndButton.hidden = this.contextCutoff == null;
|
this.contextEndButton.hidden = this.contextCutoff == null;
|
||||||
this.contextExtendButton.hidden = level.cutoff_t == null;
|
this.contextExtendButton.hidden = level.cutoff_t == null;
|
||||||
this.contextMenu.hidden = false;
|
this.contextMenu.hidden = false;
|
||||||
|
|
@ -3383,8 +3056,7 @@ class ConfluenceChart {
|
||||||
|
|
||||||
syncRthLines() {
|
syncRthLines() {
|
||||||
if (!this.rthPrimitive) return;
|
if (!this.rthPrimitive) return;
|
||||||
if (!this.rthEnabled || this.rthMode !== 'spy_rth'
|
if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') {
|
||||||
|| !this.bars.length || this.bars[0]?.tf === '1d') {
|
|
||||||
this.rthPrimitive.setMarks([]);
|
this.rthPrimitive.setMarks([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,6 @@
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section config-section">
|
<details class="sidebar-section config-section">
|
||||||
<summary>Config</summary>
|
<summary>Config</summary>
|
||||||
<label title="Today's session high and low as short ticks at the live edge"><input type="checkbox" v-model="sessionRange"> Session high / low</label>
|
|
||||||
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label>
|
|
||||||
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
|
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
|
||||||
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
|
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
|
||||||
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
|
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>
|
||||||
|
|
@ -138,9 +136,9 @@
|
||||||
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
|
||||||
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in 9" :value="width">{{ width }}px</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
<label>Alert early (pts)<input type="number" min="0" :step="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">
|
<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>
|
<button type="submit" :disabled="!alertPrice">Add</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
|
||||||
|
|
@ -283,7 +281,6 @@
|
||||||
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
|
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
|
||||||
{{ selectedAreHidden ? 'Show' : 'Hide' }}
|
{{ selectedAreHidden ? 'Show' : 'Hide' }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="duplicateSelected" :disabled="!selectedTrendline" aria-label="Duplicate selected trendline">Duplicate</button>
|
|
||||||
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
|
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawing-list-shell">
|
<div class="drawing-list-shell">
|
||||||
|
|
@ -325,11 +322,11 @@
|
||||||
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
|
<span v-else-if="item.kind === 'symbol'">#{{ item.number }} · SYMBOL · {{ item.comment.note }}</span>
|
||||||
<span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span>
|
<span v-else-if="item.kind === 'fibonacci'">#{{ item.number }} · FIB · {{ item.label }}</span>
|
||||||
<span v-else-if="item.kind === 'level'" class="level-editors">
|
<span v-else-if="item.kind === 'level'" class="level-editors">
|
||||||
<label>Price<input type="number" :min="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"
|
aria-label="Level price in drawing list"
|
||||||
@keydown.enter="$event.target.blur()"
|
@keydown.enter="$event.target.blur()"
|
||||||
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
|
@change="updateLevelNumber(item.line, 'anchor_p', $event.target.value)"></label>
|
||||||
<label>Early<input type="number" min="0" :step="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"
|
placeholder="ATR" aria-label="Level alert early points"
|
||||||
@keydown.enter="$event.target.blur()"
|
@keydown.enter="$event.target.blur()"
|
||||||
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
|
@change="updateLevelNumber(item.line, 'alert_early_points', $event.target.value)"></label>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
|
||||||
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
.chart-head-tools { display:flex; align-items:center; gap:8px; }.undo-btn { display:grid; place-items:center; width:32px; height:28px; padding:0; flex:none; }.undo-btn:disabled { opacity:.35; cursor:default; }.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
|
||||||
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
|
||||||
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden],.chart-fib-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
|
||||||
.current-price-pulse { position:absolute; left: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; }
|
.chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; }
|
||||||
.projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; }
|
.projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; }
|
||||||
@media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; 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); } }
|
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
|
||||||
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
|
||||||
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
|
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
|
||||||
|
|
|
||||||
|
|
@ -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');
|
'the rollback path no longer restores displayed-grid geometry');
|
||||||
assert.equal(result.shifted, result.sourceSecondBar,
|
assert.equal(result.shifted, result.sourceSecondBar,
|
||||||
'a source-timeframe nudge did not move by one 30m bar');
|
'a source-timeframe nudge did not move by one 30m bar');
|
||||||
const historical = result.sampledTimes.filter(time => time <= result.displayedTimes.at(-1));
|
assert.deepEqual(result.sampledTimes.slice(0, result.displayedTimes.length), result.displayedTimes,
|
||||||
assert.deepEqual(historical, [result.displayedTimes[0], result.displayedTimes.at(-1)],
|
'manual line samples no longer match the displayed candle grid');
|
||||||
'a 30m line on 1m should sample source-bar times, not every 1m candle');
|
assert.ok(result.sampledTimes.slice(result.displayedTimes.length)
|
||||||
assert.ok(result.sampledTimes.slice(historical.length)
|
|
||||||
.every(time => time > result.displayedTimes.at(-1)),
|
.every(time => time > result.displayedTimes.at(-1)),
|
||||||
'manual-line extension mixed future points into candle history');
|
'manual-line extension mixed future points into candle history');
|
||||||
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
|
assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes,
|
||||||
|
|
@ -234,8 +233,6 @@ test('daily future slots and live duration follow the server session calendar',
|
||||||
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
|
beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1),
|
||||||
shifted: c.shiftDisplayedTime(thursday, 1),
|
shifted: c.shiftDisplayedTime(thursday, 1),
|
||||||
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
|
shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1),
|
||||||
horizonShift: c.maxLineShift(horizonLine, monday, 10),
|
|
||||||
thursdayShift: c.maxLineShift(horizonLine, thursday, 10),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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.duration, 23 * 3600);
|
||||||
assert.equal(result.beyondHorizon, null);
|
assert.equal(result.beyondHorizon, null);
|
||||||
assert.equal(result.shifted, 1786917600);
|
assert.equal(result.shifted, 1786917600);
|
||||||
assert.equal(result.shiftBeyondHorizon, 1787004000);
|
assert.equal(result.shiftBeyondHorizon, null);
|
||||||
assert.equal(result.horizonShift, 0);
|
|
||||||
assert.ok(result.thursdayShift > 0);
|
|
||||||
assertNoPageErrors(page, assert);
|
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',
|
test('painted canvas pixels keep the same slope through the live edge',
|
||||||
{ timeout: 180000 }, async () => {
|
{ timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path):
|
|
||||||
one = engine(tmp_path)
|
|
||||||
assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1
|
|
||||||
assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1
|
|
||||||
assert one.evaluate(zone(), 100, 1, 90, "ES=F") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_fired_rows_without_symbol_are_es(tmp_path):
|
|
||||||
path = tmp_path / "alert_state.json"
|
|
||||||
path.write_text(
|
|
||||||
'{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
two = engine(tmp_path)
|
|
||||||
assert two.evaluate(zone(), 100, 1, 60, "/ES") == []
|
|
||||||
assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_state_file_records_centre_and_time(tmp_path):
|
def test_state_file_records_centre_and_time(tmp_path):
|
||||||
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES")
|
||||||
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8"))
|
||||||
|
|
@ -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.
|
# do not restart from 1 after a deploy and collide with a phone's history.
|
||||||
assert len(payload["fired"]) == 1
|
assert len(payload["fired"]) == 1
|
||||||
assert payload["fired"][0]["at"] == 42
|
assert payload["fired"][0]["at"] == 42
|
||||||
assert payload["fired"][0]["symbol"] == "/ES"
|
|
||||||
assert payload["next_number"] == 2
|
assert payload["next_number"] == 2
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,6 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
|
||||||
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
|
|
||||||
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
|
||||||
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
|
|
||||||
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
|
|
||||||
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
|
|
||||||
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
||||||
engine = AlertEngine(min_score=28)
|
engine = AlertEngine(min_score=28)
|
||||||
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,6 @@ def test_open_when_no_token_configured(client):
|
||||||
assert client("").get("/api/bars").status_code == 200
|
assert client("").get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_debug_snap_keeps_geometry_fields(client):
|
|
||||||
response = client("").post("/api/debug/snap", json={
|
|
||||||
"kind": "geometry",
|
|
||||||
"tf": "1m",
|
|
||||||
"gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}],
|
|
||||||
"lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}],
|
|
||||||
})
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
def test_oauth_callback_stays_open_when_a_token_is_set():
|
def test_oauth_callback_stays_open_when_a_token_is_set():
|
||||||
# The provider redirects a browser here and cannot attach the chart token.
|
# The provider redirects a browser here and cannot attach the chart token.
|
||||||
# A 401 would break the login flow at its last step, on production only,
|
# A 401 would break the login flow at its last step, on production only,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
from app.analysis.bar_space import (
|
from app.analysis.bar_space import index_at, price_in_bar_space, price_in_timeframe_space
|
||||||
fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
|
|
||||||
timeframe_index_at,
|
|
||||||
)
|
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
@ -113,25 +110,3 @@ def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser
|
||||||
assert price_in_timeframe_space(
|
assert price_in_timeframe_space(
|
||||||
line, source_times, Timeframe.M30, 2 * half_hour,
|
line, source_times, Timeframe.M30, 2 * half_hour,
|
||||||
) == 102.0
|
) == 102.0
|
||||||
|
|
||||||
|
|
||||||
def test_a_short_1m_hole_still_advances_one_index_per_minute():
|
|
||||||
times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE))
|
|
||||||
filled = fill_short_gaps(times, Timeframe.M1)
|
|
||||||
assert 10 * MINUTE in filled
|
|
||||||
assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19
|
|
||||||
line = sloped(0, 100.0, 30 * MINUTE, 130.0)
|
|
||||||
assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_weekend_is_not_filled_as_short_gaps():
|
|
||||||
weekend = 49 * 3600
|
|
||||||
times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE]
|
|
||||||
assert fill_short_gaps(times, Timeframe.M1) == times
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
|
|
||||||
five = 5 * MINUTE
|
|
||||||
times = [0, five, 3 * five]
|
|
||||||
filled = fill_short_gaps(times, Timeframe.M5)
|
|
||||||
assert filled == [0, five, 2 * five, 3 * five]
|
|
||||||
|
|
|
||||||
|
|
@ -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 [entry["message"] for entry in day] == ["dropped", "yesterday"]
|
||||||
assert more is True
|
assert more is True
|
||||||
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
|
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
|
||||||
assert EventLog(tmp_path / "events.json")._entries[0]["symbol"] == "/ES"
|
|
||||||
|
|
||||||
|
|
||||||
def test_more_pages_older_than_the_cutoff(tmp_path):
|
def test_more_pages_older_than_the_cutoff(tmp_path):
|
||||||
|
|
@ -57,6 +56,3 @@ def test_dispatched_alerts_land_in_the_event_log(tmp_path):
|
||||||
assert "#27" in events[0]["message"]
|
assert "#27" in events[0]["message"]
|
||||||
assert "confluence" not in events[0]["message"]
|
assert "confluence" not in events[0]["message"]
|
||||||
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
|
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
|
||||||
assert snapshot(runtime, Timeframe.M1)["events"][0]["symbol"] == "/ES"
|
|
||||||
assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es"
|
|
||||||
assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
|
||||||
|
|
||||||
|
|
||||||
def test_a_drawing_without_symbol_loads_as_es(tmp_path):
|
|
||||||
path = tmp_path / "manual_lines.json"
|
|
||||||
path.write_text(
|
|
||||||
'[{"id":"ml_old","tf":"1h","side":"resistance","anchor_t":100,'
|
|
||||||
'"anchor_p":5000,"slope":-0.01,"last_t":200,"created_at":300,"number":1}]\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
loaded = ManualLineStore(path).lines["ml_old"]
|
|
||||||
assert loaded.symbol == "/ES"
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_gold_drawing_keeps_its_symbol_through_json(tmp_path):
|
|
||||||
path = tmp_path / "manual_lines.json"
|
|
||||||
line = sample_line()
|
|
||||||
line.symbol = "/GC"
|
|
||||||
ManualLineStore(path).add(line)
|
|
||||||
assert ManualLineStore(path).lines["ml_test"].symbol == "/GC"
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_persistence_round_trip(tmp_path):
|
def test_json_persistence_round_trip(tmp_path):
|
||||||
path = tmp_path / "manual_lines.json"
|
path = tmp_path / "manual_lines.json"
|
||||||
store = ManualLineStore(path)
|
store = ManualLineStore(path)
|
||||||
|
|
@ -178,7 +159,6 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path):
|
||||||
"cutoff_t": 150,
|
"cutoff_t": 150,
|
||||||
}).json()
|
}).json()
|
||||||
assert created["cutoff_t"] == 150
|
assert created["cutoff_t"] == 150
|
||||||
assert created["symbol"] == "/ES"
|
|
||||||
|
|
||||||
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
|
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
|
||||||
assert cleared["cutoff_t"] is None
|
assert cleared["cutoff_t"] is None
|
||||||
|
|
@ -218,7 +198,6 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
||||||
}).json()
|
}).json()
|
||||||
assert restored["id"] == line_id
|
assert restored["id"] == line_id
|
||||||
assert restored["number"] == number
|
assert restored["number"] == number
|
||||||
assert restored["symbol"] == "/ES"
|
|
||||||
assert client.post("/api/lines/restore", json={
|
assert client.post("/api/lines/restore", json={
|
||||||
"id": line_id,
|
"id": line_id,
|
||||||
"tf": "1m",
|
"tf": "1m",
|
||||||
|
|
@ -229,30 +208,3 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path):
|
||||||
"last_t": 200,
|
"last_t": 200,
|
||||||
"number": number,
|
"number": number,
|
||||||
}).status_code == 409
|
}).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
def test_restoring_keeps_a_non_es_symbol(tmp_path):
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from app.api.routes import router
|
|
||||||
from app.config import Settings
|
|
||||||
from app.runtime import Runtime
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(router)
|
|
||||||
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
|
||||||
client = TestClient(app)
|
|
||||||
restored = client.post("/api/lines/restore", json={
|
|
||||||
"id": "ml_gold",
|
|
||||||
"tf": "1m",
|
|
||||||
"side": "support",
|
|
||||||
"anchor_t": 100,
|
|
||||||
"anchor_p": 1.0,
|
|
||||||
"slope": 0.01,
|
|
||||||
"last_t": 200,
|
|
||||||
"number": 9,
|
|
||||||
"symbol": "/GC",
|
|
||||||
}).json()
|
|
||||||
assert restored["symbol"] == "/GC"
|
|
||||||
assert ManualLineStore(tmp_path / "manual_lines.json").lines["ml_gold"].symbol == "/GC"
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
|
||||||
))
|
))
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
|
def evaluate(clusters, price, atr15, at, symbol, watched):
|
||||||
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
|
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
|
||||||
))
|
))
|
||||||
seen_members = []
|
seen_members = []
|
||||||
|
|
||||||
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
|
def evaluate(clusters, price, atr15, at, symbol, watched):
|
||||||
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
|
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,3 @@ def test_a_tick_cannot_overwrite_a_settled_bar():
|
||||||
|
|
||||||
held = store.get(Timeframe.M1)[0]
|
held = store.get(Timeframe.M1)[0]
|
||||||
assert held.closed is True and held.v == 400
|
assert held.closed is True and held.v == 400
|
||||||
|
|
||||||
|
|
||||||
def test_a_backfilled_hole_lands_behind_live_bars_without_replacing_them():
|
|
||||||
# After an outage the live stream is already newer than the hole, so put()
|
|
||||||
# dropped every recovered bar and fifteen missed days stayed missing.
|
|
||||||
store = InMemoryBarStore(4)
|
|
||||||
store.put(bar(60))
|
|
||||||
store.put(bar(600, close=7))
|
|
||||||
|
|
||||||
added = store.fill([bar(120), bar(180), bar(600, close=99)])
|
|
||||||
|
|
||||||
assert added == 2
|
|
||||||
assert [value.t for value in store.get(Timeframe.M1)] == [60, 120, 180, 600]
|
|
||||||
assert store.get(Timeframe.M1)[-1].c == 7, "the live source's bar must win"
|
|
||||||
|
|
||||||
store.fill([bar(240)])
|
|
||||||
assert [value.t for value in store.get(Timeframe.M1)] == [120, 180, 240, 600], (
|
|
||||||
"the cap keeps the newest history"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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.get("/api/prefs/ma-alerts").json() == {"1d": [10, 200]}
|
||||||
assert client.put("/api/prefs/ma-alerts", json={"1d": [7]}).status_code == 400
|
assert client.put("/api/prefs/ma-alerts", json={"1d": [7]}).status_code == 400
|
||||||
assert SHARED_USER in (tmp_path / "user_prefs.json").read_text(encoding="utf-8")
|
assert SHARED_USER in (tmp_path / "user_prefs.json").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_confluence_alerts_default_off_and_persist(tmp_path):
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(router)
|
|
||||||
app.state.runtime = Runtime(Settings(
|
|
||||||
manual_lines_path=tmp_path / "manual_lines.json",
|
|
||||||
alert_state_path=tmp_path / "alert_state.json",
|
|
||||||
user_prefs_path=tmp_path / "user_prefs.json",
|
|
||||||
))
|
|
||||||
client = TestClient(app)
|
|
||||||
|
|
||||||
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": False}
|
|
||||||
assert client.put("/api/prefs/confluence-alerts", json={"enabled": True}).json() == {
|
|
||||||
"enabled": True,
|
|
||||||
}
|
|
||||||
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": True}
|
|
||||||
assert client.put("/api/prefs/confluence-alerts", json={"enabled": "yes"}).status_code == 400
|
|
||||||
|
|
|
||||||
|
|
@ -82,20 +82,6 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path):
|
||||||
message = snapshot(runtime, Timeframe.H1)
|
message = snapshot(runtime, Timeframe.H1)
|
||||||
|
|
||||||
assert message["session_open"] == 6123.25
|
assert message["session_open"] == 6123.25
|
||||||
assert message["instrument"]["schwab_symbol"] == "/ES"
|
|
||||||
assert message["instrument"]["tick"] == 0.25
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_session_range_uses_the_forming_daily_bar(tmp_path):
|
|
||||||
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
|
|
||||||
runtime.store.put(Bar(Timeframe.D1, 1000, 100, 110, 90, 105, 1, True, "/ES", "test"))
|
|
||||||
runtime.store.put(Bar(Timeframe.D1, 2000, 105, 125, 102, 120, 1, False, "/ES", "test"))
|
|
||||||
|
|
||||||
message = snapshot(runtime, Timeframe.M1)
|
|
||||||
|
|
||||||
assert message["session_t"] == 2000
|
|
||||||
assert message["session_high"] == 125
|
|
||||||
assert message["session_low"] == 102
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
|
def test_snapshot_carries_source_times_for_manual_trendlines(tmp_path):
|
||||||
|
|
@ -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):
|
def test_browser_can_request_full_geometry_after_a_source_delta_gap(tmp_path):
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue