diff --git a/AGENTS.md b/AGENTS.md index da595b9..2e762b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ deferred fixes. Mobile interaction work also has its own detailed plan in [`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). +Adding `/NQ` `/GC` `/CL` is [`docs/investigate_added_symbols.md`](docs/investigate_added_symbols.md). ## Tests earn their place by catching a real bug diff --git a/app/analysis/alerts.py b/app/analysis/alerts.py index 7cabc2b..d0c4f91 100644 --- a/app/analysis/alerts.py +++ b/app/analysis/alerts.py @@ -7,6 +7,7 @@ from pathlib import Path from app.analysis.confluence import Cluster from app.analysis.levels import Level, LevelKind, Side +from app.instrument import DEFAULT_SYMBOL, instrument_for_symbol logger = logging.getLogger(__name__) @@ -43,6 +44,7 @@ class Alert: class _Fired: center: float at: int + symbol: str = DEFAULT_SYMBOL class AlertEngine: @@ -83,7 +85,14 @@ class AlertEngine: if isinstance(payload, dict): self._next_number = int(payload.get("next_number", 1)) payload = payload.get("fired", []) - return [_Fired(float(item["center"]), int(item["at"])) for item in payload] + return [ + _Fired( + float(item["center"]), + int(item["at"]), + str(item.get("symbol") or DEFAULT_SYMBOL), + ) + for item in payload + ] except Exception: # Corrupt state costs one burst of duplicate alerts, which is a far # better failure than refusing to start the stream. @@ -115,7 +124,12 @@ class AlertEngine: { "next_number": self._next_number, "fired": [ - {"center": entry.center, "at": entry.at} for entry in self._fired + { + "center": entry.center, + "at": entry.at, + "symbol": entry.symbol, + } + for entry in self._fired ], }, indent=2, @@ -142,6 +156,7 @@ class AlertEngine: tolerance = 0.5 * atr15 if tolerance <= 0: return [] + root = instrument_for_symbol(symbol).schwab_symbol # Two zones within an ATR of each other are the same zone as far as # being told about them goes. merge_distance = 2 * tolerance @@ -195,10 +210,12 @@ class AlertEngine: # was oscillating on re-alerted on every crossing — which is exactly # when a level is least newsworthy, not most. if any( - abs(entry.center - cluster.center) <= merge_distance for entry in self._fired + entry.symbol == root + and abs(entry.center - cluster.center) <= merge_distance + for entry in self._fired ): continue - self._fired.append(_Fired(cluster.center, now)) + self._fired.append(_Fired(cluster.center, now, root)) changed = True direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH" timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members)) @@ -239,9 +256,13 @@ class AlertEngine: 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): + if any( + entry.symbol == root + and abs(entry.center - price) <= merge_distance + for entry in self._fired + ): continue - self._fired.append(_Fired(price, now)) + self._fired.append(_Fired(price, now, root)) changed = True direction = "BEARISH" if level.side is Side.RESISTANCE else "BULLISH" name = f"{level.period} DMA" if level.period else level.label diff --git a/app/analysis/event_log.py b/app/analysis/event_log.py index ae9559b..8f0dd8c 100644 --- a/app/analysis/event_log.py +++ b/app/analysis/event_log.py @@ -3,6 +3,8 @@ import logging import time from pathlib import Path +from app.instrument import DEFAULT_SYMBOL + logger = logging.getLogger(__name__) DAY_SECONDS = 86400 @@ -39,12 +41,16 @@ class EventLog: except Exception: logger.warning("Could not persist event log", exc_info=True) - def add(self, kind: str, message: str, *, number: int | None = None, at: int | None = None) -> dict: + def add( + self, kind: str, message: str, *, number: int | None = None, + at: int | None = None, symbol: str | None = None, + ) -> dict: entry = { "kind": kind, "message": message, "number": number, "at": int(at if at is not None else time.time()), + "symbol": symbol or DEFAULT_SYMBOL, } self._entries.append(entry) self._save() diff --git a/app/analysis/levels.py b/app/analysis/levels.py index c2e6cf4..7637cb6 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -3,6 +3,7 @@ from enum import Enum from typing import Any from app.bars.models import Timeframe +from app.instrument import DEFAULT_SYMBOL class LevelKind(str, Enum): @@ -55,6 +56,7 @@ class Level: # price this line safely. Such a line remains visible but cannot cluster or # alert using the absolute-time fallback. geometry_resolved: bool = True + symbol: str = DEFAULT_SYMBOL def price_at(self, t: int) -> float: return self.anchor_p + self.slope * (t - self.anchor_t) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 91fb776..e3ec69f 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -6,6 +6,7 @@ from threading import RLock from app.analysis.levels import Level, LevelKind, Side from app.bars.models import Timeframe from app.config import TIMEFRAME_WEIGHT +from app.instrument import DEFAULT_SYMBOL @dataclass(slots=True) @@ -37,6 +38,7 @@ class ManualLine: # Visual multiplier for marks. 1 is the original 30px glyph; the pin stays # on (anchor_t, anchor_p) regardless of this value. scale: float = 1.0 + symbol: str = DEFAULT_SYMBOL @property def drawing_kind(self) -> str: @@ -93,6 +95,7 @@ class ManualLine: cutoff_t=self.cutoff_t, armed=self.armed, alert_early_points=self.alert_early_points, + symbol=self.symbol, ) def to_dict(self) -> dict: @@ -130,6 +133,7 @@ class ManualLine: if value.get("alert_early_points") is not None else None ), scale=float(value.get("scale", 1.0) or 1.0), + symbol=str(value.get("symbol") or DEFAULT_SYMBOL), ) diff --git a/app/api/routes.py b/app/api/routes.py index 4a19599..d4f6eff 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -132,6 +132,7 @@ class LineRestore(BaseModel): icon: str = "" alert_early_points: float | None = None scale: float = Field(1.0, ge=0.5, le=3) + symbol: str = "" class LinePatch(BaseModel): @@ -175,6 +176,7 @@ def status(request: Request): "worst": round(runtime.loop_lag_worst * 1000, 1), }, "needs_login": runtime.needs_login(), + "instrument": runtime.settings.profile.payload(), } @@ -279,6 +281,7 @@ def create_line(request: Request, payload: LineCreate): cutoff_t=payload.cutoff_t, armed=payload.armed, kind=payload.kind, + symbol=request.app.state.runtime.settings.profile.schwab_symbol, ) runtime = request.app.state.runtime line = runtime.manual_lines.add(line) @@ -319,6 +322,7 @@ def restore_line(request: Request, payload: LineRestore): icon=payload.icon, alert_early_points=payload.alert_early_points, scale=payload.scale, + symbol=payload.symbol or runtime.settings.profile.schwab_symbol, ) line = runtime.manual_lines.add(line) runtime.rebuild_levels() @@ -348,6 +352,7 @@ def create_price_alert(request: Request, payload: PriceAlertCreate): color=payload.color, line_width=payload.line_width, alert_early_points=payload.alert_early_points, + symbol=runtime.settings.profile.schwab_symbol, ) line = runtime.manual_lines.add(line) runtime.rebuild_levels() @@ -400,6 +405,7 @@ def create_comment(request: Request, payload: CommentCreate): y=payload.y, # A comment must never alert, whatever else changes around it. armed=False, + symbol=runtime.settings.profile.schwab_symbol, ) line = runtime.manual_lines.add(line) return line.to_dict() diff --git a/app/api/ws.py b/app/api/ws.py index c11cb6b..33e3dc4 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -193,6 +193,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: "future_times": displayed_future_times(runtime, tf), "events": events, "events_more": events_more, + "instrument": runtime.settings.profile.payload(), } diff --git a/app/config.py b/app/config.py index 0ea8213..d52af53 100644 --- a/app/config.py +++ b/app/config.py @@ -3,6 +3,7 @@ from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict from app.bars.models import Timeframe +from app.instrument import get_instrument DEFAULT_MAX_BARS_PER_TF = 5000 @@ -24,6 +25,7 @@ class Settings(BaseSettings): live_source: str = "yahoo" seed_source: str = "yahoo" + instrument: str = "es" yahoo_symbol: str = "ES=F" yahoo_poll_seconds: float = 20 seed_1h_range: str = "730d" @@ -73,6 +75,10 @@ class Settings(BaseSettings): alert_timezone: str = "America/Chicago" replay_file: Path | None = None + @property + def profile(self): + return get_instrument(self.instrument) + @property def live_symbol(self) -> str: """What the live source calls the instrument. diff --git a/app/instrument.py b/app/instrument.py new file mode 100644 index 0000000..ebf0701 --- /dev/null +++ b/app/instrument.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass + + +DEFAULT_SYMBOL = "/ES" + + +@dataclass(frozen=True) +class Instrument: + id: str + yahoo_symbol: str + schwab_symbol: str + tick: float + decimals: int = 2 + session: str = "globex_18_17" + rth: str = "spy_rth" + + def snap(self, price: float) -> float: + return round(round(price / self.tick) * self.tick, self.decimals) + + def payload(self) -> dict: + return { + "id": self.id, + "yahoo_symbol": self.yahoo_symbol, + "schwab_symbol": self.schwab_symbol, + "tick": self.tick, + "decimals": self.decimals, + "session": self.session, + "rth": self.rth, + } + + +INSTRUMENTS = { + "es": Instrument("es", "ES=F", "/ES", 0.25, rth="spy_rth"), + "nq": Instrument("nq", "NQ=F", "/NQ", 0.25, rth="spy_rth"), + "gc": Instrument("gc", "GC=F", "/GC", 0.10, rth="none"), + "cl": Instrument("cl", "CL=F", "/CL", 0.01, rth="nymex_day"), +} + + +def get_instrument(instrument_id: str) -> Instrument: + try: + return INSTRUMENTS[instrument_id] + except KeyError: + raise ValueError(f"Unknown instrument: {instrument_id}") from None + + +def instrument_for_symbol(symbol: str) -> Instrument: + for instrument in INSTRUMENTS.values(): + if symbol in (instrument.id, instrument.schwab_symbol, instrument.yahoo_symbol): + return instrument + return INSTRUMENTS["es"] diff --git a/app/runtime.py b/app/runtime.py index 4784cd6..e822bce 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -317,12 +317,18 @@ class Runtime: def _on_stream_drop(self, error: str) -> None: kind = "auth" if "invalid_grant" in error or "Refresh token" in error else "stream" - self.events.add(kind, error.split("\n", 1)[0][:200]) + self.events.add( + kind, error.split("\n", 1)[0][:200], + symbol=self.settings.profile.schwab_symbol, + ) def dispatch_alerts(self, alerts: list[Alert]) -> None: tripped: set[str] = set() for alert in alerts: - self.events.add("alert", alert.message, number=alert.number, at=alert.at) + self.events.add( + "alert", alert.message, number=alert.number, at=alert.at, + symbol=self.settings.profile.schwab_symbol, + ) self.broadcast({ "type": "alert", "cluster": alert.cluster, diff --git a/docs/implementation.md b/docs/implementation.md index d3d9b23..5752baa 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -1418,3 +1418,12 @@ which clips drawing. Slope and both handles stay. Extend clears the cutoff. The menu now offers End here for any click after the earlier of the two anchors, including on the body between them and at `last_t` itself (that stops the projection without moving the end handle). + +### 2026-08-31 — instrument profile and symbol stamps + +Drawings, alert-state rows, and events now carry `symbol`, default `/ES`. +A missing field loads as ES so production JSON does not need a rewrite +before the first save. Fired zones for `/ES` and `ES=F` are the same +root; `/GC` at the same price is not. The browser snap grid reads `tick` +from the snapshot/status profile instead of a chart constant. No +switcher and no second stream — see `docs/investigate_added_symbols.md`. diff --git a/docs/investigate_added_symbols.md b/docs/investigate_added_symbols.md new file mode 100644 index 0000000..d742da5 --- /dev/null +++ b/docs/investigate_added_symbols.md @@ -0,0 +1,160 @@ +# Added symbols — `/ES`, `/NQ`, `/GC`, `/CL` + +**Status: placeholders in, switcher not.** Agreed 2026-08-31. Profile +and `symbol` stamps are live; still one stream and no UI switcher. + +The process is one chart of one contract. Yahoo and Schwab already know +the other roots. Almost everything after the stream is *this* instrument. +The likely set is four: **ES, NQ, gold, oil**. Design for N, not a +boolean ES/gold switch. + +--- + +## Decision + +**Persist `symbol` now. Do not split `Runtime` now.** + +Same move as `user_id: "shared"` in `docs/multi_user.md`: cheap while +there is one value, expensive after two files exist. + +### Do first (placeholders, still one live series) + +1. **Instrument profile** the settings and the browser both see: + + ``` + id: "es" | "nq" | "gc" | "cl" + yahoo_symbol: "ES=F" | "NQ=F" | "GC=F" | "CL=F" + schwab_symbol: "/ES" | "/NQ" | "/GC" | "/CL" + tick: 0.25 | 0.25 | 0.10 | 0.01 + decimals: 2 + session: globex_18_17 + rth: spy_rth | spy_rth | none | nymex_day + ``` + + Snap, nudge, alert inputs, and the status label read `tick` / names + from here. Kill `ConfluenceChart.TICK = 0.25`. Session code stays + shared. RTH follows the profile (`none` hides SPY marks on gold). + +2. **Stamp persistence.** Drawings, alert-state rows, and events get + `symbol` (Schwab root, e.g. `/ES`). Missing field means `/ES`. New + writes always stamp the current profile. Do not wait for a second + chart. + +3. **Keep one store, one stream, one seed.** `Bar.symbol` already exists. + `InMemoryBarStore` stays `tf → bars` until something actually switches. + +Today’s env still selects the one live profile (`YAHOO_SYMBOL` / +`SCHWAB_SYMBOL` or an `INSTRUMENT=es` key). Default remains ES. + +### Then (after placeholders have been live) + +4. Prove a second root as a **replace**: point env at NQ or GC, restart, + confirm Yahoo history + Schwab stream + tick snaps. NQ is the cheap + proof (same tick and RTH as ES). GC or CL is the proof that tick/RTH + actually split. + +5. **Switcher** last: UI picks the profile; snapshot replace (`setBars`), + not a tick; filter drawings/alerts by `symbol`; lazy-seed the other + series; do not Schwab-sub the hidden root. + +### Not in this plan + +Two live streams (ES and gold on screen together). That is a second tick +path and a second 5k-bar store inside Stay cheap, plus an unverified +double `CHART_FUTURES` sub on one token. Not until the switcher has been +used. + +--- + +## Instrument table + +| | `/ES` | `/NQ` | `/GC` | `/CL` | +|---|---|---|---|---| +| Tick | 0.25 | 0.25 | 0.10 | 0.01 | +| Display | 2 dp | 2 dp | 2 dp | 2 dp | +| Yahoo | `ES=F` | `NQ=F` | `GC=F` | `CL=F` | +| Schwab | `/ES` | `/NQ` | `/GC` | `/CL` | +| Globex 18:00–17:00 | yes | yes | yes | yes | +| SPY RTH overlay | yes | yes | no | no (NYMEX day 9:00–14:30 ET) | +| Options UI | keep | hide or later | hide | hide | + +NQ is the cheapest second chart. Gold and oil are why tick cannot stay a +chart constant. `toFixed(2)` covers all four. + +--- + +## Why the placeholders + +The current process is one `Runtime`, one Schwab socket, one bar store, +one `manual_lines.json`, one alert-state file, one ntfy topic. + +| File | Today | After step 2 | +|---|---|---| +| `data/manual_lines.json` | no symbol | each row `symbol: "/ES"` | +| `data/alert_state.json` | zones by price | zone + symbol | +| `data/events.json` | one log | tagged or filtered by symbol | +| `data/user_prefs.json` | global | leave global until the switcher | +| `localStorage` | layers, theme | leave until the switcher | + +Without `symbol` on drawings, a switcher would mix ES lines onto NQ. +Without it on alert state, gold 2650 would be silenced by an old ES +2650. Adding the field later is a migration of production JSON. + +`ConfluenceChart.TICK = 0.25` feeds every snap, keyboard nudge, and the +price-alert `step`. Gold cannot ship with that literal. Pulling it into +the profile is not scaffolding — it is deleting a lie. + +Schwab continuous roots (`/ES`, `/NQ`, `/GC`, `/CL`) should auto-resolve +the front month the same way `/ES` → `/ESU26`. Verify each with +`scripts/check_stream.py` before trusting it. Singular `get_quote("/GC")` +is still the equity slash trap; always `get_quotes`. + +--- + +## What stays generic + +Aggregator, VWAP, daily MAs, prior-day H/L/C (session is shared Globex), +WebSocket snapshot shape, drawing tools, Fibonacci, comments, confluence +*math*, ntfy, auth. They work if the bars and the profile are the +instrument’s. + +What does not: RTH marks, tick grid, options panel, confluence *score* +(28 and the 4h cooldown were calibrated on ES — re-run +`scripts/calibrate_alerts.py` per tape before turning confluence on). + +Yahoo daily bars stay unused. 1h → session 1d, same as ES. + +--- + +## Stay cheap + +The tick budget is one series: forming tick = candle + price label. +A symbol switch is `setBars`. Do not subscribe Schwab to a hidden root. +Do not 2× the ~82s Yahoo seed; lazy-load the next instrument on first +view. + +Two symbols on one Schwab socket is unverified. Two *processes* both +opening a stream still kick each other off. + +--- + +## What not to do + +- Do not split `Runtime` or the bar store until the switcher exists. +- Do not add a disabled switcher, a second seed, or a second Schwab sub + in the placeholder change. +- Do not add a second `package.json`, Vue app, or process “for gold.” +- Do not leave `TICK = 0.25` and “just chart gold.” +- Do not reuse ES confluence calibration. +- Do not show SPY RTH on metals or oil. +- Do not build gold/oil/NQ options in the same change as the chart. +- Do not put two symbols in one `manual_lines.json` without a symbol key. +- Do not start two live streams. + +--- + +## Open, before the switcher (not before placeholders) + +1. First extra root to prove as a replace: NQ (easy) or GC (forces tick)? +2. Does `/NQ` `/GC` `/CL` on this account stream `delayed: false`? +3. Does each Yahoo `*F` 1h series go back far enough for a daily 200 SMA? diff --git a/static/app.js b/static/app.js index f9fed63..9d39a54 100644 --- a/static/app.js +++ b/static/app.js @@ -154,6 +154,7 @@ createApp({ const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); const autoScrollLivePrice = ref(localStorage.getItem('chart-auto-scroll-live-price') !== 'false'); const extraDetail = ref(localStorage.getItem('chart-extra-detail') === 'true'); + const tick = ref(0.25); const sessionRange = ref(localStorage.getItem('chart-session-range') !== 'false'); const hideLowerTfDrawings = ref(localStorage.getItem('chart-hide-lower-tf-drawings') !== 'false'); const optionPrefs = (() => { @@ -365,7 +366,13 @@ createApp({ async function refreshStatus() { const response = await apiFetch('/api/status'); - if (response.ok) status.value = await response.json(); + if (response.ok) { + status.value = await response.json(); + if (status.value.instrument && chartApi) { + chartApi.setInstrument(status.value.instrument); + tick.value = status.value.instrument.tick || tick.value; + } + } } function selectedExpiration() { @@ -525,6 +532,10 @@ createApp({ if (message.type === 'snapshot') { dataReceivedAt.value = Date.now(); chartApi.setTrendlineGeometry(message.trendline_geometry); + if (message.instrument) { + chartApi.setInstrument(message.instrument); + tick.value = message.instrument.tick || tick.value; + } chartApi.setDisplayFutureTimes(message.future_times); chartApi.setBars(message.bars); levels.value = message.levels || []; @@ -1354,14 +1365,14 @@ createApp({ if (item.line) { const line = { ...item.line }; const previous = { ...item.line }; - if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK); + if (vertical) line.anchor_p = chartApi.snapPrice(line.anchor_p + vertical * chartApi.tick); if (line.slope === 0) { if (vertical) await updateLineGeometry(line, previous); continue; } const endPrice = item.line.anchor_p + item.line.slope * (item.line.last_t - item.line.anchor_t) - + vertical * ConfluenceChart.TICK; + + vertical * chartApi.tick; if (horizontal) { if (line.geometry_resolved === false) continue; const anchorT = chartApi.shiftLineTime(line, line.anchor_t, horizontal); @@ -1383,8 +1394,8 @@ createApp({ if (comment.pinned) { const changes = {}; if (vertical) { - changes.anchor_p = ConfluenceChart.snapToTick( - comment.anchor_p + vertical * ConfluenceChart.TICK, + changes.anchor_p = chartApi.snapPrice( + comment.anchor_p + vertical * chartApi.tick, ); } if (horizontal) { @@ -1697,6 +1708,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, sessionRange, hideLowerTfDrawings, confluenceAlerts, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, selectedTrendline, duplicateSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, 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, canUndo, undoTitle, undo }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, sessionRange, hideLowerTfDrawings, confluenceAlerts, tick, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, selectedTrendline, duplicateSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, 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, canUndo, undoTitle, undo }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index fabbc37..24a1aca 100644 --- a/static/chart.js +++ b/static/chart.js @@ -294,6 +294,8 @@ class ConfluenceChart { this.lastCurrentPrice = null; this.pendingBar = null; this.pendingBarFrame = null; + this.tick = 0.25; + this.rthMode = 'spy_rth'; this.previewLine = null; this.lineBridgeLayer = null; this.bars = []; @@ -384,8 +386,6 @@ class ConfluenceChart { }; } - static TICK = 0.25; - // Pixels of travel between press and release that make a gesture a drag // rather than a click. Wide enough to survive a twitch on a deliberate click. static DRAG_THRESHOLD = 12; @@ -432,8 +432,20 @@ class ConfluenceChart { return localStorage.getItem('chart-diag') === '1'; } - static snapToTick(price) { - return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; + static snapToTick(price, tick = 0.25) { + const step = tick > 0 ? tick : 0.25; + return Math.round(price / step) * step; + } + + snapPrice(price) { + return ConfluenceChart.snapToTick(price, this.tick); + } + + setInstrument(instrument) { + if (!instrument) return; + if (instrument.tick > 0) this.tick = Number(instrument.tick); + if (instrument.rth) this.rthMode = instrument.rth; + this.syncRthLines(); } create(el) { @@ -1413,7 +1425,7 @@ class ConfluenceChart { if (point && this.withinPlot(point) && this.onCommentMove) { this.onCommentMove(node.comment, { anchor_t: Math.round(point.t), - anchor_p: ConfluenceChart.snapToTick(point.p), + anchor_p: this.snapPrice(point.p), }); } else { this.renderComments(); @@ -1828,7 +1840,7 @@ class ConfluenceChart { if (!point || !this.withinPlot(point) || point.t == null) return null; return { time: point.t, - price: ConfluenceChart.snapToTick(point.p), + price: this.snapPrice(point.p), x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1), y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1), }; @@ -2156,7 +2168,7 @@ class ConfluenceChart { this.onToolComplete?.({ tool, time: end.t, - price: ConfluenceChart.snapToTick(end.p), + price: this.snapPrice(end.p), x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1), y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1), }); @@ -2166,7 +2178,7 @@ class ConfluenceChart { if (tool === 'level') { // A click with no drag is a valid placement; the drag is only there to // let you fine-tune the price before committing. - this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) }); + this.onToolComplete?.({ tool, price: this.snapPrice(end.p) }); return; } @@ -2249,7 +2261,7 @@ class ConfluenceChart { const last = this.bars[this.bars.length - 1]; if (fallbackT > last.t) { const index = Math.round(this.indexAt(fallbackT)); - return { t: this.timeAtIndex(index), p: ConfluenceChart.snapToTick(point.p), snappedSide: null }; + return { t: this.timeAtIndex(index), p: this.snapPrice(point.p), snappedSide: null }; } // An already-snapped point carries no cursor position; return it untouched // rather than measuring against undefined. @@ -2278,7 +2290,7 @@ class ConfluenceChart { if (!this.gesture) return; const { start, end } = this.gesture; if (this.armedTool === 'level') { - const price = ConfluenceChart.snapToTick(end.p); + const price = this.snapPrice(end.p); const y = this.candles.priceToCoordinate(price); if (y == null) return; this.previewLine.removeAttribute('hidden'); @@ -3177,7 +3189,7 @@ class ConfluenceChart { ? sourcePoint - sourceStart : this.indexAt(point.t) - this.indexAt(start.t), ); - const priceShift = ConfluenceChart.snapToTick(point.p - start.p); + const priceShift = this.snapPrice(point.p - start.p); const anchorT = source ? this.shiftLineTime(original, original.anchor_t, indexShift) : this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); @@ -3243,7 +3255,7 @@ class ConfluenceChart { } const snapped = this.snapToBars ? this.snapPoint(point) - : { ...point, p: ConfluenceChart.snapToTick(point.p) }; + : { ...point, p: this.snapPrice(point.p) }; if (snapped.t == null || snapped.p == null) return; const time = snapped.t; const price = snapped.p; @@ -3371,7 +3383,8 @@ class ConfluenceChart { syncRthLines() { if (!this.rthPrimitive) return; - if (!this.rthEnabled || !this.bars.length || this.bars[0]?.tf === '1d') { + if (!this.rthEnabled || this.rthMode !== 'spy_rth' + || !this.bars.length || this.bars[0]?.tf === '1d') { this.rthPrimitive.setMarks([]); return; } diff --git a/static/index.html b/static/index.html index 801e7e0..a07bc3e 100644 --- a/static/index.html +++ b/static/index.html @@ -138,9 +138,9 @@ - +
- +

Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.

@@ -325,11 +325,11 @@ #{{ item.number }} · SYMBOL · {{ item.comment.note }} #{{ item.number }} · FIB · {{ item.label }} - - diff --git a/tests/test_alert_state.py b/tests/test_alert_state.py index eaf98b5..3b49ec1 100644 --- a/tests/test_alert_state.py +++ b/tests/test_alert_state.py @@ -58,6 +58,24 @@ def test_corrupt_state_does_not_prevent_alerting(tmp_path): assert len(engine(tmp_path).evaluate(zone(), 100, 1, 0, "/ES")) == 1 +def test_an_es_zone_does_not_suppress_the_same_price_on_gold(tmp_path): + one = engine(tmp_path) + assert len(one.evaluate(zone(), 100, 1, 0, "/ES")) == 1 + assert len(one.evaluate(zone(), 100, 1, 60, "/GC")) == 1 + assert one.evaluate(zone(), 100, 1, 90, "ES=F") == [] + + +def test_legacy_fired_rows_without_symbol_are_es(tmp_path): + path = tmp_path / "alert_state.json" + path.write_text( + '{"next_number": 2, "fired": [{"center": 100.0, "at": 0}]}\n', + encoding="utf-8", + ) + two = engine(tmp_path) + assert two.evaluate(zone(), 100, 1, 60, "/ES") == [] + assert len(two.evaluate(zone(), 100, 1, 60, "/GC")) == 1 + + def test_state_file_records_centre_and_time(tmp_path): engine(tmp_path).evaluate(zone(), 100, 1, 42, "/ES") payload = json.loads((tmp_path / "alert_state.json").read_text(encoding="utf-8")) @@ -65,4 +83,5 @@ def test_state_file_records_centre_and_time(tmp_path): # do not restart from 1 after a deploy and collide with a phone's history. assert len(payload["fired"]) == 1 assert payload["fired"][0]["at"] == 42 + assert payload["fired"][0]["symbol"] == "/ES" assert payload["next_number"] == 2 diff --git a/tests/test_event_log.py b/tests/test_event_log.py index cbb9139..c4541c9 100644 --- a/tests/test_event_log.py +++ b/tests/test_event_log.py @@ -21,6 +21,7 @@ def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path): assert [entry["message"] for entry in day] == ["dropped", "yesterday"] assert more is True assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old" + assert EventLog(tmp_path / "events.json")._entries[0]["symbol"] == "/ES" def test_more_pages_older_than_the_cutoff(tmp_path): @@ -56,3 +57,6 @@ def test_dispatched_alerts_land_in_the_event_log(tmp_path): assert "#27" in events[0]["message"] assert "confluence" not in events[0]["message"] assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number + assert snapshot(runtime, Timeframe.M1)["events"][0]["symbol"] == "/ES" + assert snapshot(runtime, Timeframe.M1)["instrument"]["id"] == "es" + assert snapshot(runtime, Timeframe.M1)["instrument"]["tick"] == 0.25 diff --git a/tests/test_instrument.py b/tests/test_instrument.py new file mode 100644 index 0000000..7e56ed1 --- /dev/null +++ b/tests/test_instrument.py @@ -0,0 +1,60 @@ +from app.config import Settings +from app.instrument import ( + DEFAULT_SYMBOL, get_instrument, instrument_for_symbol, INSTRUMENTS, +) + + +def test_settings_default_to_the_es_profile(): + settings = Settings() + assert settings.instrument == "es" + assert settings.profile.tick == 0.25 + assert Settings(instrument="gc").profile.schwab_symbol == "/GC" + + +def test_es_is_the_default_profile(): + es = get_instrument("es") + assert es.schwab_symbol == DEFAULT_SYMBOL + assert es.tick == 0.25 + assert es.rth == "spy_rth" + + +def test_nq_shares_es_tick_and_rth(): + nq = get_instrument("nq") + es = get_instrument("es") + assert nq.tick == es.tick + assert nq.rth == es.rth + assert nq.schwab_symbol == "/NQ" + + +def test_gold_and_oil_are_not_quarter_ticks(): + assert get_instrument("gc").tick == 0.10 + assert get_instrument("gc").rth == "none" + assert get_instrument("cl").tick == 0.01 + assert get_instrument("cl").rth == "nymex_day" + + +def test_yahoo_and_schwab_names_map_to_the_same_root(): + assert instrument_for_symbol("ES=F").schwab_symbol == "/ES" + assert instrument_for_symbol("/ES").id == "es" + assert instrument_for_symbol("GC=F").id == "gc" + assert instrument_for_symbol("unknown").id == "es" + + +def test_unknown_instrument_id_is_rejected(): + try: + get_instrument("btc") + except ValueError as error: + assert "btc" in str(error) + else: + raise AssertionError("unknown id was accepted") + + +def test_gold_snap_is_not_a_quarter_point(): + gc = get_instrument("gc") + assert gc.snap(3450.07) == 3450.10 + assert get_instrument("es").snap(6400.10) == 6400.00 + assert get_instrument("cl").snap(70.014) == 70.01 + + +def test_every_planned_root_is_in_the_table(): + assert set(INSTRUMENTS) == {"es", "nq", "gc", "cl"} diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index b08a7a9..689b4d5 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -9,6 +9,25 @@ def sample_line(): return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1) +def test_a_drawing_without_symbol_loads_as_es(tmp_path): + path = tmp_path / "manual_lines.json" + path.write_text( + '[{"id":"ml_old","tf":"1h","side":"resistance","anchor_t":100,' + '"anchor_p":5000,"slope":-0.01,"last_t":200,"created_at":300,"number":1}]\n', + encoding="utf-8", + ) + loaded = ManualLineStore(path).lines["ml_old"] + assert loaded.symbol == "/ES" + + +def test_a_gold_drawing_keeps_its_symbol_through_json(tmp_path): + path = tmp_path / "manual_lines.json" + line = sample_line() + line.symbol = "/GC" + ManualLineStore(path).add(line) + assert ManualLineStore(path).lines["ml_test"].symbol == "/GC" + + def test_json_persistence_round_trip(tmp_path): path = tmp_path / "manual_lines.json" store = ManualLineStore(path) @@ -159,6 +178,7 @@ def test_a_null_cutoff_clears_an_ended_line(tmp_path): "cutoff_t": 150, }).json() assert created["cutoff_t"] == 150 + assert created["symbol"] == "/ES" cleared = client.patch(f"/api/lines/{created['id']}", json={"cutoff_t": None}).json() assert cleared["cutoff_t"] is None @@ -198,6 +218,7 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path): }).json() assert restored["id"] == line_id assert restored["number"] == number + assert restored["symbol"] == "/ES" assert client.post("/api/lines/restore", json={ "id": line_id, "tf": "1m", @@ -208,3 +229,30 @@ def test_restoring_a_deleted_line_keeps_its_id_and_number(tmp_path): "last_t": 200, "number": number, }).status_code == 409 + + +def test_restoring_keeps_a_non_es_symbol(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) + restored = client.post("/api/lines/restore", json={ + "id": "ml_gold", + "tf": "1m", + "side": "support", + "anchor_t": 100, + "anchor_p": 1.0, + "slope": 0.01, + "last_t": 200, + "number": 9, + "symbol": "/GC", + }).json() + assert restored["symbol"] == "/GC" + assert ManualLineStore(tmp_path / "manual_lines.json").lines["ml_gold"].symbol == "/GC" diff --git a/tests/test_ws_preferences.py b/tests/test_ws_preferences.py index a368014..c7ee121 100644 --- a/tests/test_ws_preferences.py +++ b/tests/test_ws_preferences.py @@ -82,6 +82,8 @@ def test_quote_change_always_uses_the_daily_session_open(tmp_path): message = snapshot(runtime, Timeframe.H1) assert message["session_open"] == 6123.25 + assert message["instrument"]["schwab_symbol"] == "/ES" + assert message["instrument"]["tick"] == 0.25 def test_snapshot_session_range_uses_the_forming_daily_bar(tmp_path):