This commit is contained in:
Chris Amow 2026-08-15 05:14:28 -05:00
parent 11508e5325
commit 73beae051a
17 changed files with 298 additions and 7 deletions

View file

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

1
.gitignore vendored
View file

@ -5,4 +5,5 @@ __pycache__/
.schwab_token.json
data/manual_lines.json
data/alert_state.json
data/user_prefs.json
artifacts/playwright/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <https://chart.amow.com>. That is the intended workflow now,

43
docs/plan_dma_alerts.md Normal file
View file

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

View file

@ -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');

View file

@ -43,7 +43,7 @@
<summary>Layers</summary>
<div class="layer-group">
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
<div class="periods"><span v-for="period in [10,20,50,100,200]" :key="period" class="period"><label><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label><button type="button" class="ma-alert" :class="{on: maAlertOn(period)}" :aria-pressed="maAlertOn(period)" :title="maAlertOn(period) ? 'Stop alerting on this average' : 'Alert when price reaches this average'" @click="toggleMaAlert(period)"><i :class="maAlertOn(period) ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i></button></span></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>

View file

@ -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. */

View file

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

View file

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

39
tests/test_user_prefs.py Normal file
View file

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