diff --git a/.env.example b/.env.example index f1e01bf..32db071 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,9 @@ MA_SETS__1H= DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json CONFLUENCE_MIN_SCORE=28 -ALERT_COOLDOWN_SECONDS=900 +# 4h. Suppression is per price zone, so an unrelated zone still alerts at once; +# this governs only how often the same area repeats. See README. +ALERT_COOLDOWN_SECONDS=14400 # Notifications and access NTFY_TOPIC= diff --git a/README.md b/README.md index 95d63bb..4a02fe1 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ FastAPI backend + Vue 3 (from CDN, no build step) served at . -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 +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 [`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. @@ -43,10 +44,35 @@ Yahoo's current eight-day minute tape: python3 -m scripts.calibrate_alerts ``` -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. +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. + +Only then does the cooldown do anything useful. At threshold `28` the sweep reads: + +| cooldown | total | max/session | +|---|---|---| +| 900s | 40 | 30 | +| 3600s | 29 | 20 | +| 7200s | 22 | 14 | +| **14400s (selected)** | **17** | **9** | + +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. ## Layout diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index 61976ea..8b2bb52 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -9,11 +9,27 @@ class Alert: message: str +@dataclass(slots=True) +class _Fired: + side: str + 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_at: dict[str, int] = {} + self._fired: list[_Fired] = [] def evaluate( self, @@ -26,24 +42,34 @@ class AlertEngine: tolerance = 0.5 * atr15 if tolerance <= 0: return [] - alerts: list[Alert] = [] - 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] + # Two zones within an ATR of each other are the same zone as far as + # being told about them goes. + merge_distance = 2 * tolerance - for cluster in clusters: - if ( - cluster.score < self.min_score - or abs(cluster.center - current_price) > tolerance - or cluster.id in self._fired_at + # 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): + if cluster.score < self.min_score or abs(cluster.center - current_price) > tolerance: + continue + if any( + entry.side == cluster.side.value + and abs(entry.center - cluster.center) <= merge_distance + for entry in self._fired ): continue - self._fired_at[cluster.id] = now + self._fired.append(_Fired(cluster.side.value, cluster.center, now)) direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) message = ( diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index 3528dae..b7a31f6 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -69,8 +69,13 @@ def cluster_levels( low, high = group[0][0], group[-1][0] center = (low + high) / 2 side = Side.RESISTANCE if center >= current_price else Side.SUPPORT - identity_bucket = round(center / tolerance) - identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12] + # 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] clusters.append( Cluster( id=f"cl_{identity}", diff --git a/app/analysis/horizontals.py b/app/analysis/horizontals.py new file mode 100644 index 0000000..212cd53 --- /dev/null +++ b/app/analysis/horizontals.py @@ -0,0 +1,47 @@ +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 + ] diff --git a/app/analysis/levels.py b/app/analysis/levels.py index 3475312..ebdd56e 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -8,6 +8,7 @@ from app.bars.models import Timeframe class LevelKind(str, Enum): MANUAL = "manual" MA = "ma" + VWAP = "vwap" TRENDLINE = "trendline" HORIZONTAL = "horizontal" diff --git a/app/analysis/vwap.py b/app/analysis/vwap.py new file mode 100644 index 0000000..46afc17 --- /dev/null +++ b/app/analysis/vwap.py @@ -0,0 +1,58 @@ +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, + ) + ] diff --git a/app/api/ws.py b/app/api/ws.py index 6a04f5a..0517ade 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -11,18 +11,26 @@ 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", {}) - 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)) - ] + return [level for level in runtime.levels if level_enabled(level, enabled)] def connection_clusters(runtime, prefs: dict | None): @@ -97,7 +105,7 @@ async def websocket_endpoint(websocket: WebSocket): ) elif event["type"] == "levels": await websocket.send_json( - {"type": "levels", "levels": [level.to_dict() for level in event["levels"]]} + {"type": "levels", "changed": event["changed"], "removed": event["removed"]} ) elif event["type"] == "clusters": clusters = connection_clusters(runtime, prefs) diff --git a/app/config.py b/app/config.py index 30e04e4..134b6da 100644 --- a/app/config.py +++ b/app/config.py @@ -35,7 +35,10 @@ class Settings(BaseSettings): daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") confluence_min_score: float = 28 - alert_cooldown_seconds: int = 900 + # 4h, 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 ntfy_topic: str = "" ntfy_server: str = "https://ntfy.sh" chart_auth_token: str = "" diff --git a/app/runtime.py b/app/runtime.py index 3a09521..821066f 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -3,8 +3,10 @@ from dataclasses import dataclass, field from app.bars.models import Bar, Timeframe from app.bars.aggregator import Aggregator +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 @@ -27,6 +29,7 @@ class Runtime: atr15: float = 0.0 manual_lines: ManualLineStore = field(init=False) ma_levels: list[Level] = field(default_factory=list) + _sent_levels: dict[str, dict] = field(default_factory=dict) def __post_init__(self) -> None: self.store = InMemoryBarStore(self.settings.max_bars_per_tf) @@ -49,6 +52,9 @@ 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: @@ -62,10 +68,31 @@ class Runtime: {tf: self.store.get(tf) for tf in self.settings.ma_sets}, self.settings.ma_sets, ) - self.levels = self.ma_levels + self.manual_lines.levels() - self.broadcast({"type": "levels", "levels": self.levels}) + minute_bars = self.store.get(Timeframe.M1) + self.levels = ( + self.ma_levels + + build_prior_day_levels(self.store.get(Timeframe.D1), self.price) + + build_vwap_level(minute_bars) + + self.manual_lines.levels() + ) + self.broadcast_level_delta() self.rebuild_clusters() + 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 diff --git a/scripts/calibrate_alerts.py b/scripts/calibrate_alerts.py index 5ad4596..8a366e9 100644 --- a/scripts/calibrate_alerts.py +++ b/scripts/calibrate_alerts.py @@ -1,11 +1,21 @@ -"""Replay Yahoo's available minute tape and report alerts per CME session.""" +"""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 @@ -13,6 +23,12 @@ 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() @@ -26,31 +42,71 @@ async def main() -> None: aggregator = Aggregator(settings.enabled_timeframes) store = InMemoryBarStore(25_000) - levels = [] + # 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): - levels = build_ma_levels( + 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) - alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol) - counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts) + 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) - 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)}") + 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], + ), + ) -settings = Settings() -engine = AlertEngine(settings.confluence_min_score, settings.alert_cooldown_seconds) -counts: Counter[int] = Counter() if __name__ == "__main__": asyncio.run(main()) diff --git a/static/app.js b/static/app.js index 770328b..dab5748 100644 --- a/static/app.js +++ b/static/app.js @@ -36,7 +36,7 @@ async function apiFetch(url, options = {}) { const defaultPrefs = { base_tf: '1m', - enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false }, + enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false, horizontal: true, vwap: true }, hidden_levels_score: false, }; @@ -129,7 +129,12 @@ createApp({ price.value = message.bar.c; status.value.last_bar_t = message.bar.t; } else if (message.type === 'levels') { - levels.value = message.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()]; syncVisibleLevels(); } else if (message.type === 'clusters') { clusters.value = message.clusters; @@ -347,6 +352,8 @@ 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; } diff --git a/static/chart.js b/static/chart.js index adde704..01d0866 100644 --- a/static/chart.js +++ b/static/chart.js @@ -4,6 +4,7 @@ class ConfluenceChart { this.candles = null; this.resizeObserver = null; this.levelSeries = new Map(); + this.priceLines = new Map(); this.previewLine = null; this.bars = []; this.levels = []; @@ -130,21 +131,52 @@ class ConfluenceChart { this.renderAnchorHandles(); } + // 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 => level.kind === 'horizontal' && !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)); + } + } + syncLevels(levels) { this.levels = levels; - const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id)); + this.syncPriceLines(levels); + const drawn = levels.filter(level => level.kind !== 'horizontal'); + const wanted = new Set(drawn.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 levels) { + for (const level of drawn) { 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: level.color || ConfluenceChart.tfColors[level.tf], + color: ConfluenceChart.levelColor(level), 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, @@ -161,7 +193,7 @@ class ConfluenceChart { entry.series.applyOptions(options); } let data; - if (isMa) { + if (hasPoints) { data = (level.points || []).map(([time, value]) => ({ time, value })); const latestTime = this.bars[this.bars.length - 1]?.t; const latestValue = data[data.length - 1]?.value; @@ -294,7 +326,7 @@ class ConfluenceChart { handle.removeAttribute('hidden'); handle.setAttribute('cx', x); handle.setAttribute('cy', y); - handle.setAttribute('fill', level.color || ConfluenceChart.tfColors[level.tf]); + handle.setAttribute('fill', ConfluenceChart.levelColor(level)); }); } @@ -397,4 +429,11 @@ ConfluenceChart.tfColors = { '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; diff --git a/static/index.html b/static/index.html index bacd0dc..14b2a57 100644 --- a/static/index.html +++ b/static/index.html @@ -47,6 +47,10 @@
+
+ + +
diff --git a/static/style.css b/static/style.css index cdd5b55..ac51a98 100644 --- a/static/style.css +++ b/static/style.css @@ -19,6 +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-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; } +.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; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.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; } } diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 19f53dd..b6f180b 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -26,3 +26,26 @@ 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_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 diff --git a/tests/test_horizontals.py b/tests/test_horizontals.py new file mode 100644 index 0000000..2e3d298 --- /dev/null +++ b/tests/test_horizontals.py @@ -0,0 +1,40 @@ +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) == [] diff --git a/tests/test_vwap.py b/tests/test_vwap.py new file mode 100644 index 0000000..4c7f88e --- /dev/null +++ b/tests/test_vwap.py @@ -0,0 +1,57 @@ +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"