From daf6965ba75263569e1d37490bb84dc705e09453 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 11 Sep 2026 17:17:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(lifecycle):=20alive=20doar=20la=20deschide?= =?UTF-8?q?re/=C3=AEnchidere,=20/window=20persistent,=20/status=20cu=20fer?= =?UTF-8?q?eastra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heartbeat-ul periodic (activ/IDLE la 30 min) e oprit implicit (heartbeat_min = 0). Semnalul „alive" devin alertele zilnice „🟢 Piața deschisă" / „🔴 Piața închisă", emise pe tranziția ferestrei (operating_hours AND /window) independent de pauză — body-ul spune dacă monitorizarea e pauzată manual sau pe drift. /window era doar în memorie și se pierdea la repornire (09-09), așa că programul revenea la 16:30–23:00 și trimitea mesaje înainte/după fereastra 19:30–22:00. Acum e persistat în logs/session_window.json și restaurat la pornire; /window off șterge fișierul. /status afișează blocul de fereastră: deschisă/închisă + următoarea deschidere/închidere (ora locală), /window, orele bursei cu echivalentul local, și config | heartbeat. Config activ: heartbeat_min = 0; include și baseline_phash rescris de auto-rebase-ul canary din 09-09. DOX pass: notifier + configs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Em4jtmh4pRpYMdyd9ueHYF --- .gitignore | 1 + configs/2026-04-21-recalib.toml | 4 +- configs/AGENTS.md | 10 +- configs/example.toml | 4 +- src/atm/commands.py | 4 +- src/atm/config.py | 4 +- src/atm/main.py | 221 +++++++++++++++++++++++++------- src/atm/notifier/AGENTS.md | 23 +++- tests/test_main.py | 112 +++++++++++++++- 9 files changed, 324 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 639166c..c675867 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ logs/dead_letter.jsonl logs/detections/ logs/fires logs/pause.flag +logs/session_window.json samples/*.png samples/*.jpg samples/labels.json diff --git a/configs/2026-04-21-recalib.toml b/configs/2026-04-21-recalib.toml index 92f37cd..5af18dc 100644 --- a/configs/2026-04-21-recalib.toml +++ b/configs/2026-04-21-recalib.toml @@ -53,7 +53,7 @@ p2_y = 664 p2_price = 483.2 [canary] -baseline_phash = "c11f4a852ec09f3a8de4e4cf4ad76d84f10b19d3e708663c38f5b538877c6624" +baseline_phash = "fbe145390c1abec23204017757a326b8e37077288ef79947310a89c70e07ffff" drift_threshold = 8 [canary.roi] @@ -73,7 +73,7 @@ h = 1029 [options] debounce_depth = 1 loop_interval_s = 5.0 -heartbeat_min = 30 +heartbeat_min = 0 # 0 = fără heartbeat periodic; alive = mesajele de deschidere/închidere lockout_s = 240 low_conf_threshold = 0.2 low_conf_run = 3 diff --git a/configs/AGENTS.md b/configs/AGENTS.md index 76e38a1..e3c9897 100644 --- a/configs/AGENTS.md +++ b/configs/AGENTS.md @@ -23,8 +23,14 @@ alerts). Schema e definită și validată în `src/atm/config.py` (fail fast la `enabled`, `timezone` (NYSE local, ex. `America/New_York`), `weekdays`, `start_hhmm`, `stop_hhmm`. Timezone validat la load; `_tz_cache` reutilizat per -tick. Boundary crossings logează `market_open`/`market_closed` și notifică o dată. -Startup in-window e silent. +tick. Boundary crossings logează `market_open`/`market_closed` și notifică o dată +(și când monitorizarea e pauzată — sunt semnalul zilnic „alive"). Startup +in-window e silent. + +### `[options] heartbeat_min` + +Default `0` = fără heartbeat periodic (alive = alertele de deschidere/închidere). +`N > 0` repornește heartbeat-ul la N minute, doar în fereastra de tranzacționare. ### `[options.alerts]` diff --git a/configs/example.toml b/configs/example.toml index 2aeef8a..77ba91a 100644 --- a/configs/example.toml +++ b/configs/example.toml @@ -70,7 +70,9 @@ h = 50 [options] debounce_depth = 1 loop_interval_s = 5.0 -heartbeat_min = 30 +# Periodic heartbeat (minutes). 0 = off: the daily "Piața deschisă/închisă" +# alerts (sent even while paused) are the alive signal. +heartbeat_min = 0 lockout_s = 240 low_conf_threshold = 0.2 low_conf_run = 3 diff --git a/src/atm/commands.py b/src/atm/commands.py index 56d4848..f53914f 100644 --- a/src/atm/commands.py +++ b/src/atm/commands.py @@ -30,9 +30,9 @@ _BASE = "https://api.telegram.org/bot{token}/{method}" # command must match [a-z0-9_]{1,32}; description is 1-256 chars. Telegram has # no typed args, so arg formats live in the description text. TELEGRAM_COMMANDS: list[tuple[str, str]] = [ - ("status", "Stare FSM, uptime, ultima detecție, fereastră open/closed"), + ("status", "Stare FSM, uptime, fereastră (/window, ore bursă, următoarea deschidere/închidere)"), ("ss", "Screenshot acum (top-3 buline din ROI)"), - ("pause", "Suspendă detecția (heartbeat-urile continuă)"), + ("pause", "Suspendă detecția (mesajele de deschidere/închidere continuă)"), ("resume", "Reia detecția (șterge user-pause + drift-pause)"), ("rebase", "Propune phash nou pentru canary — aplici cu: /rebase confirm"), ("interval", "Auto-screenshot la N minute, ex: /interval 3"), diff --git a/src/atm/config.py b/src/atm/config.py index a5a74e6..69bd9dd 100644 --- a/src/atm/config.py +++ b/src/atm/config.py @@ -248,7 +248,7 @@ class Config: chart_window_region: ROI | None = None # virtual-desktop absolute region; when set, runtime uses full-desktop capture + crop debounce_depth: int = 1 loop_interval_s: float = 5.0 - heartbeat_min: int = 30 + heartbeat_min: int = 0 # 0 = off; alive signal = daily open/close alerts lockout_s: int = 240 low_conf_threshold: float = 0.2 low_conf_run: int = 3 @@ -369,7 +369,7 @@ class Config: chart_window_region=region, debounce_depth=int(opts.get("debounce_depth", 1)), loop_interval_s=float(opts.get("loop_interval_s", 5.0)), - heartbeat_min=int(opts.get("heartbeat_min", 30)), + heartbeat_min=int(opts.get("heartbeat_min", 0)), lockout_s=int(opts.get("lockout_s", 240)), low_conf_threshold=float(opts.get("low_conf_threshold", 0.2)), low_conf_run=int(opts.get("low_conf_run", 3)), diff --git a/src/atm/main.py b/src/atm/main.py index 46282a3..9601cbb 100644 --- a/src/atm/main.py +++ b/src/atm/main.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import asyncio import contextlib +import json import os import sys import time @@ -1176,6 +1177,30 @@ class LifecycleState: # Local wall-clock (start_hhmm, stop_hhmm) set via /window; None = no window. # ANDed with operating_hours: detection runs only when inside BOTH. session_window: tuple[str, str] | None = None + # Where /window is persisted so it survives restarts. None = in-memory only + # (unit tests); run_live_async points it at logs/session_window.json. + session_window_path: Path | None = None + + +def _load_session_window(path: Path) -> tuple[str, str] | None: + """Read a persisted /window; None when missing or unreadable.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + start, stop = str(data["start"]), str(data["stop"]) + except (OSError, ValueError, KeyError, TypeError): + return None + return (start, stop) + + +def _save_session_window(path: Path | None, window: tuple[str, str] | None) -> None: + """Persist /window (or remove the file on /window off). No-op when path is None.""" + if path is None: + return + if window is None: + path.unlink(missing_ok=True) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"start": window[0], "stop": window[1]}), encoding="utf-8") # Locale-independent weekday names; index matches datetime.weekday() (MON=0). @@ -1210,6 +1235,15 @@ def _should_skip(now_ts: float, state: LifecycleState, cfg, canary) -> str | Non return "user_paused" if getattr(canary, "is_paused", False): return "drift_paused" + return _window_skip_reason(now_ts, state, cfg) + + +def _window_skip_reason(now_ts: float, state: LifecycleState, cfg) -> str | None: + """Market-window part of _should_skip: operating_hours AND session window. + + Ignores user/drift pauses on purpose — the open/close ("alive") alerts + follow the trading window even while detection is paused. + """ oh = getattr(cfg, "operating_hours", None) if oh is not None and oh.enabled: tz = getattr(oh, "_tz_cache", None) @@ -1232,6 +1266,71 @@ def _should_skip(now_ts: float, state: LifecycleState, cfg, canary) -> str | Non return None +_WEEKDAY_RO: tuple[str, ...] = ("Lu", "Ma", "Mi", "Jo", "Vi", "Sâ", "Du") + + +def _next_window_change( + now_ts: float, state: LifecycleState, cfg, horizon_days: int = 8, +) -> float | None: + """First minute boundary after now_ts where the window flips open↔closed. + + Brute-force minute scan (boundaries are HH:MM, so minute resolution is + exact); ~11k cheap evaluations, only run on /status. None = no flip within + the horizon (e.g. no window configured → always open). + """ + is_open = _window_skip_reason(now_ts, state, cfg) is None + t = (int(now_ts) // 60 + 1) * 60 + end = now_ts + horizon_days * 86400 + while t <= end: + if (_window_skip_reason(t, state, cfg) is None) != is_open: + return float(t) + t += 60 + return None + + +def _fmt_local_when(ts: float, now_ts: float) -> str: + """'azi 22:00' / 'mâine 19:30' / 'Lu 19:30' in local wall-clock.""" + d = datetime.fromtimestamp(ts) + days = (d.date() - datetime.fromtimestamp(now_ts).date()).days + day = "azi" if days == 0 else "mâine" if days == 1 else _WEEKDAY_RO[d.weekday()] + return f"{day} {d:%H:%M}" + + +def _window_status_lines(now_ts: float, state: LifecycleState, cfg) -> list[str]: + """/status block: window open/closed + next change, /window, exchange hours. + + Empty when neither operating_hours nor /window is configured (always open). + """ + oh = getattr(cfg, "operating_hours", None) + oh_on = oh is not None and oh.enabled and isinstance(getattr(oh, "_tz_cache", None), tzinfo) + sw = state.session_window + if not oh_on and sw is None: + return [] + + is_open = _window_skip_reason(now_ts, state, cfg) is None + head = f"fereastră: {'deschisă' if is_open else 'închisă'}" + nxt = _next_window_change(now_ts, state, cfg) + if nxt is not None: + head += f" → {'închidere' if is_open else 'deschidere'} {_fmt_local_when(nxt, now_ts)}" + lines = [head, f"/window: {sw[0]}–{sw[1]} (local)" if sw else "/window: —"] + + if oh_on: + tz = oh._tz_cache + now_ex = datetime.fromtimestamp(now_ts, tz=tz) + + def _to_local(hhmm: str) -> str: + h, m = (int(p) for p in hhmm.split(":")) + return now_ex.replace(hour=h, minute=m, second=0, microsecond=0).astimezone().strftime("%H:%M") + + days = tuple(oh.weekdays) + days_txt = "MON–FRI" if days == _WEEKDAY_NAMES[:5] else ",".join(days) + lines.append( + f"bursă: {oh.start_hhmm}–{oh.stop_hhmm} {oh.timezone} {days_txt} " + f"(local {_to_local(oh.start_hhmm)}–{_to_local(oh.stop_hhmm)})" + ) + return lines + + def _maybe_log_transition( reason: str | None, state: LifecycleState, @@ -1267,15 +1366,17 @@ def _maybe_log_transition( event_name = "market_open" if window_reason == "open" else "market_closed" audit.log({"ts": now, "event": event_name, "reason": reason}) + # These two alerts double as the daily "alive" signal (the periodic + # heartbeat is off by default), so they fire even while paused. if event_name == "market_closed": - body = "Piața închisă — monitorizare pauzată până la următoarea deschidere." + body = "Sfârșit sesiune — ATM activ, monitorizare pauzată până la următoarea deschidere." else: - body = "Piața deschisă — monitorizare reluată." + body = "Început sesiune — ATM activ." if status_body: body = f"{body}\n{status_body}" notifier.send(Alert( kind="status", - title="Piața deschisă" if event_name == "market_open" else "Piața închisă", + title="🟢 Piața deschisă" if event_name == "market_open" else "🔴 Piața închisă", body=body, )) state.last_window_state = window_reason @@ -1403,6 +1504,40 @@ def _brief_status(ctx) -> str: return f"{fsm_state} | semnale: {ctx.state.fire_count} | {h:.1f}h" +def _session_status_body(ctx, skip: str | None) -> str: + """Body tail for the open/close alerts: FSM brief + pause + /window info.""" + lines = [_brief_status(ctx)] + if skip == "user_paused": + lines.append("⏸ monitorizare oprită manual — /resume pentru a relua") + elif skip == "drift_paused": + lines.append("⚠️ detecție pauzată (drift) — /resume pentru a relua") + sw = ctx.lifecycle.session_window if ctx.lifecycle is not None else None + if sw is not None: + lines.append(f"fereastră: {sw[0]}–{sw[1]} (ora locală)") + return "\n".join(lines) + + +async def _lifecycle_gate(ctx: RunContext, now: float) -> str | None: + """Emit window open/close transitions, return the detection skip reason. + + The transition follows the market window only (not user/drift pause), so + the daily open/close alerts arrive even while monitoring is paused. + """ + skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary) + transition = _maybe_log_transition( + _window_skip_reason(now, ctx.lifecycle, ctx.cfg), + ctx.lifecycle, now, ctx.audit, ctx.notifier, + status_body=_session_status_body(ctx, skip), + ) + if transition == "market_open" and skip is None and ctx.cfg.window_title: + title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title) + ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title}) + await asyncio.sleep(0.15) + elif transition == "market_closed": + _handle_market_closed(ctx, now) + return skip + + async def _run_tick(ctx: RunContext) -> _TickSyncResult: """Execute one `_sync_detection_tick` in a thread; returns result or empty. @@ -1413,18 +1548,7 @@ async def _run_tick(ctx: RunContext) -> _TickSyncResult: """ now = time.time() if ctx.lifecycle is not None: - skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary) - sb = _brief_status(ctx) - transition = _maybe_log_transition( - skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb, - ) - if transition == "market_open" and ctx.cfg.window_title: - title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title) - ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title}) - await asyncio.sleep(0.15) - elif transition == "market_closed": - _handle_market_closed(ctx, now) - if skip is not None: + if await _lifecycle_gate(ctx, now) is not None: # No detection this tick. Empty result → _handle_fsm_result no-op. return _TickSyncResult() return await asyncio.to_thread( @@ -1570,18 +1694,7 @@ async def _run_multi_tick(ctx: RunContext) -> "list[_TickSyncResult]": """ now = time.time() if ctx.lifecycle is not None: - skip = _should_skip(now, ctx.lifecycle, ctx.cfg, ctx.canary) - sb = _brief_status(ctx) - transition = _maybe_log_transition( - skip, ctx.lifecycle, now, ctx.audit, ctx.notifier, status_body=sb, - ) - if transition == "market_open" and ctx.cfg.window_title: - title = await asyncio.to_thread(_focus_window_by_title, ctx.cfg.window_title) - ctx.audit.log({"ts": now, "event": "window_focused", "command": "market_open", "title": title}) - await asyncio.sleep(0.15) - elif transition == "market_closed": - _handle_market_closed(ctx, now) - if skip is not None: + if await _lifecycle_gate(ctx, now) is not None: return [] frame = await asyncio.to_thread(ctx.capture) @@ -1827,15 +1940,14 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: f"{line1_state} | semnale: {ctx.state.fire_count} | {uptime_h:.1f}h", f"{last_color} ({last_conf}) | poller: {sched_info}", ] - oh = getattr(ctx.cfg, "operating_hours", None) - if oh is not None and oh.enabled: - window_val = ctx.lifecycle.last_window_state if ctx.lifecycle else "—" - window_ro = {"open": "deschisă", "closed": "închisă"}.get(window_val or "", window_val or "—") - lines.append(f"fereastră: {window_ro}") + if ctx.lifecycle is not None: + lines.extend(_window_status_lines(time.time(), ctx.lifecycle, ctx.cfg)) cfg_name = getattr(ctx.cfg, "config_version", None) or getattr(ctx.cfg, "version", None) if isinstance(cfg_name, str) and cfg_name not in ("", "unknown"): - lines.append(f"config: {cfg_name}") + hb = getattr(ctx.cfg, "heartbeat_min", 0) + hb_txt = f"{hb} min" if isinstance(hb, int) and hb > 0 else "oprit" + lines.append(f"config: {cfg_name} | heartbeat: {hb_txt}") ctx.notifier.send(Alert(kind="status", title="ATM Status", body="\n".join(lines))) elif cmd.action == "ss": @@ -1959,6 +2071,7 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: elif cmd.action == "window": if ctx.lifecycle is None: return + _save_session_window(ctx.lifecycle.session_window_path, cmd.window) if cmd.window is None: ctx.lifecycle.session_window = None ctx.audit.log({"ts": time.time(), "event": "session_window_cleared"}) @@ -1977,15 +2090,18 @@ async def _dispatch_command(ctx: RunContext, cmd) -> None: ctx.notifier.send(Alert( kind="status", title=f"Fereastră monitorizare: {start_w}–{stop_w} (ora locală, zilnic)", - body="În afara intervalului monitorizarea se pauzează automat.", + body=( + "În afara intervalului monitorizarea se pauzează automat.\n" + "Salvată — rămâne activă și după repornire." + ), )) elif cmd.action == "rebase": await _dispatch_rebase(ctx, cmd) elif cmd.action == "help": body = ( - "/status — stare FSM, uptime, ultima detecție\n" + "/status — stare FSM, uptime, fereastră + ore bursă\n" "/ss — screenshot acum\n" - "/pause — oprește detecția (heartbeat continuă)\n" + "/pause — oprește detecția (mesajele de deschidere/închidere continuă)\n" "/resume — reia detecția (șterge user-pause și drift-pause)\n" "/rebase — propune phash nou pentru canary (confirm cu /rebase confirm)\n" "/3 — screenshot automat la fiecare 3 min (sau orice număr)\n" @@ -2286,11 +2402,24 @@ async def run_live_async(cfg, duration_s=None, capture_stub: bool = False) -> No ) poller = TelegramPoller(cfg.telegram, cmd_queue, audit) - lifecycle = LifecycleState() + sw_path = Path("logs/session_window.json") + lifecycle = LifecycleState( + session_window=_load_session_window(sw_path), session_window_path=sw_path, + ) + if lifecycle.session_window is not None: + sw_start, sw_stop = lifecycle.session_window + audit.log({"ts": time.time(), "event": "session_window_restored", + "start": sw_start, "stop": sw_stop}) + notifier.send(Alert( + kind="status", + title=f"Fereastră monitorizare restaurată: {sw_start}–{sw_stop} (ora locală)", + body="/window off pentru a o dezactiva.", + silent=True, + )) # Seed lifecycle.last_window_state with the current status so we don't emit # a spurious market_open alert on the very first tick (R2). - _pre_skip = _should_skip(time.time(), lifecycle, cfg, canary) - _maybe_log_transition(_pre_skip, lifecycle, time.time(), audit, notifier) + _now = time.time() + _maybe_log_transition(_window_skip_reason(_now, lifecycle, cfg), lifecycle, _now, audit, notifier) ctx = RunContext( cfg=cfg, capture=capture, canary=canary, detector=detector, fsm=fsm, @@ -2350,7 +2479,12 @@ async def run_live_async(cfg, duration_s=None, capture_stub: bool = False) -> No # Launch background tasks t_scheduler = asyncio.create_task(scheduler.run(), name="scheduler") t_poller = asyncio.create_task(poller.run(), name="poller") - t_heartbeat = asyncio.create_task(_heartbeat_loop(), name="heartbeat") + # heartbeat_min = 0 (default): no periodic heartbeat — the daily + # "Piața deschisă/închisă" alerts are the alive signal instead. + t_heartbeat = ( + asyncio.create_task(_heartbeat_loop(), name="heartbeat") + if cfg.heartbeat_min > 0 else None + ) try: await _detection_loop() @@ -2365,9 +2499,10 @@ async def run_live_async(cfg, duration_s=None, capture_stub: bool = False) -> No with contextlib.suppress(asyncio.CancelledError, Exception): await t_poller # 3. cancel heartbeat - t_heartbeat.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await t_heartbeat + if t_heartbeat is not None: + t_heartbeat.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await t_heartbeat # 4. drain detection — complete (we awaited _detection_loop directly) # 5. send shutdown alert try: diff --git a/src/atm/notifier/AGENTS.md b/src/atm/notifier/AGENTS.md index 9b2477a..dc2324d 100644 --- a/src/atm/notifier/AGENTS.md +++ b/src/atm/notifier/AGENTS.md @@ -35,6 +35,10 @@ comenzi live Telegram. Fan-out trimite același eveniment pe mai multe canale. `/rebase confirm` în ≤180s rescrie `baseline_phash` în TOML-ul activ (păstrează comentariile), mirror în `cfg` la runtime, clear `user_paused` + `drift_paused`. Fără confirm, nimic nu se schimbă. +- **`/status`** — stare/semnale/uptime, ultima culoare, poller; blocul de + fereastră (`_window_status_lines`, doar dacă e configurat `operating_hours` + sau `/window`): `deschisă/închisă → următoarea deschidere/închidere` (ora + locală), `/window`, orele bursei + echivalentul local; `config | heartbeat`. - **`/ss`** — top-3 buline din `dot_roi`: cerc roșu gros pe pick-ul FSM, cercuri colorate subțiri pe vecini; caption cu nume/RGB/distanță/confidence + `config: {version}`. Culoarea cercului = `cfg.colors[name].rgb` (DRY cu paleta). @@ -43,13 +47,20 @@ comenzi live Telegram. Fan-out trimite același eveniment pe mai multe canale. Dacă capture pică, title conține `⚠️ captură eșuată` și resume se execută oricum. - **Drift-pause** — un singur alert Telegram pe tranziție. Cât e pauzat, `/set_interval` e refuzat, caption-ul `/ss` avertizează că detecția e oprită, - heartbeat arată `⚠️ pauzat (drift)` în loc de `activ`. + heartbeat-ul (dacă e pornit) arată `⚠️ pauzat (drift)` în loc de `activ`. +- **Alive signal** — heartbeat-ul periodic e **oprit implicit** + (`heartbeat_min = 0`). Semnalul „sunt viu" = alertele zilnice + „🟢 Piața deschisă" / „🔴 Piața închisă", emise pe tranziția ferestrei + (`_window_skip_reason` = `operating_hours` AND `/window`), **independent de + pauză** (user/drift) — body-ul spune dacă monitorizarea e pauzată. - **`/window HH:MM-HH:MM`** (sau `HH:MM HH:MM`) — fereastră de monitorizare în - **ora locală**, **recurentă zilnic**, stocată în `LifecycleState.session_window`. - Se combină prin **AND** cu `operating_hours` (vezi `../AGENTS.md` → scheduler): - în afara intervalului `_should_skip` întoarce `out_of_window_hours` ⇒ pauză - automată (alertă „Piața închisă" o dată + scheduler oprit + FSM reset). - `/window off` (sau `clear`) șterge fereastra. Format invalid e ignorat. + **ora locală**, **recurentă zilnic**, stocată în `LifecycleState.session_window` + și **persistată** în `logs/session_window.json` (restaurată la pornire, cu o + alertă silent). Se combină prin **AND** cu `operating_hours` (vezi + `../AGENTS.md` → scheduler): în afara intervalului `_should_skip` întoarce + `out_of_window_hours` ⇒ pauză automată (alertă „Piața închisă" o dată + + scheduler oprit + FSM reset). `/window off` (sau `clear`) șterge fereastra și + fișierul. Format invalid e ignorat. ## Work Guidance diff --git a/tests/test_main.py b/tests/test_main.py index 58d78fa..b48b6cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -870,6 +870,63 @@ async def test_window_command_sets_and_clears_session_window(): assert any(e.get("event") == "session_window_cleared" for e in ctx.audit.events) +@pytest.mark.asyncio +async def test_window_command_persists_across_restart(tmp_path): + """/window is saved to disk and restored on the next start; /window off deletes it.""" + import atm.main as _main + from atm.commands import Command + + sw_path = tmp_path / "logs" / "session_window.json" + ctx = _dispatch_ctx(lifecycle=_main.LifecycleState(session_window_path=sw_path)) + await _main._dispatch_command(ctx, Command(action="window", window=("19:30", "22:00"))) + assert _main._load_session_window(sw_path) == ("19:30", "22:00") + + await _main._dispatch_command(ctx, Command(action="window", window=None)) + assert not sw_path.exists() + assert _main._load_session_window(sw_path) is None + + +def test_load_session_window_corrupt_file_is_none(tmp_path): + import atm.main as _main + + p = tmp_path / "session_window.json" + p.write_text("{not json", encoding="utf-8") + assert _main._load_session_window(p) is None + p.write_text('{"start": "19:30"}', encoding="utf-8") + assert _main._load_session_window(p) is None + + +@pytest.mark.asyncio +async def test_open_close_alerts_sent_while_user_paused(): + """Daily open/close alerts are the alive signal → they fire even when paused.""" + import atm.main as _main + + cfg = _oh_cfg() + cfg.lockout_s = 240 + tz = cfg.operating_hours._tz_cache + lifecycle = _main.LifecycleState(user_paused=True, last_window_state="closed") + ctx = _dispatch_ctx(lifecycle=lifecycle, cfg=cfg) + ctx.charts = [] + + mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp() + assert await _main._lifecycle_gate(ctx, mid) == "user_paused" + assert lifecycle.last_window_state == "open" + opened = [a for a in ctx.notifier.alerts if "deschisă" in a.title] + assert len(opened) == 1 and "oprită manual" in opened[0].body + # Paused → TradeStation isn't pulled to the front on open. + assert not any(e.get("event") == "window_focused" for e in ctx.audit.events) + + close = _dt.datetime(2026, 4, 20, 16, 5, tzinfo=tz).timestamp() + assert await _main._lifecycle_gate(ctx, close) == "user_paused" + assert lifecycle.last_window_state == "closed" + assert sum("închisă" in a.title for a in ctx.notifier.alerts) == 1 + + # Same side of the boundary again → no duplicate. + n = len(ctx.notifier.alerts) + await _main._lifecycle_gate(ctx, close + 60) + assert len(ctx.notifier.alerts) == n + + # --------------------------------------------------------------------------- # Commit 5: /pause /resume dispatch (plan tests #11-15, #16, R2 #21) # --------------------------------------------------------------------------- @@ -1741,7 +1798,60 @@ async def test_status_window_line_when_oh_enabled(): status = [a for a in ctx.notifier.alerts if a.kind == "status"] body = status[0].body - assert "fereastră: deschisă" in body + # Open/closed depends on the real clock here; exact content is covered by + # the _window_status_lines tests below with fixed timestamps. + assert "fereastră: " in body + assert "/window: —" in body + assert "bursă: 09:30–16:00 America/New_York MON–FRI" in body + + +def test_window_status_lines_session_window_only(): + """Local /window only (oh off) → open/closed + next change in local time.""" + import atm.main as _main + + cfg = _oh_cfg(enabled=False) + lifecycle = _main.LifecycleState(session_window=("19:30", "22:00")) + inside = _dt.datetime(2026, 4, 20, 20, 0).timestamp() + assert _main._window_status_lines(inside, lifecycle, cfg) == [ + "fereastră: deschisă → închidere azi 22:00", + "/window: 19:30–22:00 (local)", + ] + after = _dt.datetime(2026, 4, 20, 22, 30).timestamp() + assert _main._window_status_lines(after, lifecycle, cfg)[0] == ( + "fereastră: închisă → deschidere mâine 19:30" + ) + + +def test_window_status_lines_nothing_configured_is_empty(): + import atm.main as _main + lines = _main._window_status_lines(0.0, _main.LifecycleState(), _oh_cfg(enabled=False)) + assert lines == [] + + +def test_window_status_lines_exchange_hours_local_conversion(): + import atm.main as _main + + cfg = _oh_cfg() + tz = cfg.operating_hours._tz_cache + mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp() + loc = lambda h, m: _dt.datetime(2026, 4, 20, h, m, tzinfo=tz).astimezone().strftime("%H:%M") # noqa: E731 + lines = _main._window_status_lines(mid, _main.LifecycleState(), cfg) + assert lines[0].startswith("fereastră: deschisă → închidere ") + assert lines[1] == "/window: —" + assert lines[2] == ( + f"bursă: 09:30–16:00 America/New_York MON–FRI (local {loc(9, 30)}–{loc(16, 0)})" + ) + + +def test_next_window_change_skips_weekend(): + """Friday after close → next flip is Monday's open, not Saturday.""" + import atm.main as _main + + cfg = _oh_cfg() + tz = cfg.operating_hours._tz_cache + fri_evening = _dt.datetime(2026, 4, 24, 17, 0, tzinfo=tz).timestamp() + mon_open = _dt.datetime(2026, 4, 27, 9, 30, tzinfo=tz).timestamp() + assert _main._next_window_change(fri_evening, _main.LifecycleState(), cfg) == mon_open # ---------------------------------------------------------------------------