feat(lifecycle): /window + stop screenshot-uri la închiderea bursei

Fix #1: la tranziția market_closed scheduler-ul e oprit forțat și FSM
resetat la IDLE (_handle_market_closed), ca o bulină dark_* rămasă PRIMED
să nu mai trimită screenshot-uri periodice după închidere — și ca scheduler-ul
să poată reporni curat în sesiunea următoare (muchia 0->1 n_primed_global).

Fix #2: comandă Telegram /window HH:MM-HH:MM (sau HH:MM HH:MM) — fereastră
de monitorizare în ora locală, recurentă zilnic, ANDed cu operating_hours;
în afara intervalului pauză automată. /window off șterge fereastra.
Discord e webhook outbound, fără poller — comanda e doar Telegram.

DOX pass: src/atm + notifier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 13:25:41 +03:00
parent 39f2439246
commit 6f71c1d633
6 changed files with 267 additions and 20 deletions

View File

@@ -769,6 +769,107 @@ def test_should_skip_canary_drift_wins_over_window():
assert _main._should_skip(mid, lifecycle, cfg, _fake_canary(paused=True)) == "drift_paused"
# ---------------------------------------------------------------------------
# Session window (/window): local wall-clock, recurring daily, ANDed with oh
# ---------------------------------------------------------------------------
def test_should_skip_session_window_local_only():
"""operating_hours off → only the local session window gates detection."""
import atm.main as _main
cfg = _oh_cfg(enabled=False)
lifecycle = _main.LifecycleState(session_window=("19:40", "21:45"))
canary = _fake_canary()
# Naive datetimes → .timestamp() interprets them as local wall-clock, and
# _should_skip reads them back via datetime.fromtimestamp (also local), so
# this is machine-timezone independent.
inside = _dt.datetime(2026, 4, 20, 20, 30).timestamp()
before = _dt.datetime(2026, 4, 20, 19, 0).timestamp()
after = _dt.datetime(2026, 4, 20, 22, 0).timestamp()
boundary_stop = _dt.datetime(2026, 4, 20, 21, 45).timestamp() # stop is exclusive
assert _main._should_skip(inside, lifecycle, cfg, canary) is None
assert _main._should_skip(before, lifecycle, cfg, canary) == "out_of_window_hours"
assert _main._should_skip(after, lifecycle, cfg, canary) == "out_of_window_hours"
assert _main._should_skip(boundary_stop, lifecycle, cfg, canary) == "out_of_window_hours"
def test_should_skip_session_window_no_window_is_open():
import atm.main as _main
cfg = _oh_cfg(enabled=False)
lifecycle = _main.LifecycleState(session_window=None)
any_ts = _dt.datetime(2026, 4, 20, 3, 0).timestamp()
assert _main._should_skip(any_ts, lifecycle, cfg, _fake_canary()) is None
def test_should_skip_operating_hours_wins_over_session_window():
"""Out of operating_hours → skip even if the local session window is open."""
import atm.main as _main
cfg = _oh_cfg() # NY 09:30-16:00 enabled
tz = cfg.operating_hours._tz_cache
pre_open = _dt.datetime(2026, 4, 20, 8, 0, tzinfo=tz).timestamp() # NY pre-open
# Session window that contains the corresponding local time (so only oh skips).
local_hhmm = _dt.datetime.fromtimestamp(pre_open).strftime("%H:%M")
lifecycle = _main.LifecycleState(session_window=("00:00", "23:59"))
skip = _main._should_skip(pre_open, lifecycle, cfg, _fake_canary())
assert skip == "out_of_window_hours"
# sanity: local time really is inside the wide session window
assert "00:00" <= local_hhmm < "23:59"
def test_should_skip_session_window_ands_inside_operating_hours():
"""Inside operating_hours but outside the local session window → skip."""
import atm.main as _main
cfg = _oh_cfg() # NY 09:30-16:00 enabled
tz = cfg.operating_hours._tz_cache
mid = _dt.datetime(2026, 4, 20, 12, 0, tzinfo=tz).timestamp() # in NY window
local_hhmm = _dt.datetime.fromtimestamp(mid).strftime("%H:%M")
h = int(local_hhmm[:2])
# 1-minute window two hours away from the current local time → excludes it.
off = (h + 2) % 24
lifecycle = _main.LifecycleState(session_window=(f"{off:02d}:00", f"{off:02d}:01"))
assert _main._should_skip(mid, lifecycle, cfg, _fake_canary()) == "out_of_window_hours"
def test_handle_market_closed_stops_scheduler_and_resets_fsm():
import atm.main as _main
from atm.state_machine import State, StateMachine
ctx = _dispatch_ctx()
ctx.cfg.lockout_s = 240
ctx.scheduler.is_running = True
ctx.charts = []
ctx.state.n_primed_global = 2
_main._handle_market_closed(ctx, 0.0)
assert ctx.scheduler.is_running is False
assert ctx.state.n_primed_global == 0
assert isinstance(ctx.fsm, StateMachine)
assert ctx.fsm.state == State.IDLE
assert any(
e.get("event") == "scheduler_stopped" and e.get("reason") == "market_closed"
for e in ctx.audit.events
)
@pytest.mark.asyncio
async def test_window_command_sets_and_clears_session_window():
import atm.main as _main
from atm.commands import Command
ctx = _dispatch_ctx()
await _main._dispatch_command(ctx, Command(action="window", window=("19:40", "21:45")))
assert ctx.lifecycle.session_window == ("19:40", "21:45")
assert any(e.get("event") == "session_window_set" for e in ctx.audit.events)
await _main._dispatch_command(ctx, Command(action="window", window=None))
assert ctx.lifecycle.session_window is None
assert any(e.get("event") == "session_window_cleared" for e in ctx.audit.events)
# ---------------------------------------------------------------------------
# Commit 5: /pause /resume dispatch (plan tests #11-15, #16, R2 #21)
# ---------------------------------------------------------------------------