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>
This commit is contained in:
Chris Amow 2026-08-10 00:36:21 -05:00
parent 45802c1a2f
commit 8ca624d435
18 changed files with 485 additions and 56 deletions

View file

@ -15,7 +15,9 @@ MA_SETS__1H=
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json
CONFLUENCE_MIN_SCORE=28 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 # Notifications and access
NTFY_TOPIC= NTFY_TOPIC=

View file

@ -3,8 +3,9 @@
FastAPI backend + Vue 3 (from CDN, no build step) served at FastAPI backend + Vue 3 (from CDN, no build step) served at
<https://chart.amow.com>. <https://chart.amow.com>.
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes and daily moving The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes, daily moving
averages, and alerts on confluence zones. The full spec lives in 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 [`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. 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 python3 -m scripts.calibrate_alerts
``` ```
The M4 calibration on 2026-08-09 replayed 8,065 minute bars across seven sessions. It sweeps threshold and cooldown in a single replay pass and prints alerts per session.
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 The 2026-08-09 calibration ran when daily moving averages were the only levels, and
daily MAs (score `36`) and should be revisited as more varied tapes are recorded. `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 ## Layout

View file

@ -9,11 +9,27 @@ class Alert:
message: str message: str
@dataclass(slots=True)
class _Fired:
side: str
center: float
at: int
class AlertEngine: 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): def __init__(self, min_score: float, cooldown_seconds: int = 900):
self.min_score = min_score self.min_score = min_score
self.cooldown_seconds = cooldown_seconds self.cooldown_seconds = cooldown_seconds
self._fired_at: dict[str, int] = {} self._fired: list[_Fired] = []
def evaluate( def evaluate(
self, self,
@ -26,24 +42,34 @@ class AlertEngine:
tolerance = 0.5 * atr15 tolerance = 0.5 * atr15
if tolerance <= 0: if tolerance <= 0:
return [] return []
alerts: list[Alert] = [] # Two zones within an ATR of each other are the same zone as far as
active_ids = {cluster.id for cluster in clusters} # being told about them goes.
for cluster_id, fired_at in list(self._fired_at.items()): merge_distance = 2 * tolerance
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: # Re-arming needs both elapsed time and real separation. Time alone lets
if ( # price oscillating on a level alert forever.
cluster.score < self.min_score self._fired = [
or abs(cluster.center - current_price) > tolerance entry
or cluster.id in self._fired_at 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 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" direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
message = ( message = (

View file

@ -69,8 +69,13 @@ def cluster_levels(
low, high = group[0][0], group[-1][0] low, high = group[0][0], group[-1][0]
center = (low + high) / 2 center = (low + high) / 2
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
identity_bucket = round(center / tolerance) # Identity is the set of levels converging, not a price bucket. The
identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12] # 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( clusters.append(
Cluster( Cluster(
id=f"cl_{identity}", id=f"cl_{identity}",

View file

@ -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
]

View file

@ -8,6 +8,7 @@ from app.bars.models import Timeframe
class LevelKind(str, Enum): class LevelKind(str, Enum):
MANUAL = "manual" MANUAL = "manual"
MA = "ma" MA = "ma"
VWAP = "vwap"
TRENDLINE = "trendline" TRENDLINE = "trendline"
HORIZONTAL = "horizontal" HORIZONTAL = "horizontal"

58
app/analysis/vwap.py Normal file
View file

@ -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,
)
]

View file

@ -11,18 +11,26 @@ from app.notify.ntfy import send_ntfy
router = APIRouter() 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): def enabled_levels(runtime, prefs: dict | None):
if not prefs or prefs.get("hidden_levels_score"): if not prefs or prefs.get("hidden_levels_score"):
return runtime.levels return runtime.levels
enabled = prefs.get("enabled", {}) enabled = prefs.get("enabled", {})
ma = enabled.get("ma", {}) return [level for level in runtime.levels if level_enabled(level, enabled)]
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): def connection_clusters(runtime, prefs: dict | None):
@ -97,7 +105,7 @@ async def websocket_endpoint(websocket: WebSocket):
) )
elif event["type"] == "levels": elif event["type"] == "levels":
await websocket.send_json( 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": elif event["type"] == "clusters":
clusters = connection_clusters(runtime, prefs) clusters = connection_clusters(runtime, prefs)

View file

@ -35,7 +35,10 @@ class Settings(BaseSettings):
daily_anchor_et: str = "18:00" daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json") manual_lines_path: Path = Path("./data/manual_lines.json")
confluence_min_score: float = 28 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_topic: str = ""
ntfy_server: str = "https://ntfy.sh" ntfy_server: str = "https://ntfy.sh"
chart_auth_token: str = "" chart_auth_token: str = ""

View file

@ -3,8 +3,10 @@ from dataclasses import dataclass, field
from app.bars.models import Bar, Timeframe from app.bars.models import Bar, Timeframe
from app.bars.aggregator import Aggregator from app.bars.aggregator import Aggregator
from app.analysis.horizontals import build_prior_day_levels
from app.analysis.levels import Level from app.analysis.levels import Level
from app.analysis.moving_averages import build_ma_levels 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.confluence import Cluster, cluster_levels
from app.analysis.indicators import atr from app.analysis.indicators import atr
from app.analysis.manual_lines import ManualLineStore from app.analysis.manual_lines import ManualLineStore
@ -27,6 +29,7 @@ class Runtime:
atr15: float = 0.0 atr15: float = 0.0
manual_lines: ManualLineStore = field(init=False) manual_lines: ManualLineStore = field(init=False)
ma_levels: list[Level] = field(default_factory=list) ma_levels: list[Level] = field(default_factory=list)
_sent_levels: dict[str, dict] = field(default_factory=dict)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
@ -49,6 +52,9 @@ class Runtime:
if evaluate_alerts: if evaluate_alerts:
values = atr(self.store.get(Timeframe.M15), 14) values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) 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) self.rebuild_clusters(evaluate_alerts=True)
def broadcast(self, event: dict) -> None: def broadcast(self, event: dict) -> None:
@ -62,10 +68,31 @@ class Runtime:
{tf: self.store.get(tf) for tf in self.settings.ma_sets}, {tf: self.store.get(tf) for tf in self.settings.ma_sets},
self.settings.ma_sets, self.settings.ma_sets,
) )
self.levels = self.ma_levels + self.manual_lines.levels() minute_bars = self.store.get(Timeframe.M1)
self.broadcast({"type": "levels", "levels": self.levels}) 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() 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: def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
if self.price is None or self.stream.last_bar_t is None: if self.price is None or self.stream.last_bar_t is None:
return return

View file

@ -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 import asyncio
from collections import Counter from collections import Counter
from app.analysis.alerts import AlertEngine from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels 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.indicators import atr
from app.analysis.moving_averages import build_ma_levels 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.aggregator import Aggregator
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.bars.session import bucket_start from app.bars.session import bucket_start
@ -13,6 +23,12 @@ from app.bars.store import InMemoryBarStore
from app.config import Settings from app.config import Settings
from app.market.yahoo import YahooSource 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: async def main() -> None:
settings = Settings() settings = Settings()
@ -26,31 +42,71 @@ async def main() -> None:
aggregator = Aggregator(settings.enabled_timeframes) aggregator = Aggregator(settings.enabled_timeframes)
store = InMemoryBarStore(25_000) 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 cutoff = minutes[0].t
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes: for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
for bar in aggregator.update(source_bar): for bar in aggregator.update(source_bar):
store.put(bar) store.put(bar)
if settings.ma_sets.get(bar.tf): 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 {tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
) )
if bar.tf is not Timeframe.M1 or not bar.closed: if bar.tf is not Timeframe.M1 or not bar.closed:
continue continue
atr_values = atr(store.get(Timeframe.M15), 14) atr_values = atr(store.get(Timeframe.M15), 14)
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0) 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) clusters = cluster_levels(levels, bar.t, bar.c, atr15)
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol) session = bucket_start(bar.t, Timeframe.D1)
counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts) 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)}") sessions = sorted({session for counter in counts.values() for session in counter})
print("alerts/session:", ", ".join(str(value) for _, value in sorted(counts.items()))) print(f"minute_bars={len(minutes)} sessions={len(sessions)}")
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__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View file

@ -36,7 +36,7 @@ async function apiFetch(url, options = {}) {
const defaultPrefs = { const defaultPrefs = {
base_tf: '1m', 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, hidden_levels_score: false,
}; };
@ -129,7 +129,12 @@ createApp({
price.value = message.bar.c; price.value = message.bar.c;
status.value.last_bar_t = message.bar.t; status.value.last_bar_t = message.bar.t;
} else if (message.type === 'levels') { } 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(); syncVisibleLevels();
} else if (message.type === 'clusters') { } else if (message.type === 'clusters') {
clusters.value = message.clusters; clusters.value = message.clusters;
@ -347,6 +352,8 @@ createApp({
function enabled(level) { function enabled(level) {
if (level.kind === 'ma') return (prefs.value.enabled.ma[level.tf] || []).includes(level.period); 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 === '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; return prefs.value.enabled.auto;
} }

View file

@ -4,6 +4,7 @@ class ConfluenceChart {
this.candles = null; this.candles = null;
this.resizeObserver = null; this.resizeObserver = null;
this.levelSeries = new Map(); this.levelSeries = new Map();
this.priceLines = new Map();
this.previewLine = null; this.previewLine = null;
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
@ -130,21 +131,52 @@ class ConfluenceChart {
this.renderAnchorHandles(); 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) { syncLevels(levels) {
this.levels = 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) { for (const [id, entry] of this.levelSeries) {
if (!wanted.has(id)) { if (!wanted.has(id)) {
this.chart.removeSeries(entry.series); this.chart.removeSeries(entry.series);
this.levelSeries.delete(id); this.levelSeries.delete(id);
} }
} }
for (const level of levels) { for (const level of drawn) {
if (level.hidden) continue; if (level.hidden) continue;
let entry = this.levelSeries.get(level.id); 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 isMa = level.kind === 'ma';
const options = { const options = {
color: level.color || ConfluenceChart.tfColors[level.tf], color: ConfluenceChart.levelColor(level),
lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1),
lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
@ -161,7 +193,7 @@ class ConfluenceChart {
entry.series.applyOptions(options); entry.series.applyOptions(options);
} }
let data; let data;
if (isMa) { if (hasPoints) {
data = (level.points || []).map(([time, value]) => ({ time, value })); data = (level.points || []).map(([time, value]) => ({ time, value }));
const latestTime = this.bars[this.bars.length - 1]?.t; const latestTime = this.bars[this.bars.length - 1]?.t;
const latestValue = data[data.length - 1]?.value; const latestValue = data[data.length - 1]?.value;
@ -294,7 +326,7 @@ class ConfluenceChart {
handle.removeAttribute('hidden'); handle.removeAttribute('hidden');
handle.setAttribute('cx', x); handle.setAttribute('cx', x);
handle.setAttribute('cy', y); 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', '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; window.ConfluenceChart = ConfluenceChart;

View file

@ -47,6 +47,10 @@
<div class="layer-group optional"> <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> <label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</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>
<div class="layer-group"> <div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label> <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> <label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>

View file

@ -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; } 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; } .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; } .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; } .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; } } @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; } }

View file

@ -26,3 +26,26 @@ def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
engine = AlertEngine(min_score=28) engine = AlertEngine(min_score=28)
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1) cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == [] 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

40
tests/test_horizontals.py Normal file
View file

@ -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) == []

57
tests/test_vwap.py Normal file
View file

@ -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"