Compare commits

..

No commits in common. "73beae051a34d5773fab383bfe789b6ffabbc9d0" and "5ee32b327c825a11401a995d752b88fb05c5962f" have entirely different histories.

19 changed files with 14 additions and 407 deletions

View file

@ -27,7 +27,6 @@ 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,5 +5,4 @@ __pycache__/
.schwab_token.json
data/manual_lines.json
data/alert_state.json
data/user_prefs.json
artifacts/playwright/

View file

@ -13,7 +13,6 @@ 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,10 +354,6 @@ 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 Level, LevelKind, Side
from app.analysis.levels import LevelKind
logger = logging.getLogger(__name__)
@ -128,7 +128,6 @@ class AlertEngine:
atr15: float,
now: int,
symbol: str,
watched: list[Level] | None = None,
) -> list[Alert]:
tolerance = 0.5 * atr15
if tolerance <= 0:
@ -221,43 +220,6 @@ 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

@ -1,57 +0,0 @@
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,7 +15,6 @@ 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
@ -177,21 +176,6 @@ 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:
@ -357,8 +341,6 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
# None deliberately clears a per-level override and restores ATR behavior.
if "alert_early_points" in payload.model_fields_set:
changes["alert_early_points"] = payload.alert_early_points
if "cutoff_t" in payload.model_fields_set:
changes["cutoff_t"] = payload.cutoff_t
if changes.get("anchor_t") == changes.get("last_t") and "anchor_t" in changes:
raise HTTPException(400, "Line endpoints must have different times")
try:

View file

@ -62,7 +62,6 @@ 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,13 +10,12 @@ 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, LevelKind
from app.analysis.levels import Level
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
@ -38,7 +37,6 @@ 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)
@ -62,7 +60,6 @@ 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.
@ -255,27 +252,9 @@ 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`, `docs/plan_light_dark_themes.md` and
> `docs/plan_dma_alerts.md` for designs not yet built.
> `docs/vite_build.md` and `docs/plan_light_dark_themes.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,

View file

@ -1,43 +0,0 @@
# 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

@ -1,4 +1,4 @@
const { createApp, ref, computed, watch, onMounted, onUnmounted, nextTick } = Vue;
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
// Keep an existing token long enough to migrate an already-open browser to the
// password session. Successful login removes it; new browsers never store the
@ -131,14 +131,11 @@ 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);
const snap = ref(true);
const selectedDrawing = ref(null);
const selectedDrawings = ref([]);
const drawingList = ref(null);
const alertPrice = ref(null);
const alertNote = ref('');
const levelColor = ref('#e0a34a');
@ -286,11 +283,10 @@ createApp({
const needle = drawingFilter.value.trim().toLowerCase();
return drawings.value.filter(item => {
if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false;
const timeframe = item.line?.tf || item.comment?.tf || '';
if (drawingTf.value !== 'all' && timeframe !== drawingTf.value) return false;
if (!needle) return true;
// Matched against what is on screen — the label or comment text, the
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
const timeframe = item.line?.tf || item.comment?.tf || '';
return `${item.label} ${item.kind} ${timeframe} #${item.number}`.toLowerCase().includes(needle);
});
});
@ -1119,27 +1115,6 @@ 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] || [])] : [];
}
@ -1150,42 +1125,11 @@ createApp({
sendPrefs();
}, { deep: true });
function startDrawingListResize(event) {
const el = drawingList.value;
if (!el) return;
event.preventDefault();
const origin = event.clientY;
const start = el.getBoundingClientRect().height;
const move = ev => {
el.style.height = `${Math.max(88, Math.round(start + ev.clientY - origin))}px`;
};
const stop = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
const height = Math.round(el.getBoundingClientRect().height);
if (height > 80) localStorage.setItem('chart-drawing-list-height', String(height));
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
}
function revealSelectedDrawing(id) {
if (!id) return;
const list = drawingList.value;
const section = list?.closest('details');
if (section) section.open = true;
nextTick(() => {
const row = list?.querySelector(`[data-drawing-id="${CSS.escape(id)}"]`);
row?.scrollIntoView({ block: 'nearest' });
});
}
watch(selectedDrawing, id => {
if (chartApi) {
chartApi.setSelectedDrawing(id);
chartApi.setSelectedLine(levels.value.some(level => level.id === id) ? id : null);
}
revealSelectedDrawing(id);
});
watch(snap, value => {
@ -1227,15 +1171,9 @@ createApp({
chartApi.setAutoScrollLivePrice(autoScrollLivePrice.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown);
nextTick(() => {
const el = drawingList.value;
const saved = Number(localStorage.getItem('chart-drawing-list-height'));
if (el && saved > 80) el.style.height = `${saved}px`;
});
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();
@ -1248,6 +1186,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, 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 };
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, 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, 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

@ -40,7 +40,6 @@ class ConfluenceChart {
this.contextCutoff = null;
this.contextLineId = null;
this.contextEndButton = null;
this.contextExtendButton = null;
this.contextListener = null;
this.onLineEnd = null;
this.onLineDuplicate = null;
@ -320,18 +319,10 @@ class ConfluenceChart {
this.endSelectedLineHere();
});
this.contextEndButton = endHere;
const extend = document.createElement('button');
extend.type = 'button';
extend.textContent = 'Extend trendline';
extend.addEventListener('click', event => {
event.stopPropagation();
this.extendSelectedLine();
});
this.contextExtendButton = extend;
const duplicate = document.createElement('button');
duplicate.type = 'button';
duplicate.dataset.action = 'duplicate';
duplicate.textContent = 'Duplicate';
duplicate.textContent = 'Duplicate 10 bars right';
duplicate.addEventListener('click', event => {
event.stopPropagation();
const level = this.levels.find(value => value.id === this.contextLineId);
@ -349,7 +340,7 @@ class ConfluenceChart {
if (id && this.onLineDelete) this.onLineDelete(id);
});
this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.append(endHere, extend, duplicate, deleteLine);
this.contextMenu.append(endHere, duplicate, deleteLine);
this.overlayLayer.appendChild(this.contextMenu);
this.priceTag = document.createElement('div');
this.priceTag.className = 'chart-price-tag';
@ -2209,7 +2200,6 @@ class ConfluenceChart {
this.contextLineId = level.id;
this.contextCutoff = cutoff > level.last_t ? cutoff : null;
this.contextEndButton.hidden = this.contextCutoff == null;
this.contextExtendButton.hidden = level.cutoff_t == null;
this.contextMenu.hidden = false;
const plot = this.plotCanvas().getBoundingClientRect();
this.contextMenu.style.left = `${Math.max(4, Math.min(point.x, plot.width - 170))}px`;
@ -2239,17 +2229,6 @@ class ConfluenceChart {
if (this.onLineEnd) this.onLineEnd({ ...level });
}
extendSelectedLine() {
const level = this.levels.find(value => value.id === this.selectedLineId);
if (!level || level.cutoff_t == null) return;
level.cutoff_t = null;
const entry = this.levelSeries.get(level.id);
if (entry) entry.series.setData(this.seriesLineData(level));
this.renderLineBridges();
this.hideContextMenu();
if (this.onLineEnd) this.onLineEnd({ ...level });
}
// Tinted by the bar's own direction, muted so the candles stay the subject.
static toVolume(bar) {
return {

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"><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 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>
<div class="layer-group">
<label><input type="checkbox" v-model="prefs.enabled.horizontal"><span class="swatch horizontal"></span>Prior day H/L/C</label>
@ -221,7 +221,7 @@
</div>
</div>
</details>
<details class="sidebar-section drawings-section" open>
<details class="sidebar-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="drawing-filters">
<select v-model="drawingKind" aria-label="Filter drawings by type">
@ -231,11 +231,7 @@
<option value="comment">Comments</option>
<option value="symbol">Symbols</option>
</select>
<select v-model="drawingTf" aria-label="Filter drawings by timeframe">
<option value="all">All TFs</option>
<option v-for="tf in timeframes" :key="tf" :value="tf">{{ tf }}</option>
</select>
<input v-model="drawingFilter" placeholder="Filter text" aria-label="Filter drawings by text">
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
@ -247,13 +243,10 @@
</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div>
<div class="drawing-list-shell">
<div class="drawing-list" ref="drawingList">
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:data-drawing-id="item.id"
:class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}">
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@ -351,10 +344,6 @@
</div>
</div>
</div>
</div>
<div class="drawing-list-resize" role="separator" aria-orientation="horizontal"
aria-label="Resize drawings list" @pointerdown="startDrawingListResize"></div>
</div>
</details>
<details class="sidebar-section">
<summary>Confluence zones</summary>

View file

@ -22,11 +22,6 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { display:flex; gap:24px; margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.drawing-list-shell { display:flex; flex-direction:column; min-width:0; }
.drawing-list { overflow:auto; min-height:88px; height:180px; border:1px solid var(--line); border-bottom:0; }
.drawing-list-resize { flex:none; height:11px; border:1px solid var(--line); background:var(--chart-bg); cursor:ns-resize; touch-action:none; }
.drawing-list-resize::after { content:''; display:block; width:36px; height:3px; margin:3px auto 0; border-radius:1px; background:var(--muted); }
.drawing-list-resize:hover::after { background:var(--accent); }
.trendline-actions { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:4px; margin-bottom:7px; }.trendline-actions button { padding:3px 5px; font-size:8px; white-space:nowrap; }.selection-count { color:var(--muted); font-size:8px; text-align:center; white-space:nowrap; }
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
@ -35,7 +30,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: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; }
.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; }
.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. */
@ -105,7 +100,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.option-row { display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:4px; align-items:center; padding:3px 0; border-bottom:1px solid var(--line); font-size:10px; }
.option-row.option-head { color:var(--muted); text-transform:uppercase; letter-spacing:.3px; font-size:8px; }
.option-row button { padding:2px 6px; font-size:8px; }
.drawing-filters { display:grid; grid-template-columns:auto auto 1fr; gap:6px; margin-bottom:8px; }
.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; }
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }

View file

@ -142,51 +142,6 @@ 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

@ -136,27 +136,3 @@ def test_every_drawing_gets_a_number_including_comments(tmp_path):
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
assert [first.number, note.number, third.number] == [1, 2, 3]
def test_a_null_cutoff_clears_an_ended_line(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.routes import router
from app.config import Settings
from app.runtime import Runtime
app = FastAPI()
app.include_router(router)
app.state.runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
client = TestClient(app)
created = client.post("/api/lines", json={
"tf": "1m", "side": "support",
"anchor_t": 100, "anchor_p": 1.0,
"end_t": 200, "end_p": 2.0,
"cutoff_t": 150,
}).json()
assert created["cutoff_t"] == 150
cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json()
assert cleared["cutoff_t"] is None

View file

@ -18,7 +18,6 @@ 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,
)

View file

@ -1,39 +0,0 @@
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")