Commit graph

18 commits

Author SHA1 Message Date
2efcec6a76 Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs
The trendline bug: a line continued past its second anchor at a different
slope. Two conventions were fighting, and both were wrong.

Lightweight Charts spaces bars evenly however much time separates them — a
weekend is forty-nine hours and one bar wide. The renderer extended the line by
interpolating between bar indices, which looked straight but disagreed with the
server, since price_at() advances per second. Measured on real bars that reached
147 points: the chart drew a level the alerts did not believe in. Making the
renderer match price_at() fixed the disagreement and made the visible kick worse,
because now the line really did climb an hour's worth of slope across a one-bar
maintenance break.

Neither convention is what a person means by drawing a line. A trendline advances
per bar, so both sides now evaluate in bar space: a new bar_space module the
runtime uses to position sloped levels, mirrored by indexAt() in the chart. The
line is straight on screen and the alert fires where it is drawn.

Also, from testing against the live chart:

- A plain click with the trendline tool armed did nothing and left the tool
  armed, so the next click began a new line — which is how the slope change was
  first noticed. Click-click and press-drag-release are both supported now, with
  the rubber band following the cursor between clicks.
- Hand-placed levels are armed, fire once, then disarm themselves, and can be
  re-armed from the sidebar. Verified end to end: created armed, tripped within
  thirty seconds, disarmed, re-armed.
- Layers is collapsible.
- The 1h moving averages are gone; only the daily set remains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:15:05 -05:00
1a1514bc77 Add a price alert input
There was no way to say "tell me when ES reaches 7800". The only user-settable
alert was a drawn trendline, placed by clicking two points on a canvas — so you
could not hit an exact price, and making the line flat was fiddly.

A price alert is a manual line with zero slope. Reusing that rather than
building a parallel concept means it inherits JSON persistence, renaming,
recolouring, deletion, clustering, and the rule that a hand-placed level alerts
whatever its confluence score. The only genuinely new code is the input, an
endpoint that takes a price instead of two anchors, and the decision to render
zero-slope manual lines as price lines — which spans the chart and labels the
axis, instead of drawing a stubby two-point segment.

Zero-slope lines also skip drag handles, hit-testing and the "end line here"
menu: a price line has no endpoints to grab. They are managed from the sidebar.

Unlabelled alerts are named by their price, since "1d resistance" does not say
which alert fired.

Verified live: a level typed 25 points above price clusters as resistance at
that price and stays quiet, as it should until price arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:09:14 -05:00
51a9636ea7 Stop re-alerting when price crosses a level
Suppression matched on cluster side as well as position, but side is positional:
a level sitting at price is resistance when price is a tick below it and support
a tick later. Every crossing failed the side match and fired as a brand-new
zone — which is exactly when a level is least newsworthy, not most.

Found the honest way. A flat line placed at the live price produced four phone
pushes in two minutes.

Zones are now matched on position alone. Side still determines whether the
message reads BULLISH or BEARISH; it just no longer decides whether you are told
twice. Over the same six replayed sessions at threshold 28 that is 40 alerts
down to 26, and 10 at the configured four-hour cooldown with a worst session
of 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:51:54 -05:00
9da7d43434 Make drawn trendlines able to alert at all
A hand-drawn line could never produce an alert, for two independent reasons:

- cluster_levels dropped any single-member group scoring under 8, and a manual
  line weighs 1 on 5m rising to 4 on 1h. A lone drawn line was discarded before
  it ever reached the alert engine.
- Even had it survived, the engine gates on confluence score, and the threshold
  is 28.

Both are wrong for a drawn line specifically. Weight exists to rank levels
nobody asked for; a line you drew by hand is an explicit statement that this
price matters, so it survives clustering on its own and bypasses the score
threshold. Everything else still has to earn its place.

Alerts naming a drawn line say so — "BEARISH LINE" with the line's label rather
than "BEARISH ZONE ... confluence 1", since knowing which drawing to go look at
is the actionable part. A line that happens to coincide with other levels still
reports as a zone, with the line named alongside.

Verified against the running app: a line placed at the current price now forms a
cluster, where before it was discarded outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:56:21 -05:00
e9c22f6bbd Move alert evaluation server-side so push works without a browser open
Alerts were evaluated inside the WebSocket handler, with a separate AlertEngine
per connection. Three consequences, all of which defeated the point of phone
push:

- No browser connected meant no alert at all. The notification only existed if
  a tab was open to receive it, which is precisely when you least need it.
- Two tabs meant two notifications, since each connection evaluated
  independently.
- Cooldowns lived and died with the connection, so reloading the page cleared
  them and a zone that had just alerted alerted again at once.

The third also meant the calibration in the README described a system nobody was
running: it models a single engine, which is what this now is.

Evaluation moves into Runtime, once per closed 1m bar, over every level. Layer
preferences are deliberately not consulted — they are a display choice made in
one browser, and a push notification should not depend on which checkboxes that
browser has ticked. Sockets now only relay what the runtime produced.

ntfy dispatch is a detached task with its own error handling. It previously ran
inline in the socket loop and called raise_for_status(), where the only except
clause caught disconnects — so a transient ntfy outage dropped the client's
connection.

Delivery verified end to end against ntfy.sh: title, priority and the multi-line
body all arrive as intended. NTFY_TOPIC still has to be set for anything to send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:48:03 -05:00
8c2ef80966 Remove the 4h timeframe
It was never in the enabled timeframes, so it held zero bars and produced no
levels, but it still carried weight 8 in the scoring table and forced
bucket_start to special-case a wall-clock ET anchor whose entire purpose was
surviving DST transitions. That was the most intricate logic in session.py,
maintained for a timeframe nobody used.

Daily is now the only session-anchored bucket, which is a much easier rule to
state and to keep correct. The DST parametrised tests go with it; the Sunday
open and daily boundary cases remain.

Manual-line tests move to 1h, so the weight assertions drop from 8 to 4.

The plan document keeps its 4h examples — rewriting a dozen illustrative
sentences would churn more than it clarifies — but the timeframe-roles section
now records the removal so nothing reads as a spec to build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:43:47 -05:00
8ca624d435 Add prior-day levels and session VWAP; fix alert repetition they exposed
The confluence engine had nothing to work with. Daily moving averages were the
only level source, and they sat 163 to 697 points from price, so every cluster
had exactly one member and no alert could ever fire.

Two new sources, chosen for having a real following — the engine is a bet that
many participants watch the same price, which is what makes a level hold:

- Prior day high/low/close, from the last *closed* daily bar so mid-session the
  levels do not silently switch to today's own developing range. Full daily
  weight rather than the 0.75 average discount: a traded high is structure, not
  a derived average.
- Session VWAP, anchored to the 18:00 ET open like the daily bars. Institutional
  execution is benchmarked against it, and zero-volume overnight minutes are
  skipped rather than dividing by zero.

Both are stamped 1d, so they get their own colours to stay distinguishable from
the daily averages. Prior-day levels draw as price lines, which span the chart
and label the axis instead of relying on bar-index interpolation.

VWAP re-prices every minute while a daily average carries hundreds of points and
changes once a session, so broadcasting the whole level set on the VWAP cadence
would have pushed the entire history every minute. Levels now go out as a delta
that clients merge by id.

Adding the levels then exposed two defects that had been invisible while nothing
could cluster:

- Cluster identity was sha1(side + round(center / tolerance)), and tolerance
  derives from ATR, so it changed every bar. The same zone was continually
  issued a new id, never matched the cooldown table, and the cooldown did
  nothing. Identity is now the set of converging levels.
- Alert suppression keyed on that identity, so a level drifting in or out of a
  group read as a new zone. It now suppresses by proximity: two zones within an
  ATR are the same zone, and the strongest is the one reported.

Over six replayed sessions at threshold 28 that is 247 alerts, then 54, then 40;
raising the cooldown to 4h — which only affects repeats of the same area, never
a genuinely new zone — gives 17 total with a worst session of 9.

calibrate_alerts.py now sweeps threshold and cooldown together in one pass,
since the threshold turns out to be quantised and nearly useless as a control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:36:21 -05:00
45802c1a2f Fix trendline deletion while typing, audio leak, prefs drift, cluster payload
Five defects found by exercising the running app rather than reading it:

- Backspace inside the sidebar rename field deleted the trendline instead of
  a character. The window keydown handler never checked what was focused, so
  correcting a typo in a line's name destroyed the line.
- playAlert() built a new AudioContext per alert and never closed it. Browsers
  cap a document at roughly six, after which alerts stop making any sound.
  One shared context now, with nodes released on end and a resume() for the
  autoplay policy.
- Stored layer preferences were used verbatim, so any key added to
  defaultPrefs later would be missing for existing visitors. A missing
  enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged
  onto the defaults, and unparseable state falls back instead of throwing.
- The alert log keyed rows on a second-resolution timestamp, so two alerts in
  the same second collided.
- Clusters embedded whole Level objects, including a moving average's entire
  point history — hundreds of entries reaching back years. Because clusters
  are re-sent on every closed 1m bar, this shipped the whole levels payload
  once a minute. Members are now compact summaries and the client joins on
  id; Cluster.to_dict() also stops round-tripping through asdict(), which was
  deep-copying those arrays before discarding them.

/api/confluence drops from 61,838 to 1,245 bytes with five clusters live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:17:40 -05:00
641492ae62 Enforce CHART_AUTH_TOKEN on /api and /ws; restore /api/version for wait-deploy 2026-08-10 04:38:36 +00:00
9777188a43 end trendline here possible 2026-08-09 23:28:23 -05:00
6599c3cd77 trendlines namable deleteable 2026-08-09 23:00:46 -05:00
49cd06089f Implement M5 manual trendlines 2026-08-09 20:55:05 -05:00
e3ebe2914d Implement M4 confluence alerts 2026-08-09 20:51:32 -05:00
7f2fcc2020 Implement M3 projected daily moving averages 2026-08-09 20:44:11 -05:00
f87ca0a153 Implement M2 session-aware aggregation 2026-08-09 20:41:36 -05:00
acc59817c4 Implement M1 live one-minute chart 2026-08-09 20:38:50 -05:00
e071acd3a9 Implement M0 Yahoo market data and replay 2026-08-09 20:36:13 -05:00
8e50d5cbc2 Add implementation plan for /ES multi-timeframe confluence chart
Planning-only commit: no application code yet.

The plan specifies a realtime /ES chart that derives moving averages and
trendlines across multiple timeframes, projects them onto one chart in a
shared (time, price) plane, and alerts when levels from different
timeframes converge.

Key findings that shaped it, all verified against source rather than
assumed:

- Schwab streams realtime futures fine (CHART_FUTURES, LEVEL_ONE_FUTURES)
  but provides no futures price *history* at all. An account does not
  change this; it is an API-surface limit.
- Yahoo's chart endpoint needs no key and has exactly what Schwab lacks:
  ~730d of hourly ES=F (~750 sessions), enough to warm a 200DMA from
  startup. So it serves as both the no-keys dev source and the history
  seeder, behind one MarketDataSource protocol.
- Yahoo anchors daily bars to midnight ET while the CME session runs
  18:00-17:00 ET, so daily bars are built from hourly using our own
  session rules instead.
- Lightweight Charts v5 replaced addCandlestickSeries() with
  addSeries(CandlestickSeries, ...); most tutorials online are v4.

Build order defers judgment-heavy work: moving averages first (fully
deterministic), then confluence scoring, then hand-drawn trendlines.
Automatic trendline detection comes last, tuned against the hand-drawn
lines as ground truth.

Includes a real trimmed Yahoo response as a test fixture; it contains a
null in the OHLC arrays, which is the parsing case that needs handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:31:40 -05:00