Compare commits

..

No commits in common. "a13a54bc3f207f9e04007de3848d00b078a67ba6" and "821f0a0a8f07da39314d65ce164c9efd93910b07" have entirely different histories.

43 changed files with 278 additions and 2943 deletions

View file

@ -6,28 +6,16 @@ YAHOO_POLL_SECONDS=20
SEED_1H_RANGE=730d
SEED_1M_RANGE=8d
# Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then.
# The callback must match the app registration exactly — changing it there can
# re-trigger approval. The same URL works from local and production: the
# redirect lands in your browser, not on the machine running the code.
SCHWAB_API_KEY=
SCHWAB_APP_SECRET=
SCHWAB_CALLBACK_URL=https://chart.amow.com/api/qt
# Under data/ so it survives a deploy — that path is the Coolify volume.
SCHWAB_TOKEN_PATH=./data/.schwab_token.json
SCHWAB_SYMBOL=/ES
# Chart and analysis
TIMEFRAMES=1m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d
MAX_BARS_PER_TF=5000
MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
MA_SETS__1H=
DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json
CONFLUENCE_MIN_SCORE=28
# Four hours. Suppression is per price zone, so an unrelated zone still alerts;
# this governs only how often the same area repeats. See README.
ALERT_COOLDOWN_SECONDS=14400
ALERT_COOLDOWN_SECONDS=900
# Notifications and access
NTFY_TOPIC=

144
README.md
View file

@ -3,9 +3,8 @@
FastAPI backend + Vue 3 (from CDN, no build step) served at
<https://chart.amow.com>.
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes, daily moving
averages, prior-day high/low/close and session VWAP, and alerts on confluence zones.
The full spec lives in
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes and daily moving
averages, and alerts on confluence zones. The full spec lives in
[`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) — read it before writing
code; it records decisions and verified API facts that are expensive to rediscover.
@ -44,39 +43,10 @@ Yahoo's current eight-day minute tape:
python3 -m scripts.calibrate_alerts
```
It sweeps threshold and cooldown in a single replay pass and prints alerts per session.
The 2026-08-09 calibration ran when daily moving averages were the only levels, and
`28` produced no alerts at all — there was nothing for a daily MA to cluster *with*.
Adding prior-day H/L/C and VWAP changed that completely: the same threshold went to 247
alerts over six sessions, 185 of them in one day.
Two fixes brought it back, in this order:
- **Cluster identity** was `sha1(side + round(center / tolerance))`, and `tolerance`
derives from ATR — so it moved every bar. The same zone was continually issued a new
id, never matched the cooldown table, and the cooldown was silently defeated. 247 → 54.
- **Alert suppression** keyed on cluster identity, so a level drifting in or out of a
group counted as a new zone. It now suppresses by *proximity*: two zones within one
ATR are the same zone. 54 → 40.
- **Suppression also matched on side**, and side is positional — a level sitting at
price flips between support and resistance every time price ticks across it. Each
flip read as a new zone. Found by putting a real line at the live price and getting
four pushes in two minutes. 40 → 26.
Only then does the cooldown do anything useful. At threshold `28` the sweep reads:
| cooldown | total | max/session |
|---|---|---|
| 900s | 26 | 19 |
| 3600s | 18 | 12 |
| 7200s | 12 | 7 |
| **14400s (selected)** | **10** | **5** |
Note the threshold itself is a blunt control: scores are sums of 12s (moving averages,
VWAP) and 16s (prior-day levels), so `20`, `24` and `28` behave identically and `32`
falls to zero. Cooldown is the finer knob. Revisit both as more varied tapes are
recorded — six sessions is not much, and one of them dominates the totals.
The M4 calibration on 2026-08-09 replayed 8,065 minute bars across seven sessions.
Threshold `12` generated 210 alerts from lone daily MAs; `24` and the selected `28`
generated none. The selected threshold deliberately requires at least three clustered
daily MAs (score `36`) and should be revisited as more varied tapes are recorded.
## Layout
@ -154,108 +124,6 @@ To clear it: `localStorage.removeItem('chart-token')`.
polls the latter from whatever machine you pushed from, and neither reveals
anything about the market data or the configuration.
## Setting an alert on a price
Type the price into **Price alert** in the sidebar. It appears as a horizontal line
across the chart with a price-axis label, and alerts when price reaches it.
A price alert is stored as a manual line with `slope = 0`, so it inherits the whole
manual-line pipeline — JSON persistence, renaming, recolouring, deletion, clustering
with nearby levels — rather than being a parallel system. Two consequences worth
knowing:
- It **alerts regardless of confluence score**, like any hand-placed level. Weight
exists to rank levels you did not ask for; you asked for this one.
- It has no drag handles and no "end line here" menu, because a price line has no
endpoints to grab. Manage it from the sidebar list.
If it lands near other levels it clusters with them and the score adds up, so a typed
level sitting on the prior-day close reads as one zone rather than two alerts.
## Market data: Yahoo for the past, Schwab for the present
Both sources run together. This is the intended configuration, not a fallback:
- **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES`
(`delayed: false`), but serves **no futures history at all** — everything it
knows starts when you connect.
- **Yahoo** has roughly 730 days of hourly data, which is what makes a 200-day
moving average warm at startup rather than in ten months. It lags ~10 minutes.
Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever
you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says
`ES=F`, Schwab says `/ES` — and `Settings.live_symbol` picks the right one. Every
bar carries a `source` tag so the seam stays visible.
**Expect a gap of up to ten minutes at the right-hand edge after a restart.**
Yahoo's history reaches to *now − 10 min* while the stream starts at *now*, so
the most recent bars are briefly missing. It backfills itself as Yahoo catches
up. Live price and alerts are unaffected — those come from the stream. Persisting
bars would remove it entirely.
`/ES` resolves to the active contract (`/ESU26` today) on Schwab's side, so
contract rolls need no handling.
### Rate limits
The developer portal shows **Order Limit: 120**, which caps orders per minute —
this app places none. Schwab separately rate-limits REST calls, commonly cited at
120 per minute.
Neither constrains us, because **streaming is not REST**. The WebSocket is a
single connection and bars arrive by push, so steady-state Schwab REST usage is
essentially zero. This is a concrete advantage of the stream over the polling
fallback: polling `get_quotes()` once a second would have sat at roughly half the
limit permanently, forever.
The exception is reconnects. Each one calls `get_user_preferences()` to fetch the
socket URL, and `StreamService` retries every five seconds, so a sustained outage
generates about twelve REST calls a minute. Comfortably under, but not nothing —
worth remembering before shortening that backoff.
Yahoo is a different service and none of these limits apply to it.
### Authenticating
```bash
python3 -m scripts.check_schwab # prints the login URL
python3 -m scripts.check_schwab --redirect-url '…' # exchanges the code
python3 -m scripts.check_stream 60 /ES # confirms bars arrive
```
Two steps, neither interactive, so the browser can be on a different machine —
you copy a URL out and paste one back. **The authorisation code expires in about
thirty seconds**, so have the second command ready before you approve. The
callback page 404s until this branch is deployed; that is cosmetic, the code is
in the address bar regardless.
The token refreshes itself for seven days, then needs the flow again. It is
per-machine: `.schwab_token.json` locally, and under `data/` in production, which
is the Coolify volume. Locally `data/` is owned by root because Docker created it
through the bind mount, which is why the local path differs.
## Alerts and ntfy
Alerts are evaluated **server-side**, once per closed 1m bar, by a single engine
living in `Runtime`. They do not depend on a browser being connected — that is the
whole point of the phone push — and opening two tabs does not double-notify.
`NTFY_TOPIC` must be set or nothing sends; `send_ntfy` returns immediately on a blank
topic. Set it in **Coolify's environment variables** for production, not in this repo.
**Use a different topic locally — or better, none.** Cooldown state is in memory, so
every restart starts with empty cooldowns and the first closed bar re-alerts whatever
zone price is sitting on. Locally that means every `--reload` save. Leaving
`NTFY_TOPIC` blank keeps the in-browser sound and banner while suppressing the push;
set a `-dev` topic only while testing the push path itself.
The same applies to production, more slowly: **a deploy resets the cooldowns**, so a
zone that alerted an hour ago can alert again right after a redeploy. Persisting the
fired-zone table would fix it.
Note that ntfy topics are public by default: anyone who knows the name can both read
your alerts and publish to it. Treat the topic name as a secret.
## Saved trendlines
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).

View file

@ -1,38 +1,19 @@
from dataclasses import dataclass
from app.analysis.confluence import Cluster
from app.analysis.levels import LevelKind
@dataclass(slots=True)
class Alert:
cluster: Cluster
message: str
# Hand-placed levels that caused this alert; the caller disarms them so a
# one-shot alert stays one-shot.
tripped: tuple[str, ...] = ()
@dataclass(slots=True)
class _Fired:
center: float
at: int
class AlertEngine:
"""Fires once per price zone, then stays quiet until price genuinely leaves.
Suppression is by proximity rather than cluster identity. Membership churns
constantly — a moving average drifts in and out of a group, changing the
cluster's identity while a human still sees one zone sitting at the prior
day's close. Keying on identity let every reshuffle through as a fresh
alert; keying on where the zone *is* does not.
"""
def __init__(self, min_score: float, cooldown_seconds: int = 900):
self.min_score = min_score
self.cooldown_seconds = cooldown_seconds
self._fired: list[_Fired] = []
self._fired_at: dict[str, int] = {}
def evaluate(
self,
@ -45,61 +26,30 @@ class AlertEngine:
tolerance = 0.5 * atr15
if tolerance <= 0:
return []
# Two zones within an ATR of each other are the same zone as far as
# being told about them goes.
merge_distance = 2 * tolerance
# Re-arming needs both elapsed time and real separation. Time alone lets
# price oscillating on a level alert forever.
self._fired = [
entry
for entry in self._fired
if not (
now - entry.at >= self.cooldown_seconds
and abs(entry.center - current_price) > merge_distance
)
]
alerts: list[Alert] = []
# Strongest first, so when several overlapping zones qualify at once the
# one that survives suppression is the most significant.
for cluster in sorted(clusters, key=lambda item: item.score, reverse=True):
drawn = [
member
for member in cluster.members
if member.kind is LevelKind.MANUAL and member.armed
]
# A drawn line bypasses the score threshold entirely. Weights run
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
# score would mean a line you deliberately drew could never alert.
# A disarmed one has already had its say and no longer qualifies.
if not drawn and cluster.score < self.min_score:
continue
if abs(cluster.center - current_price) > tolerance:
continue
# Deliberately not matched on side. A level sitting at price flips
# between support and resistance every time price ticks across it,
# because the side is positional. Matching on it meant a zone price
# was oscillating on re-alerted on every crossing — which is exactly
# when a level is least newsworthy, not most.
if any(
abs(entry.center - cluster.center) <= merge_distance for entry in self._fired
active_ids = {cluster.id for cluster in clusters}
for cluster_id, fired_at in list(self._fired_at.items()):
cluster = next((item for item in clusters if item.id == cluster_id), None)
separated = cluster is None or abs(cluster.center - current_price) > 2 * tolerance
if separated and now - fired_at >= self.cooldown_seconds:
del self._fired_at[cluster_id]
elif cluster_id not in active_ids and now - fired_at >= self.cooldown_seconds:
del self._fired_at[cluster_id]
for cluster in clusters:
if (
cluster.score < self.min_score
or abs(cluster.center - current_price) > tolerance
or cluster.id in self._fired_at
):
continue
self._fired.append(_Fired(cluster.center, now))
self._fired_at[cluster.id] = now
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
# Naming the line matters: "your line" is actionable in a way that
# "confluence 4" is not, and it says which drawing to go look at.
headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE"
detail = (
f"{cluster.side.value.title()} confluence {cluster.score:g} "
f"@ {cluster.low:.2f}-{cluster.high:.2f}"
)
if drawn:
detail += "\n" + ", ".join(member.label for member in drawn)
message = (
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
f"{direction} ZONE {symbol} {current_price:.2f}\n"
f"{cluster.side.value.title()} confluence {cluster.score:g} "
f"@ {cluster.low:.2f}-{cluster.high:.2f}\n{timeframes}"
)
alerts.append(Alert(cluster, message, tuple(member.id for member in drawn)))
alerts.append(Alert(cluster, message))
return alerts

View file

@ -1,41 +0,0 @@
"""Positions within a bar series, rather than on a clock.
A chart spaces bars evenly no matter how much time separates them: a weekend is
forty-nine hours but one bar wide. So a line that looks straight is straight in
*index* space, and a trendline advances per bar, not per second.
Evaluating trendlines any other way makes the drawn line and the alerted price
disagree — measured at 147 points across a weekend on a real /ES chart.
"""
from bisect import bisect_right
def index_at(times: list[int], t: int) -> float:
"""Fractional index of a timestamp within an ascending bar-time series."""
if not times:
return 0.0
if len(times) == 1:
return 0.0
# Outside the series there are no bars to measure against, so fall back to
# the spacing at the nearest edge.
if t <= times[0]:
step = times[1] - times[0]
return (t - times[0]) / step if step else 0.0
if t >= times[-1]:
step = times[-1] - times[-2]
return (len(times) - 1) + ((t - times[-1]) / step if step else 0.0)
lower = bisect_right(times, t) - 1
span = times[lower + 1] - times[lower]
return lower + ((t - times[lower]) / span if span else 0.0)
def price_in_bar_space(level, times: list[int], t: int) -> float:
"""A level's price at `t`, interpolated across bars rather than seconds."""
start_index = index_at(times, level.anchor_t)
end_index = index_at(times, level.last_t)
if end_index == start_index:
return level.anchor_p
end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t)
ratio = (index_at(times, t) - start_index) / (end_index - start_index)
return level.anchor_p + (end_price - level.anchor_p) * ratio

View file

@ -1,8 +1,8 @@
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from hashlib import sha1
from typing import Any
from app.analysis.levels import Level, LevelKind, Side
from app.analysis.levels import Level, Side
@dataclass(slots=True)
@ -17,18 +17,10 @@ class Cluster:
distance: float
def to_dict(self) -> dict[str, Any]:
# Built field by field rather than via asdict(), which would deep-copy
# every member's point history before we replaced it with summaries.
return {
"id": self.id,
"side": self.side.value,
"low": self.low,
"high": self.high,
"center": self.center,
"score": self.score,
"members": [member.summary() for member in self.members],
"distance": self.distance,
}
value = asdict(self)
value["side"] = self.side.value
value["members"] = [member.to_dict() for member in self.members]
return value
def cluster_levels(
@ -39,7 +31,7 @@ def cluster_levels(
return []
groups: list[list[tuple[float, Level]]] = []
positioned = [
(level.current_p if level.current_p is not None else level.price_at(current_t), level)
(level.price_at(current_t), level)
for level in levels
if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t)
]
@ -64,22 +56,13 @@ def cluster_levels(
clusters: list[Cluster] = []
for group in groups:
score = sum(level.weight for _, level in group)
# A hand-drawn line survives on its own however little it weighs: it is
# an explicit statement that this price matters. Everything else has to
# earn its place by clustering or by being a heavyweight daily level.
drawn = any(level.kind is LevelKind.MANUAL for _, level in group)
if not drawn and len(group) < 2 and score < 8:
if len(group) < 2 and score < 8:
continue
low, high = group[0][0], group[-1][0]
center = (low + high) / 2
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
# Identity is the set of levels converging, not a price bucket. The
# bucket was sized by tolerance, which is derived from ATR and so moves
# every bar — the same zone kept being issued a new id, the alert
# engine never recognised it as already fired, and the cooldown was
# silently defeated.
members = ",".join(sorted(level.id for _, level in group))
identity = sha1(f"{side.value}:{members}".encode()).hexdigest()[:12]
identity_bucket = round(center / tolerance)
identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12]
clusters.append(
Cluster(
id=f"cl_{identity}",

View file

@ -1,47 +0,0 @@
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Bar, Timeframe
from app.config import TIMEFRAME_WEIGHT
PRIOR_DAY_SPECS = (("high", "PDH", "Prior day high"), ("low", "PDL", "Prior day low"), ("close", "PDC", "Prior day close"))
def build_prior_day_levels(daily_bars: list[Bar], current_price: float | None) -> list[Level]:
"""Prior session high, low and close.
The newest daily bar is normally still forming, so "prior day" means the
last *closed* session. Taking the last bar outright would silently switch
the levels to today's own developing range partway through the session,
which is not what anyone means by PDH.
These carry the full daily weight rather than the moving-average discount:
an actual prior high is traded structure, not a derived average.
"""
closed = [bar for bar in daily_bars if bar.closed]
if not closed:
return []
prior = closed[-1]
reference = current_price if current_price is not None else prior.c
prices = {"high": prior.h, "low": prior.l, "close": prior.c}
return [
Level(
id=f"pd:{key}",
kind=LevelKind.HORIZONTAL,
tf=Timeframe.D1,
side=Side.SUPPORT if prices[key] <= reference else Side.RESISTANCE,
weight=TIMEFRAME_WEIGHT[Timeframe.D1],
score=1.0,
label=short,
anchor_t=prior.t,
anchor_p=prices[key],
slope=0.0,
points=None,
touches=0,
first_t=prior.t,
last_t=prior.t,
provisional=False,
hidden=False,
)
for key, short, _description in PRIOR_DAY_SPECS
]

View file

@ -8,7 +8,6 @@ from app.bars.models import Timeframe
class LevelKind(str, Enum):
MANUAL = "manual"
MA = "ma"
VWAP = "vwap"
TRENDLINE = "trendline"
HORIZONTAL = "horizontal"
@ -41,13 +40,6 @@ class Level:
line_width: int | None = None
number: int | None = None
cutoff_t: int | None = None
# Hand-placed levels fire once and disarm themselves; everything derived
# (averages, prior-day, VWAP) is permanently armed.
armed: bool = True
# Sloped lines are evaluated across bars, not seconds (see bar_space). The
# runtime fills this in where the bar series is available; price_at() is the
# fallback for levels that are already flat or have no series to measure.
current_p: float | None = None
def price_at(self, t: int) -> float:
return self.anchor_p + self.slope * (t - self.anchor_t)
@ -58,20 +50,3 @@ class Level:
value["tf"] = self.tf.value
value["side"] = self.side.value
return value
def summary(self) -> dict[str, Any]:
"""Compact form for embedding inside a cluster.
Clusters go out on every closed 1m bar, and a moving average carries its
whole point history — hundreds of entries reaching back years. Embedding
the full level duplicated the entire levels payload once a minute. The
client already holds the full levels and joins on id.
"""
return {
"id": self.id,
"kind": self.kind.value,
"tf": self.tf.value,
"side": self.side.value,
"weight": self.weight,
"label": self.label,
}

View file

@ -24,17 +24,6 @@ class ManualLine:
line_width: int = 2
number: int = 0
cutoff_t: int | None = None
armed: bool = True
@property
def horizontal(self) -> bool:
"""A typed price level rather than a drawn trendline."""
return self.slope == 0.0
def default_label(self) -> str:
if self.horizontal:
return f"@ {self.anchor_p:.2f}"
return f"{self.tf.value} {self.side.value}"
def to_level(self) -> Level:
return Level(
@ -44,7 +33,7 @@ class ManualLine:
side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0,
label=self.note or self.default_label(),
label=self.note or f"{self.tf.value} {self.side.value}",
anchor_t=self.anchor_t,
anchor_p=self.anchor_p,
slope=self.slope,
@ -58,7 +47,6 @@ class ManualLine:
line_width=self.line_width,
number=self.number,
cutoff_t=self.cutoff_t,
armed=self.armed,
)
def to_dict(self) -> dict:
@ -84,7 +72,6 @@ class ManualLine:
line_width=int(value.get("line_width", 2)),
number=int(value.get("number", 0)),
cutoff_t=int(value["cutoff_t"]) if value.get("cutoff_t") is not None else None,
armed=bool(value.get("armed", True)),
)

View file

@ -1,58 +0,0 @@
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Bar, Timeframe
from app.bars.session import bucket_start
from app.config import MA_WEIGHT_FACTOR, TIMEFRAME_WEIGHT
def build_vwap_level(minute_bars: list[Bar]) -> list[Level]:
"""Session VWAP, anchored to the CME session open.
Institutional execution is benchmarked against VWAP, which is what earns it
a place here: it is watched by people whose orders are large enough to move
price, not merely by chartists.
Anchoring uses the same 18:00 ET session boundary as the daily bars, so
VWAP resets when the trading day does rather than at UTC midnight.
"""
if not minute_bars:
return []
session_open = bucket_start(minute_bars[-1].t, Timeframe.D1)
cumulative_pv = 0.0
cumulative_volume = 0
points: list[tuple[int, float]] = []
for bar in minute_bars:
if bar.t < session_open:
continue
typical = (bar.h + bar.l + bar.c) / 3
cumulative_pv += typical * bar.v
cumulative_volume += bar.v
# Yahoo reports zero-volume minutes in thin overnight trade; they carry
# no VWAP information and must not divide by zero.
if cumulative_volume > 0:
points.append((bar.t, cumulative_pv / cumulative_volume))
if not points:
return []
current = points[-1][1]
last_bar = minute_bars[-1]
return [
Level(
id="vwap:session",
kind=LevelKind.VWAP,
tf=Timeframe.D1,
side=Side.SUPPORT if current <= last_bar.c else Side.RESISTANCE,
weight=TIMEFRAME_WEIGHT[Timeframe.D1] * MA_WEIGHT_FACTOR,
score=1.0,
label="Session VWAP",
anchor_t=points[-1][0],
anchor_p=current,
slope=0.0,
points=points,
touches=0,
first_t=points[0][0],
last_t=points[-1][0],
provisional=not last_bar.closed,
hidden=False,
)
]

View file

@ -27,21 +27,6 @@ class LineCreate(BaseModel):
line_width: int = Field(2, ge=1, le=4)
class PriceAlertCreate(BaseModel):
"""A horizontal level typed in rather than drawn.
Structurally just a manual line with zero slope, so it inherits persistence,
editing, clustering and — importantly — the rule that a hand-placed level
alerts regardless of confluence score.
"""
price: float = Field(gt=0)
note: str = ""
tf: Timeframe = Timeframe.D1
color: str = Field("#e0a34a", pattern=r"^#[0-9a-fA-F]{6}$")
line_width: int = Field(2, ge=1, le=4)
class LinePatch(BaseModel):
side: Side | None = None
note: str | None = None
@ -53,7 +38,6 @@ class LinePatch(BaseModel):
slope: float | None = None
last_t: int | None = None
cutoff_t: int | None = None
armed: bool | None = None
@router.get("/status")
@ -125,32 +109,6 @@ def create_line(request: Request, payload: LineCreate):
return line.to_level().to_dict()
@router.post("/lines/price", status_code=201)
def create_price_alert(request: Request, payload: PriceAlertCreate):
runtime = request.app.state.runtime
now = int(time.time())
# Side is only used for the label; clustering derives it positionally.
reference = runtime.price if runtime.price is not None else payload.price
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
side=Side.RESISTANCE if payload.price >= reference else Side.SUPPORT,
anchor_t=now,
anchor_p=payload.price,
slope=0.0,
# A horizontal level has no natural end. The span only matters to the
# fallback geometry; price_at() is constant either way.
last_t=now + 3600,
created_at=now,
note=payload.note,
color=payload.color,
line_width=payload.line_width,
)
line = runtime.manual_lines.add(line)
runtime.rebuild_levels()
return line.to_level().to_dict()
@router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True)

View file

@ -1,72 +0,0 @@
"""Schwab OAuth callback.
Schwab requires an HTTPS callback URL. The usual answer is
``https://127.0.0.1:8182`` with a self-signed certificate, which means clicking
through a browser warning on every re-authentication — and the refresh token
expires weekly. There are also reports of Schwab refusing to register apps whose
callback is a loopback address.
This app already terminates real HTTPS, so it can receive the redirect itself.
Deliberately unauthenticated: Schwab redirects a browser here and cannot attach
the chart token. Nothing is stored — the page only echoes the query string of
the request that produced it, which the caller already has in their address bar.
Storing the code would mean a later, unauthenticated visitor could read it.
The path is deliberately unrevealing. That is not a security control — the
endpoint's safety is that it is inert — it simply avoids advertising which
brokerage this host talks to. Treat it as fixed: changing a registered callback
URL means editing the Schwab app, which can send it back through approval.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter(prefix="/api")
PAGE = """<!doctype html>
<meta charset="utf-8">
<title>Callback</title>
<style>
body {{ font: 15px/1.6 ui-sans-serif, system-ui, sans-serif; max-width: 46rem;
margin: 3rem auto; padding: 0 1.5rem; background: #14161a; color: #e8eaed; }}
h1 {{ font-size: 1.2rem; }}
code, textarea {{ font-family: ui-monospace, monospace; font-size: 13px; }}
textarea {{ width: 100%; height: 7rem; padding: .7rem; border-radius: 6px;
border: 1px solid #2a2e35; background: #0e1013; color: #e8eaed; }}
.warn {{ color: #efb643; }}
.muted {{ color: #9aa1ab; }}
</style>
<h1>{heading}</h1>
{body}
"""
RECEIVED = """
<p>Paste this entire URL into the waiting login prompt:</p>
<textarea readonly onclick="this.select()">{url}</textarea>
<p class="warn">Single use, and it expires within minutes. Do not share it.</p>
<p class="muted">Nothing was stored on the server. This page shows only the URL
you just arrived with.</p>
"""
IDLE = """
<p>OAuth callback endpoint. Register this exact URL with the provider:</p>
<p><code>{url}</code></p>
<p class="muted">Arriving here directly is expected and harmless — the useful
version of this page is the one you are redirected to.</p>
"""
@router.get("/qt", response_class=HTMLResponse)
def callback(request: Request) -> HTMLResponse:
if request.query_params.get("code"):
page = PAGE.format(
heading="Authorisation received",
body=RECEIVED.format(url=str(request.url)),
)
else:
page = PAGE.format(
heading="Callback endpoint",
body=IDLE.format(url=str(request.url).split("?")[0]),
)
# Never cached: it carries a single-use authorisation code.
return HTMLResponse(page, headers={"Cache-Control": "no-store"})

View file

@ -4,31 +4,25 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import token_matches
from app.bars.models import Timeframe
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.notify.ntfy import send_ntfy
router = APIRouter()
def level_enabled(level, enabled: dict) -> bool:
kind = level.kind.value
if kind == "ma":
return level.period in enabled.get("ma", {}).get(level.tf.value, [])
if kind == "manual":
return enabled.get("manual", True)
if kind == "trendline":
return enabled.get("auto", False)
if kind == "horizontal":
return enabled.get("horizontal", True)
if kind == "vwap":
return enabled.get("vwap", True)
return False
def enabled_levels(runtime, prefs: dict | None):
if not prefs or prefs.get("hidden_levels_score"):
return runtime.levels
enabled = prefs.get("enabled", {})
return [level for level in runtime.levels if level_enabled(level, enabled)]
ma = enabled.get("ma", {})
return [
level
for level in runtime.levels
if (level.kind.value == "ma" and level.period in ma.get(level.tf.value, []))
or (level.kind.value == "manual" and enabled.get("manual", True))
or (level.kind.value == "trendline" and enabled.get("auto", False))
]
def connection_clusters(runtime, prefs: dict | None):
@ -65,6 +59,9 @@ async def websocket_endpoint(websocket: WebSocket):
runtime.subscribers.add(queue)
tf = Timeframe.M1
prefs = None
alert_engine = AlertEngine(
runtime.settings.confluence_min_score, runtime.settings.alert_cooldown_seconds
)
await websocket.send_json(snapshot(runtime, tf, prefs))
async def receive():
@ -100,7 +97,7 @@ async def websocket_endpoint(websocket: WebSocket):
)
elif event["type"] == "levels":
await websocket.send_json(
{"type": "levels", "changed": event["changed"], "removed": event["removed"]}
{"type": "levels", "levels": [level.to_dict() for level in event["levels"]]}
)
elif event["type"] == "clusters":
clusters = connection_clusters(runtime, prefs)
@ -111,15 +108,23 @@ async def websocket_endpoint(websocket: WebSocket):
"clusters": [cluster.to_dict() for cluster in clusters],
}
)
elif event["type"] == "alert":
# Alerts are produced once, server-side. This socket only relays
# them, so opening a second tab cannot double-notify.
alerts = (
alert_engine.evaluate(
clusters,
runtime.price,
runtime.atr15,
runtime.stream.last_bar_t or 0,
runtime.stream.symbol,
)
if event.get("evaluate_alerts")
else []
)
for alert in alerts:
await websocket.send_json(
{
"type": "alert",
"cluster": event["cluster"].to_dict(),
"message": event["message"],
}
{"type": "alert", "cluster": alert.cluster.to_dict(), "message": alert.message}
)
await send_ntfy(
runtime.settings.ntfy_server, runtime.settings.ntfy_topic, alert.message
)
except (WebSocketDisconnect, asyncio.CancelledError):
pass

View file

@ -14,7 +14,7 @@ class Aggregator:
if source is Timeframe.M1:
return True
if source is Timeframe.H1:
return target in (Timeframe.H1, Timeframe.D1)
return target in (Timeframe.H1, Timeframe.H4, Timeframe.D1)
return source is target
def update(self, incoming: Bar) -> list[Bar]:

View file

@ -10,6 +10,7 @@ class Timeframe(str, Enum):
M15 = "15m"
M30 = "30m"
H1 = "1h"
H4 = "4h"
D1 = "1d"
@property
@ -21,6 +22,7 @@ class Timeframe(str, Enum):
self.M15: 900,
self.M30: 1800,
self.H1: 3600,
self.H4: 14400,
}
if self is self.D1:
raise ValueError("1d is session-defined, not a fixed number of seconds")

View file

@ -14,11 +14,17 @@ def _session_open_local(current: datetime) -> datetime:
def bucket_start(t: int, tf: Timeframe) -> int:
# Everything below a day divides the hour evenly, so UTC boundaries are
# correct and session anchoring is unnecessary. Only the daily bar needs to
# know that the CME trading day runs 18:00 to 17:00 ET.
if tf is not Timeframe.D1:
if tf not in (Timeframe.H4, Timeframe.D1):
return (t // tf.seconds) * tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
return int(_session_open_local(current).timestamp())
session_open = _session_open_local(current)
if tf is Timeframe.D1:
return int(session_open.timestamp())
# CME's 4h anchors are wall-clock ET anchors. This intentionally makes the
# DST-transition bucket three or five elapsed hours instead of shifting it.
elapsed_wall = current.replace(tzinfo=None) - session_open.replace(tzinfo=None)
bucket_hours = int(elapsed_wall.total_seconds() // 14400) * 4
local_start = session_open.replace(tzinfo=None) + timedelta(hours=bucket_hours)
return int(local_start.replace(tzinfo=EASTERN).timestamp())

View file

@ -12,6 +12,7 @@ TIMEFRAME_WEIGHT = {
Timeframe.M15: 2,
Timeframe.M30: 3,
Timeframe.H1: 4,
Timeframe.H4: 8,
Timeframe.D1: 16,
}
MA_WEIGHT_FACTOR = 0.75
@ -30,48 +31,26 @@ class Settings(BaseSettings):
base_timeframes: str = "1m,30m,1d"
max_bars_per_tf: int = 5000
ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200"
ma_sets__1h: str = ""
daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json")
# Schwab. Empty until the app's keys are issued; nothing reads them while
# live_source is yahoo. The token lives under data/ so it lands on the
# Coolify persistent volume — a rebuild would otherwise log you out, and
# re-authenticating is an interactive browser flow.
schwab_api_key: str = ""
schwab_app_secret: str = ""
schwab_callback_url: str = "https://chart.amow.com/api/qt"
schwab_token_path: Path = Path("./data/.schwab_token.json")
schwab_symbol: str = "/ES"
# Seconds between forming-bar emissions built from LEVEL_ONE_FUTURES ticks.
# Set negative to drop the Level 1 subscription and take closed minute bars
# only. 1.0 is a candle that visibly moves without a broadcast per trade.
schwab_tick_seconds: float = 1.0
confluence_min_score: float = 28
# Four hours, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is
# per price zone, so an unrelated zone still alerts immediately; this only
# governs how often the *same* area repeats itself.
alert_cooldown_seconds: int = 14400
alert_cooldown_seconds: int = 900
ntfy_topic: str = ""
ntfy_server: str = "https://ntfy.sh"
chart_auth_token: str = ""
replay_file: Path | None = None
@property
def live_symbol(self) -> str:
"""What the live source calls the instrument.
Yahoo wants ES=F, Schwab wants /ES. Seeding always uses the Yahoo
symbol, because Yahoo is always the source of history.
"""
return self.schwab_symbol if self.live_source == "schwab" else self.yahoo_symbol
@property
def enabled_timeframes(self) -> list[Timeframe]:
return [Timeframe(value.strip()) for value in self.timeframes.split(",") if value.strip()]
@property
def ma_sets(self) -> dict[Timeframe, list[tuple[str, int]]]:
configured = {Timeframe.D1: self.ma_sets__1d}
configured = {
Timeframe.D1: self.ma_sets__1d,
Timeframe.H1: self.ma_sets__1h,
}
result: dict[Timeframe, list[tuple[str, int]]] = {}
for tf, value in configured.items():
definitions = []

View file

@ -7,27 +7,15 @@ from app.market.yahoo import YahooSource
def live_source(settings: Settings) -> MarketDataSource:
if settings.live_source == "yahoo":
return YahooSource(settings.yahoo_poll_seconds)
if settings.live_source == "schwab":
# Imported here so schwab-py stays optional while the live source is
# Yahoo, which is still the default.
from app.market.schwab import SchwabSource
return SchwabSource(settings)
if settings.live_source == "replay" and settings.replay_file:
return ReplaySource(settings.replay_file)
raise ValueError(f"Unsupported LIVE_SOURCE: {settings.live_source}")
def seed_source(settings: Settings) -> MarketDataSource | None:
"""The source of history, which is never Schwab.
Schwab serves price history for equities and ETFs only, so with
LIVE_SOURCE=schwab the seed stays on Yahoo. That pairing is the intended
configuration, not a fallback: Yahoo supplies the past, Schwab the present.
"""
if settings.seed_source == "none":
return None
if settings.seed_source in ("yahoo", "schwab"):
if settings.seed_source == "yahoo":
return YahooSource(settings.yahoo_poll_seconds)
if settings.seed_source == "replay" and settings.replay_file:
return ReplaySource(settings.replay_file)

View file

@ -1,218 +0,0 @@
"""Real-time /ES bars from Schwab's CHART_FUTURES stream.
Verified against a live account before this was written:
- Streaming works. CHART_FUTURES delivers one minute bar per symbol per minute
with true exchange OHLCV, and LEVEL_ONE_FUTURES reports ``delayed: False``.
- The continuous root resolves itself. Subscribing to ``/ES`` returns data keyed
``/ES`` while quotes report the active contract as ``/ESU26``, so contract
rolls need no handling here.
- There is no history. Schwab serves price history for equities and ETFs only,
so this source seeds nothing; Yahoo remains the only source of the past.
The delayed sibling is worth stating plainly: Yahoo lags about ten minutes, so
at startup the most recent bars are missing until Yahoo catches up. Keep both
sources running rather than switching Yahoo off once this connects.
"""
import asyncio
import logging
import time
from collections.abc import AsyncIterator
from dataclasses import replace
from app.bars.models import Bar, Timeframe
logger = logging.getLogger(__name__)
# CHART_FUTURES field names as schwab-py labels them.
FIELD_TIME = "CHART_TIME_MILLIS"
FIELD_OPEN = "OPEN_PRICE"
FIELD_HIGH = "HIGH_PRICE"
FIELD_LOW = "LOW_PRICE"
FIELD_CLOSE = "CLOSE_PRICE"
FIELD_VOLUME = "VOLUME"
# LEVEL_ONE_FUTURES field names, as schwab-py labels them. Verified realtime on
# this account: the service reports delayed: False for /ES.
FIELD_LAST_PRICE = "LAST_PRICE"
FIELD_LAST_SIZE = "LAST_SIZE"
FIELD_TRADE_TIME = "TRADE_TIME_MILLIS"
def parse_level_one(message: dict) -> list[tuple[int, float, int]]:
"""Turn one LEVEL_ONE_FUTURES message into (trade time ms, price, size).
Level 1 messages are partial: a quote that moves only the bid carries no
LAST_PRICE at all. Those are skipped rather than carried forward, because a
bid tick is not a trade and must not extend a candle's high or low.
"""
ticks: list[tuple[int, float, int]] = []
for content in message.get("content") or []:
price = content.get(FIELD_LAST_PRICE)
if price is None:
continue
millis = content.get(FIELD_TRADE_TIME)
if millis is None:
# No trade stamp on this update; the wall clock is close enough to
# bucket it, and being one minute out at a boundary is corrected by
# the authoritative CHART_FUTURES bar moments later.
millis = int(time.time() * 1000)
ticks.append((int(millis), float(price), int(content.get(FIELD_LAST_SIZE) or 0)))
return ticks
def parse_chart_futures(message: dict, symbol: str) -> list[Bar]:
"""Turn one CHART_FUTURES message into bars.
A bar arrives once its minute has elapsed, so it is complete on arrival and
marked closed. Anything missing a timestamp or a price is skipped rather
than defaulted — a bar invented from partial data would be indistinguishable
from a real one downstream.
"""
bars: list[Bar] = []
for content in message.get("content") or []:
millis = content.get(FIELD_TIME)
prices = [content.get(field) for field in (FIELD_OPEN, FIELD_HIGH, FIELD_LOW, FIELD_CLOSE)]
if millis is None or any(price is None for price in prices):
continue
open_, high, low, close = (float(price) for price in prices)
bars.append(
Bar(
tf=Timeframe.M1,
t=int(millis) // 1000,
o=open_,
h=high,
l=low,
c=close,
v=int(content.get(FIELD_VOLUME) or 0),
closed=True,
symbol=str(content.get("key") or symbol),
source="schwab",
)
)
return bars
class SchwabSource:
"""Live minute bars. Holds no history — see the module docstring."""
name = "schwab"
delay_minutes = 0
def __init__(self, settings, stream_client_factory=None):
self._settings = settings
# Injectable so the parsing and dispatch can be tested without a socket.
self._stream_client_factory = stream_client_factory or self._build_stream_client
# None disables the Level 1 subscription entirely and leaves the source
# exactly as it was: one closed bar a minute.
seconds = getattr(settings, "schwab_tick_seconds", 1.0)
self._tick_seconds = None if seconds is None or seconds < 0 else seconds
def supports_history(self) -> bool:
return False
async def history(self, symbol, tf, start, end, *, range_=None) -> list[Bar]:
return []
def supports_stream(self) -> bool:
return True
def _build_stream_client(self):
from schwab.auth import client_from_token_file
from schwab.streaming import StreamClient
settings = self._settings
if not settings.schwab_token_path.exists():
raise RuntimeError(
f"No Schwab token at {settings.schwab_token_path}. "
"Run: python3 -m scripts.check_schwab"
)
client = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
asyncio=True,
)
return StreamClient(client)
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
stream_client = self._stream_client_factory()
queue: asyncio.Queue[tuple[str, dict]] = asyncio.Queue(maxsize=256)
def enqueue(kind: str):
def handler(message: dict) -> None:
# Dropping the oldest keeps a slow consumer from stalling the
# socket; a minute bar that late is of no use anyway.
if queue.full():
queue.get_nowait()
queue.put_nowait((kind, message))
return handler
await stream_client.login()
# Registered before subscribing: the service starts sending straight
# away and messages without a handler are discarded.
stream_client.add_chart_futures_handler(enqueue("chart"))
await stream_client.chart_futures_subs([symbol])
logger.info("Subscribed to CHART_FUTURES for %s", symbol)
if self._tick_seconds is not None:
# Same socket, same login — no extra REST call and no extra rate
# limit. CHART_FUTURES only speaks once a minute, after the minute
# is over; this is what makes the candle move in between.
stream_client.add_level_one_futures_handler(enqueue("quote"))
await stream_client.level_one_futures_subs([symbol])
logger.info("Subscribed to LEVEL_ONE_FUTURES for %s", symbol)
forming: Bar | None = None
last_closed_t = 0
last_emit = 0.0
pump = asyncio.create_task(self._pump(stream_client), name="schwab-stream-pump")
try:
while True:
if pump.done():
# Surface the socket's failure rather than hanging on a
# queue nothing is filling any more.
pump.result()
return
try:
kind, message = await asyncio.wait_for(queue.get(), timeout=5)
except (asyncio.TimeoutError, TimeoutError):
continue
if kind == "chart":
for bar in parse_chart_futures(message, symbol):
last_closed_t = max(last_closed_t, bar.t)
# The exchange's own bar supersedes whatever the ticks
# had built for that minute.
if forming is not None and forming.t <= bar.t:
forming = None
yield bar
continue
for millis, price, size in parse_level_one(message):
minute = millis // 60000 * 60
# A tick for a minute already closed by CHART_FUTURES would
# otherwise overwrite an authoritative bar with a partial.
if minute <= last_closed_t:
continue
if forming is None or forming.t != minute:
forming = Bar(
tf=Timeframe.M1, t=minute, o=price, h=price, l=price, c=price,
v=size, closed=False, symbol=symbol, source="schwab",
)
else:
forming.h = max(forming.h, price)
forming.l = min(forming.l, price)
forming.c = price
forming.v += size
# Throttled: /ES trades many times a second, and every
# emission costs a store write and a broadcast to every
# open socket.
now = time.monotonic()
if now - last_emit >= self._tick_seconds:
last_emit = now
yield replace(forming)
finally:
pump.cancel()
@staticmethod
async def _pump(stream_client) -> None:
while True:
await stream_client.handle_message()

View file

@ -22,17 +22,11 @@ class StreamService:
self._handlers.append(handler)
async def seed(
self,
source: MarketDataSource | None,
tf: Timeframe,
range_: str,
symbol: str | None = None,
self, source: MarketDataSource | None, tf: Timeframe, range_: str
) -> None:
# The seed source names the instrument differently from the live one:
# Yahoo says ES=F where Schwab says /ES.
if source is None or not source.supports_history():
return
bars = await source.history(symbol or self.symbol, tf, None, None, range_=range_)
bars = await source.history(self.symbol, tf, None, None, range_=range_)
for bar in bars:
await self._emit(bar)

View file

@ -1,15 +1,10 @@
import asyncio
import logging
from dataclasses import dataclass, field
from app.analysis.alerts import Alert, AlertEngine
from app.bars.models import Bar, Timeframe
from app.bars.aggregator import Aggregator
from app.analysis.bar_space import price_in_bar_space
from app.analysis.horizontals import build_prior_day_levels
from app.analysis.levels import Level
from app.analysis.moving_averages import build_ma_levels
from app.analysis.vwap import build_vwap_level
from app.analysis.confluence import Cluster, cluster_levels
from app.analysis.indicators import atr
from app.analysis.manual_lines import ManualLineStore
@ -17,9 +12,6 @@ from app.bars.store import InMemoryBarStore
from app.config import Settings
from app.market.factory import live_source, seed_source
from app.market.stream import StreamService
from app.notify.ntfy import send_ntfy
logger = logging.getLogger(__name__)
@dataclass
@ -35,39 +27,16 @@ class Runtime:
atr15: float = 0.0
manual_lines: ManualLineStore = field(init=False)
ma_levels: list[Level] = field(default_factory=list)
alert_engine: AlertEngine = field(init=False)
_sent_levels: dict[str, dict] = field(default_factory=dict)
_notify_tasks: set[asyncio.Task] = field(default_factory=set)
def __post_init__(self) -> None:
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
self.aggregator = Aggregator(self.settings.enabled_timeframes)
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
# One engine for the process, not one per browser connection. Cooldowns
# are only meaningful if they outlive a page reload, and a phone push
# must not depend on a tab being open to produce it.
self.alert_engine = AlertEngine(
self.settings.confluence_min_score, self.settings.alert_cooldown_seconds
)
self.levels = self.manual_lines.levels()
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol)
self.stream.add_handler(self.on_bar)
async def on_bar(self, bar: Bar) -> None:
# A tick-built bar is provisional and arrives many times a minute. It
# updates the last candle and the live price, and stops there.
#
# It must not reach the aggregator: that accumulates volume with
# `current.v += incoming.v`, so re-sending the same forming minute would
# add its volume to every higher timeframe again on each update. Alerts
# stay on closed bars for the same reason they always were — a level is
# judged on a settled bar, not on a price that may not last the minute.
if not bar.closed:
self.store.put(bar)
self.price = bar.c
self.broadcast({"type": "bar", "bar": bar})
return
evaluate_alerts = False
for aggregated in self.aggregator.update(bar):
self.store.put(aggregated)
@ -80,9 +49,6 @@ class Runtime:
if evaluate_alerts:
values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
# VWAP re-prices every minute, so levels are rebuilt here too. The
# broadcast is a delta, which is what keeps that affordable.
self.rebuild_levels()
self.rebuild_clusters(evaluate_alerts=True)
def broadcast(self, event: dict) -> None:
@ -96,113 +62,28 @@ class Runtime:
{tf: self.store.get(tf) for tf in self.settings.ma_sets},
self.settings.ma_sets,
)
minute_bars = self.store.get(Timeframe.M1)
manual = self.manual_lines.levels()
self.position_manual_levels(manual, minute_bars)
self.levels = (
self.ma_levels
+ build_prior_day_levels(self.store.get(Timeframe.D1), self.price)
+ build_vwap_level(minute_bars)
+ manual
)
self.broadcast_level_delta()
self.levels = self.ma_levels + self.manual_lines.levels()
self.broadcast({"type": "levels", "levels": self.levels})
self.rebuild_clusters()
@staticmethod
def position_manual_levels(levels: list[Level], bars: list[Bar]) -> None:
"""Price sloped lines across bars rather than seconds.
The chart spaces bars evenly, so the line a person drew advances per bar.
Pricing it per second instead put the alert somewhere the line visibly
was not — 147 points out across a weekend.
"""
if not bars:
return
times = [bar.t for bar in bars]
now = times[-1]
for level in levels:
if level.slope:
level.current_p = price_in_bar_space(level, times, now)
def broadcast_level_delta(self) -> None:
"""Send only levels whose serialised form actually changed.
A daily moving average carries hundreds of points and changes once a
session; VWAP changes every minute. Broadcasting the whole set on the
VWAP cadence would push the entire history every minute, so subscribers
get a delta and merge it by id.
"""
current = {level.id: level.to_dict() for level in self.levels}
changed = [value for id_, value in current.items() if self._sent_levels.get(id_) != value]
removed = [id_ for id_ in self._sent_levels if id_ not in current]
self._sent_levels = current
if changed or removed:
self.broadcast({"type": "levels", "changed": changed, "removed": removed})
def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
if self.price is None or self.stream.last_bar_t is None:
return
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
self.broadcast({"type": "clusters", "price": self.price, "clusters": self.clusters})
if evaluate_alerts:
# Evaluated over every level, deliberately ignoring per-connection
# layer preferences: those are a display choice made in one browser,
# and a push notification has no business depending on them.
self.dispatch_alerts(
self.alert_engine.evaluate(
self.clusters,
self.price,
self.atr15,
self.stream.last_bar_t,
self.stream.symbol,
self.broadcast(
{
"type": "clusters",
"price": self.price,
"clusters": self.clusters,
"evaluate_alerts": evaluate_alerts,
}
)
)
def dispatch_alerts(self, alerts: list[Alert]) -> None:
tripped: set[str] = set()
for alert in alerts:
self.broadcast({"type": "alert", "cluster": alert.cluster, "message": alert.message})
task = asyncio.create_task(self.notify(alert.message))
# Held so the task is not garbage collected mid-flight.
self._notify_tasks.add(task)
task.add_done_callback(self._notify_tasks.discard)
tripped.update(alert.tripped)
if tripped:
self.disarm(tripped)
def disarm(self, ids: set[str]) -> None:
"""A hand-placed level fires once, then waits to be re-armed."""
changed = False
for line_id in ids:
try:
self.manual_lines.update(line_id, {"armed": False})
changed = True
except KeyError:
continue
# Rebuilt after the loop, not inside it: rebuild_levels re-enters
# rebuild_clusters, and doing that mid-dispatch would rewrite the very
# clusters being iterated.
if changed:
self.rebuild_levels()
async def notify(self, message: str) -> None:
try:
await send_ntfy(self.settings.ntfy_server, self.settings.ntfy_topic, message)
except Exception:
# A push outage must not take down the stream or the sockets.
logger.warning("ntfy delivery failed", exc_info=True)
async def start(self) -> asyncio.Task:
try:
source = seed_source(self.settings)
# Always the Yahoo symbol: Schwab has no history to seed from.
seed_symbol = self.settings.yahoo_symbol
await self.stream.seed(
source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol
)
await self.stream.seed(
source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol
)
await self.stream.seed(source, Timeframe.H1, self.settings.seed_1h_range)
await self.stream.seed(source, Timeframe.M1, self.settings.seed_1m_range)
except Exception:
# A transient seed failure must not prevent the live stream or UI starting.
pass

View file

@ -86,13 +86,6 @@ if your parser doesn't filter those, that fixture will catch it.
### Timeframe roles
> **4h was removed from the product on 2026-08-10.** It was never enabled, and
> dropping it deleted the fiddliest logic in `session.py` — the wall-clock ET 4h
> anchor and its DST edge cases — for a timeframe nobody was using. Later
> sections of this document still use 4h in examples; read those as
> illustrative, not as a spec to build. Nothing below a day is session-anchored
> any more, so `bucket_start` now special-cases only `1d`.
```
1m base chart + alert evaluation weight 1 ← a switchable base timeframe
2m display only
@ -100,6 +93,7 @@ if your parser doesn't filter those, that fixture will catch it.
15m manual lines weight 2
30m base chart + manual lines weight 3 ← a switchable base timeframe
1h manual lines (+ optional MAs) weight 4
4h manual lines (+ optional MAs) weight 8
1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe
```
@ -162,43 +156,16 @@ disagree with every other chart you'll compare against.
**Build daily bars yourself by aggregating Yahoo's 1h bars** through the same
`aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars
are anchored to the top of the ET hour, so they compose into session-anchored 1d
are anchored to the top of the ET hour, so they compose into session-anchored 4h and 1d
buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA
(~200 sessions) with room to spare.
Yahoo's native 1d bars may be used *only* for multi-year context, clearly labelled.
## 2.2 Schwab entitlements — **answered empirically 2026-08-10**
## 2.2 Verify these when adding the Schwab source (M6, not before)
All verified against a live production app holding both Market Data Production and
Accounts and Trading Production.
| Question | Answer |
|---|---|
| Futures market data entitled? | **Yes** — but only via `get_quotes()` (plural) |
| Symbol format | **`/ES`**, which auto-resolves to the active contract `/ESU26` |
| `CHART_FUTURES` streaming | **Works** — one true-OHLCV minute bar per symbol per minute |
| `LEVEL_ONE_FUTURES` | **Works**, and reports `delayed: false` |
| Futures price history | **Still none.** Yahoo remains the only source of the past |
Three traps found the hard way, all of which cost a round trip:
- **`get_quote()` (singular) silently returns the wrong instrument.** It puts the
symbol in the URL *path*, where the leading slash is normalised away, so `/ES`
comes back as `ES` — Eversource Energy, an equity, at $72. HTTP 200 with a
populated body. `get_quotes()` passes symbols as a query parameter and returns
the future correctly. **Never treat a 200 as proof; check `assetMainType`.**
- **Streaming needs the Accounts and Trading product.** `StreamClient.login()`
reads `/trader/v1/userPreference` for its socket URL, and that path is not in
Market Data Production. A market-data-only app cannot stream at all.
- **Authorisation codes expire in about thirty seconds**, and an unwritable token
path spends one before revealing itself. `scripts/check_schwab.py` preflights
the key, the secret and the token path for exactly this reason.
Because `/ES` resolves to the active contract on Schwab's side, contract roll
handling — an open problem in §10 — needs no code here.
### The remaining unknowns for Schwab
These are load-bearing unknowns for Schwab specifically. They no longer block the
project — M0–M5 run entirely on Yahoo. If any fails, stop and report.
1. **Futures market-data entitlement.** It is not publicly documented whether
`CHART_FUTURES` requires futures trading approval or a CME non-professional market
@ -353,7 +320,7 @@ Use dataclasses (or pydantic where it crosses the API boundary). All times are
```python
class Timeframe(str, Enum):
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; D1="1d"
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; H4="4h"; D1="1d"
@property
def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6
@ -414,7 +381,7 @@ The weight table referenced throughout — define it once, in `config.py`:
```python
TIMEFRAME_WEIGHT = {
Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2,
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.D1: 16,
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.H4: 8, Timeframe.D1: 16,
}
MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure
```
@ -450,9 +417,10 @@ This is where a naive implementation silently produces wrong lines. CME ES is no
- `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the
hour evenly, so session anchoring is unnecessary and UTC keeps it simple.
- `1d` — one bar per futures session as defined above. **This is the only
session-anchored timeframe.** 4h used to be the other one and was the reason this
section warned about DST; with 4h gone, that whole class of edge case went with it.
- `4h` — **anchor to the 18:00 ET session open**, not UTC midnight. Buckets are
18:00, 22:00, 02:00, 06:00, 10:00, 14:00 ET. UTC-anchored 4h buckets straddle the
session boundary and produce meaningless bars.
- `1d` — one bar per futures session as defined above.
Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit
test it hard, including both DST transitions and the Sunday open. **This function is
@ -639,6 +607,7 @@ displayed.**
MA_SETS = {
"1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)],
# optional, off by default:
"4h": [("ema", 9), ("ema", 21)],
"1h": [("ema", 9), ("ema", 21)],
}
BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available
@ -810,26 +779,6 @@ const chartApi = shallowRef(null); // ✅
// const chart = ref(null); // ❌ will appear to work, then misbehave
```
### Logical indices address the whole chart, not your bar array
**Never derive a viewport from `bars.length`.** A logical index addresses the
chart's *shared* time scale — the union of the time points of every series on
it — not the candle array. Any series whose points pre-date the candle window
prepends to that scale and shifts every logical index by its count.
```js
// ❌ off by however many points the other series contribute
timeScale().setVisibleLogicalRange({ from: bars.length - 160, to: bars.length + 5 });
// ✅ an instant cannot be renumbered by a later series
timeScale().setVisibleRange({ from: bars.at(-160).t, to: bars.at(-1).t + step * 5 });
```
This is not theoretical — see the 2026-08-10 entry in §16. The daily MAs carry
one point per daily bar (617 of them, back ~2 years). They are attached by
`syncVisibleLevels()` *immediately after* `setBars()`, so a logical range that
was correct when set silently slid 617 bars — about ten hours — into the past
one tick later. The chart looked frozen while the socket was perfectly healthy.
Structure:
- `chart.js` — a plain, framework-free wrapper class owning the LWC instance:
`create(el)`, `setBars()`, `updateBar()`, `syncLevels(levels)`, `destroy()`.
@ -876,6 +825,8 @@ LAYERS
☑ Daily MAs ██
☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200
─────────────────────────────────
☐ 4h MAs ██
☐ EMA9 ☐ EMA21
☐ 1h MAs ▓▓
☐ EMA9 ☐ EMA21
─────────────────────────────────
@ -938,6 +889,7 @@ would mean waiting months for the parts of the product that matter most:
|---|---|---|
| 5m, 15m | ~2–4 hours | immediate |
| 30m, 1h | ~1–2 sessions | immediate |
| 4h | ~1–2 weeks | immediate (from 1h) |
| 1d | months | immediate (~500 sessions) |
| 1d 200SMA | ~10 months | immediate |
@ -991,7 +943,7 @@ SCHWAB_ACCOUNT_ID=
SCHWAB_SYMBOL=/ES
# --- timeframes & indicators ---
TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d
TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
@ -1032,7 +984,7 @@ Everything downstream of `StreamService` is then testable, deterministically, of
Required tests:
- `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the
17:00–18:00 break, and Friday close. **Write these first.**
17:00–18:00 break, Friday close, and the 4h session anchor. **Write these first.**
- `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay.
- `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values;
assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds
@ -1089,7 +1041,7 @@ once per session on the intraday views, dashed while the current session is unfi
### M3.5 — Layer panel
Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with
M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it.
**Done when:** unchecking a group removes its every level from the chart and the state
**Done when:** unchecking "4h" removes every 4h level from the chart and the state
survives a reload.
### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build
@ -1191,151 +1143,3 @@ enforces for data sources.
| Schwab token expiry (7 days) | Stream dies | Surface prominently in status bar; document re-auth |
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
| Seed replays every bar through `on_bar` | ~82 s startup; port refuses connections | Known, unfixed — §16, 2026-08-10 |
| Headless browser without a real locale | `Intl` throws; blank canvas mimics an app bug | Launch Chromium with `--lang=en-US` — §16 |
## 16. Session log
Dated record of problems hit and how they were resolved. Times are UTC; the
repo's commit timestamps are -0500.
### 2026-08-10 — rebuild, and a chart that looked frozen
**10:30 · The rebuild was genuinely required.** `schwab-py` had been added to
`requirements.txt`, but the running image was built at 2026-08-09 22:05, before
that line existed. The bind mount (`.:/app`) hides this: source edits appear
live, so the Schwab commits looked deployed while `pip freeze` in the container
showed no `schwab-py` at all. Anything imported rather than read from disk needs
`docker compose build`. Rebuilt to `schwab-py 1.5.1` and recreated the container.
**10:30–10:31 · Startup takes ~82 seconds, and the port is closed the whole
time.** `Runtime.start()` replays every seeded bar through `on_bar`, and each
daily-bar update re-runs `rebuild_levels()` → `broadcast_level_delta()`, which
serialises and diffs five MA levels carrying ~730 points each. With a 730d/1h
seed plus an 8d/1m seed that is quadratic work before uvicorn binds. Measured:
10:30:24 "Waiting for application startup" → 10:31:46 "Application startup
complete". An open browser tab polling `/api/status` throughout logs a wall of
`ERR_CONNECTION_REFUSED`; that is the restart window, not a fault.
*Unfixed.* The fix is to bulk-load seeded bars and rebuild levels once at the
end, rather than once per bar. Related: M7 persistence would cut the seed itself.
**Diagnosing a hang that is actually slowness:** `docker stats` reported ~0.1%
CPU while the process was in fact grinding, so it pointed the wrong way. What
worked was `faulthandler.dump_traceback_later(25, exit=True)`, which named the
exact frame (`indicators.py:sma` under `runtime.py:62`). `py-spy` is unusable
here — it needs `SYS_PTRACE`, which the container does not have.
**Do not write scratch files into the repo while diagnosing.** A `_probe.py`
dropped in the project root is inside the bind mount, so `--reload` restarted
the lifespan and reset the 82-second clock — twice — which is what made
slow startup look like an infinite hang. Pipe throwaway scripts over stdin
(`docker exec -i … python -`) instead. Only `.py` changes trigger the reloader;
writing screenshots into `artifacts/` is safe.
**10:35 · A blank chart canvas that was not a bug.** The Playwright container
has no usable locale, so Chromium reports `en-US@posix`; Lightweight Charts
formats its time axis through `Intl`, which throws `Invalid language tag` and
leaves the canvas empty. `docker-compose.yml` already sets
`LANG=en_US.UTF-8` for that service and it is *not* sufficient. Launch with
`chromium.launch({ args: ['--lang=en-US'] })` — with that, the page renders and
reports zero console errors. Worth stating plainly: this failure looks exactly
like a broken app, and it is not.
**10:41–10:50 · The real bug — the chart sat ~10 hours behind a healthy feed.**
Symptom: header price live at 7785.00 while the last candle closed 7772.75, and
the series appeared to end at 00:20. Everything downstream checked out —
`/api/bars` newest 10:39 from `schwab`; `store.put` keeps bars strictly
ascending; the WebSocket snapshot delivered 1000 ascending bars ending 10:42 and
live `bar` events arrived every minute; the browser received all of it.
Interrogating `window.__chart` gave the answer:
```
seriesLen 1000 seriesLast 08-10T10:48 (7786.25) ← data complete
visible 08-07T20:41 → 08-10T00:35 ← viewport wrong
logical from 840 to 1005
```
The series was complete; the *viewport* was 617 bars too far left — exactly
`bars_held.1d`. `setBars()` set a visible **logical** range from the candle
array length, then `syncVisibleLevels()` attached the daily MA series, whose 617
daily points pre-date the 1m window; prepending them renumbered every logical
index and dragged the view off the live edge. Fixed in `static/chart.js` by
anchoring the viewport to a **time** range. Verified in a real browser: visible
range 08:02 → 10:49, last candle 7786.25 matching the header. See §9.
**Method note.** Three checks in a row said "healthy" — the REST API, the
WebSocket, and the frontend source all looked correct in isolation, because each
of them *was* correct. Only querying the live page's own chart object separated
"the data is missing" from "the data is off-screen". Screenshots alone were
actively misleading here: the stale time axis was read as a session gap.
### 2026-08-10 (later) — real-time ticks, and what to do about cold restarts
**The chart now moves between minute closes.** `CHART_FUTURES` emits a bar only
once its minute is over, so the chart stepped once a minute and sat still in
between — read, reasonably, as a dead feed. `LEVEL_ONE_FUTURES` carries real
trades on the same socket (`delayed: False`, verified on this account back in
M6), and it was never subscribed. It is now, and it builds a forming bar for the
current minute which the authoritative `CHART_FUTURES` bar then supersedes.
Three constraints shaped it, each of which would have caused a real bug:
- **Tick bars must never reach the aggregator.** It accumulates with
`current.v += incoming.v`, so re-sending the same forming minute would add its
volume into every higher timeframe on every update. `Runtime.on_bar` returns
early for `not bar.closed`: store the bar, set the price, broadcast, stop.
- **Ticks are throttled** (`SCHWAB_TICK_SECONDS`, default 1.0). /ES trades many
times a second and each emission costs a store write plus a broadcast to every
open socket. Setting it negative drops the Level 1 subscription entirely and
returns the source to closed bars only.
- **A tick for a minute already closed is dropped**, or a late trade would
overwrite a settled exchange bar with a partial one.
Alerts deliberately stay on closed bars. A level is judged on a settled bar, not
on a price that may not last the minute — and `on_bar` already gated on
`closed`, so this needed no change. Intra-bar alerting is a separate decision.
Bid-only Level 1 updates are skipped rather than carried forward: a bid is not a
trade and must not extend a candle's high or low. Verified live — 15 forming
bars and 2 closed bars in 100 seconds, and in a browser the candle's high and low
visibly extend within the minute.
**Cold restarts — the options, and a recommendation.** Every restart costs ~82
seconds of refused connections, re-seeds from Yahoo, and starts with empty alert
cooldowns, so a deploy can re-alert whatever price is sitting on.
1. *Make seeding non-quadratic.* Seeding replays every bar through `on_bar`, and
each daily-bar update rebuilds all five MA levels and diffs them. Bulk-load
the seeded bars and rebuild levels once at the end. Contained, testable, and
removes most of the 82 seconds. **Do this first** — it is the cheapest real
win and needs no new storage.
2. *Persist bars (M7, SQLite).* Restarts then seed only the gap. Removes the
Yahoo dependency from the startup path and shrinks the window further. This
is the durable answer, and the plan already scopes it.
3. *Persist alert cooldowns and armed state.* Independent of 1 and 2, and the
part that actually misbehaves rather than merely being slow: without it every
deploy re-alerts. Small table, big behavioural win.
4. *Serve before seeding finishes.* Start uvicorn immediately and seed in a
background task, so the port never refuses. The chart would open cold and
fill in, which is better than an unreachable page — but it changes what
"warm" means to every consumer of `/api/status`, so it wants its own thought.
Recommended order: 1, then 3, then 2. 4 only if the window still bites after 1.
**Stale bar events across a timeframe switch.** `Cannot update oldest data`
appeared in the console once ticks were live. Switching timeframe races: the
server answers `subscribe` with a fresh snapshot from one coroutine while
another is still draining bar events for the timeframe just left, so a 1m bar
can land after the 1h snapshot. Applied to the 1h series it is older than every
point in it, and Lightweight Charts throws rather than ignoring it — taking the
app down instead of dropping one bar. The race predates the tick feed; Level 1
made bar events ~15x more frequent, which is what surfaced it.
Guarded at both ends. `app.js` honours the `tf` the event already carries and
drops anything for a timeframe that is no longer selected. `chart.js` refuses a
bar older than the series' last point regardless of where it came from — a bar
behind the last one has nothing to contribute. Verified: 36 rapid timeframe
switches under a live tick feed produce zero errors, and calling
`candles.update()` directly with a stale bar still throws while the guarded
`updateBar()` does not.

33
main.py
View file

@ -3,16 +3,12 @@ import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
import re
from hashlib import sha256
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.meta import router as meta_router
from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
from app.api.ws import router as ws_router
from app.config import Settings
from app.runtime import Runtime
@ -38,35 +34,10 @@ app = FastAPI(title="chart", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router)
app.include_router(ws_router)
ASSET_REF = re.compile(r'((?:src|href)="/static/[^"?]+)"')
def asset_version() -> str:
"""A digest of the served assets, so the URL changes iff the content does.
StaticFiles sends an ETag but no Cache-Control, so a browser is free to keep
using the copy it already has — and a tab left open simply never fetches
again. That turned a fixed bug into a bug that still reproduced, because the
page was running the JavaScript it had loaded hours earlier.
Hashing rather than stamping mtimes: a deploy checks every file out fresh,
which would otherwise invalidate assets that never changed.
"""
digest = sha256()
for path in sorted(STATIC_DIR.glob("*.*")):
digest.update(path.read_bytes())
return digest.hexdigest()[:12]
@app.get("/")
def index():
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
html = ASSET_REF.sub(rf'\1?v={asset_version()}"', html)
# The document itself must never be cached, or the versioned URLs inside it
# are the stale thing instead.
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
return FileResponse(STATIC_DIR / "index.html")

View file

@ -2,5 +2,3 @@ fastapi
uvicorn[standard]
httpx
pydantic-settings
# Live futures stream; imported only when LIVE_SOURCE=schwab.
schwab-py

View file

@ -1,21 +1,11 @@
"""Replay Yahoo's available minute tape and report alerts per CME session.
Sweeps a range of thresholds in a single pass rather than testing only the
configured one: the useful question is where the alert rate crosses from silent
to noisy, which a single number cannot show.
Manual trendlines are deliberately excluded — they are user data, and a
threshold calibrated against one person's drawings would not transfer.
"""
"""Replay Yahoo's available minute tape and report alerts per CME session."""
import asyncio
from collections import Counter
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.analysis.horizontals import build_prior_day_levels
from app.analysis.indicators import atr
from app.analysis.moving_averages import build_ma_levels
from app.analysis.vwap import build_vwap_level
from app.bars.aggregator import Aggregator
from app.bars.models import Timeframe
from app.bars.session import bucket_start
@ -23,12 +13,6 @@ from app.bars.store import InMemoryBarStore
from app.config import Settings
from app.market.yahoo import YahooSource
THRESHOLDS = (12, 16, 20, 24, 28, 32, 40)
# Scores are sums of 12s and 16s, so the threshold is quantised and blunt:
# several values behave identically and then it falls to zero. Cooldown is the
# finer control over how often a zone price is chopping around repeats itself.
COOLDOWNS = (900, 1800, 3600, 7200, 14400)
async def main() -> None:
settings = Settings()
@ -42,71 +26,31 @@ async def main() -> None:
aggregator = Aggregator(settings.enabled_timeframes)
store = InMemoryBarStore(25_000)
# Keyed on (threshold, cooldown) so one replay pass measures both sweeps.
combos = [(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS]
combos += [
(settings.confluence_min_score, cooldown)
for cooldown in COOLDOWNS
if cooldown != settings.alert_cooldown_seconds
]
engines = {combo: AlertEngine(combo[0], combo[1]) for combo in combos}
counts: dict[tuple, Counter[int]] = {combo: Counter() for combo in combos}
ma_levels: list = []
levels = []
cutoff = minutes[0].t
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
for bar in aggregator.update(source_bar):
store.put(bar)
if settings.ma_sets.get(bar.tf):
ma_levels = build_ma_levels(
levels = build_ma_levels(
{tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
)
if bar.tf is not Timeframe.M1 or not bar.closed:
continue
atr_values = atr(store.get(Timeframe.M15), 14)
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0)
levels = (
ma_levels
+ build_prior_day_levels(store.get(Timeframe.D1), bar.c)
+ build_vwap_level(store.get(Timeframe.M1))
)
# Clustering is threshold-independent, so it is done once and the
# result fed to every engine.
clusters = cluster_levels(levels, bar.t, bar.c, atr15)
session = bucket_start(bar.t, Timeframe.D1)
for combo, engine in engines.items():
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
counts[combo][session] += len(alerts)
counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts)
sessions = sorted({session for counter in counts.values() for session in counter})
print(f"minute_bars={len(minutes)} sessions={len(sessions)}")
print(f"threshold={settings.confluence_min_score:g} minute_bars={len(minutes)}")
print("alerts/session:", ", ".join(str(value) for _, value in sorted(counts.items())))
print(f"total={sum(counts.values())} max_session={max(counts.values(), default=0)}")
def report(title: str, selected: list[tuple]) -> None:
print(f"\n{title}")
print(f"{'threshold':>9} {'cooldown':>9} {'total':>6} {'max/sess':>9} per-session")
for combo in selected:
per_session = [counts[combo][session] for session in sessions]
configured = combo == (settings.confluence_min_score, settings.alert_cooldown_seconds)
print(
f"{combo[0]:>9g} {combo[1]:>9} {sum(per_session):>6} "
f"{max(per_session, default=0):>9} "
f"{', '.join(str(value) for value in per_session)}"
f"{' <- configured' if configured else ''}"
)
report(
f"threshold sweep (cooldown={settings.alert_cooldown_seconds}s)",
[(threshold, settings.alert_cooldown_seconds) for threshold in THRESHOLDS],
)
report(
f"cooldown sweep (threshold={settings.confluence_min_score:g})",
sorted(
{(settings.confluence_min_score, cooldown) for cooldown in COOLDOWNS}
| {(settings.confluence_min_score, settings.alert_cooldown_seconds)},
key=lambda combo: combo[1],
),
)
settings = Settings()
engine = AlertEngine(settings.confluence_min_score, settings.alert_cooldown_seconds)
counts: Counter[int] = Counter()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,255 +0,0 @@
"""Find out what a Schwab app is actually entitled to, before building on it.
Answers the three questions that decide the design, empirically rather than
from documentation:
1. Do the credentials authenticate at all?
2. Do REST quotes work for /ES — futures market data is a separate
entitlement from equities and may not be granted.
3. Does the streamer connect? Its bootstrap reads /trader/v1/userPreference,
which belongs to the Accounts and Trading product, so an app registered
for Market Data Production alone is expected to fail there.
Two steps, neither of them interactive, so this works over a pipe or from an
agent session where stdin is not a terminal:
python3 -m scripts.check_schwab
prints the Schwab login URL.
python3 -m scripts.check_schwab --redirect-url 'https://.../api/qt?code=…'
exchanges the code, saves the token, runs the checks.
The browser does not have to be on this machine. Nothing is captured locally —
you copy a URL out, and paste a URL back. The authorisation code is single use
and expires within minutes, so do not leave it sitting between the two steps.
Once a token exists, running with no arguments skips straight to the checks.
"""
import argparse
import sys
from urllib.parse import parse_qs, urlparse
from app.config import Settings
def heading(text: str) -> None:
print(f"\n{text}\n{'-' * len(text)}")
def load_settings() -> Settings:
settings = Settings()
if not settings.schwab_api_key or not settings.schwab_app_secret:
raise SystemExit("Set SCHWAB_API_KEY and SCHWAB_APP_SECRET in .env first")
return settings
def key_is_live(authorization_url: str) -> bool:
"""Ask Schwab whether it recognises the app key, before opening a browser.
A key Schwab does not know produces `invalid_client` here — and so does a
deliberately invented one, byte for byte, so this cannot tell "wrong value"
from "not active yet". It can still save a confusing round trip through the
login page.
"""
import httpx
try:
response = httpx.get(authorization_url, follow_redirects=False, timeout=15)
except Exception:
return True # Network trouble is not evidence about the key.
return "invalid_client" not in response.text
def secret_is_valid(settings: Settings) -> bool | None:
"""Check the secret without needing an authorisation code.
The token endpoint authenticates the key and secret over HTTP Basic before
it looks at the grant, so a deliberately invalid code separates the two
failures: bad credentials give invalid_client, good credentials give
invalid_grant. Otherwise a truncated secret survives the login unnoticed and
only surfaces at the exchange, after the code has been spent.
None when the answer is not clear enough to act on.
"""
import httpx
try:
response = httpx.post(
"https://api.schwabapi.com/v1/oauth/token",
auth=(settings.schwab_api_key, settings.schwab_app_secret),
data={
"grant_type": "authorization_code",
"code": "deliberately-invalid-code",
"redirect_uri": settings.schwab_callback_url,
},
timeout=20,
)
except Exception:
return None
if "invalid_grant" in response.text:
return True
if "invalid_client" in response.text or response.status_code == 401:
return False
return None
def print_login_url(settings: Settings) -> None:
from schwab.auth import get_auth_context
context = get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
if secret_is_valid(settings) is False:
print("Schwab rejects the app key and secret pair.\n")
print("The key alone is accepted at the authorize endpoint, so this is")
print("the secret. Re-copy it from the portal using the Show icon —")
print("a value clipped by one character looks entirely normal.")
sys.exit(1)
if not key_is_live(context.authorization_url):
print("Schwab rejects this app key with invalid_client.\n")
print("The secret is not involved yet — it is only used when the code is")
print("exchanged — so this is the key itself or the app's readiness.\n")
print(" 1. Re-copy the App Key from the portal using the Show icon.")
print(" 2. If it matches, the app is most likely not live yet. Newly")
print(" created or newly edited apps take time to propagate, and the")
print(" portal says Ready For Use before the key works.")
print("\nRe-run this to check again; nothing else is needed.")
sys.exit(1)
print("Open this in any browser, on any machine, and approve the app:\n")
print(f" {context.authorization_url}\n")
print("You will land on the callback URL. A 404 there is fine until the")
print("branch is deployed — the code is in the address bar either way.")
print("Copy the ENTIRE address and run:\n")
print(" python3 -m scripts.check_schwab --redirect-url '<paste it here>'")
def ensure_token_path_writable(settings: Settings) -> None:
path = settings.schwab_token_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
probe = path.parent / f".{path.name}.probe"
probe.touch()
probe.unlink()
except OSError as error:
raise SystemExit(
f"Cannot write the token to {path} ({error.strerror}).\n"
f"Point SCHWAB_TOKEN_PATH at a directory you own and try again — "
f"the authorisation code is spent either way, so fix this first."
)
def exchange(settings: Settings, redirect_url: str):
from schwab import auth
from schwab.auth import AuthContext, client_from_received_url
state = parse_qs(urlparse(redirect_url).query).get("state", [None])[0]
if not state:
raise SystemExit("That URL has no ?state= — paste the full address you landed on")
# Checked before the exchange, not after. An authorisation code lives about
# thirty seconds and is single use, so discovering an unwritable token path
# afterwards costs a whole round trip through the browser — which is exactly
# what happened the first time, against a data/ directory owned by root
# because Docker created it through the bind mount.
ensure_token_path_writable(settings)
# The library's own writer, so the token file keeps the shape its loader
# expects rather than one guessed at here.
write_token = getattr(auth, "__make_update_token_func")(str(settings.schwab_token_path))
# Only the state is needed again; the authorisation URL is not, which is
# what lets the two steps share nothing. Taking the state from the pasted
# URL makes the CSRF check a formality — acceptable because the thing being
# guarded against is a redirect you did not initiate, and you pasted this
# one in by hand.
context = AuthContext(settings.schwab_callback_url, None, state)
return client_from_received_url(
settings.schwab_api_key,
settings.schwab_app_secret,
context,
redirect_url,
write_token,
)
def run_checks(client, settings: Settings) -> None:
heading(f"REST quote for {settings.schwab_symbol}")
quote = client.get_quote(settings.schwab_symbol)
print(f" HTTP {quote.status_code}")
payload = quote.json() if quote.status_code == 200 else {}
quotes_ok = False
if payload:
for symbol, data in list(payload.items())[:1]:
values = data.get("quote", {})
kind = data.get("assetMainType")
description = (data.get("reference") or {}).get("description")
print(f" {symbol}: {kind} — {description}")
print(f" last={values.get('lastPrice')} bid={values.get('bidPrice')} "
f"ask={values.get('askPrice')}")
# Schwab strips the leading slash and happily returns the equity of
# the same name: /ES comes back as Eversource Energy at 72. A 200
# with a body is not evidence of futures data, and treating it as
# such is worse than a clean failure.
quotes_ok = kind == "FUTURE"
if not quotes_ok:
print(" -> NOT futures. The slash was stripped and an equity")
print(" returned in its place; REST futures quotes are unavailable.")
else:
print(" -> futures market data IS available over REST")
else:
print(f" body: {quote.text[:200]}")
print(" -> no quote returned")
heading("Streamer bootstrap (/trader/v1/userPreference)")
prefs = client.get_user_preferences()
print(f" HTTP {prefs.status_code}")
streaming_ok = prefs.status_code == 200 and bool(prefs.json().get("streamerInfo"))
if streaming_ok:
print(f" streamerInfo entries: {len(prefs.json()['streamerInfo'])}")
print(" -> streaming is available; CHART_FUTURES should work")
else:
print(f" body: {prefs.text[:200]}")
print(" -> streaming is NOT available on this app")
heading("Summary")
print(f" Quotes : {'yes' if quotes_ok else 'no'}")
print(f" Streaming : {'yes' if streaming_ok else 'no'}")
if quotes_ok and not streaming_ok:
print("\n Polling REST quotes is then the real-time path: it removes")
print(" Yahoo's ten-minute delay without needing trading scope, at the")
print(" cost of building bars from snapshots rather than receiving")
print(" true exchange OHLCV.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--redirect-url", help="the full URL you were redirected to")
args = parser.parse_args()
settings = load_settings()
try:
from schwab.auth import client_from_token_file
except ImportError:
raise SystemExit("pip install -r requirements-dev.txt (schwab-py is not installed)")
if args.redirect_url:
heading("Exchanging the authorisation code")
client = exchange(settings, args.redirect_url)
print(f" token written to {settings.schwab_token_path}")
elif settings.schwab_token_path.exists():
heading("Authentication")
client = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
)
print(f" reused the token at {settings.schwab_token_path}")
else:
print_login_url(settings)
sys.exit(0)
run_checks(client, settings)
if __name__ == "__main__":
main()

View file

@ -1,98 +0,0 @@
"""Does the Schwab stream actually deliver /ES bars?
REST is already known not to: a quote for /ES comes back as Eversource Energy,
because Schwab strips the leading slash and resolves the equity of the same
name. Streaming is a separate entitlement with its own services, so it has to be
tested separately — and it is the only remaining route to real-time futures,
since price history does not cover them either.
Subscribes to both futures services for a short window and reports what arrives:
python3 -m scripts.check_stream [seconds] [symbol ...]
Symbols default to the continuous /ES and the front-month contract, because the
streamer may accept one and not the other — REST accepts neither.
CHART_FUTURES is the one that matters — it carries the minute OHLCV the chart is
built on. LEVEL_ONE_FUTURES is the fallback: quotes only, from which bars would
have to be synthesised.
"""
import asyncio
import sys
from app.config import Settings
received: dict[str, list] = {"chart": [], "quote": []}
async def main(seconds: float, symbols: list[str]) -> None:
from schwab.auth import client_from_token_file
from schwab.streaming import StreamClient
settings = Settings()
if not settings.schwab_token_path.exists():
raise SystemExit("No token yet — run: python3 -m scripts.check_schwab")
client = client_from_token_file(
str(settings.schwab_token_path),
settings.schwab_api_key,
settings.schwab_app_secret,
asyncio=True,
)
stream = StreamClient(client)
print("logging in to the streamer...")
await stream.login()
print(" logged in")
# Handlers must be registered before subscribing: several services start
# sending immediately, and messages with no handler are dropped.
stream.add_chart_futures_handler(lambda msg: received["chart"].append(msg))
stream.add_level_one_futures_handler(lambda msg: received["quote"].append(msg))
for name, subscribe in (
("CHART_FUTURES", stream.chart_futures_subs),
("LEVEL_ONE_FUTURES", stream.level_one_futures_subs),
):
try:
await subscribe(symbols)
print(f" subscribed to {name} for {', '.join(symbols)}")
except Exception as error:
print(f" {name} subscription REJECTED: {type(error).__name__}: {error}")
print(f"\nlistening for {seconds:g}s...")
try:
await asyncio.wait_for(_pump(stream), timeout=seconds)
except asyncio.TimeoutError:
pass
print("\nResults")
print("-------")
for label, key in (("CHART_FUTURES (minute OHLCV)", "chart"),
("LEVEL_ONE_FUTURES (quotes)", "quote")):
messages = received[key]
print(f" {label}: {len(messages)} message(s)")
if messages:
print(f" sample: {str(messages[0])[:300]}")
if received["chart"]:
print("\n -> CHART_FUTURES works. Real-time minute bars are available,")
print(" which removes Yahoo's ten-minute delay entirely.")
elif received["quote"]:
print("\n -> Only quotes arrived. Bars would have to be synthesised")
print(" from them: real-time, but highs and lows approximated.")
else:
print("\n -> Nothing arrived. Either futures market data is not")
print(" entitled on this account, or the market is closed.")
async def _pump(stream) -> None:
while True:
await stream.handle_message()
if __name__ == "__main__":
args = sys.argv[1:]
window = float(args[0]) if args else 45
wanted = args[1:] or ["/ES", "/ESU26"]
asyncio.run(main(window, wanted))

View file

@ -36,61 +36,29 @@ async function apiFetch(url, options = {}) {
const defaultPrefs = {
base_tf: '1m',
enabled: { ma: { '1d': [10, 20, 50, 100, 200] }, manual: true, auto: false, horizontal: true, vwap: true },
enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false },
hidden_levels_score: false,
};
// Stored preferences outlive the shape they were written in. Anything added to
// defaultPrefs later would otherwise be missing for every existing visitor —
// and a missing `enabled.ma` is a crash, not a cosmetic gap.
function mergePrefs(defaults, stored) {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return structuredClone(defaults);
const merged = structuredClone(defaults);
for (const [key, value] of Object.entries(stored)) {
if (!(key in merged)) continue;
const fallback = merged[key];
if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) {
merged[key] = mergePrefs(fallback, value);
} else if (Array.isArray(fallback)) {
if (Array.isArray(value)) merged[key] = value;
} else if (value !== null && typeof value !== 'object') {
merged[key] = value;
}
}
return merged;
}
function loadPrefs() {
try {
return mergePrefs(defaultPrefs, JSON.parse(localStorage.getItem('chart-layer-prefs')));
} catch {
return structuredClone(defaultPrefs);
}
}
createApp({
setup() {
const status = ref({ stream: 'disconnected', bars_held: {} });
const price = ref(null);
const prefs = ref(loadPrefs());
const storedPrefs = localStorage.getItem('chart-layer-prefs');
const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs));
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
const clusters = ref([]);
const alerts = ref([]);
let alertSequence = 0;
// Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawMode = ref(false);
const drawName = ref('');
const drawColor = ref('#65b7cf');
const drawWidth = ref(2);
const drawSide = ref('support');
const snap = ref(true);
const drawPoints = ref([]);
const selectedLine = ref(null);
const selectedLines = ref([]);
const alertPrice = ref(null);
const alertNote = ref('');
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const now = ref(Date.now());
let chartApi = null;
@ -129,33 +97,18 @@ createApp({
syncVisibleLevels();
price.value = message.price;
} else if (message.type === 'bar') {
// Switching timeframe races: the server answers `subscribe` with a
// fresh snapshot from one coroutine while another is still draining
// bar events for the timeframe just left. A 1m bar applied over a 1h
// series is older than everything in it, which the chart rejects
// outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return;
chartApi.updateBar(message.bar);
price.value = message.bar.c;
status.value.last_bar_t = message.bar.t;
} else if (message.type === 'levels') {
// A delta, not a replacement: VWAP changes every minute while the
// daily averages carry hundreds of points and change once a session.
const removed = new Set(message.removed || []);
const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level]));
for (const level of message.changed || []) byId.set(level.id, level);
levels.value = [...byId.values()];
levels.value = message.levels;
syncVisibleLevels();
} else if (message.type === 'clusters') {
clusters.value = message.clusters;
price.value = message.price;
} else if (message.type === 'alert') {
// Keyed on a counter, not the timestamp: two alerts inside the same
// second would collide and Vue would reuse the wrong row.
alerts.value = [
{ key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
...alerts.value,
].slice(0, 20);
alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message });
alerts.value = alerts.value.slice(0, 20);
playAlert();
}
};
@ -173,68 +126,59 @@ createApp({
};
}
// One context for the page, not one per alert. Browsers cap how many a
// document may hold (~6), after which alerts silently stop making a sound.
let audioContext = null;
function playAlert() {
const Context = window.AudioContext || window.webkitAudioContext;
if (!Context) return;
if (!audioContext) audioContext = new Context();
// Autoplay policy suspends a context created before any user gesture.
if (audioContext.state === 'suspended') audioContext.resume();
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const context = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.frequency.value = 740;
gain.gain.setValueAtTime(0.12, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35);
oscillator.connect(gain).connect(audioContext.destination);
gain.gain.setValueAtTime(0.12, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.35);
// Nodes are single-use; release them rather than letting them pile up.
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
oscillator.stop(context.currentTime + 0.35);
}
function armTool(tool) {
armedTool.value = armedTool.value === tool ? null : tool;
function toggleDraw() {
drawMode.value = !drawMode.value;
drawPoints.value = [];
selectedLine.value = null;
chartApi.armTool(armedTool.value);
chartApi.clearLinePreview();
}
function handleChartClick(param) {
function handleChartMove(param) {
if (!drawMode.value || drawPoints.value.length !== 1) return;
chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value);
}
async function handleChartClick(param) {
if (!drawMode.value) {
selectedLine.value = chartApi.hitTest(param);
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
}
async function handleToolComplete(result) {
// One placement per arming, so a tool cannot keep firing on stray drags.
armedTool.value = null;
chartApi.armTool(null);
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
return;
}
await createTrendline(result.start, result.end);
}
async function createTrendline(first, second) {
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
const side = start.snappedSide || end.snappedSide || drawSide.value;
const point = chartApi.pointFromClick(param, snap.value);
if (point.snappedSide) drawSide.value = point.snappedSide;
drawPoints.value.push(point);
if (drawPoints.value.length < 2) return;
chartApi.clearLinePreview();
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
if (start.t === end.t) { drawPoints.value = []; return; }
const temporaryId = `tmp_${Date.now()}`;
const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side,
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${side}`,
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value,
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.value}`,
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
color: drawColor.value, line_width: drawWidth.value,
};
levels.value.push(optimistic);
syncVisibleLevels();
drawPoints.value = [];
drawMode.value = false;
try {
const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tf: timeframe.value, side, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
@ -249,23 +193,6 @@ createApp({
syncVisibleLevels();
}
async function addPriceAlert() {
if (!alertPrice.value) return;
const response = await apiFetch('/api/lines/price', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
price: alertPrice.value, note: alertNote.value,
color: levelColor.value, line_width: levelWidth.value,
}),
});
if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; }
const saved = await response.json();
levels.value = [...levels.value.filter(level => level.id !== saved.id), saved];
alertPrice.value = null;
alertNote.value = '';
syncVisibleLevels();
}
async function deleteSelected() {
const ids = selectedLines.value.length ? selectedLines.value : [selectedLine.value].filter(Boolean);
await deleteLines(ids);
@ -312,10 +239,6 @@ createApp({
await deleteLines([...selectedLines.value]);
}
async function setArmed(line, armed) {
await updateLineStyle(line, { armed });
}
async function renameLine(line, name) {
await updateLineStyle(line, { note: name.trim() });
}
@ -356,16 +279,8 @@ createApp({
syncVisibleLevels();
}
// Backspace is a normal editing key inside a field. Without this guard,
// fixing a typo in a trendline's name deletes the trendline.
function isEditing(target) {
if (!target) return false;
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
}
function handleKeydown(event) {
if (isEditing(event.target)) return;
if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) {
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
event.preventDefault();
deleteSelected();
}
@ -382,8 +297,6 @@ createApp({
function enabled(level) {
if (level.kind === 'ma') return (prefs.value.enabled.ma[level.tf] || []).includes(level.period);
if (level.kind === 'manual') return prefs.value.enabled.manual;
if (level.kind === 'horizontal') return prefs.value.enabled.horizontal;
if (level.kind === 'vwap') return prefs.value.enabled.vwap;
return prefs.value.enabled.auto;
}
@ -397,15 +310,13 @@ createApp({
}
}
const MA_PERIODS = { '1d': [10, 20, 50, 100, 200] };
function allEnabled(tf) {
const available = MA_PERIODS[tf] || [];
const available = tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21];
return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period));
}
function toggleGroup(tf, checked) {
prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : [];
prefs.value.enabled.ma[tf] = checked ? (tf === '1d' ? [10, 20, 50, 100, 200] : [9, 21]) : [];
}
watch(prefs, () => {
@ -418,22 +329,13 @@ createApp({
if (chartApi) chartApi.setSelectedLine(id);
});
watch(snap, value => {
if (chartApi) chartApi.setSnap(value);
});
onMounted(() => {
chartApi = new ConfluenceChart();
// Deliberate debug handle. Chart geometry bugs are invisible from the
// outside — this is how the trendline slope was measured rather than
// guessed at: __chart.lineData(level) against timeToCoordinate().
window.__chart = chartApi;
chartApi.create(document.getElementById('chart'));
chartApi.setClickHandler(handleChartClick);
chartApi.setToolCompleteHandler(handleToolComplete);
chartApi.setMoveHandler(handleChartMove);
chartApi.setLineChangeHandler(updateLineGeometry);
chartApi.setLineEndHandler(endLineHere);
chartApi.setSnap(snap.value);
window.addEventListener('keydown', handleKeydown);
refreshStatus();
connect();
@ -446,6 +348,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed };
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle };
},
}).mount('#app');

View file

@ -4,11 +4,11 @@ class ConfluenceChart {
this.candles = null;
this.resizeObserver = null;
this.levelSeries = new Map();
this.priceLines = new Map();
this.previewLine = null;
this.bars = [];
this.levels = [];
this.onChartClick = null;
this.onChartMove = null;
this.tooltip = null;
this.chartEl = null;
this.clickListener = null;
@ -22,23 +22,6 @@ class ConfluenceChart {
this.contextCutoff = null;
this.contextListener = null;
this.onLineEnd = null;
// Tool arming: the sidebar decides what the next chart gesture creates.
this.armedTool = null;
this.gesture = null;
this.pendingAnchor = null;
this.onToolComplete = null;
this.snapToBars = true;
this.priceTag = null;
this.toolDownListener = null;
this.toolMoveListener = null;
this.toolUpListener = null;
this.pendingView = null;
}
static TICK = 0.25;
static snapToTick(price) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
}
create(el) {
@ -104,16 +87,6 @@ class ConfluenceChart {
this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.appendChild(endHere);
el.appendChild(this.contextMenu);
this.priceTag = document.createElement('div');
this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true;
el.appendChild(this.priceTag);
this.toolDownListener = event => this.startToolGesture(event);
this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event);
el.addEventListener('pointerdown', this.toolDownListener);
window.addEventListener('pointermove', this.toolMoveListener);
window.addEventListener('pointerup', this.toolUpListener);
this.anchorMoveListener = event => this.moveAnchor(event);
this.anchorUpListener = event => this.finishAnchorDrag(event);
window.addEventListener('pointermove', this.anchorMoveListener);
@ -122,9 +95,6 @@ class ConfluenceChart {
el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => {
this.hideContextMenu();
// A placement gesture ends in a click too; selecting on it would pick
// whatever the new line happens to overlap.
if (this.armedTool) return;
if (!this.onChartClick) return;
const bounds = el.getBoundingClientRect();
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
@ -138,6 +108,7 @@ class ConfluenceChart {
return;
}
this.updateLineTooltip(param);
if (this.onChartMove) this.onChartMove(param);
});
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
}
@ -145,126 +116,35 @@ class ConfluenceChart {
setBars(bars) {
this.bars = bars;
this.candles.setData(bars.map(this.toCandle));
// Anchored by time, not by logical index. A logical index addresses the
// chart's *shared* scale — the union of every series' time points — not
// this array. The daily MAs land straight after with hundreds of points
// pre-dating the 1m window, and prepending them shifts every logical index
// by that count, silently dragging the view ten hours off the live edge.
// A time range names the instant, so later series cannot move it.
if (bars.length) {
const last = bars[bars.length - 1];
// Keep the old five bars of right-hand breathing room, in seconds.
const step = bars.length > 1 ? last.t - bars[bars.length - 2].t : 60;
this.pendingView = {
from: bars[Math.max(0, bars.length - 160)].t,
to: last.t + step * 5,
};
this.chart.timeScale().setVisibleRange(this.pendingView);
}
this.chart.timeScale().setVisibleLogicalRange({
from: Math.max(0, bars.length - 160),
to: bars.length + 5,
});
requestAnimationFrame(() => this.renderAnchorHandles());
}
updateBar(bar) {
// update() throws on anything older than the series' last point, which
// takes the whole app down rather than dropping one stale bar. Ticks made
// this reachable often enough to matter, so refuse it here as well as at
// the source: a bar behind the last one has nothing to contribute.
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
this.candles.update(this.toCandle(bar));
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar);
this.renderAnchorHandles();
}
// A typed price alert is a manual line with zero slope. Treating it as flat
// here is what makes it render as a level rather than a stubby segment.
static isFlat(level) {
return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0);
}
// Flat levels are drawn as price lines rather than two-point series: they
// span the whole chart regardless of scroll and get a price-axis label.
syncPriceLines(levels) {
const flat = levels.filter(level => ConfluenceChart.isFlat(level) && !level.hidden);
const wanted = new Set(flat.map(level => level.id));
for (const [id, line] of this.priceLines) {
if (!wanted.has(id)) {
this.candles.removePriceLine(line);
this.priceLines.delete(id);
}
}
for (const level of flat) {
const options = {
price: level.anchor_p,
color: ConfluenceChart.levelColor(level),
lineWidth: level.line_width || 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true,
title: level.label,
};
const existing = this.priceLines.get(level.id);
if (existing) existing.applyOptions(options);
else this.priceLines.set(level.id, this.candles.createPriceLine(options));
}
}
// Level points are sampled on their own timeframe — VWAP every minute, the
// daily averages once a session — and every distinct timestamp claims its own
// slot on the chart's shared scale. Left raw, a minute-resolution VWAP spread
// 160 hourly candles across 908 slots and drew them as unreadable slivers.
// Snapping onto the candle grid preserves the line's shape while keeping the
// scale one slot per candle, which is what makes the bars their proper width.
snapPointsToBars(points) {
if (!this.bars.length || !points.length) return points;
const times = this.bars.map(bar => bar.t);
const last = times[times.length - 1];
const byTime = new Map();
for (const point of points) {
// Clamped, not passed through: on a daily chart every one of VWAP's ~760
// minute points falls after the last candle's session open, and letting
// them keep their own times put all 760 back on the scale. Projection to
// the right of the last bar is handled by the caller instead.
if (point.time >= last) {
byTime.set(last, point.value);
continue;
}
let lo = 0;
let hi = times.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (times[mid] < point.time) lo = mid + 1;
else hi = mid;
}
// Points older than the window collapse onto the first candle; the newest
// of them wins, which is the value in force when the window opens.
byTime.set(times[lo], point.value);
}
return [...byTime.entries()]
.sort((a, b) => a[0] - b[0])
.map(([time, value]) => ({ time, value }));
}
syncLevels(levels) {
this.levels = levels;
this.syncPriceLines(levels);
const drawn = levels.filter(level => !ConfluenceChart.isFlat(level));
const wanted = new Set(drawn.filter(level => !level.hidden).map(level => level.id));
const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id));
for (const [id, entry] of this.levelSeries) {
if (!wanted.has(id)) {
this.chart.removeSeries(entry.series);
this.levelSeries.delete(id);
}
}
for (const level of drawn) {
for (const level of levels) {
if (level.hidden) continue;
let entry = this.levelSeries.get(level.id);
// Both trace a series of points, but a higher-timeframe average holds its
// value between closes while VWAP moves continuously.
const hasPoints = level.kind === 'ma' || level.kind === 'vwap';
const isMa = level.kind === 'ma';
const options = {
color: ConfluenceChart.levelColor(level),
color: level.color || ConfluenceChart.tfColors[level.tf],
lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1),
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
@ -281,8 +161,8 @@ class ConfluenceChart {
entry.series.applyOptions(options);
}
let data;
if (hasPoints) {
data = this.snapPointsToBars((level.points || []).map(([time, value]) => ({ time, value })));
if (isMa) {
data = (level.points || []).map(([time, value]) => ({ time, value }));
const latestTime = this.bars[this.bars.length - 1]?.t;
const latestValue = data[data.length - 1]?.value;
if (latestTime != null && latestValue != null && latestTime > data[data.length - 1].time) {
@ -293,163 +173,13 @@ class ConfluenceChart {
}
entry.series.setData(data);
}
// Re-anchor once, after the level series have reshaped the scale. setBars
// runs before them, so the width it asked for was derived from the previous
// timeframe's point density and the chart holds that width as new series
// arrive. Consumed rather than reapplied every time: VWAP resyncs a level
// every minute, and re-anchoring on each would yank the view back from
// wherever the user had panned it.
if (this.pendingView) {
this.chart.timeScale().setVisibleRange(this.pendingView);
this.pendingView = null;
}
this.renderAnchorHandles();
}
// --- tool arming and placement gestures ---------------------------------
setToolCompleteHandler(handler) { this.onToolComplete = handler; }
setSnap(enabled) { this.snapToBars = enabled; }
/**
* Arm a tool so the next chart gesture places one. Panning is suspended
* while armed, otherwise the drag that draws a line also drags the chart out
* from under it.
*/
armTool(tool) {
this.armedTool = tool;
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null;
this.clearGesture();
this.clearLinePreview();
}
eventPoint(event) {
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left;
const y = event.clientY - bounds.top;
const price = this.candles.coordinateToPrice(y);
if (price == null) return null;
const time = this.chart.timeScale().coordinateToTime(x);
return { x, y, p: price, t: time == null ? null : Number(time) };
}
startToolGesture(event) {
if (!this.armedTool || event.button !== 0) return;
const point = this.eventPoint(event);
if (!point) return;
event.preventDefault();
this.gesture = { start: point, end: point };
this.chartEl.setPointerCapture?.(event.pointerId);
this.renderGesture();
}
moveToolGesture(event) {
if (!this.armedTool) return;
const point = this.eventPoint(event);
if (!point) return;
if (this.gesture) {
this.gesture.end = point;
this.renderGesture();
return;
}
// Waiting on the second click: rubber-band from the placed anchor.
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
}
renderPending(a, b) {
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
finishToolGesture(event) {
if (!this.armedTool || !this.gesture) return;
const tool = this.armedTool;
const { start, end } = this.gesture;
this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === 'level') {
// A click with no drag is a valid placement; the drag is only there to
// let you fine-tune the price before committing.
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) });
return;
}
// Both idioms are supported. Press-drag-release places a trendline in one
// gesture; a plain click sets the first anchor and the next click finishes
// it. Rejecting short gestures outright left the tool armed and silent, so
// the next click began a whole new line — which read as the line suddenly
// continuing at a different slope.
const dragged = Math.hypot(end.x - start.x, end.y - start.y) >= 5;
const a = this.snapPoint(dragged ? start : (this.pendingAnchor ?? end));
const b = this.snapPoint(end);
if (!dragged && !this.pendingAnchor) {
this.pendingAnchor = b;
this.renderPending(b, b);
return;
}
this.pendingAnchor = null;
this.clearLinePreview();
if (a.t == null || b.t == null || a.t === b.t) return;
this.onToolComplete?.({ tool, start: a, end: b });
}
/** Trendline anchors snap to bar extremes; levels snap to the tick grid. */
snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
const base = { t: fallbackT, p: point.p, snappedSide: null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
const nearest = this.bars.reduce(
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
);
const candidates = [
{ p: nearest.h, side: 'resistance' },
{ p: nearest.l, side: 'support' },
];
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return base;
}
renderGesture() {
if (!this.gesture) return;
const { start, end } = this.gesture;
if (this.armedTool === 'level') {
const price = ConfluenceChart.snapToTick(end.p);
const y = this.candles.priceToCoordinate(price);
if (y == null) return;
this.previewLine.removeAttribute('hidden');
this.previewLine.setAttribute('x1', 0);
this.previewLine.setAttribute('y1', y);
this.previewLine.setAttribute('x2', this.chartEl.clientWidth);
this.previewLine.setAttribute('y2', y);
this.previewLine.setAttribute('stroke', '#e0a34a');
this.previewLine.setAttribute('stroke-width', 2);
this.previewLine.setAttribute('stroke-dasharray', '6 4');
this.priceTag.hidden = false;
this.priceTag.textContent = price.toFixed(2);
this.priceTag.style.top = `${y}px`;
this.priceTag.style.left = `${Math.min(end.x + 14, this.chartEl.clientWidth - 70)}px`;
return;
}
const a = this.snapPoint(start);
const b = this.snapPoint(end);
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
clearGesture() {
this.gesture = null;
if (this.priceTag) this.priceTag.hidden = true;
// The pending anchor's rubber band must survive between the two clicks.
if (!this.pendingAnchor) this.clearLinePreview();
}
setClickHandler(handler) { this.onChartClick = handler; }
setMoveHandler(handler) { this.onChartMove = handler; }
setLineChangeHandler(handler) { this.onLineChange = handler; }
setLineEndHandler(handler) { this.onLineEnd = handler; }
@ -491,11 +221,25 @@ class ConfluenceChart {
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
}
pointFromClick(param, snap) {
const rawPrice = this.candles.coordinateToPrice(param.point.y);
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
if (!snap || !this.bars.length) return point;
const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best);
const candidates = [
{ p: nearest.h, side: 'resistance' },
{ p: nearest.l, side: 'support' },
];
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return point;
}
hitTest(param) {
let best = null;
// Flat levels draw as price lines, which have no series geometry to hit —
// they are selected and removed from the sidebar list instead.
for (const level of this.levels.filter(value => value.kind === 'manual' && !ConfluenceChart.isFlat(value))) {
for (const level of this.levels.filter(value => value.kind === 'manual')) {
const points = this.lineData(level).map(point => ({
x: this.chart.timeScale().timeToCoordinate(point.time),
y: this.candles.priceToCoordinate(point.value),
@ -512,61 +256,28 @@ class ConfluenceChart {
return best?.id || null;
}
/**
* Fractional index of a timestamp in the bar series. Mirrors index_at() in
* app/analysis/bar_space.py — the chart spaces bars evenly however much time
* separates them, so this is the space a straight line is straight in.
*/
indexAt(time) {
const bars = this.bars;
if (bars.length < 2) return 0;
if (time <= bars[0].t) {
const step = bars[1].t - bars[0].t;
return step ? (time - bars[0].t) / step : 0;
}
const last = bars.length - 1;
if (time >= bars[last].t) {
const step = bars[last].t - bars[last - 1].t;
return last + (step ? (time - bars[last].t) / step : 0);
}
let lo = 0;
let hi = last;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (bars[mid].t <= time) lo = mid; else hi = mid;
}
const span = bars[hi].t - bars[lo].t;
return lo + (span ? (time - bars[lo].t) / span : 0);
}
lineData(level) {
// Interpolated across bars, matching what the server scores the level at.
// Doing it per second instead kicked the line upward at every session gap,
// because a one-hour halt is one bar wide but an hour of slope.
const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
const times = [level.anchor_t, level.last_t];
const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t);
const points = [
{ time: level.anchor_t, value: level.anchor_p },
{ time: level.last_t, value: secondPrice },
];
const anchorIndex = this.bars.findIndex(bar => bar.t >= level.anchor_t);
const secondIndex = this.bars.findIndex(bar => bar.t >= level.last_t);
const cutoffIndex = level.cutoff_t == null
? -1
: this.bars.findIndex(bar => bar.t >= level.cutoff_t);
const target = cutoffIndex >= 0 ? this.bars[cutoffIndex] : this.bars[this.bars.length - 1];
if (target && target.t > level.last_t) times.push(target.t);
const startIndex = this.indexAt(level.anchor_t);
const span = this.indexAt(level.last_t) - startIndex;
return times
.filter((time, index) => index === 0 || time !== times[index - 1])
.map(time => ({
time,
value: span
? level.anchor_p + (endPrice - level.anchor_p) * (this.indexAt(time) - startIndex) / span
: level.anchor_p,
}));
const targetIndex = cutoffIndex >= 0 ? cutoffIndex : this.bars.length - 1;
if (anchorIndex >= 0 && secondIndex > anchorIndex && targetIndex > secondIndex) {
const value = level.anchor_p
+ (secondPrice - level.anchor_p) * (targetIndex - anchorIndex) / (secondIndex - anchorIndex);
points.push({ time: this.bars[targetIndex].t, value });
}
return points.filter((point, index) => index === 0 || point.time !== points[index - 1].time);
}
renderAnchorHandles() {
const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
);
const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual');
if (!level || !this.anchorHandles.length) {
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
return;
@ -583,7 +294,7 @@ class ConfluenceChart {
handle.removeAttribute('hidden');
handle.setAttribute('cx', x);
handle.setAttribute('cy', y);
handle.setAttribute('fill', ConfluenceChart.levelColor(level));
handle.setAttribute('fill', level.color || ConfluenceChart.tfColors[level.tf]);
});
}
@ -632,9 +343,7 @@ class ConfluenceChart {
}
showContextMenu(event) {
const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),
);
const level = this.levels.find(value => value.id === this.selectedLineId && value.kind === 'manual');
if (!level || !this.bars.length) return;
event.preventDefault();
const bounds = this.chartEl.getBoundingClientRect();
@ -679,23 +388,13 @@ class ConfluenceChart {
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener);
if (this.chart) this.chart.remove();
}
}
ConfluenceChart.tfColors = {
'1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b',
'30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
'30m':'#a8c85d', '1h':'#efb643', '4h':'#ec7b42', '1d':'#d96073',
};
// VWAP and the prior-day levels are both stamped 1d, so without their own
// colours they would be indistinguishable from the daily moving averages.
ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' };
ConfluenceChart.levelColor = level =>
level.color || ConfluenceChart.kindColors[level.kind] || ConfluenceChart.tfColors[level.tf];
window.ConfluenceChart = ConfluenceChart;

View file

@ -5,10 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ES Confluence</title>
<link rel="stylesheet" href="/static/style.css">
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
Pinned deliberately: an unpinned icon set is a silent redesign on someone
else's release. cdnjs 404s on this path — it does not carry 7.3.1. -->
<link rel="stylesheet" href="https://unpkg.com/@fortawesome/fontawesome-free@7.3.1/css/all.min.css">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
</head>
@ -24,71 +20,40 @@
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div>
<div class="drawing-tools">
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
<input class="line-name" v-model.trim="drawName" placeholder="Line name" aria-label="Trendline name">
<input type="color" v-model="drawColor" aria-label="New trendline color">
<select v-model.number="drawWidth" aria-label="New trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
<label><input type="checkbox" v-model="snap">Snap</label>
<button @click="deleteSelected" :disabled="!hasLineSelection">Delete</button>
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
<span v-else-if="selectedLine">Line selected</span>
</div>
<div id="chart"></div>
<div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span>
<span>LAST BAR <b>{{ barAge }}</b></span>
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
</div>
</section>
<aside>
<h2>Tools</h2>
<div class="tool" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
</button>
<div class="tool-body">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
<div class="row">
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div>
<div class="row">
<label>Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
</div>
</div>
</div>
<div class="tool" :class="{armed: armedTool === 'level'}">
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body">
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
<div class="row">
<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 [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div>
<form class="row price-row" @submit.prevent="addPriceAlert">
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div>
</div>
<details class="sidebar-section" open>
<summary>Layers</summary>
<h2>Layers</h2>
<div class="layer-group">
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
</div>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
<label><input type="checkbox" v-model="prefs.enabled.vwap"><span class="swatch vwap"></span>Session VWAP</label>
<div class="layer-group optional">
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
</div>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
</div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
</details>
<details class="sidebar-section" open>
<summary>Lines &amp; levels ({{ manualLines.length }})</summary>
<summary>Trendlines ({{ manualLines.length }})</summary>
<div class="trendline-actions" v-if="manualLines.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
@ -97,12 +62,7 @@
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
<span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<label class="armed-toggle" :class="{off: !line.armed}" @click.stop :title="line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
<input type="checkbox" :checked="line.armed" @change="setArmed(line, $event.target.checked)">
{{ line.armed ? 'armed' : 'tripped' }}
</label>
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
<div class="line-style-controls" @click.stop>
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
@ -122,7 +82,7 @@
</details>
<h2>Alert log</h2>
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
</aside>
</main>
</div>

View file

@ -19,36 +19,6 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
/* Tool palette: the head arms the tool, the body configures what it creates. */
.tool { border:1px solid var(--line); border-radius:7px; margin-bottom:8px; overflow:hidden; }
.tool.armed { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
.tool-head { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; font:inherit; font-size:12px;
background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; }
.tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); }
.tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
.tool.armed .tool-glyph { color:var(--accent); }
.tool-state { margin-left:auto; font-size:10px; color:var(--accent); }
.tool-body { padding:2px 10px 10px; display:grid; gap:7px; }
.tool-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
.tool-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
.tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.tool-body input[type=color] { padding:2px; height:26px; }
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.tool-body .check input { width:auto; }
.price-row { grid-template-columns:1fr auto; }
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
/* Armed tools take over the pointer, so the chart must not look draggable. */
#chart.armed { cursor:crosshair; }
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
.armed-toggle { display:flex; align-items:center; gap:4px; font-size:10px; color:var(--accent); cursor:pointer; }
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }

View file

@ -8,10 +8,6 @@ def level(id_: str, price: float, weight: float):
return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
def drawn_line(id_: str, price: float, label: str = "swing high", weight: float = 1):
return Level(id_, LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, weight, 1, label, 100, price, 0, None, 0, 100, 100, False, False)
def test_oscillation_fires_once_until_separation_and_cooldown():
engine = AlertEngine(min_score=6, cooldown_seconds=900)
levels = [level("a", 100, 3), level("b", 100.1, 4)]
@ -30,71 +26,3 @@ def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
engine = AlertEngine(min_score=28)
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
def test_a_third_level_joining_the_zone_does_not_re_alert():
# Membership churns constantly as levels drift in and out of tolerance.
# Suppression is by proximity precisely so this reads as one zone.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
two = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert len(engine.evaluate(two, 100, 1, 0, "/ES")) == 1
three = cluster_levels(
[level("a", 100, 3), level("b", 100.1, 4), level("c", 100.2, 5)], 100, 100, 1
)
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
# would mean a line you deliberately drew could never alert.
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
assert len(clusters) == 1, "a lone drawn line must survive clustering"
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
assert len(alerts) == 1
assert "LINE" in alerts[0].message
assert "swing high" in alerts[0].message
def test_a_lone_weak_non_drawn_level_still_does_not_alert():
# The bypass is for drawn lines only; a lone 5m average stays quiet.
weak = level("ma", 100, 1)
weak.tf = Timeframe.M5
assert cluster_levels([weak], 100, 100, 1) == []
def test_drawn_line_clustering_with_levels_reports_as_a_zone():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([drawn_line("ml_1", 100), level("pd", 100.1, 16)], 100, 100, 1)
alerts = engine.evaluate(clusters, 100, 1, 0, "/ES")
assert len(alerts) == 1
assert "ZONE" in alerts[0].message
assert "swing high" in alerts[0].message # the line is still named
def test_price_crossing_a_level_does_not_re_alert_on_the_side_flip():
# Side is positional, so a level sitting at price flips between support and
# resistance on every tick across it. This produced a fresh alert per
# crossing — four in two minutes when first tried against a live line.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
line = [drawn_line("ml_1", 100, weight=8)]
below = cluster_levels(line, 100, 99.9, 1) # level above price -> resistance
assert len(engine.evaluate(below, 99.9, 1, 0, "/ES")) == 1
above = cluster_levels(line, 100, 100.1, 1) # price crossed -> now support
assert above and above[0].side is not below[0].side, "the flip must actually occur"
assert engine.evaluate(above, 100.1, 1, 30, "/ES") == []
def test_a_genuinely_separate_zone_still_alerts_during_cooldown():
# The cooldown is per zone, not global: a level far away is new information.
engine = AlertEngine(min_score=6, cooldown_seconds=900)
near = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert len(engine.evaluate(near, 100, 1, 0, "/ES")) == 1
far = cluster_levels([level("c", 120, 3), level("d", 120.1, 4)], 100, 120, 1)
assert len(engine.evaluate(far, 120, 1, 60, "/ES")) == 1

View file

@ -1,32 +0,0 @@
import re
from fastapi.testclient import TestClient
import main
def test_assets_are_versioned_and_the_page_is_not_cached():
# Not a context manager: that would run the lifespan, which seeds months of
# history from Yahoo before serving anything.
response = TestClient(main.app).get("/")
assert response.headers["cache-control"] == "no-store"
refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', response.text)
assert refs, "no static assets referenced"
# Without this a tab left open keeps running the JavaScript it first loaded,
# which made a fixed bug look like it was still broken.
assert all("?v=" in ref for ref in refs), refs
def test_version_tracks_content_not_timestamps(tmp_path, monkeypatch):
asset = tmp_path / "app.js"
asset.write_text("//", encoding="utf-8")
monkeypatch.setattr(main, "STATIC_DIR", tmp_path)
first = main.asset_version()
asset.touch()
# A deploy checks every file out fresh; unchanged assets must stay cached.
assert main.asset_version() == first
asset.write_text("// changed", encoding="utf-8")
assert main.asset_version() != first

View file

@ -5,7 +5,6 @@ from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router
from app.api.routes import router as api_router
from app.api.schwab_auth import router as schwab_auth_router
from app.api.ws import router as ws_router
from app.config import Settings
from app.runtime import Runtime
@ -20,7 +19,6 @@ def client(tmp_path):
)
app = FastAPI()
app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router)
app.include_router(ws_router)
app.state.runtime = Runtime(settings)
@ -33,29 +31,6 @@ def test_open_when_no_token_configured(client):
assert client("").get("/api/bars").status_code == 200
def test_oauth_callback_stays_open_when_a_token_is_set():
# The provider redirects a browser here and cannot attach the chart token.
# A 401 would break the login flow at its last step, on production only,
# where CHART_AUTH_TOKEN is the one thing that differs from local.
from fastapi import FastAPI as _FastAPI
from app.config import Settings as _Settings
from app.runtime import Runtime as _Runtime
import tempfile, pathlib as _pathlib
with tempfile.TemporaryDirectory() as tmp:
app = _FastAPI()
app.include_router(meta_router)
app.include_router(schwab_auth_router)
app.include_router(api_router)
app.state.runtime = _Runtime(
_Settings(chart_auth_token="s3cret",
manual_lines_path=_pathlib.Path(tmp) / "manual_lines.json")
)
probe = TestClient(app)
assert probe.get("/api/qt").status_code == 200
assert probe.get("/api/status").status_code == 401
def test_rejects_missing_token(client):
assert client("s3cret").get("/api/bars").status_code == 401

View file

@ -1,45 +0,0 @@
from app.analysis.bar_space import index_at, price_in_bar_space
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
MINUTE = 60
def sloped(anchor_t: int, anchor_p: float, last_t: int, last_p: float) -> Level:
slope = (last_p - anchor_p) / (last_t - anchor_t)
return Level(
"ml", LevelKind.MANUAL, Timeframe.M1, Side.SUPPORT, 1, 1, "line",
anchor_t, anchor_p, slope, None, 0, anchor_t, last_t, False, False,
)
def test_index_is_linear_when_bars_are_evenly_spaced():
times = [i * MINUTE for i in range(10)]
assert index_at(times, 0) == 0
assert index_at(times, 5 * MINUTE) == 5
assert index_at(times, int(2.5 * MINUTE)) == 2.5
def test_a_gap_costs_one_index_however_long_it_is():
# Two bars either side of a weekend are adjacent on screen.
times = [0, MINUTE, MINUTE + 49 * 3600, MINUTE + 49 * 3600 + MINUTE]
assert index_at(times, MINUTE) == 1
assert index_at(times, MINUTE + 49 * 3600) == 2
def test_line_stays_straight_in_bar_space_across_a_gap():
# Bars: ten minutes, a 49-hour weekend, then ten more.
weekend = 49 * 3600
times = [i * MINUTE for i in range(10)] + [10 * MINUTE + weekend + i * MINUTE for i in range(10)]
level = sloped(times[0], 100.0, times[9], 109.0) # 1 point per bar
# Extending nine further bars must add nine more points, gap notwithstanding.
assert price_in_bar_space(level, times, times[18]) == 118.0
# Clock-based pricing would have run away during the halt.
assert level.price_at(times[18]) > 3000
def test_flat_anchors_return_the_anchor_price():
times = [i * MINUTE for i in range(5)]
level = sloped(0, 100.0, MINUTE, 100.0)
assert price_in_bar_space(level, times, times[4]) == 100.0

View file

@ -37,25 +37,3 @@ def test_level_ended_before_current_time_is_excluded():
ended = level("ended", 98, 12, Timeframe.D1)
ended.cutoff_t = 150
assert cluster_levels([ended], 200, 100, 1) == []
def test_cluster_members_omit_point_history():
# Clusters are re-sent on every closed 1m bar. A moving average's point
# history is hundreds of entries, so embedding whole levels here shipped the
# entire levels payload once a minute.
heavy = level("ma", 98, 12, Timeframe.D1)
heavy.points = [(t, 1.0) for t in range(600)]
payload = cluster_levels([heavy], 200, 100, 1)[0].to_dict()
assert payload["members"] == [
{
"id": "ma",
"kind": "ma",
"tf": "1d",
"side": "resistance",
"weight": 12,
"label": "ma",
}
]
assert "points" not in payload["members"][0]

View file

@ -1,40 +0,0 @@
from app.analysis.horizontals import build_prior_day_levels
from app.bars.models import Bar, Timeframe
def daily(t: int, o: float, h: float, low: float, c: float, closed: bool = True) -> Bar:
return Bar(Timeframe.D1, t, o, h, low, c, 1000, closed, "ES=F", "test")
def test_prior_day_uses_last_closed_session_not_the_forming_one():
bars = [
daily(1, 100, 110, 90, 105),
daily(2, 105, 120, 100, 118),
daily(3, 118, 125, 117, 124, closed=False),
]
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=119)}
# The forming session's 125 high must not become "prior day high" mid-session.
assert levels["pd:high"].anchor_p == 120
assert levels["pd:low"].anchor_p == 100
assert levels["pd:close"].anchor_p == 118
def test_side_is_positional_against_current_price():
bars = [daily(1, 100, 110, 90, 105)]
levels = {level.id: level for level in build_prior_day_levels(bars, current_price=100)}
assert levels["pd:high"].side.value == "resistance"
assert levels["pd:low"].side.value == "support"
def test_prior_day_carries_full_daily_weight_not_the_average_discount():
levels = build_prior_day_levels([daily(1, 100, 110, 90, 105)], current_price=100)
# Traded structure, not a derived average, so no 0.75 factor.
assert all(level.weight == 16 for level in levels)
def test_no_closed_session_yields_nothing():
assert build_prior_day_levels([daily(1, 100, 110, 90, 105, closed=False)], 100) == []

View file

@ -6,7 +6,7 @@ from app.bars.models import Timeframe
def sample_line():
return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
def test_json_persistence_round_trip(tmp_path):
@ -23,12 +23,12 @@ def test_json_persistence_round_trip(tmp_path):
assert ManualLineStore(path).lines == {}
def test_hourly_line_uses_absolute_time_on_one_minute_chart():
def test_four_hour_line_uses_absolute_time_on_one_minute_chart():
level = sample_line().to_level()
instant = 160
assert level.tf is Timeframe.H1
assert level.tf is Timeframe.H4
assert level.price_at(instant) == 4999.4
assert level.weight == 4
assert level.weight == 8
def test_manual_line_raises_existing_ma_cluster_score():
@ -39,4 +39,4 @@ def test_manual_line_raises_existing_ma_cluster_score():
before = cluster_levels([ma], 160, 4998, 2)[0]
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12
assert after.score == 16
assert after.score == 20

View file

@ -1,45 +0,0 @@
from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
from app.bars.models import Timeframe
def price_alert(price: float, note: str = "") -> ManualLine:
return ManualLine(
"ml_price", Timeframe.D1, Side.RESISTANCE, 1000, price, 0.0, 4600, 1000, note=note
)
def test_a_typed_level_holds_its_price_at_any_time():
level = price_alert(7800).to_level()
assert level.price_at(0) == 7800
assert level.price_at(10**9) == 7800
def test_unlabelled_alert_is_named_by_its_price():
# "1d resistance" tells you nothing about which alert fired.
assert price_alert(7800).to_level().label == "@ 7800.00"
assert price_alert(7800, note="gap fill").to_level().label == "gap fill"
def test_typed_level_alerts_regardless_of_confluence_score():
engine = AlertEngine(min_score=28, cooldown_seconds=900)
clusters = cluster_levels([price_alert(7800, "gap fill").to_level()], 5000, 7800, 1)
assert len(clusters) == 1
alerts = engine.evaluate(clusters, 7800, 1, 5000, "/ES")
assert len(alerts) == 1
assert "gap fill" in alerts[0].message
def test_price_alerts_survive_the_json_round_trip(tmp_path):
from app.analysis.manual_lines import ManualLineStore
store = ManualLineStore(tmp_path / "manual_lines.json")
store.add(price_alert(7800, "gap fill"))
reloaded = ManualLineStore(tmp_path / "manual_lines.json").lines["ml_price"]
assert reloaded.slope == 0.0
assert reloaded.horizontal
assert reloaded.anchor_p == 7800

View file

@ -1,74 +0,0 @@
import asyncio
import pytest
from app.analysis.alerts import Alert
from app.analysis.confluence import Cluster
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
from app.config import Settings
from app.runtime import Runtime
def runtime(tmp_path, **overrides) -> Runtime:
settings = Settings(
manual_lines_path=tmp_path / "manual_lines.json",
ntfy_topic=overrides.pop("ntfy_topic", ""),
**overrides,
)
return Runtime(settings)
def alert() -> Alert:
level = Level(
"pd:high", LevelKind.HORIZONTAL, Timeframe.D1, Side.RESISTANCE, 16, 1, "PDH",
100, 5000, 0, None, 0, 100, 100, False, False,
)
cluster = Cluster("cl_x", Side.RESISTANCE, 5000, 5000, 5000, 16, [level], 1.0)
return Alert(cluster, "BEARISH ZONE /ES")
def test_one_engine_serves_every_connection(tmp_path):
# Previously each WebSocket built its own engine, so reloading the page
# cleared the cooldown and the same zone alerted again immediately.
instance = runtime(tmp_path)
assert instance.alert_engine is not None
assert instance.alert_engine.min_score == instance.settings.confluence_min_score
def test_alerts_reach_subscribers(tmp_path):
instance = runtime(tmp_path)
queue: asyncio.Queue = asyncio.Queue(maxsize=10)
instance.subscribers.add(queue)
async def scenario():
instance.dispatch_alerts([alert()])
return queue.get_nowait()
event = asyncio.run(scenario())
assert event["type"] == "alert"
assert event["message"] == "BEARISH ZONE /ES"
def test_ntfy_failure_does_not_propagate(tmp_path, monkeypatch):
# send_ntfy used to be awaited inside the WebSocket loop, whose except
# clause only caught disconnects — so a push outage killed the connection.
instance = runtime(tmp_path, ntfy_topic="chart-test")
async def explode(*args, **kwargs):
raise RuntimeError("ntfy is down")
monkeypatch.setattr("app.runtime.send_ntfy", explode)
asyncio.run(instance.notify("anything"))
def test_blank_topic_sends_nothing(tmp_path, monkeypatch):
instance = runtime(tmp_path)
calls = []
async def record(server, topic, message):
calls.append(topic)
monkeypatch.setattr("app.runtime.send_ntfy", record)
asyncio.run(instance.notify("anything"))
assert calls == [""] # send_ntfy itself is the one that short-circuits

View file

@ -1,41 +0,0 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.schwab_auth import router
def client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_callback_needs_no_chart_token():
# Schwab redirects a browser here and cannot attach the token, so this
# endpoint has to stay open the way /health and /version do.
assert client().get("/api/qt").status_code == 200
def test_page_does_not_name_the_brokerage():
# The path is neutral so the host does not advertise who it trades with;
# the page saying it anyway would defeat that.
assert "chwab" not in client().get("/api/qt").text
def test_landing_here_directly_explains_itself():
body = client().get("/api/qt").text
assert "Register this exact URL" in body
assert "code" not in body.split("<style>")[0]
def test_authorisation_code_is_echoed_for_the_manual_flow():
response = client().get("/api/qt", params={"code": "abc123", "session": "s"})
assert "abc123" in response.text
assert response.headers["cache-control"] == "no-store"
def test_the_code_is_not_retained_for_a_later_visitor():
session = client()
session.get("/api/qt", params={"code": "secret-code"})
# A second, code-less request must not replay the first one's code.
assert "secret-code" not in session.get("/api/qt").text

View file

@ -1,253 +0,0 @@
import asyncio
import pytest
from app.bars.models import Timeframe
from app.config import Settings
from app.market.factory import live_source, seed_source
from app.market.schwab import SchwabSource, parse_chart_futures, parse_level_one
# Shape taken from a live CHART_FUTURES message, not invented.
LIVE_MESSAGE = {
"service": "CHART_FUTURES",
"timestamp": 1786356976793,
"command": "SUBS",
"content": [
{
"seq": 50,
"key": "/ES",
"CHART_TIME_MILLIS": 1786356900000,
"OPEN_PRICE": 7786.75,
"HIGH_PRICE": 7787,
"LOW_PRICE": 7786.5,
"CLOSE_PRICE": 7787,
"VOLUME": 107,
}
],
}
def test_parses_a_live_chart_futures_message():
bar = parse_chart_futures(LIVE_MESSAGE, "/ES")[0]
assert bar.tf is Timeframe.M1
assert bar.t == 1786356900 # milliseconds down to seconds
assert (bar.o, bar.h, bar.l, bar.c) == (7786.75, 7787.0, 7786.5, 7787.0)
assert bar.v == 107
assert bar.symbol == "/ES"
assert bar.source == "schwab"
# The minute has elapsed by the time the message arrives.
assert bar.closed is True
def test_incomplete_content_is_skipped_not_defaulted():
# A bar invented from partial data is indistinguishable downstream from a
# real one, which is worse than having no bar.
for missing in ("CHART_TIME_MILLIS", "OPEN_PRICE", "CLOSE_PRICE"):
content = dict(LIVE_MESSAGE["content"][0])
del content[missing]
assert parse_chart_futures({"content": [content]}, "/ES") == []
def test_schwab_offers_no_history():
source = SchwabSource(Settings())
assert source.supports_history() is False
assert source.supports_stream() is True
assert asyncio.run(source.history("/ES", Timeframe.M1, None, None)) == []
def test_seeding_stays_on_yahoo_even_when_live_is_schwab(tmp_path):
# Schwab has no history, so the pairing is the intended configuration
# rather than a fallback.
settings = Settings(
live_source="schwab",
seed_source="schwab",
manual_lines_path=tmp_path / "lines.json",
)
assert seed_source(settings).name == "yahoo"
assert live_source(settings).name == "schwab"
def test_live_symbol_follows_the_live_source():
# Yahoo says ES=F where Schwab says /ES; seeding always uses the Yahoo one.
assert Settings(live_source="yahoo").live_symbol == "ES=F"
assert Settings(live_source="schwab").live_symbol == "/ES"
def test_stream_yields_bars_from_the_socket():
class FakeStreamClient:
def __init__(self):
self.handler = None
self.subscribed = []
self.quote_handler = None
self.quote_subscribed = []
async def login(self):
return None
def add_chart_futures_handler(self, handler):
self.handler = handler
async def chart_futures_subs(self, symbols):
self.subscribed = list(symbols)
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
self.quote_subscribed = list(symbols)
async def handle_message(self):
# One message, then idle rather than returning — a real socket
# never stops on its own.
if self.handler:
self.handler(LIVE_MESSAGE)
self.handler = None
await asyncio.sleep(3600)
fake = FakeStreamClient()
source = SchwabSource(Settings(), stream_client_factory=lambda: fake)
async def first_bar():
async for bar in source.stream("/ES"):
return bar
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
assert bar.c == 7787.0
assert fake.subscribed == ["/ES"]
def test_socket_failure_surfaces_rather_than_hanging():
class ExplodingStreamClient:
async def login(self):
return None
def add_chart_futures_handler(self, handler):
pass
def add_level_one_futures_handler(self, handler):
pass
async def level_one_futures_subs(self, symbols):
return None
async def chart_futures_subs(self, symbols):
pass
async def handle_message(self):
raise RuntimeError("socket closed")
source = SchwabSource(Settings(), stream_client_factory=ExplodingStreamClient)
async def drain():
async for _ in source.stream("/ES"):
pass
with pytest.raises(RuntimeError, match="socket closed"):
asyncio.run(asyncio.wait_for(drain(), timeout=15))
# Shape taken from a live LEVEL_ONE_FUTURES message.
QUOTE_MESSAGE = {
"service": "LEVEL_ONE_FUTURES",
"command": "SUBS",
"content": [
{"key": "/ES", "LAST_PRICE": 7786.25, "LAST_SIZE": 3, "TRADE_TIME_MILLIS": 1786356930000}
],
}
def test_parses_a_level_one_trade():
assert parse_level_one(QUOTE_MESSAGE) == [(1786356930000, 7786.25, 3)]
def test_quotes_without_a_trade_are_skipped():
# A bid-only update is not a trade and must not extend a candle's range.
bid_only = {"content": [{"key": "/ES", "BID_PRICE": 7786.0, "ASK_PRICE": 7786.5}]}
assert parse_level_one(bid_only) == []
def test_ticks_build_an_unclosed_bar_for_the_current_minute():
class QuotingClient:
def __init__(self):
self.quote_handler = None
async def login(self):
return None
def add_chart_futures_handler(self, handler):
pass
async def chart_futures_subs(self, symbols):
return None
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
return None
async def handle_message(self):
if self.quote_handler:
self.quote_handler(QUOTE_MESSAGE)
self.quote_handler = None
await asyncio.sleep(3600)
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=QuotingClient)
async def first_bar():
async for bar in source.stream("/ES"):
return bar
bar = asyncio.run(asyncio.wait_for(first_bar(), timeout=10))
# Bucketed to its minute, and explicitly not closed — the minute is still
# running, and a closed flag would let it into the aggregator.
assert bar.t == 1786356900
assert bar.closed is False
assert (bar.o, bar.h, bar.l, bar.c) == (7786.25, 7786.25, 7786.25, 7786.25)
def test_a_tick_for_an_already_closed_minute_is_ignored():
# CHART_FUTURES is authoritative. A late tick for a minute it has already
# settled would otherwise overwrite a real bar with a partial one.
class LateTickClient:
def __init__(self):
self.chart_handler = None
self.quote_handler = None
async def login(self):
return None
def add_chart_futures_handler(self, handler):
self.chart_handler = handler
async def chart_futures_subs(self, symbols):
return None
def add_level_one_futures_handler(self, handler):
self.quote_handler = handler
async def level_one_futures_subs(self, symbols):
return None
async def handle_message(self):
if self.chart_handler:
self.chart_handler(LIVE_MESSAGE) # closes 1786356900
self.quote_handler(QUOTE_MESSAGE) # tick inside it
self.chart_handler = None
await asyncio.sleep(3600)
source = SchwabSource(Settings(schwab_tick_seconds=0), stream_client_factory=LateTickClient)
async def two_bars():
seen = []
async for bar in source.stream("/ES"):
seen.append(bar)
if len(seen) == 1:
# Give the late tick a chance to be wrongly emitted.
await asyncio.sleep(0.2)
break
return seen
seen = asyncio.run(asyncio.wait_for(two_bars(), timeout=10))
assert [bar.closed for bar in seen] == [True]

View file

@ -22,6 +22,9 @@ def epoch(value: str, zone=UTC) -> int:
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
("2026-08-10T01:59:00", Timeframe.H4, "2026-08-09T22:00:00"),
("2026-08-10T02:00:00", Timeframe.H4, "2026-08-10T02:00:00"),
("2026-08-10T17:59:00", Timeframe.H4, "2026-08-10T14:00:00"),
],
)
def test_session_boundaries(value, tf, expected):
@ -34,6 +37,21 @@ def test_intraday_buckets_use_utc_boundaries():
)
@pytest.mark.parametrize(
("value", "expected"),
[
# Spring forward: the 22:00 ET bucket ends at 02:00 EDT after three real hours.
("2026-03-08T06:59:00", "2026-03-08T03:00:00"),
("2026-03-08T07:00:00", "2026-03-08T07:00:00"),
# Fall back: the 22:00 ET bucket lasts five real hours and ends at 02:00 EST.
("2026-11-01T06:59:00", "2026-11-01T02:00:00"),
("2026-11-01T07:00:00", "2026-11-01T07:00:00"),
],
)
def test_four_hour_wall_clock_anchor_across_dst(value, expected):
assert bucket_start(epoch(value), Timeframe.H4) == epoch(expected)
@pytest.mark.parametrize(
("local_value", "expected_local"),
[

View file

@ -1,57 +0,0 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from app.analysis.vwap import build_vwap_level
from app.bars.models import Bar, Timeframe
EASTERN = ZoneInfo("America/New_York")
def at(year: int, month: int, day: int, hour: int, minute: int = 0) -> int:
return int(datetime(year, month, day, hour, minute, tzinfo=EASTERN).timestamp())
def minute(t: int, price: float, volume: int) -> Bar:
return Bar(Timeframe.M1, t, price, price, price, price, volume, True, "ES=F", "test")
def test_vwap_is_volume_weighted_not_a_simple_mean():
bars = [minute(at(2026, 8, 10, 19), 100, 1), minute(at(2026, 8, 10, 20), 200, 3)]
level = build_vwap_level(bars)[0]
assert level.anchor_p == (100 * 1 + 200 * 3) / 4 # 175, not 150
def test_prior_session_bars_are_excluded():
bars = [
# Before Monday's 18:00 open, so part of the previous session.
minute(at(2026, 8, 10, 17), 500, 10),
minute(at(2026, 8, 10, 19), 100, 1),
minute(at(2026, 8, 10, 20), 200, 1),
]
level = build_vwap_level(bars)[0]
assert level.anchor_p == 150
assert level.first_t == at(2026, 8, 10, 19)
def test_zero_volume_minutes_do_not_divide_by_zero():
bars = [minute(at(2026, 8, 10, 19), 100, 0), minute(at(2026, 8, 10, 20), 200, 2)]
level = build_vwap_level(bars)[0]
assert level.anchor_p == 200
# The zero-volume minute contributes no point rather than a NaN.
assert len(level.points) == 1
def test_no_volume_at_all_yields_no_level():
assert build_vwap_level([minute(at(2026, 8, 10, 19), 100, 0)]) == []
def test_side_tracks_price_relative_to_vwap():
bars = [minute(at(2026, 8, 10, 19), 100, 1), minute(at(2026, 8, 10, 20), 200, 1)]
# Last close 200 sits above VWAP 150, so VWAP is support beneath price.
assert build_vwap_level(bars)[0].side.value == "support"