chart/scripts/calibrate_alerts.py
Chris Amow 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

112 lines
4.9 KiB
Python

"""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.
"""
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
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()
source = YahooSource(settings.yahoo_poll_seconds)
hourly, minutes = await asyncio.gather(
source.history(settings.yahoo_symbol, Timeframe.H1, range_=settings.seed_1h_range),
source.history(settings.yahoo_symbol, Timeframe.M1, range_=settings.seed_1m_range),
)
if not minutes:
raise RuntimeError("Yahoo returned no minute tape")
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 = []
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(
{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)
sessions = sorted({session for counter in counts.values() for session in counter})
print(f"minute_bars={len(minutes)} sessions={len(sessions)}")
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],
),
)
if __name__ == "__main__":
asyncio.run(main())