tweaks
This commit is contained in:
parent
11508e5325
commit
73beae051a
17 changed files with 298 additions and 7 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -5,4 +5,5 @@ __pycache__/
|
|||
.schwab_token.json
|
||||
data/manual_lines.json
|
||||
data/alert_state.json
|
||||
data/user_prefs.json
|
||||
artifacts/playwright/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
app/analysis/user_prefs.py
Normal file
57
app/analysis/user_prefs.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
43
docs/plan_dma_alerts.md
Normal 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.
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
39
tests/test_user_prefs.py
Normal 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")
|
||||
Loading…
Reference in a new issue