Compare commits
No commits in common. "73beae051a34d5773fab383bfe789b6ffabbc9d0" and "5ee32b327c825a11401a995d752b88fb05c5962f" have entirely different histories.
73beae051a
...
5ee32b327c
19 changed files with 14 additions and 407 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -5,5 +5,4 @@ __pycache__/
|
|||
.schwab_token.json
|
||||
data/manual_lines.json
|
||||
data/alert_state.json
|
||||
data/user_prefs.json
|
||||
artifacts/playwright/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Reference in a new issue