From 73beae051a34d5773fab383bfe789b6ffabbc9d0 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Sat, 15 Aug 2026 05:14:28 -0500 Subject: [PATCH] tweaks --- .env.example | 1 + .gitignore | 1 + AGENTS.md | 1 + README.md | 4 +++ app/analysis/alerts.py | 40 ++++++++++++++++++++++++- app/analysis/user_prefs.py | 57 ++++++++++++++++++++++++++++++++++++ app/api/routes.py | 16 ++++++++++ app/config.py | 1 + app/runtime.py | 23 ++++++++++++++- docs/plan.md | 4 +-- docs/plan_dma_alerts.md | 43 +++++++++++++++++++++++++++ static/app.js | 25 +++++++++++++++- static/index.html | 2 +- static/style.css | 2 +- tests/test_alerts.py | 45 ++++++++++++++++++++++++++++ tests/test_runtime_alerts.py | 1 + tests/test_user_prefs.py | 39 ++++++++++++++++++++++++ 17 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 app/analysis/user_prefs.py create mode 100644 docs/plan_dma_alerts.md create mode 100644 tests/test_user_prefs.py diff --git a/.env.example b/.env.example index 0250fd8..0418498 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ TRENDLINE_SOURCE_GEOMETRY=true MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json +USER_PREFS_PATH=./data/user_prefs.json CONFLUENCE_MIN_SCORE=28 # Four hours. Suppression is per price zone, so an unrelated zone still alerts; # this governs only how often the same area repeats. See README. diff --git a/.gitignore b/.gitignore index 7d2ef95..2ac8191 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__/ .schwab_token.json data/manual_lines.json data/alert_state.json +data/user_prefs.json artifacts/playwright/ diff --git a/AGENTS.md b/AGENTS.md index 64248da..13ecf4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ deferred fixes. Mobile interaction work also has its own detailed plan in [`docs/mobile_enhance.md`](docs/mobile_enhance.md). The CDN-to-Vite move is [`docs/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are [`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md). +Daily MA alert toggles are [`docs/plan_dma_alerts.md`](docs/plan_dma_alerts.md). ## Tests earn their place by catching a real bug diff --git a/README.md b/README.md index a9c94b2..cdd84f0 100644 --- a/README.md +++ b/README.md @@ -354,6 +354,10 @@ whole point of the phone push — and opening two tabs does not double-notify. `NTFY_TOPIC` must be set or nothing sends; `send_ntfy` returns immediately on a blank topic. Set it in **Coolify's environment variables** for production, not in this repo. +Daily MA bells in Layers watch the 10/20/50/100/200 independently of whether +the line is drawn. They use the same leave-and-return cooldown as zones, not +one-shot disarm. The watches live in `USER_PREFS_PATH` (`./data/user_prefs.json`). + **Cooldown state survives a restart.** The fired-zone table is written to `ALERT_STATE_PATH` (`./data/alert_state.json`), which in production is the same persistent volume as the trendlines. Before that, every deploy started with empty diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index 83ed119..92d8839 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from app.analysis.confluence import Cluster -from app.analysis.levels import LevelKind +from app.analysis.levels import Level, LevelKind, Side logger = logging.getLogger(__name__) @@ -128,6 +128,7 @@ class AlertEngine: atr15: float, now: int, symbol: str, + watched: list[Level] | None = None, ) -> list[Alert]: tolerance = 0.5 * atr15 if tolerance <= 0: @@ -220,6 +221,43 @@ class AlertEngine: push=f"#{number} {message}\n{self._stamp(fired_at)}", ) ) + for level in watched or []: + price = level.current_p if level.current_p is not None else level.anchor_p + if abs(price - current_price) > tolerance: + continue + if any(abs(entry.center - price) <= merge_distance for entry in self._fired): + continue + self._fired.append(_Fired(price, now)) + changed = True + direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH" + name = f"{level.period} DMA" if level.period else level.label + message = ( + f"{direction} DMA {symbol} {current_price:.2f}\n" + f"{name} @ {price:.2f}\n{level.tf.value}" + ) + number = self._next_number + self._next_number += 1 + fired_at = int(now) + cluster = Cluster( + f"dma:{level.id}", + level.side, + price, + price, + price, + level.weight, + [level], + abs(price - current_price), + ) + alerts.append( + Alert( + cluster, + message, + (), + number=number, + at=fired_at, + push=f"#{number} {message}\n{self._stamp(fired_at)}", + ) + ) if changed: self._save() return alerts diff --git a/app/analysis/user_prefs.py b/app/analysis/user_prefs.py new file mode 100644 index 0000000..490d676 --- /dev/null +++ b/app/analysis/user_prefs.py @@ -0,0 +1,57 @@ +import json +import logging +from pathlib import Path +from threading import RLock +from typing import Any + +logger = logging.getLogger(__name__) + +SHARED_USER = "shared" +MA_ALERT_PERIODS = (10, 20, 50, 100, 200) + + +class UserPrefStore: + """Per-user JSON bags, one file. + + Namespaces are versioned objects. ``ma_alerts`` is ``{"1d": [200]}``. + ``user_id`` is ``shared`` until OIDC supplies a subject. + """ + + def __init__(self, path: str | Path): + self.path = Path(path) + self._lock = RLock() + self._by_user: dict[str, dict[str, Any]] = self._load() + + def _load(self) -> dict[str, dict[str, Any]]: + if not self.path.exists(): + return {} + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except Exception: + logger.warning("Could not read user prefs; starting empty", exc_info=True) + return {} + if not isinstance(payload, dict): + return {} + loaded: dict[str, dict[str, Any]] = {} + for user_id, namespaces in payload.items(): + if isinstance(namespaces, dict): + loaded[str(user_id)] = namespaces + return loaded + + def _save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_text( + json.dumps(self._by_user, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary.replace(self.path) + + def get(self, namespace: str, default: Any = None, user_id: str = SHARED_USER) -> Any: + return self._by_user.get(user_id, {}).get(namespace, default) + + def put(self, namespace: str, value: Any, user_id: str = SHARED_USER) -> Any: + with self._lock: + self._by_user.setdefault(user_id, {})[namespace] = value + self._save() + return value diff --git a/app/api/routes.py b/app/api/routes.py index 62c92de..6822a40 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -15,6 +15,7 @@ from app.bars.models import Timeframe from app.config import DEFAULT_MAX_BARS_PER_TF from app.analysis.levels import Side from app.analysis.manual_lines import ManualLine +from app.analysis.user_prefs import MA_ALERT_PERIODS from app.api.deps import require_token from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture from app.market.es_options import nearby_expirations @@ -176,6 +177,21 @@ def confluence(request: Request): } +@router.get("/prefs/ma-alerts") +def get_ma_alerts(request: Request): + return request.app.state.runtime.ma_alerts() + + +@router.put("/prefs/ma-alerts") +def put_ma_alerts(request: Request, payload: dict): + periods = payload.get("1d", []) + if not isinstance(periods, list) or not all(period in MA_ALERT_PERIODS for period in periods): + raise HTTPException(400, "Unknown MA period") + return request.app.state.runtime.set_ma_alerts({ + "1d": sorted({int(period) for period in periods}), + }) + + @router.post("/lines", status_code=201) def create_line(request: Request, payload: LineCreate): if payload.end_t == payload.anchor_t: diff --git a/app/config.py b/app/config.py index 44c5125..55e42bb 100644 --- a/app/config.py +++ b/app/config.py @@ -62,6 +62,7 @@ class Settings(BaseSettings): # On the persistent volume in production: suppression has to outlive a # deploy or every push re-fires every zone that currently qualifies. alert_state_path: Path = Path("./data/alert_state.json") + user_prefs_path: Path = Path("./data/user_prefs.json") ntfy_topic: str = "" ntfy_server: str = "https://ntfy.sh" chart_auth_token: str = "" diff --git a/app/runtime.py b/app/runtime.py index f4ee69b..a155629 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -10,12 +10,13 @@ from app.bars.aggregator import Aggregator from app.bars.session import bucket_start from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space from app.analysis.horizontals import build_prior_day_levels -from app.analysis.levels import Level +from app.analysis.levels import Level, LevelKind from app.analysis.moving_averages import build_ma_levels from app.analysis.vwap import build_vwap_level from app.analysis.confluence import Cluster, cluster_levels from app.analysis.indicators import atr from app.analysis.manual_lines import ManualLineStore +from app.analysis.user_prefs import UserPrefStore from app.bars.store import InMemoryBarStore from app.config import Settings from app.market.factory import live_source, seed_source @@ -37,6 +38,7 @@ class Runtime: price: float | None = None atr15: float = 0.0 manual_lines: ManualLineStore = field(init=False) + user_prefs: UserPrefStore = field(init=False) ma_levels: list[Level] = field(default_factory=list) alert_engine: AlertEngine = field(init=False) _sent_levels: dict[str, dict] = field(default_factory=dict) @@ -60,6 +62,7 @@ class Runtime: self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.aggregator = Aggregator(self.settings.enabled_timeframes) self.manual_lines = ManualLineStore(self.settings.manual_lines_path) + self.user_prefs = UserPrefStore(self.settings.user_prefs_path) # One engine for the process, not one per browser connection. Cooldowns # are only meaningful if they outlive a page reload, and a phone push # must not depend on a tab being open to produce it. @@ -252,9 +255,27 @@ class Runtime: self.atr15, self.stream.last_bar_t, self.stream.symbol, + self.watched_ma_levels(), ) ) + def ma_alerts(self) -> dict: + stored = self.user_prefs.get("ma_alerts") or {} + return {"1d": list(stored.get("1d") or [])} + + def set_ma_alerts(self, value: dict) -> dict: + self.user_prefs.put("ma_alerts", value) + return self.ma_alerts() + + def watched_ma_levels(self) -> list[Level]: + armed = set(self.ma_alerts().get("1d", [])) + return [ + level for level in self.levels + if level.kind is LevelKind.MA + and level.tf is Timeframe.D1 + and level.period in armed + ] + def dispatch_alerts(self, alerts: list[Alert]) -> None: tripped: set[str] = set() for alert in alerts: diff --git a/docs/plan.md b/docs/plan.md index 8ac4bd7..3cc0780 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -37,8 +37,8 @@ See [§14](#14-why-execution-is-out-of-scope) for why, and for the seam left beh > automatically. It points at the live planning documents: > `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`, > `docs/multi_user.md`, `docs/feature_undo.md`, `docs/mobile_enhance.md`, -> `docs/vite_build.md` and `docs/plan_light_dark_themes.md` for designs not -> yet built. +> `docs/vite_build.md`, `docs/plan_light_dark_themes.md` and +> `docs/plan_dma_alerts.md` for designs not yet built. > > **`main` deploys to production.** A push triggers a Forgejo webhook and > Coolify rebuild of . That is the intended workflow now, diff --git a/docs/plan_dma_alerts.md b/docs/plan_dma_alerts.md new file mode 100644 index 0000000..2c64d5e --- /dev/null +++ b/docs/plan_dma_alerts.md @@ -0,0 +1,43 @@ +# Alerts on daily moving averages + +**Status: built.** Agreed 2026-08-15. Bells live in Layers; watches persist in +`user_prefs.json` under namespace `ma_alerts`. + +Daily SMAs (10/20/50/100/200) already exist as `Level`s and already join +confluence clusters. This is a per-period, on/off watch that fires when +price reaches that average, not a new drawing type. + +## UX + +A bell next to each period in Layers, off by default, independent of the +visibility checkbox. Hiding the line must not mute the alert — same rule +as confluence: layer prefs are a display choice, a push is not. + +Keep them out of the drawings list. They are not drawings. + +## How it fires + +Same proximity test as a typed price alert: `/ES` within half an ATR of +that MA's current price. The engine already runs every closed minute. + +Do **not** copy manual-line disarm. A line you drew is one-shot. A 200 DMA +is a standing level: fire, then stay quiet until price leaves and comes +back (the existing cooldown). Sitting on the average must not chatter. + +The push names the average (`200 DMA`), not a zone. The forming daily SMA +moves as the session prints, so a “touch” can be the average walking to +price. That is real; the label is what stops it being mistaken for +confluence. + +## Persistence + +Five booleans, not rows in `manual_lines.json`. When a user-keyed +preference store exists (`docs/multi_user.md`), these belong there. Until +then, one small prefs object — same shape, single constant `user_id`. + +## What not to do + +- Do not alert because the layer is visible. +- Do not put MA watches in the drawings list or give them drawing numbers. +- Do not one-shot-disarm them like a hand-placed line. +- Do not gate them on confluence score. You asked for this average. diff --git a/static/app.js b/static/app.js index 077341c..c13e22b 100644 --- a/static/app.js +++ b/static/app.js @@ -131,6 +131,7 @@ createApp({ const comments = ref([]); const drawingFilter = ref(''); const drawingKind = ref('all'); + const maAlerts = ref({ '1d': [] }); const drawingTf = ref('all'); const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const captureBusy = ref(false); @@ -1118,6 +1119,27 @@ createApp({ return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period)); } + function maAlertOn(period) { + return (maAlerts.value['1d'] || []).includes(period); + } + + async function refreshMaAlerts() { + const response = await apiFetch('/api/prefs/ma-alerts'); + if (response.ok) maAlerts.value = await response.json(); + } + + async function toggleMaAlert(period) { + const armed = new Set(maAlerts.value['1d'] || []); + if (armed.has(period)) armed.delete(period); + else armed.add(period); + const response = await apiFetch('/api/prefs/ma-alerts', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ '1d': [...armed].sort((a, b) => a - b) }), + }); + if (response.ok) maAlerts.value = await response.json(); + } + function toggleGroup(tf, checked) { prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : []; } @@ -1213,6 +1235,7 @@ createApp({ await migrateLegacyToken(); await refreshStatus(); await refreshComments(); + await refreshMaAlerts(); const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' }; connect(); @@ -1225,6 +1248,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, 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, deleteSelected, deleteLine, toggleDrawingSelection, 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 }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, 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, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, 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 }; }, }).mount('#app'); diff --git a/static/index.html b/static/index.html index 66a2897..1af8669 100644 --- a/static/index.html +++ b/static/index.html @@ -43,7 +43,7 @@ Layers
-
+
diff --git a/static/style.css b/static/style.css index 069349d..bcacba4 100644 --- a/static/style.css +++ b/static/style.css @@ -35,7 +35,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .color-popover { grid-template-columns:1fr; width:178px; gap:3px; } .color-family-row { display:grid; grid-template-columns:70px 1fr; align-items:center; gap:5px; min-width:0; }.color-family-row:nth-child(3),.color-family-row:nth-child(5) { margin-top:4px; padding-top:4px; border-top:1px solid var(--line); }.color-family-input { width:70px; height:18px; padding:1px 3px; border:1px solid transparent; border-radius:2px; background:transparent; color:var(--muted); font:inherit; font-size:8px; }.color-family-input:hover,.color-family-input:focus { border-color:var(--line); background:var(--chart-bg); color:var(--fg); outline:0; }.color-family-swatches { display:grid; grid-template-columns:repeat(4,18px); gap:3px; }.color-family-swatches>button,.color-popover-footer button { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-family-swatches>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover-footer { display:grid; grid-template-columns:18px 1fr 18px; align-items:center; gap:3px; margin-top:4px; padding-top:5px; border-top:1px solid var(--line); }.color-popover-footer .palette-close { grid-column:3; color:var(--muted); background:var(--chart-bg); } .level-editors { display:flex; gap:4px; overflow:visible; text-transform:none; }.level-editors label { display:flex; align-items:center; gap:2px; }.level-editors input { width:54px; height:19px; padding:1px 3px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; } -.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:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; } +.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:grid; grid-template-columns:repeat(3, max-content); gap:7px 10px; padding-left:20px; }.periods .period { display:flex; align-items:center; gap:2px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.ma-alert { display:grid; place-items:center; width:16px; height:16px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; }.ma-alert.on { color:var(--accent); }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; } /* Tool palette: the head arms the tool, the body configures what it creates. */ diff --git a/tests/test_alerts.py b/tests/test_alerts.py index e644cf8..3ac310c 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -142,6 +142,51 @@ def test_an_old_bare_list_state_file_still_loads(tmp_path): assert engine._next_number == 1 +def test_a_watched_daily_ma_alerts_despite_the_score_threshold(): + engine = AlertEngine(min_score=28, cooldown_seconds=900) + watched = level("ma:1d:sma:200", 100, 12) + watched.period = 200 + + alerts = engine.evaluate([], 100, 1, 0, "/ES", [watched]) + assert len(alerts) == 1 + assert alerts[0].tripped == () + assert "200 DMA" in alerts[0].message + assert "ZONE" not in alerts[0].message + + +def test_an_unwatched_daily_ma_does_not_alert_alone(): + engine = AlertEngine(min_score=28, cooldown_seconds=900) + lone = level("ma:1d:sma:200", 100, 12) + lone.period = 200 + clusters = cluster_levels([lone], 100, 100, 1) + + assert engine.evaluate(clusters, 100, 1, 0, "/ES") == [] + + +def test_a_watched_daily_ma_stays_quiet_until_price_leaves(): + engine = AlertEngine(min_score=28, cooldown_seconds=900) + watched = level("ma:1d:sma:50", 100, 12) + watched.period = 50 + + assert len(engine.evaluate([], 100, 1, 0, "/ES", [watched])) == 1 + assert engine.evaluate([], 100.2, 1, 60, "/ES", [watched]) == [] + assert engine.evaluate([], 103, 1, 901, "/ES", [watched]) == [] + assert len(engine.evaluate([], 100, 1, 902, "/ES", [watched])) == 1 + + +def test_a_zone_at_the_same_price_suppresses_a_dma_alert(): + engine = AlertEngine(min_score=6, cooldown_seconds=900) + members = [level("a", 100, 3), level("b", 100.1, 4)] + clusters = cluster_levels(members, 100, 100, 1) + watched = level("ma:1d:sma:200", 100, 12) + watched.period = 200 + + alerts = engine.evaluate(clusters, 100, 1, 0, "/ES", [watched]) + assert len(alerts) == 1 + assert "ZONE" in alerts[0].message + assert "DMA" not in alerts[0].message + + def test_the_push_carries_the_number_and_time_but_the_screen_message_does_not(tmp_path): # ntfy is plain text, so the number and the local time have to live in the # body. The browser gets `number` as a field and draws a badge, so printing diff --git a/tests/test_runtime_alerts.py b/tests/test_runtime_alerts.py index afbe8c6..d075029 100644 --- a/tests/test_runtime_alerts.py +++ b/tests/test_runtime_alerts.py @@ -18,6 +18,7 @@ def runtime(tmp_path, **overrides) -> Runtime: # so without this every test shares one alert-suppression file and they # silence each other. alert_state_path=tmp_path / "alert_state.json", + user_prefs_path=tmp_path / "user_prefs.json", ntfy_topic=overrides.pop("ntfy_topic", ""), **overrides, ) diff --git a/tests/test_user_prefs.py b/tests/test_user_prefs.py new file mode 100644 index 0000000..8c5c3b8 --- /dev/null +++ b/tests/test_user_prefs.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.analysis.user_prefs import SHARED_USER, UserPrefStore +from app.api.routes import router +from app.config import Settings +from app.runtime import Runtime + + +def test_ma_alert_prefs_round_trip(tmp_path): + store = UserPrefStore(tmp_path / "user_prefs.json") + store.put("ma_alerts", {"1d": [200]}) + + loaded = UserPrefStore(tmp_path / "user_prefs.json") + assert loaded.get("ma_alerts") == {"1d": [200]} + assert loaded.get("ma_alerts", user_id="other") is None + + +def test_corrupt_prefs_start_empty(tmp_path): + path = tmp_path / "user_prefs.json" + path.write_text("{not json", encoding="utf-8") + assert UserPrefStore(path).get("ma_alerts", {"1d": []}) == {"1d": []} + + +def test_ma_alert_endpoints_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/ma-alerts").json() == {"1d": []} + assert client.put("/api/prefs/ma-alerts", json={"1d": [10, 200]}).json() == {"1d": [10, 200]} + 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")