Default confluence zone alerts off; Config can turn them back on.

Armed drawings and DMA bells still fire. The pref lives in user_prefs
so pushes follow it, not a display checkbox.
This commit is contained in:
Chris Amow 2026-08-27 17:09:23 -05:00
parent 3626795e03
commit 6f03560e74
9 changed files with 70 additions and 4 deletions

View file

@ -137,6 +137,7 @@ class AlertEngine:
now: int,
symbol: str,
watched: list[Level] | None = None,
confluence: bool = True,
) -> list[Alert]:
tolerance = 0.5 * atr15
if tolerance <= 0:
@ -184,7 +185,7 @@ class AlertEngine:
# from 1 (5m) to 4 (1h) against a threshold of 28, so gating on
# score would mean a line you deliberately drew could never alert.
# A disarmed one has already had its say and no longer qualifies.
if not drawn and cluster.score < self.min_score:
if not drawn and (not confluence or cluster.score < self.min_score):
continue
if abs(cluster.center - current_price) > tolerance and not early_drawn:
continue

View file

@ -231,6 +231,18 @@ def confluence(request: Request):
}
@router.get("/prefs/confluence-alerts")
def get_confluence_alerts(request: Request):
return {"enabled": request.app.state.runtime.confluence_alerts_enabled()}
@router.put("/prefs/confluence-alerts")
def put_confluence_alerts(request: Request, payload: dict):
if "enabled" not in payload or not isinstance(payload["enabled"], bool):
raise HTTPException(400, "enabled must be a boolean")
return request.app.state.runtime.set_confluence_alerts(payload["enabled"])
@router.get("/prefs/ma-alerts")
def get_ma_alerts(request: Request):
return request.app.state.runtime.ma_alerts()

View file

@ -287,9 +287,17 @@ class Runtime:
evaluation_t,
self.stream.symbol,
self.watched_ma_levels(),
confluence=self.confluence_alerts_enabled(),
)
)
def confluence_alerts_enabled(self) -> bool:
return bool(self.user_prefs.get("confluence_alerts"))
def set_confluence_alerts(self, enabled: bool) -> dict:
self.user_prefs.put("confluence_alerts", bool(enabled))
return {"enabled": self.confluence_alerts_enabled()}
def ma_alerts(self) -> dict:
stored = self.user_prefs.get("ma_alerts") or {}
return {"1d": list(stored.get("1d") or [])}

View file

@ -1143,6 +1143,9 @@ this is a complete, useful product with zero hand-drawn input and zero tuning.
**Done when:** a replayed tape produces a sane number of alerts (single digits per
session), each corresponding to a real multi-timeframe convergence.
**Update 2026-08-27:** Auto confluence (ZONE) alerts are off by default. Config →
Confluence alerts. Armed drawings and DMA bells still fire.
### M5 — Manual trendlines
Two-click drawing, snapping, persistence, feeding the same confluence engine (§7.3a).
Hand-drawn lines are authoritative — full weight, no quality discount.

View file

@ -135,6 +135,7 @@ createApp({
const drawingFilter = ref('');
const drawingKind = ref('all');
const maAlerts = ref({ '1d': [] });
const confluenceAlerts = ref(false);
const drawingTf = ref('all');
const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false);
@ -1474,6 +1475,11 @@ createApp({
if (response.ok) maAlerts.value = await response.json();
}
async function refreshConfluenceAlerts() {
const response = await apiFetch('/api/prefs/confluence-alerts');
if (response.ok) confluenceAlerts.value = Boolean((await response.json()).enabled);
}
async function toggleMaAlert(period) {
const armed = new Set(maAlerts.value['1d'] || []);
if (armed.has(period)) armed.delete(period);
@ -1549,6 +1555,14 @@ createApp({
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
}, { deep: true });
watch(confluenceAlerts, value => {
apiFetch('/api/prefs/confluence-alerts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: Boolean(value) }),
}).catch(() => {});
});
watch(animateCurrentPrice, value => {
localStorage.setItem('chart-animate-current-price', String(value));
if (chartApi) chartApi.setCurrentPriceAnimation(value);
@ -1601,6 +1615,7 @@ createApp({
await refreshStatus();
await refreshComments();
await refreshMaAlerts();
await refreshConfluenceAlerts();
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
connect();
@ -1613,6 +1628,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo };
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, confluenceAlerts, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo };
},
}).mount('#app');

View file

@ -74,6 +74,7 @@
</details>
<details class="sidebar-section config-section">
<summary>Config</summary>
<label><input type="checkbox" v-model="confluenceAlerts"> Confluence alerts</label>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
<label><input type="checkbox" v-model="autoScrollLivePrice"> Autoscroll to live price</label>
<label><input type="checkbox" v-model="hideLowerTfDrawings"> Hide lower-TF drawings</label>

View file

@ -26,6 +26,14 @@ def test_oscillation_fires_once_until_separation_and_cooldown():
assert len(engine.evaluate(cluster, 100, 1, 903, "/ES")) == 1
def test_confluence_off_skips_auto_zones_but_keeps_drawn_lines():
engine = AlertEngine(min_score=6, cooldown_seconds=900)
auto = cluster_levels([level("a", 100, 3), level("b", 100.1, 4)], 100, 100, 1)
assert engine.evaluate(auto, 100, 1, 0, "/ES", confluence=False) == []
drawn = cluster_levels([drawn_line("ml_1", 100)], 100, 100, 1)
assert len(engine.evaluate(drawn, 100, 1, 0, "/ES", confluence=False)) == 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)

View file

@ -122,7 +122,7 @@ def test_closed_bar_alerts_use_the_closed_timestamp_not_the_provisional_tail(
))
seen = {}
def evaluate(clusters, price, atr15, at, symbol, watched):
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen.update(at=at, price=price, centers=[cluster.center for cluster in clusters])
return []
@ -158,7 +158,7 @@ def test_anchor_eviction_excludes_stale_manual_geometry_before_alerting(
))
seen_members = []
def evaluate(clusters, price, atr15, at, symbol, watched):
def evaluate(clusters, price, atr15, at, symbol, watched, **_):
seen_members.extend(member.id for cluster in clusters for member in cluster.members)
return []

View file

@ -37,3 +37,21 @@ def test_ma_alert_endpoints_persist(tmp_path):
assert client.get("/api/prefs/ma-alerts").json() == {"1d": [10, 200]}
assert client.put("/api/prefs/ma-alerts", json={"1d": [7]}).status_code == 400
assert SHARED_USER in (tmp_path / "user_prefs.json").read_text(encoding="utf-8")
def test_confluence_alerts_default_off_and_persist(tmp_path):
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(
manual_lines_path=tmp_path / "manual_lines.json",
alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
))
client = TestClient(app)
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": False}
assert client.put("/api/prefs/confluence-alerts", json={"enabled": True}).json() == {
"enabled": True,
}
assert client.get("/api/prefs/confluence-alerts").json() == {"enabled": True}
assert client.put("/api/prefs/confluence-alerts", json={"enabled": "yes"}).status_code == 400