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>
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>
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>
Resolves main.py: the branch's application supersedes the placeholder, and
/api/version + /api/health now live in app/api/meta.py so bin/wait-deploy
keeps working.
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>