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 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json
USER_PREFS_PATH=./data/user_prefs.json
CONFLUENCE_MIN_SCORE=28 CONFLUENCE_MIN_SCORE=28
# Four hours. Suppression is per price zone, so an unrelated zone still alerts; # Four hours. Suppression is per price zone, so an unrelated zone still alerts;
# this governs only how often the same area repeats. See README. # this governs only how often the same area repeats. See README.

1
.gitignore vendored
View file

@ -5,4 +5,5 @@ __pycache__/
.schwab_token.json .schwab_token.json
data/manual_lines.json data/manual_lines.json
data/alert_state.json data/alert_state.json
data/user_prefs.json
artifacts/playwright/ 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/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/vite_build.md`](docs/vite_build.md). Light/dark theme constraints are
[`docs/plan_light_dark_themes.md`](docs/plan_light_dark_themes.md). [`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 ## 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 `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. 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 **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 `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 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 pathlib import Path
from app.analysis.confluence import Cluster from app.analysis.confluence import Cluster
from app.analysis.levels import LevelKind from app.analysis.levels import Level, LevelKind, Side
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -128,6 +128,7 @@ class AlertEngine:
atr15: float, atr15: float,
now: int, now: int,
symbol: str, symbol: str,
watched: list[Level] | None = None,
) -> list[Alert]: ) -> list[Alert]:
tolerance = 0.5 * atr15 tolerance = 0.5 * atr15
if tolerance <= 0: if tolerance <= 0:
@ -220,6 +221,43 @@ class AlertEngine:
push=f"#{number} {message}\n{self._stamp(fired_at)}", 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: if changed:
self._save() self._save()
return alerts 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.config import DEFAULT_MAX_BARS_PER_TF
from app.analysis.levels import Side from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine 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.deps import require_token
from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture from app.api.captures import CAPTURE_MAX_BYTES, capture_path, save_capture
from app.market.es_options import nearby_expirations 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) @router.post("/lines", status_code=201)
def create_line(request: Request, payload: LineCreate): def create_line(request: Request, payload: LineCreate):
if payload.end_t == payload.anchor_t: 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 # On the persistent volume in production: suppression has to outlive a
# deploy or every push re-fires every zone that currently qualifies. # deploy or every push re-fires every zone that currently qualifies.
alert_state_path: Path = Path("./data/alert_state.json") alert_state_path: Path = Path("./data/alert_state.json")
user_prefs_path: Path = Path("./data/user_prefs.json")
ntfy_topic: str = "" ntfy_topic: str = ""
ntfy_server: str = "https://ntfy.sh" ntfy_server: str = "https://ntfy.sh"
chart_auth_token: str = "" chart_auth_token: str = ""

View file

@ -10,12 +10,13 @@ from app.bars.aggregator import Aggregator
from app.bars.session import bucket_start from app.bars.session import bucket_start
from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space 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.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.moving_averages import build_ma_levels
from app.analysis.vwap import build_vwap_level from app.analysis.vwap import build_vwap_level
from app.analysis.confluence import Cluster, cluster_levels from app.analysis.confluence import Cluster, cluster_levels
from app.analysis.indicators import atr from app.analysis.indicators import atr
from app.analysis.manual_lines import ManualLineStore from app.analysis.manual_lines import ManualLineStore
from app.analysis.user_prefs import UserPrefStore
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
@ -37,6 +38,7 @@ class Runtime:
price: float | None = None price: float | None = None
atr15: float = 0.0 atr15: float = 0.0
manual_lines: ManualLineStore = field(init=False) manual_lines: ManualLineStore = field(init=False)
user_prefs: UserPrefStore = field(init=False)
ma_levels: list[Level] = field(default_factory=list) ma_levels: list[Level] = field(default_factory=list)
alert_engine: AlertEngine = field(init=False) alert_engine: AlertEngine = field(init=False)
_sent_levels: dict[str, dict] = field(default_factory=dict) _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.store = InMemoryBarStore(self.settings.max_bars_per_tf)
self.aggregator = Aggregator(self.settings.enabled_timeframes) self.aggregator = Aggregator(self.settings.enabled_timeframes)
self.manual_lines = ManualLineStore(self.settings.manual_lines_path) 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 # One engine for the process, not one per browser connection. Cooldowns
# are only meaningful if they outlive a page reload, and a phone push # are only meaningful if they outlive a page reload, and a phone push
# must not depend on a tab being open to produce it. # must not depend on a tab being open to produce it.
@ -252,9 +255,27 @@ class Runtime:
self.atr15, self.atr15,
self.stream.last_bar_t, self.stream.last_bar_t,
self.stream.symbol, 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: def dispatch_alerts(self, alerts: list[Alert]) -> None:
tripped: set[str] = set() tripped: set[str] = set()
for alert in alerts: 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: > automatically. It points at the live planning documents:
> `docs/NEXT_STEPS.md` for the short list, `docs/async_refactor.md`, > `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/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 > `docs/vite_build.md`, `docs/plan_light_dark_themes.md` and
> yet built. > `docs/plan_dma_alerts.md` for designs not yet built.
> >
> **`main` deploys to production.** A push triggers a Forgejo webhook and > **`main` deploys to production.** A push triggers a Forgejo webhook and
> Coolify rebuild of <https://chart.amow.com>. That is the intended workflow now, > 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 comments = ref([]);
const drawingFilter = ref(''); const drawingFilter = ref('');
const drawingKind = ref('all'); const drawingKind = ref('all');
const maAlerts = ref({ '1d': [] });
const drawingTf = ref('all'); const drawingTf = ref('all');
const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const diagnosticMode = ref(ConfluenceChart.diagnosticMode());
const captureBusy = ref(false); const captureBusy = ref(false);
@ -1118,6 +1119,27 @@ createApp({
return available.every(period => (prefs.value.enabled.ma[tf] || []).includes(period)); 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) { function toggleGroup(tf, checked) {
prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : []; prefs.value.enabled.ma[tf] = checked ? [...(MA_PERIODS[tf] || [])] : [];
} }
@ -1213,6 +1235,7 @@ createApp({
await migrateLegacyToken(); await migrateLegacyToken();
await refreshStatus(); await refreshStatus();
await refreshComments(); await refreshComments();
await refreshMaAlerts();
const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); const version = await fetch('/api/version').then(response => response.json()).catch(() => ({}));
build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' }; build.value = { commit: version.commit || 'dev', startedAt: version.started_at || '' };
connect(); connect();
@ -1225,6 +1248,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -43,7 +43,7 @@
<summary>Layers</summary> <summary>Layers</summary>
<div class="layer-group"> <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> <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>
<div class="layer-group"> <div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label> <label><input type="checkbox" v-model="prefs.enabled.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-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); } .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; } .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; } .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. */ /* 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 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): 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 # 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 # 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 # so without this every test shares one alert-suppression file and they
# silence each other. # silence each other.
alert_state_path=tmp_path / "alert_state.json", alert_state_path=tmp_path / "alert_state.json",
user_prefs_path=tmp_path / "user_prefs.json",
ntfy_topic=overrides.pop("ntfy_topic", ""), ntfy_topic=overrides.pop("ntfy_topic", ""),
**overrides, **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")