Compare commits
No commits in common. "a13a54bc3f207f9e04007de3848d00b078a67ba6" and "821f0a0a8f07da39314d65ce164c9efd93910b07" have entirely different histories.
a13a54bc3f
...
821f0a0a8f
43 changed files with 278 additions and 2943 deletions
16
.env.example
16
.env.example
|
|
@ -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
144
README.md
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
143
app/runtime.py
143
app/runtime.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
33
main.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -2,5 +2,3 @@ fastapi
|
|||
uvicorn[standard]
|
||||
httpx
|
||||
pydantic-settings
|
||||
# Live futures stream; imported only when LIVE_SOURCE=schwab.
|
||||
schwab-py
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
184
static/app.js
184
static/app.js
|
|
@ -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');
|
||||
|
|
|
|||
397
static/chart.js
397
static/chart.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in a new issue