Implement M4 confluence alerts
This commit is contained in:
parent
ff7d9c6e1c
commit
e3ebe2914d
14 changed files with 408 additions and 12 deletions
21
README.md
21
README.md
|
|
@ -3,10 +3,8 @@
|
||||||
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>.
|
||||||
|
|
||||||
Currently a placeholder: the frontend calls `/api/hello` and prints the JSON.
|
The app charts Yahoo's `ES=F` feed, builds CME-session-aware timeframes and daily moving
|
||||||
|
averages, and alerts on confluence zones. The full spec lives in
|
||||||
**Where this is going:** a realtime `/ES` chart that derives trendlines and moving
|
|
||||||
averages across many timeframes and alerts when they converge. 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.
|
||||||
|
|
||||||
|
|
@ -30,11 +28,24 @@ pip install -r requirements.txt
|
||||||
uvicorn main:app --reload
|
uvicorn main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Copy settings from `.env.example` as needed. To recalibrate the alert threshold against
|
||||||
|
Yahoo's current eight-day minute tape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m scripts.calibrate_alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
The M4 calibration on 2026-08-09 replayed 8,065 minute bars across seven sessions.
|
||||||
|
Threshold `12` generated 210 alerts from lone daily MAs; `24` and the selected `28`
|
||||||
|
generated none. The selected threshold deliberately requires at least three clustered
|
||||||
|
daily MAs (score `36`) and should be revisited as more varied tapes are recorded.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main.py` | FastAPI app — JSON under `/api`, serves the SPA at `/` |
|
| `main.py` | FastAPI lifespan and app wiring; JSON under `/api`, SPA at `/` |
|
||||||
|
| `app/` | Market sources, aggregation, analysis, alerts, and API |
|
||||||
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
|
| `static/` | `index.html`, `app.js`, `style.css` — Vue 3 loaded from unpkg |
|
||||||
| `requirements.txt` | Python deps |
|
| `requirements.txt` | Python deps |
|
||||||
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
|
| `Procfile` | Start command; **nixpacks needs this** or the deploy has nothing to run |
|
||||||
|
|
|
||||||
55
app/analysis/alerts.py
Normal file
55
app/analysis/alerts.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.analysis.confluence import Cluster
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Alert:
|
||||||
|
cluster: Cluster
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlertEngine:
|
||||||
|
def __init__(self, min_score: float, cooldown_seconds: int = 900):
|
||||||
|
self.min_score = min_score
|
||||||
|
self.cooldown_seconds = cooldown_seconds
|
||||||
|
self._fired_at: dict[str, int] = {}
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
clusters: list[Cluster],
|
||||||
|
current_price: float,
|
||||||
|
atr15: float,
|
||||||
|
now: int,
|
||||||
|
symbol: str,
|
||||||
|
) -> list[Alert]:
|
||||||
|
tolerance = 0.5 * atr15
|
||||||
|
if tolerance <= 0:
|
||||||
|
return []
|
||||||
|
alerts: list[Alert] = []
|
||||||
|
active_ids = {cluster.id for cluster in clusters}
|
||||||
|
for cluster_id, fired_at in list(self._fired_at.items()):
|
||||||
|
cluster = next((item for item in clusters if item.id == cluster_id), None)
|
||||||
|
separated = cluster is None or abs(cluster.center - current_price) > 2 * tolerance
|
||||||
|
if separated and now - fired_at >= self.cooldown_seconds:
|
||||||
|
del self._fired_at[cluster_id]
|
||||||
|
elif cluster_id not in active_ids and now - fired_at >= self.cooldown_seconds:
|
||||||
|
del self._fired_at[cluster_id]
|
||||||
|
|
||||||
|
for cluster in clusters:
|
||||||
|
if (
|
||||||
|
cluster.score < self.min_score
|
||||||
|
or abs(cluster.center - current_price) > tolerance
|
||||||
|
or cluster.id in self._fired_at
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self._fired_at[cluster.id] = now
|
||||||
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||||
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||||
|
message = (
|
||||||
|
f"{direction} ZONE {symbol} {current_price:.2f}\n"
|
||||||
|
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
||||||
|
f"@ {cluster.low:.2f}-{cluster.high:.2f}\n{timeframes}"
|
||||||
|
)
|
||||||
|
alerts.append(Alert(cluster, message))
|
||||||
|
return alerts
|
||||||
74
app/analysis/confluence.py
Normal file
74
app/analysis/confluence.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from hashlib import sha1
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.analysis.levels import Level, Side
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Cluster:
|
||||||
|
id: str
|
||||||
|
side: Side
|
||||||
|
low: float
|
||||||
|
high: float
|
||||||
|
center: float
|
||||||
|
score: float
|
||||||
|
members: list[Level]
|
||||||
|
distance: float
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
value = asdict(self)
|
||||||
|
value["side"] = self.side.value
|
||||||
|
value["members"] = [member.to_dict() for member in self.members]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_levels(
|
||||||
|
levels: list[Level], current_t: int, current_price: float, atr15: float
|
||||||
|
) -> list[Cluster]:
|
||||||
|
tolerance = 0.4 * atr15
|
||||||
|
if tolerance <= 0:
|
||||||
|
return []
|
||||||
|
groups: list[list[tuple[float, Level]]] = []
|
||||||
|
positioned = [(level.price_at(current_t), level) for level in levels if not level.hidden]
|
||||||
|
for positional_side in (Side.SUPPORT, Side.RESISTANCE):
|
||||||
|
side_levels = sorted(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in positioned
|
||||||
|
if (Side.RESISTANCE if item[0] >= current_price else Side.SUPPORT)
|
||||||
|
is positional_side
|
||||||
|
),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
)
|
||||||
|
side_groups: list[list[tuple[float, Level]]] = []
|
||||||
|
for item in side_levels:
|
||||||
|
if not side_groups or item[0] - side_groups[-1][-1][0] > tolerance:
|
||||||
|
side_groups.append([item])
|
||||||
|
else:
|
||||||
|
side_groups[-1].append(item)
|
||||||
|
groups.extend(side_groups)
|
||||||
|
|
||||||
|
clusters: list[Cluster] = []
|
||||||
|
for group in groups:
|
||||||
|
score = sum(level.weight for _, level in group)
|
||||||
|
if len(group) < 2 and score < 8:
|
||||||
|
continue
|
||||||
|
low, high = group[0][0], group[-1][0]
|
||||||
|
center = (low + high) / 2
|
||||||
|
side = Side.RESISTANCE if center >= current_price else Side.SUPPORT
|
||||||
|
identity_bucket = round(center / tolerance)
|
||||||
|
identity = sha1(f"{side.value}:{identity_bucket}".encode()).hexdigest()[:12]
|
||||||
|
clusters.append(
|
||||||
|
Cluster(
|
||||||
|
id=f"cl_{identity}",
|
||||||
|
side=side,
|
||||||
|
low=low,
|
||||||
|
high=high,
|
||||||
|
center=center,
|
||||||
|
score=score,
|
||||||
|
members=[level for _, level in group],
|
||||||
|
distance=center - current_price,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(clusters, key=lambda cluster: abs(cluster.distance))
|
||||||
|
|
@ -43,3 +43,12 @@ def levels(request: Request, tf: str = "all"):
|
||||||
raise HTTPException(400, "Unknown timeframe") from exc
|
raise HTTPException(400, "Unknown timeframe") from exc
|
||||||
values = [level for level in values if level.tf is timeframe]
|
values = [level for level in values if level.tf is timeframe]
|
||||||
return {"levels": [level.to_dict() for level in values]}
|
return {"levels": [level.to_dict() for level in values]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/confluence")
|
||||||
|
def confluence(request: Request):
|
||||||
|
runtime = request.app.state.runtime
|
||||||
|
return {
|
||||||
|
"price": runtime.price,
|
||||||
|
"clusters": [cluster.to_dict() for cluster in runtime.clusters],
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,42 @@ import asyncio
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.notify.ntfy import send_ntfy
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def snapshot(runtime, tf: Timeframe) -> dict:
|
def enabled_levels(runtime, prefs: dict | None):
|
||||||
|
if not prefs or prefs.get("hidden_levels_score"):
|
||||||
|
return runtime.levels
|
||||||
|
enabled = prefs.get("enabled", {})
|
||||||
|
ma = enabled.get("ma", {})
|
||||||
|
return [
|
||||||
|
level
|
||||||
|
for level in runtime.levels
|
||||||
|
if (level.kind.value == "ma" and level.period in ma.get(level.tf.value, []))
|
||||||
|
or (level.kind.value == "manual" and enabled.get("manual", True))
|
||||||
|
or (level.kind.value == "trendline" and enabled.get("auto", False))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def connection_clusters(runtime, prefs: dict | None):
|
||||||
|
if runtime.price is None or runtime.stream.last_bar_t is None:
|
||||||
|
return []
|
||||||
|
return cluster_levels(
|
||||||
|
enabled_levels(runtime, prefs), runtime.stream.last_bar_t, runtime.price, runtime.atr15
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"type": "snapshot",
|
"type": "snapshot",
|
||||||
"tf": tf.value,
|
"tf": tf.value,
|
||||||
"bars": [bar.to_dict() for bar in runtime.store.get(tf, 1000)],
|
"bars": [bar.to_dict() for bar in runtime.store.get(tf, 1000)],
|
||||||
"levels": [level.to_dict() for level in runtime.levels],
|
"levels": [level.to_dict() for level in runtime.levels],
|
||||||
"clusters": [],
|
"clusters": [cluster.to_dict() for cluster in connection_clusters(runtime, prefs)],
|
||||||
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
|
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
|
||||||
if runtime.store.get(Timeframe.M1, 1)
|
if runtime.store.get(Timeframe.M1, 1)
|
||||||
else None,
|
else None,
|
||||||
|
|
@ -28,7 +53,10 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
runtime.subscribers.add(queue)
|
runtime.subscribers.add(queue)
|
||||||
tf = Timeframe.M1
|
tf = Timeframe.M1
|
||||||
prefs = None
|
prefs = None
|
||||||
await websocket.send_json(snapshot(runtime, tf))
|
alert_engine = AlertEngine(
|
||||||
|
runtime.settings.confluence_min_score, runtime.settings.alert_cooldown_seconds
|
||||||
|
)
|
||||||
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
nonlocal tf, prefs
|
nonlocal tf, prefs
|
||||||
|
|
@ -36,9 +64,17 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
message = await websocket.receive_json()
|
message = await websocket.receive_json()
|
||||||
if message.get("type") == "subscribe":
|
if message.get("type") == "subscribe":
|
||||||
tf = Timeframe(message.get("tf", "1m"))
|
tf = Timeframe(message.get("tf", "1m"))
|
||||||
await websocket.send_json(snapshot(runtime, tf))
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
elif message.get("type") == "prefs":
|
elif message.get("type") == "prefs":
|
||||||
prefs = message
|
prefs = message
|
||||||
|
clusters = connection_clusters(runtime, prefs)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "clusters",
|
||||||
|
"price": runtime.price,
|
||||||
|
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
receiver = asyncio.create_task(receive())
|
receiver = asyncio.create_task(receive())
|
||||||
try:
|
try:
|
||||||
|
|
@ -52,6 +88,33 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "levels", "levels": [level.to_dict() for level in event["levels"]]}
|
{"type": "levels", "levels": [level.to_dict() for level in event["levels"]]}
|
||||||
)
|
)
|
||||||
|
elif event["type"] == "clusters":
|
||||||
|
clusters = connection_clusters(runtime, prefs)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "clusters",
|
||||||
|
"price": runtime.price,
|
||||||
|
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
alerts = (
|
||||||
|
alert_engine.evaluate(
|
||||||
|
clusters,
|
||||||
|
runtime.price,
|
||||||
|
runtime.atr15,
|
||||||
|
runtime.stream.last_bar_t or 0,
|
||||||
|
runtime.stream.symbol,
|
||||||
|
)
|
||||||
|
if event.get("evaluate_alerts")
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
for alert in alerts:
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "alert", "cluster": alert.cluster.to_dict(), "message": alert.message}
|
||||||
|
)
|
||||||
|
await send_ntfy(
|
||||||
|
runtime.settings.ntfy_server, runtime.settings.ntfy_topic, alert.message
|
||||||
|
)
|
||||||
except (WebSocketDisconnect, asyncio.CancelledError):
|
except (WebSocketDisconnect, asyncio.CancelledError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
1
app/notify/__init__.py
Normal file
1
app/notify/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Alert notification transports."""
|
||||||
13
app/notify/ntfy.py
Normal file
13
app/notify/ntfy.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
async def send_ntfy(server: str, topic: str, message: str) -> None:
|
||||||
|
if not topic:
|
||||||
|
return
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{server.rstrip('/')}/{topic}",
|
||||||
|
content=message,
|
||||||
|
headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
@ -5,6 +5,8 @@ from app.bars.models import Bar, Timeframe
|
||||||
from app.bars.aggregator import Aggregator
|
from app.bars.aggregator import Aggregator
|
||||||
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.confluence import Cluster, cluster_levels
|
||||||
|
from app.analysis.indicators import atr
|
||||||
from app.bars.store import InMemoryBarStore
|
from app.bars.store import InMemoryBarStore
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.market.factory import live_source, seed_source
|
from app.market.factory import live_source, seed_source
|
||||||
|
|
@ -19,6 +21,9 @@ class Runtime:
|
||||||
subscribers: set[asyncio.Queue[dict]] = field(default_factory=set)
|
subscribers: set[asyncio.Queue[dict]] = field(default_factory=set)
|
||||||
aggregator: Aggregator = field(init=False)
|
aggregator: Aggregator = field(init=False)
|
||||||
levels: list[Level] = field(default_factory=list)
|
levels: list[Level] = field(default_factory=list)
|
||||||
|
clusters: list[Cluster] = field(default_factory=list)
|
||||||
|
price: float | None = None
|
||||||
|
atr15: float = 0.0
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -32,6 +37,11 @@ class Runtime:
|
||||||
self.broadcast({"type": "bar", "bar": aggregated})
|
self.broadcast({"type": "bar", "bar": aggregated})
|
||||||
if self.settings.ma_sets.get(aggregated.tf):
|
if self.settings.ma_sets.get(aggregated.tf):
|
||||||
self.rebuild_levels()
|
self.rebuild_levels()
|
||||||
|
if aggregated.tf is Timeframe.M1 and aggregated.closed:
|
||||||
|
self.price = aggregated.c
|
||||||
|
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.rebuild_clusters(evaluate_alerts=True)
|
||||||
|
|
||||||
def broadcast(self, event: dict) -> None:
|
def broadcast(self, event: dict) -> None:
|
||||||
for queue in self.subscribers.copy():
|
for queue in self.subscribers.copy():
|
||||||
|
|
@ -45,6 +55,20 @@ class Runtime:
|
||||||
self.settings.ma_sets,
|
self.settings.ma_sets,
|
||||||
)
|
)
|
||||||
self.broadcast({"type": "levels", "levels": self.levels})
|
self.broadcast({"type": "levels", "levels": self.levels})
|
||||||
|
self.rebuild_clusters()
|
||||||
|
|
||||||
|
def rebuild_clusters(self, evaluate_alerts: bool = False) -> None:
|
||||||
|
if self.price is None or self.stream.last_bar_t is None:
|
||||||
|
return
|
||||||
|
self.clusters = cluster_levels(self.levels, self.stream.last_bar_t, self.price, self.atr15)
|
||||||
|
self.broadcast(
|
||||||
|
{
|
||||||
|
"type": "clusters",
|
||||||
|
"price": self.price,
|
||||||
|
"clusters": self.clusters,
|
||||||
|
"evaluate_alerts": evaluate_alerts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def start(self) -> asyncio.Task:
|
async def start(self) -> asyncio.Task:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
56
scripts/calibrate_alerts.py
Normal file
56
scripts/calibrate_alerts.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Replay Yahoo's available minute tape and report alerts per CME session."""
|
||||||
|
import asyncio
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.indicators import atr
|
||||||
|
from app.analysis.moving_averages import build_ma_levels
|
||||||
|
from app.bars.aggregator import Aggregator
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.bars.session import bucket_start
|
||||||
|
from app.bars.store import InMemoryBarStore
|
||||||
|
from app.config import Settings
|
||||||
|
from app.market.yahoo import YahooSource
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
settings = Settings()
|
||||||
|
source = YahooSource(settings.yahoo_poll_seconds)
|
||||||
|
hourly, minutes = await asyncio.gather(
|
||||||
|
source.history(settings.yahoo_symbol, Timeframe.H1, range_=settings.seed_1h_range),
|
||||||
|
source.history(settings.yahoo_symbol, Timeframe.M1, range_=settings.seed_1m_range),
|
||||||
|
)
|
||||||
|
if not minutes:
|
||||||
|
raise RuntimeError("Yahoo returned no minute tape")
|
||||||
|
|
||||||
|
aggregator = Aggregator(settings.enabled_timeframes)
|
||||||
|
store = InMemoryBarStore(25_000)
|
||||||
|
levels = []
|
||||||
|
cutoff = minutes[0].t
|
||||||
|
for source_bar in [bar for bar in hourly if bar.t < cutoff] + minutes:
|
||||||
|
for bar in aggregator.update(source_bar):
|
||||||
|
store.put(bar)
|
||||||
|
if settings.ma_sets.get(bar.tf):
|
||||||
|
levels = build_ma_levels(
|
||||||
|
{tf: store.get(tf) for tf in settings.ma_sets}, settings.ma_sets
|
||||||
|
)
|
||||||
|
if bar.tf is not Timeframe.M1 or not bar.closed:
|
||||||
|
continue
|
||||||
|
atr_values = atr(store.get(Timeframe.M15), 14)
|
||||||
|
atr15 = next((value for value in reversed(atr_values) if value is not None), 0.0)
|
||||||
|
clusters = cluster_levels(levels, bar.t, bar.c, atr15)
|
||||||
|
alerts = engine.evaluate(clusters, bar.c, atr15, bar.t, settings.yahoo_symbol)
|
||||||
|
counts[bucket_start(bar.t, Timeframe.D1)] += len(alerts)
|
||||||
|
|
||||||
|
print(f"threshold={settings.confluence_min_score:g} minute_bars={len(minutes)}")
|
||||||
|
print("alerts/session:", ", ".join(str(value) for _, value in sorted(counts.items())))
|
||||||
|
print(f"total={sum(counts.values())} max_session={max(counts.values(), default=0)}")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
engine = AlertEngine(settings.confluence_min_score, settings.alert_cooldown_seconds)
|
||||||
|
counts: Counter[int] = Counter()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -14,6 +14,8 @@ createApp({
|
||||||
const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs));
|
const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs));
|
||||||
const timeframe = ref(prefs.value.base_tf || '1m');
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
||||||
const levels = ref([]);
|
const levels = ref([]);
|
||||||
|
const clusters = ref([]);
|
||||||
|
const alerts = ref([]);
|
||||||
const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d'];
|
const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d'];
|
||||||
const now = ref(Date.now());
|
const now = ref(Date.now());
|
||||||
let chartApi = null;
|
let chartApi = null;
|
||||||
|
|
@ -52,6 +54,13 @@ createApp({
|
||||||
} else if (message.type === 'levels') {
|
} else if (message.type === 'levels') {
|
||||||
levels.value = message.levels;
|
levels.value = message.levels;
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
|
} else if (message.type === 'clusters') {
|
||||||
|
clusters.value = message.clusters;
|
||||||
|
price.value = message.price;
|
||||||
|
} else if (message.type === 'alert') {
|
||||||
|
alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message });
|
||||||
|
alerts.value = alerts.value.slice(0, 20);
|
||||||
|
playAlert();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
|
@ -60,6 +69,18 @@ createApp({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playAlert() {
|
||||||
|
const context = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
oscillator.frequency.value = 740;
|
||||||
|
gain.gain.setValueAtTime(0.12, context.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35);
|
||||||
|
oscillator.connect(gain).connect(context.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(context.currentTime + 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
function selectTimeframe(tf) {
|
function selectTimeframe(tf) {
|
||||||
timeframe.value = tf;
|
timeframe.value = tf;
|
||||||
prefs.value.base_tf = tf;
|
prefs.value.base_tf = tf;
|
||||||
|
|
@ -112,6 +133,6 @@ createApp({
|
||||||
if (chartApi) chartApi.destroy();
|
if (chartApi) chartApi.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status, price, barAge, timeframe, timeframes, prefs, selectTimeframe, allEnabled, toggleGroup };
|
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, selectTimeframe, allEnabled, toggleGroup };
|
||||||
},
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,16 @@
|
||||||
</div>
|
</div>
|
||||||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||||||
<h2>Confluence zones</h2>
|
<h2>Confluence zones</h2>
|
||||||
<div class="empty">Analysis layers arrive after aggregation.</div>
|
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
||||||
|
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
|
||||||
|
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
||||||
|
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
|
||||||
|
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
|
||||||
|
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
|
||||||
|
</div>
|
||||||
<h2>Alert log</h2>
|
<h2>Alert log</h2>
|
||||||
<div class="empty">No alerts fired.</div>
|
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
||||||
|
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
|
||||||
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
|
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
|
||||||
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; }
|
||||||
.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; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||||
|
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
|
||||||
@media (max-width:850px) { #app { padding:10px; }main { grid-template-columns:1fr; }#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; }main { grid-template-columns:1fr; }#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; } }
|
||||||
|
|
|
||||||
28
tests/test_alerts.py
Normal file
28
tests/test_alerts.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from app.analysis.alerts import AlertEngine
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
def level(id_: str, price: float, weight: float):
|
||||||
|
return Level(id_, LevelKind.MA, Timeframe.D1, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oscillation_fires_once_until_separation_and_cooldown():
|
||||||
|
engine = AlertEngine(min_score=6, cooldown_seconds=900)
|
||||||
|
levels = [level("a", 100, 3), level("b", 100.1, 4)]
|
||||||
|
cluster = cluster_levels(levels, 100, 100, 1)
|
||||||
|
|
||||||
|
assert len(engine.evaluate(cluster, 100, 1, 0, "/ES")) == 1
|
||||||
|
assert engine.evaluate(cluster, 100.2, 1, 60, "/ES") == []
|
||||||
|
assert engine.evaluate(cluster, 100, 1, 901, "/ES") == []
|
||||||
|
|
||||||
|
far_cluster = cluster_levels(levels, 100, 103, 1)
|
||||||
|
assert engine.evaluate(far_cluster, 103, 1, 902, "/ES") == []
|
||||||
|
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_threshold_blocks_two_daily_mas_at_default_calibration():
|
||||||
|
engine = AlertEngine(min_score=28)
|
||||||
|
cluster = cluster_levels([level("a", 100, 12), level("b", 100.1, 12)], 100, 100, 1)
|
||||||
|
assert engine.evaluate(cluster, 100, 1, 0, "/ES") == []
|
||||||
33
tests/test_confluence.py
Normal file
33
tests/test_confluence.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
||||||
|
def level(id_: str, price: float, weight: float, tf=Timeframe.H1):
|
||||||
|
return Level(id_, LevelKind.MA, tf, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_linkage_cluster_has_known_score_and_effective_side():
|
||||||
|
clusters = cluster_levels(
|
||||||
|
[level("a", 99.8, 2), level("b", 100.1, 4), level("c", 105, 1)],
|
||||||
|
current_t=200,
|
||||||
|
current_price=99,
|
||||||
|
atr15=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(clusters) == 1
|
||||||
|
assert clusters[0].low == 99.8
|
||||||
|
assert clusters[0].high == 100.1
|
||||||
|
assert clusters[0].score == 6
|
||||||
|
assert clusters[0].side is Side.RESISTANCE
|
||||||
|
|
||||||
|
|
||||||
|
def test_lone_daily_level_is_emitted():
|
||||||
|
clusters = cluster_levels([level("daily", 98, 12, Timeframe.D1)], 200, 100, 1)
|
||||||
|
assert len(clusters) == 1
|
||||||
|
assert clusters[0].side is Side.SUPPORT
|
||||||
|
|
||||||
|
|
||||||
|
def test_levels_on_opposite_sides_of_price_do_not_cluster():
|
||||||
|
clusters = cluster_levels([level("below", 99.9, 2), level("above", 100.1, 2)], 200, 100, 1)
|
||||||
|
assert clusters == []
|
||||||
Loading…
Reference in a new issue