enable symbols and comments in the future whitespace
This commit is contained in:
parent
44cec38750
commit
08cff3ce53
16 changed files with 316 additions and 37 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,5 +5,6 @@ __pycache__/
|
||||||
.schwab_token.json
|
.schwab_token.json
|
||||||
data/manual_lines.json
|
data/manual_lines.json
|
||||||
data/alert_state.json
|
data/alert_state.json
|
||||||
|
data/events.json
|
||||||
data/user_prefs.json
|
data/user_prefs.json
|
||||||
artifacts/playwright/
|
artifacts/playwright/
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ from app.analysis.levels import Level, LevelKind, Side
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _drawn_name(member: Level) -> str:
|
||||||
|
number = f"#{member.number}" if member.number is not None else ""
|
||||||
|
label = (member.label or "").strip()
|
||||||
|
if number and label and label not in (number, f"#{member.number}"):
|
||||||
|
return f"{number} {label}"
|
||||||
|
return number or label or member.id
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Alert:
|
class Alert:
|
||||||
cluster: Cluster
|
cluster: Cluster
|
||||||
|
|
@ -193,15 +201,20 @@ class AlertEngine:
|
||||||
changed = True
|
changed = True
|
||||||
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
direction = "BEARISH" if cluster.side.value == "resistance" else "BULLISH"
|
||||||
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
timeframes = ", ".join(dict.fromkeys(member.tf.value for member in cluster.members))
|
||||||
# Naming the line matters: "your line" is actionable in a way that
|
# A drawing number is what you look up. "confluence 28" is the
|
||||||
# "confluence 4" is not, and it says which drawing to go look at.
|
# score — easy to read as the line id when the drawing is #27.
|
||||||
headline = "LINE" if drawn and len(cluster.members) == len(drawn) else "ZONE"
|
names = ", ".join(_drawn_name(member) for member in drawn)
|
||||||
detail = (
|
lone_line = bool(drawn) and len(cluster.members) == len(drawn)
|
||||||
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
headline = "LINE" if lone_line else "ZONE"
|
||||||
f"@ {cluster.low:.2f}-{cluster.high:.2f}"
|
if lone_line:
|
||||||
)
|
detail = f"{cluster.side.value.title()} {names} @ {cluster.center:.2f}"
|
||||||
if drawn:
|
else:
|
||||||
detail += "\n" + ", ".join(member.label for member in drawn)
|
detail = (
|
||||||
|
f"{cluster.side.value.title()} confluence {cluster.score:g} "
|
||||||
|
f"@ {cluster.low:.2f}-{cluster.high:.2f}"
|
||||||
|
)
|
||||||
|
if names:
|
||||||
|
detail = f"{cluster.side.value.title()} {names}\n" + detail
|
||||||
message = (
|
message = (
|
||||||
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
f"{direction} {headline} {symbol} {current_price:.2f}\n{detail}\n{timeframes}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
72
app/analysis/event_log.py
Normal file
72
app/analysis/event_log.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAY_SECONDS = 86400
|
||||||
|
|
||||||
|
|
||||||
|
class EventLog:
|
||||||
|
"""Sent alerts and stream notes. Kept on the volume; the UI asks for a day."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | None = None):
|
||||||
|
self.path = Path(path) if path else None
|
||||||
|
self._entries = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> list[dict]:
|
||||||
|
if not self.path or not self.path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
return payload if isinstance(payload, list) else []
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not read event log; starting empty", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if not self.path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(self._entries, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(self.path)
|
||||||
|
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:
|
||||||
|
entry = {
|
||||||
|
"kind": kind,
|
||||||
|
"message": message,
|
||||||
|
"number": number,
|
||||||
|
"at": int(at if at is not None else time.time()),
|
||||||
|
}
|
||||||
|
self._entries.append(entry)
|
||||||
|
self._save()
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def page(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
before: int | None = None,
|
||||||
|
since: int | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[list[dict], bool]:
|
||||||
|
items = self._entries
|
||||||
|
if before is not None:
|
||||||
|
items = [entry for entry in items if int(entry.get("at") or 0) < before]
|
||||||
|
if since is not None:
|
||||||
|
items = [entry for entry in items if int(entry.get("at") or 0) >= since]
|
||||||
|
newest = list(reversed(items))
|
||||||
|
return newest[:limit], len(newest) > limit
|
||||||
|
|
||||||
|
def recent(self, since: int | None = None) -> tuple[list[dict], bool]:
|
||||||
|
cutoff = int(since if since is not None else time.time() - DAY_SECONDS)
|
||||||
|
events, _ = self.page(since=cutoff, limit=1000)
|
||||||
|
older = any(int(entry.get("at") or 0) < cutoff for entry in self._entries)
|
||||||
|
return events, older
|
||||||
|
|
@ -144,6 +144,16 @@ def status(request: Request):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
def events(
|
||||||
|
request: Request,
|
||||||
|
before: int | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
items, more = request.app.state.runtime.events.page(before=before, limit=limit)
|
||||||
|
return {"events": items, "more": more}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schwab/login")
|
@router.get("/schwab/login")
|
||||||
def schwab_login(request: Request):
|
def schwab_login(request: Request):
|
||||||
settings = request.app.state.runtime.settings
|
settings = request.app.state.runtime.settings
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ def trendline_series(runtime, tf: Timeframe) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
|
events, events_more = runtime.events.recent()
|
||||||
return {
|
return {
|
||||||
"type": "snapshot",
|
"type": "snapshot",
|
||||||
"tf": tf.value,
|
"tf": tf.value,
|
||||||
|
|
@ -94,6 +95,8 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
else None,
|
else None,
|
||||||
"session_open": session_open(runtime),
|
"session_open": session_open(runtime),
|
||||||
"trendline_geometry": trendline_geometry(runtime),
|
"trendline_geometry": trendline_geometry(runtime),
|
||||||
|
"events": events,
|
||||||
|
"events_more": events_more,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class Settings(BaseSettings):
|
||||||
# On the persistent volume in production: suppression has to outlive a
|
# On the persistent volume in production: suppression has to outlive a
|
||||||
# deploy or every push re-fires every zone that currently qualifies.
|
# deploy or every push re-fires every zone that currently qualifies.
|
||||||
alert_state_path: Path = Path("./data/alert_state.json")
|
alert_state_path: Path = Path("./data/alert_state.json")
|
||||||
|
events_path: Path = Path("./data/events.json")
|
||||||
user_prefs_path: Path = Path("./data/user_prefs.json")
|
user_prefs_path: Path = Path("./data/user_prefs.json")
|
||||||
ntfy_topic: str = ""
|
ntfy_topic: str = ""
|
||||||
ntfy_server: str = "https://ntfy.sh"
|
ntfy_server: str = "https://ntfy.sh"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class StreamService:
|
||||||
self.last_bar_t: int | None = None
|
self.last_bar_t: int | None = None
|
||||||
self._handlers: list[BarHandler] = []
|
self._handlers: list[BarHandler] = []
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
|
self.on_drop = None
|
||||||
|
|
||||||
def add_handler(self, handler: BarHandler) -> None:
|
def add_handler(self, handler: BarHandler) -> None:
|
||||||
self._handlers.append(handler)
|
self._handlers.append(handler)
|
||||||
|
|
@ -56,8 +57,11 @@ class StreamService:
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
was_up = self.status == "connected"
|
||||||
self.last_error = str(exc)
|
self.last_error = str(exc)
|
||||||
logger.exception("Market stream failed; reconnecting")
|
logger.exception("Market stream failed; reconnecting")
|
||||||
|
if was_up and self.on_drop:
|
||||||
|
self.on_drop(str(exc))
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=5)
|
await asyncio.wait_for(self._stop.wait(), timeout=5)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from dataclasses import dataclass, field, replace
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from app.analysis.alerts import Alert, AlertEngine
|
from app.analysis.alerts import Alert, AlertEngine
|
||||||
|
from app.analysis.event_log import EventLog
|
||||||
from app.bars.models import Bar, Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
from app.bars.aggregator import Aggregator
|
from app.bars.aggregator import Aggregator
|
||||||
from app.bars.session import bucket_start
|
from app.bars.session import bucket_start
|
||||||
|
|
@ -59,12 +60,14 @@ class Runtime:
|
||||||
_rebuild_task: asyncio.Task | None = None
|
_rebuild_task: asyncio.Task | None = None
|
||||||
_token_task: asyncio.Task | None = None
|
_token_task: asyncio.Task | None = None
|
||||||
schwab_login: object | None = None
|
schwab_login: object | None = None
|
||||||
|
events: EventLog = field(init=False)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
||||||
self.aggregator = Aggregator(self.settings.enabled_timeframes)
|
self.aggregator = Aggregator(self.settings.enabled_timeframes)
|
||||||
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
|
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
|
||||||
self.user_prefs = UserPrefStore(self.settings.user_prefs_path)
|
self.user_prefs = UserPrefStore(self.settings.user_prefs_path)
|
||||||
|
self.events = EventLog(self.settings.events_path)
|
||||||
# One engine for the process, not one per browser connection. Cooldowns
|
# One engine for the process, not one per browser connection. Cooldowns
|
||||||
# are only meaningful if they outlive a page reload, and a phone push
|
# are only meaningful if they outlive a page reload, and a phone push
|
||||||
# must not depend on a tab being open to produce it.
|
# must not depend on a tab being open to produce it.
|
||||||
|
|
@ -77,6 +80,7 @@ class Runtime:
|
||||||
self.levels = self.manual_lines.levels()
|
self.levels = self.manual_lines.levels()
|
||||||
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
self.stream = StreamService(live_source(self.settings), self.settings.live_symbol)
|
||||||
self.stream.add_handler(self.on_bar)
|
self.stream.add_handler(self.on_bar)
|
||||||
|
self.stream.on_drop = self._on_stream_drop
|
||||||
|
|
||||||
async def on_bar(self, bar: Bar) -> None:
|
async def on_bar(self, bar: Bar) -> None:
|
||||||
# A tick-built bar is provisional and arrives many times a minute. It
|
# A tick-built bar is provisional and arrives many times a minute. It
|
||||||
|
|
@ -278,9 +282,14 @@ class Runtime:
|
||||||
and level.period in armed
|
and level.period in armed
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
def dispatch_alerts(self, alerts: list[Alert]) -> None:
|
||||||
tripped: set[str] = set()
|
tripped: set[str] = set()
|
||||||
for alert in alerts:
|
for alert in alerts:
|
||||||
|
self.events.add("alert", alert.message, number=alert.number, at=alert.at)
|
||||||
self.broadcast({
|
self.broadcast({
|
||||||
"type": "alert",
|
"type": "alert",
|
||||||
"cluster": alert.cluster,
|
"cluster": alert.cluster,
|
||||||
|
|
|
||||||
|
|
@ -1189,3 +1189,21 @@ Layer toggle **SPY open/close**, on by default.
|
||||||
Hovering a sloped line now shows signed points per hour (`+1.25 /h`,
|
Hovering a sloped line now shows signed points per hour (`+1.25 /h`,
|
||||||
`-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe.
|
`-0.40 /h`). Degrees follow the zoom; points per bar follow the timeframe.
|
||||||
The stored slope is still price per second. Flat levels omit the line.
|
The stored slope is still price per second. Flat levels omit the line.
|
||||||
|
|
||||||
|
### 2026-08-18 — Events is the sent-alert log
|
||||||
|
|
||||||
|
Events was a tab-local buffer, so phone pushes never appeared after a
|
||||||
|
reload. Sent alerts (and a stream drop) now append to `data/events.json`
|
||||||
|
and stay there. The snapshot loads the most recent day; **More** pages
|
||||||
|
older rows. A lone drawing alert names `#27`, not `confluence 28`.
|
||||||
|
|
||||||
|
JSON is enough at this volume — single-digit alerts per session. SQLite
|
||||||
|
waits for drawings/users, not this list.
|
||||||
|
|
||||||
|
### 2026-08-18 — Marks in future whitespace
|
||||||
|
|
||||||
|
Pinned symbols and comments used to clamp to the last candle: anything
|
||||||
|
past `bars[-1].t` asked `timeToCoordinate` for that bar. Placement and
|
||||||
|
rendering now share the same future-slot map trendlines already use
|
||||||
|
(`indexAt` / `timeAtIndex` / `coordinateAtTime`). A mark in the right-hand
|
||||||
|
whitespace stays there.
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ createApp({
|
||||||
const levels = ref([]);
|
const levels = ref([]);
|
||||||
const clusters = ref([]);
|
const clusters = ref([]);
|
||||||
const events = ref([]);
|
const events = ref([]);
|
||||||
|
const eventsMore = ref(false);
|
||||||
let eventSequence = 0;
|
let eventSequence = 0;
|
||||||
// Which tool the next chart gesture creates. null = pan/select as normal.
|
// Which tool the next chart gesture creates. null = pan/select as normal.
|
||||||
const armedTool = ref(null);
|
const armedTool = ref(null);
|
||||||
|
|
@ -300,11 +301,50 @@ createApp({
|
||||||
const selectedAreHidden = computed(() => selectedDrawings.value.length > 0
|
const selectedAreHidden = computed(() => selectedDrawings.value.length > 0
|
||||||
&& selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden));
|
&& selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden));
|
||||||
|
|
||||||
function addEvent(message, kind = 'info', url = null, number = null) {
|
function formatEventTime(epoch) {
|
||||||
events.value = [
|
const at = epoch ? new Date(epoch * 1000) : new Date();
|
||||||
{ key: ++eventSequence, at: new Date().toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }), message, kind, url, number },
|
return at.toLocaleString(undefined, {
|
||||||
...events.value,
|
weekday: 'short', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
].slice(0, 20);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventRow(entry) {
|
||||||
|
return {
|
||||||
|
key: entry.number != null ? `alert-${entry.number}` : `evt-${entry.at}-${++eventSequence}`,
|
||||||
|
epoch: entry.at || null,
|
||||||
|
at: formatEventTime(entry.at),
|
||||||
|
message: entry.message,
|
||||||
|
kind: entry.kind || 'info',
|
||||||
|
url: entry.url || null,
|
||||||
|
number: entry.number ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(message, kind = 'info', url = null, number = null, at = null) {
|
||||||
|
const row = eventRow({ message, kind, url, number, at });
|
||||||
|
if (number != null && events.value.some(item => item.number === number)) return;
|
||||||
|
events.value = [row, ...events.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateEvents(incoming, more = eventsMore.value) {
|
||||||
|
const have = new Set(events.value.map(item => item.key));
|
||||||
|
const extra = (incoming || []).map(eventRow).filter(row => !have.has(row.key));
|
||||||
|
eventsMore.value = more;
|
||||||
|
if (!extra.length) return;
|
||||||
|
events.value = [...events.value, ...extra]
|
||||||
|
.sort((a, b) => (b.epoch || 0) - (a.epoch || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderEvents() {
|
||||||
|
const oldest = events.value.reduce((min, item) => {
|
||||||
|
if (item.epoch == null) return min;
|
||||||
|
return min == null || item.epoch < min ? item.epoch : min;
|
||||||
|
}, null);
|
||||||
|
const query = oldest == null ? '' : `?before=${oldest}`;
|
||||||
|
const response = await apiFetch(`/api/events${query}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
hydrateEvents(payload.events, payload.more);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
|
|
@ -453,6 +493,7 @@ createApp({
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
price.value = message.price;
|
price.value = message.price;
|
||||||
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
|
hydrateEvents(message.events, message.events_more);
|
||||||
} else if (message.type === 'bar') {
|
} else if (message.type === 'bar') {
|
||||||
// Switching timeframe races: the server answers `subscribe` with a
|
// Switching timeframe races: the server answers `subscribe` with a
|
||||||
// fresh snapshot from one coroutine while another is still draining
|
// fresh snapshot from one coroutine while another is still draining
|
||||||
|
|
@ -505,7 +546,7 @@ createApp({
|
||||||
// second would collide and Vue would reuse the wrong row.
|
// second would collide and Vue would reuse the wrong row.
|
||||||
// The server numbers alerts; the client's own counter restarts on
|
// The server numbers alerts; the client's own counter restarts on
|
||||||
// reload and differs per tab, so it cannot match a phone.
|
// reload and differs per tab, so it cannot match a phone.
|
||||||
addEvent(message.message, 'alert', null, message.number || null);
|
addEvent(message.message, 'alert', null, message.number || null, message.at || null);
|
||||||
playAlert();
|
playAlert();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1262,6 +1303,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
window.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, 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, extraDetail, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption };
|
||||||
},
|
},
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|
|
||||||
|
|
@ -990,22 +990,16 @@ class ConfluenceChart {
|
||||||
*/
|
*/
|
||||||
anchorCoordinate(anchorT) {
|
anchorCoordinate(anchorT) {
|
||||||
if (!this.bars.length) return { x: null, before: false, after: false };
|
if (!this.bars.length) return { x: null, before: false, after: false };
|
||||||
if (anchorT < this.bars[0].t) return { x: null, before: true, after: false };
|
const x = this.coordinateAtTime(anchorT);
|
||||||
const lastBar = this.bars[this.bars.length - 1];
|
const width = this.overlayLayer?.clientWidth ?? this.chartEl?.clientWidth ?? 0;
|
||||||
if (anchorT > lastBar.t) {
|
if (x == null) {
|
||||||
// Past the newest bar is not off the chart — the right edge carries
|
return {
|
||||||
// whitespace, so ask the scale directly and only park it if it answers.
|
x: null,
|
||||||
const beyond = this.chart.timeScale().timeToCoordinate(lastBar.t);
|
before: anchorT < this.bars[0].t,
|
||||||
return { x: beyond, before: false, after: beyond === null };
|
after: anchorT > this.bars[this.bars.length - 1].t,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let lo = 0;
|
return { x, before: x < 0, after: x > width };
|
||||||
let hi = this.bars.length - 1;
|
|
||||||
while (lo < hi) {
|
|
||||||
const mid = (lo + hi + 1) >> 1;
|
|
||||||
if (this.bars[mid].t <= anchorT) lo = mid;
|
|
||||||
else hi = mid - 1;
|
|
||||||
}
|
|
||||||
return { x: this.chart.timeScale().timeToCoordinate(this.bars[lo].t), before: false, after: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCommentNode(comment) {
|
buildCommentNode(comment) {
|
||||||
|
|
@ -1428,16 +1422,16 @@ class ConfluenceChart {
|
||||||
const price = this.candles.coordinateToPrice(y);
|
const price = this.candles.coordinateToPrice(y);
|
||||||
if (price == null) return null;
|
if (price == null) return null;
|
||||||
const time = this.chart.timeScale().coordinateToTime(x);
|
const time = this.chart.timeScale().coordinateToTime(x);
|
||||||
const index = time == null ? this.indexAtCoordinate(x) : null;
|
const index = time == null ? this.indexAtCoordinate(x) : this.indexAt(Number(time));
|
||||||
const projectedTime = index == null ? null : this.timeAtIndex(index);
|
const slotted = index == null ? (time == null ? null : Number(time)) : this.timeAtIndex(Math.round(index));
|
||||||
return { x, y, p: price, t: time == null ? projectedTime : Number(time) };
|
return { x, y, p: price, t: slotted };
|
||||||
}
|
}
|
||||||
|
|
||||||
placementAtClient(clientX, clientY) {
|
placementAtClient(clientX, clientY) {
|
||||||
const point = this.eventPoint({ clientX, clientY });
|
const point = this.eventPoint({ clientX, clientY });
|
||||||
if (!point || !this.withinPlot(point)) return null;
|
if (!point || !this.withinPlot(point) || point.t == null) return null;
|
||||||
return {
|
return {
|
||||||
time: Math.round(point.t),
|
time: point.t,
|
||||||
price: ConfluenceChart.snapToTick(point.p),
|
price: ConfluenceChart.snapToTick(point.p),
|
||||||
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
x: Math.min(Math.max(point.x / this.overlayLayer.clientWidth, 0), 1),
|
||||||
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
y: Math.min(Math.max(point.y / this.overlayLayer.clientHeight, 0), 1),
|
||||||
|
|
@ -1738,6 +1732,8 @@ class ConfluenceChart {
|
||||||
* however far away the cursor was.
|
* however far away the cursor was.
|
||||||
*/
|
*/
|
||||||
barNearestX(x) {
|
barNearestX(x) {
|
||||||
|
const index = this.indexAtCoordinate(x);
|
||||||
|
if (index != null) return this.timeAtIndex(Math.round(index));
|
||||||
let best = null;
|
let best = null;
|
||||||
const timeScale = this.chart.timeScale();
|
const timeScale = this.chart.timeScale();
|
||||||
for (const bar of this.bars) {
|
for (const bar of this.bars) {
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,7 @@
|
||||||
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
||||||
<template v-else>{{ event.message }}</template>
|
<template v-else>{{ event.message }}</template>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="eventsMore" class="events-more" type="button" @click="loadOlderEvents">More</button>
|
||||||
</details>
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
|
||||||
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
|
||||||
.arm-hint { color:var(--accent); }
|
.arm-hint { color:var(--accent); }
|
||||||
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
|
||||||
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }
|
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); }.events-more { width:100%; margin-top:6px; padding:5px; font-size:10px; }
|
||||||
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
|
||||||
/* --- chart comments ---------------------------------------------------- */
|
/* --- chart comments ---------------------------------------------------- */
|
||||||
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,47 @@ test('a comment is never a level', { timeout: 180000 }, async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a mark can sit in the future whitespace', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
await armTool(page, 'Mark');
|
||||||
|
const target = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const last = chart.bars[chart.bars.length - 1];
|
||||||
|
const prev = chart.bars[chart.bars.length - 2];
|
||||||
|
const lastX = chart.coordinateAtTime(last.t);
|
||||||
|
const step = lastX - chart.coordinateAtTime(prev.t);
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: plot.left + lastX + step * 2,
|
||||||
|
y: plot.top + plot.height * 0.4,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.mouse.click(target.x, target.y);
|
||||||
|
await page.waitForFunction(() => window.__chart.comments.some(comment =>
|
||||||
|
comment.kind === 'symbol' && comment.anchor_t > window.__chart.bars.at(-1).t));
|
||||||
|
const placed = await page.evaluate(() => {
|
||||||
|
const chart = window.__chart;
|
||||||
|
const last = chart.bars[chart.bars.length - 1];
|
||||||
|
const symbol = chart.comments.filter(comment => comment.kind === 'symbol')
|
||||||
|
.sort((a, b) => b.number - a.number)[0];
|
||||||
|
const node = document.querySelector(`.chart-comment.symbol[data-drawing-id="${symbol.id}"]`);
|
||||||
|
const box = node.getBoundingClientRect();
|
||||||
|
const plot = chart.plotCanvas().getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
anchorT: symbol.anchor_t,
|
||||||
|
lastT: last.t,
|
||||||
|
centerX: box.left + box.width / 2 - plot.left,
|
||||||
|
lastX: chart.coordinateAtTime(last.t),
|
||||||
|
parked: node.classList.contains('off-right'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.ok(placed.anchorT > placed.lastT, 'the mark was clamped to the last bar');
|
||||||
|
assert.equal(placed.parked, false, 'the mark was parked on the right edge');
|
||||||
|
assert.ok(placed.centerX > placed.lastX + 4, 'the mark rendered on the last candle');
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('a symbol can be dropped at a price and dragged to a new one', {
|
test('a symbol can be dropped at a price and dragged to a new one', {
|
||||||
timeout: 180000,
|
timeout: 180000,
|
||||||
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',
|
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,17 @@ def test_a_third_level_joining_the_zone_does_not_re_alert():
|
||||||
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
|
assert engine.evaluate(three, 100, 1, 60, "/ES") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_numbered_drawn_line_is_named_by_its_drawing_not_the_score():
|
||||||
|
engine = AlertEngine(min_score=28, cooldown_seconds=900)
|
||||||
|
line = drawn_line("ml_1", 100, label="up1h")
|
||||||
|
line.number = 27
|
||||||
|
alerts = engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, 0, "/ES")
|
||||||
|
assert len(alerts) == 1
|
||||||
|
assert "#27" in alerts[0].message
|
||||||
|
assert "up1h" in alerts[0].message
|
||||||
|
assert "confluence" not in alerts[0].message
|
||||||
|
|
||||||
|
|
||||||
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
|
def test_a_lone_drawn_line_alerts_despite_the_score_threshold():
|
||||||
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
|
# A 5m line weighs 1 against a threshold of 28. Gating drawn lines on score
|
||||||
# would mean a line you deliberately drew could never alert.
|
# would mean a line you deliberately drew could never alert.
|
||||||
|
|
|
||||||
58
tests/test_event_log.py
Normal file
58
tests/test_event_log.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.analysis.event_log import EventLog
|
||||||
|
from app.analysis.confluence import cluster_levels
|
||||||
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
|
from app.api.ws import snapshot
|
||||||
|
from app.bars.models import Timeframe
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_keeps_old_entries_and_recent_is_the_last_day(tmp_path):
|
||||||
|
log = EventLog(tmp_path / "events.json")
|
||||||
|
now = int(time.time())
|
||||||
|
log.add("alert", "old", number=1, at=now - 3 * 86400)
|
||||||
|
log.add("alert", "yesterday", number=2, at=now - 12 * 3600)
|
||||||
|
log.add("stream", "dropped", at=now)
|
||||||
|
|
||||||
|
day, more = log.recent()
|
||||||
|
assert [entry["message"] for entry in day] == ["dropped", "yesterday"]
|
||||||
|
assert more is True
|
||||||
|
assert EventLog(tmp_path / "events.json")._entries[0]["message"] == "old"
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_pages_older_than_the_cutoff(tmp_path):
|
||||||
|
log = EventLog(tmp_path / "events.json")
|
||||||
|
now = int(time.time())
|
||||||
|
log.add("alert", "old", number=1, at=now - 3 * 86400)
|
||||||
|
log.add("alert", "new", number=2, at=now)
|
||||||
|
page, more = log.page(before=now - 86400, limit=50)
|
||||||
|
assert [entry["message"] for entry in page] == ["old"]
|
||||||
|
assert more is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatched_alerts_land_in_the_event_log(tmp_path):
|
||||||
|
runtime = Runtime(Settings(
|
||||||
|
manual_lines_path=tmp_path / "lines.json",
|
||||||
|
events_path=tmp_path / "events.json",
|
||||||
|
alert_state_path=tmp_path / "alerts.json",
|
||||||
|
))
|
||||||
|
line = Level(
|
||||||
|
"ml_1", LevelKind.MANUAL, Timeframe.M5, Side.RESISTANCE, 1, 1,
|
||||||
|
"up1h", 100, 100, 0, None, 0, 100, 100, False, False, number=27,
|
||||||
|
)
|
||||||
|
now = int(time.time())
|
||||||
|
alerts = runtime.alert_engine.evaluate(cluster_levels([line], 100, 100, 1), 100, 1, now, "/ES")
|
||||||
|
|
||||||
|
async def send():
|
||||||
|
runtime.dispatch_alerts(alerts)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
asyncio.run(send())
|
||||||
|
events, _ = runtime.events.recent(since=0)
|
||||||
|
assert events[0]["kind"] == "alert"
|
||||||
|
assert "#27" in events[0]["message"]
|
||||||
|
assert "confluence" not in events[0]["message"]
|
||||||
|
assert snapshot(runtime, Timeframe.M1)["events"][0]["number"] == alerts[0].number
|
||||||
Loading…
Reference in a new issue