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:
parent
45802c1a2f
commit
8ca624d435
18 changed files with 485 additions and 56 deletions
|
|
@ -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=
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
47
app/analysis/horizontals.py
Normal file
47
app/analysis/horizontals.py
Normal 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
|
||||||
|
]
|
||||||
|
|
@ -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
58
app/analysis/vwap.py
Normal 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,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
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)
|
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
|
||||||
counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts)
|
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())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; } }
|
||||||
|
|
|
||||||
|
|
@ -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
40
tests/test_horizontals.py
Normal 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
57
tests/test_vwap.py
Normal 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"
|
||||||
Loading…
Reference in a new issue