feat(run): arm + prime alerts, mid-session catchup, late-start guard

Notify on IDLE→ARMED and ARMED→PRIMED so the user gets staged warnings
before FIRE. If atm run starts mid-cycle on dark_green/dark_red, synth
a catchup arm so the prime alert still fires (audit tagged catchup:true).
If it starts on light_green/light_red, emit one late_start warn and skip
the FSM feed — FIRE already passed.

Extracted _handle_tick() as a pure helper (takes fsm + duck-typed
notifier/audit Protocols) so the dispatch is unit-testable without
mocking FanoutNotifier. 9 new tests cover arm, prime, refresh silence,
catchup synth-arm (+ audit), and late-start on both directions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 14:30:01 +00:00
parent 34fde8328c
commit e7369ca632
4 changed files with 347 additions and 10 deletions

View File

@@ -23,6 +23,18 @@ Standalone helper `atm dump --duration 4h --dir samples/` that just captures fra
Expand README with concrete Windows Task Scheduler XML export + import steps, example triggers for 16:30→18:30 and 21:00→23:00, and DST-change checklist reminder. Expand README with concrete Windows Task Scheduler XML export + import steps, example triggers for 16:30→18:30 and 21:00→23:00, and DST-change checklist reminder.
## P2-alert-mute-flags
Per-kind mute toggles for notifications in case arm/prime turn out too noisy in live sessions:
- `cfg.notify.arm: bool = true`
- `cfg.notify.prime: bool = true`
- `cfg.notify.late_start: bool = true`
Default all true. Gate each `notifier.send()` in `_handle_tick()` on the flag. Start after 3+ live sessions confirm the signal/noise ratio.
Blocked on: Faza 1 baseline evidence.
## P3-faza2-exec ## P3-faza2-exec
Auto-execution on TradeLocker. Blocked on TOS audit (see `docs/phase2-prop-firm-audit.md`). Not started until GO decision + 20+ Faza 1 sessions. Auto-execution on TradeLocker. Blocked on TOS audit (see `docs/phase2-prop-firm-audit.md`). Not started until GO decision + 20+ Faza 1 sessions.

View File

@@ -5,14 +5,25 @@ import argparse
import os import os
import sys import sys
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, Protocol, cast
from atm.config import Config # stdlib-only (tomllib); safe at module level from atm.config import Config # stdlib-only (tomllib); safe at module level
from atm.notifier import Alert
from atm.state_machine import State, StateMachine, Transition
if TYPE_CHECKING: if TYPE_CHECKING:
from atm.state_machine import DotColor from atm.state_machine import DotColor
class _NotifierLike(Protocol):
def send(self, alert: Alert) -> None: ...
class _AuditLike(Protocol):
def log(self, event: dict) -> None: ...
# Module-level reference — set lazily by _cmd_dryrun; tests may monkeypatch it. # Module-level reference — set lazily by _cmd_dryrun; tests may monkeypatch it.
dryrun = None dryrun = None
@@ -324,14 +335,106 @@ def _cmd_report(args) -> None:
# Live loop # Live loop
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _handle_tick(
fsm: StateMachine,
color: str,
now: float,
notifier: _NotifierLike,
audit: _AuditLike,
first_accepted: bool,
) -> Transition | None:
"""Feed FSM for a single accepted color and dispatch arm/prime/late_start
alerts. Returns the final Transition, or None when the color triggered a
late-start short-circuit (FSM untouched; caller should skip FIRE handling).
Pure in the sense that all state lives in the arguments — safe to unit-test
with a FakeNotifier and FakeAudit.
"""
# Late start: the very first accepted color is already at FIRE phase.
# User came online after the trade signal fired — warn and skip FSM feed.
if first_accepted and color in ("light_green", "light_red") and fsm.state == State.IDLE:
direction = "BUY" if color == "light_green" else "SELL"
audit.log({
"ts": now,
"event": "late_start",
"observed_color": color,
})
notifier.send(Alert(
kind="late_start",
title=f"ATM started late — {direction} already fired",
body=f"Observed {color} at startup. Check chart manually.",
direction=direction,
))
return None
# Catchup synth-arm: first accepted color is already at PRIME phase.
# Drive FSM through a synthetic arm so the real PRIME transition fires a
# normal prime alert below. Audit entry is tagged catchup:true.
catchup = False
if first_accepted and color in ("dark_green", "dark_red") and fsm.state == State.IDLE:
assert fsm.state == State.IDLE, "synth-arm invariant: FSM must be IDLE"
arm_color = "turquoise" if color == "dark_green" else "yellow"
direction = "BUY" if color == "dark_green" else "SELL"
tr_synth = fsm.feed(cast("DotColor", arm_color), now)
audit.log({
"ts": now,
"event": "tick",
"color": arm_color,
"state": tr_synth.next.value,
"reason": tr_synth.reason,
"catchup": True,
"synthesized_from": color,
})
notifier.send(Alert(
kind="arm",
title=f"{direction} armed ({arm_color}) — catchup",
body=f"catchup — session already armed at startup "
f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
direction=direction,
))
catchup = True
# Normal FSM feed
tr = fsm.feed(cast("DotColor", color), now)
tick_event: dict = {
"ts": now,
"event": "tick",
"color": color,
"state": tr.next.value,
"reason": tr.reason,
}
if catchup:
tick_event["catchup"] = True
audit.log(tick_event)
# ARM: turquoise (BUY) / yellow (SELL) — only on fresh IDLE→ARMED
if tr.reason == "arm":
direction = "BUY" if tr.next == State.ARMED_BUY else "SELL"
notifier.send(Alert(
kind="arm",
title=f"{direction} armed ({color})",
body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
direction=direction,
))
# PRIME: dark_green (BUY) / dark_red (SELL) — only on ARMED→PRIMED
elif tr.reason == "prime":
direction = "BUY" if tr.next == State.PRIMED_BUY else "SELL"
suffix = " — catchup" if catchup else ""
notifier.send(Alert(
kind="prime",
title=f"{direction} primed ({color}){suffix}",
body=f"@ {datetime.fromtimestamp(now).isoformat(timespec='seconds')}",
direction=direction,
))
return tr
def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None: def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
"""Main live monitoring loop. Imports are lazy to keep --help fast.""" """Main live monitoring loop. Imports are lazy to keep --help fast."""
try: try:
from atm.detector import Detector from atm.detector import Detector
from atm.state_machine import StateMachine
from atm.canary import Canary from atm.canary import Canary
from atm.levels import LevelsExtractor from atm.levels import LevelsExtractor
from atm.notifier import Alert
from atm.notifier.fanout import FanoutNotifier from atm.notifier.fanout import FanoutNotifier
from atm.notifier.discord import DiscordNotifier from atm.notifier.discord import DiscordNotifier
from atm.notifier.telegram import TelegramNotifier from atm.notifier.telegram import TelegramNotifier
@@ -351,7 +454,6 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
notifier = FanoutNotifier(backends, Path(cfg.dead_letter_path)) notifier = FanoutNotifier(backends, Path(cfg.dead_letter_path))
# Sanity check: capture one frame, confirm canary matches calibration. # Sanity check: capture one frame, confirm canary matches calibration.
from datetime import datetime
first_frame = capture() first_frame = capture()
if first_frame is None: if first_frame is None:
print("WARN: first capture returned None — window/region missing", flush=True) print("WARN: first capture returned None — window/region missing", flush=True)
@@ -378,6 +480,7 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
heartbeat_due = start + cfg.heartbeat_min * 60 heartbeat_due = start + cfg.heartbeat_min * 60
levels_extractor = None levels_extractor = None
last_saved_color: str | None = None last_saved_color: str | None = None
first_accepted = True
samples_dir = Path("samples") samples_dir = Path("samples")
samples_dir.mkdir(exist_ok=True) samples_dir.mkdir(exist_ok=True)
fires_dir = Path("logs") / "fires" fires_dir = Path("logs") / "fires"
@@ -401,11 +504,13 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
# detection # detection
res = detector.step(now) res = detector.step(now)
if res.accepted and res.color: if res.accepted and res.color:
tr = fsm.feed(cast("DotColor", res.color), now) is_first = first_accepted
audit.log({ first_accepted = False
"ts": now, "event": "tick", tr = _handle_tick(fsm, res.color, now, notifier, audit, is_first)
"color": res.color, "state": tr.next.value, "reason": tr.reason, if tr is None:
}) # late_start short-circuit: FSM untouched, skip FIRE + corpus save
time.sleep(cfg.loop_interval_s)
continue
# corpus: save full frame on each new distinct color for later labeling # corpus: save full frame on each new distinct color for later labeling
if res.color != last_saved_color: if res.color != last_saved_color:
ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S") ts_str = datetime.fromtimestamp(now).strftime("%Y%m%d_%H%M%S")

View File

@@ -5,7 +5,7 @@ from typing import Protocol
@dataclass @dataclass
class Alert: class Alert:
kind: str # "trigger" | "heartbeat" | "levels" | "warn" kind: str # "trigger" | "heartbeat" | "levels" | "warn" | "arm" | "prime" | "late_start"
title: str title: str
body: str body: str
image_path: Path | None = None # annotated screenshot image_path: Path | None = None # annotated screenshot

220
tests/test_handle_tick.py Normal file
View File

@@ -0,0 +1,220 @@
"""Unit tests for atm.main._handle_tick.
Covers the six cases from the arm+prime notification plan:
1. fresh BUY arm (turquoise)
2. fresh SELL arm (yellow)
3. BUY prime (turquoise → dark_green)
4. silent refresh (turquoise → turquoise)
5. catchup synth-arm from dark_green + prime alert, audit tagged catchup:true
6. late-start guard (light_green from IDLE with first_accepted=True)
"""
from __future__ import annotations
from atm.main import _handle_tick
from atm.notifier import Alert
from atm.state_machine import State, StateMachine
class FakeNotifier:
"""Captures alerts instead of fanning out. send() is synchronous."""
def __init__(self) -> None:
self.alerts: list[Alert] = []
def send(self, alert: Alert) -> None:
self.alerts.append(alert)
class FakeAudit:
"""Captures audit events in-memory."""
def __init__(self) -> None:
self.events: list[dict] = []
def log(self, event: dict) -> None:
self.events.append(event)
# ---------------------------------------------------------------------------
# 1. fresh BUY arm
# ---------------------------------------------------------------------------
def test_handle_tick_arm_buy():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert tr.reason == "arm"
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "BUY"
assert "turquoise" in notif.alerts[0].title
# ---------------------------------------------------------------------------
# 2. fresh SELL arm
# ---------------------------------------------------------------------------
def test_handle_tick_arm_sell():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "yellow", 1.0, notif, audit, first_accepted=False)
assert tr is not None
assert tr.next == State.ARMED_SELL
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "SELL"
assert "yellow" in notif.alerts[0].title
# ---------------------------------------------------------------------------
# 3. BUY prime — turquoise then dark_green
# ---------------------------------------------------------------------------
def test_handle_tick_prime_buy():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
tr2 = _handle_tick(fsm, "dark_green", 2.0, notif, audit, first_accepted=False)
assert tr2 is not None
assert tr2.next == State.PRIMED_BUY
assert len(notif.alerts) == 2
assert notif.alerts[0].kind == "arm"
assert notif.alerts[1].kind == "prime"
assert notif.alerts[1].direction == "BUY"
# Non-catchup prime alert must not mention catchup
assert "catchup" not in notif.alerts[1].title.lower()
# ---------------------------------------------------------------------------
# 4. refresh is silent — second turquoise in ARMED_BUY emits no alert
# ---------------------------------------------------------------------------
def test_handle_tick_refresh_no_alert():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
_handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=False)
tr2 = _handle_tick(fsm, "turquoise", 2.0, notif, audit, first_accepted=False)
assert tr2 is not None
assert tr2.reason == "refresh"
assert len(notif.alerts) == 1 # only the first (real) arm
# ---------------------------------------------------------------------------
# 5. catchup synth-arm from dark_green with first_accepted=True
# ---------------------------------------------------------------------------
def test_handle_tick_catchup_dark_green():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_green", 1.0, notif, audit, first_accepted=True)
# Synth arm + real prime feed → FSM lands in PRIMED_BUY
assert tr is not None
assert tr.next == State.PRIMED_BUY
assert tr.reason == "prime"
# Two alerts: synth-arm (catchup-tagged) + prime
assert len(notif.alerts) == 2
assert notif.alerts[0].kind == "arm"
assert notif.alerts[0].direction == "BUY"
assert "catchup" in notif.alerts[0].title.lower() or "catchup" in notif.alerts[0].body.lower()
assert notif.alerts[1].kind == "prime"
assert notif.alerts[1].direction == "BUY"
assert "catchup" in notif.alerts[1].title.lower() or "catchup" in notif.alerts[1].body.lower()
# Audit: both tick entries tagged catchup:true
catchup_events = [e for e in audit.events if e.get("catchup")]
assert len(catchup_events) == 2
synth = next(e for e in catchup_events if e.get("synthesized_from") == "dark_green")
assert synth["color"] == "turquoise"
assert synth["reason"] == "arm"
def test_handle_tick_catchup_dark_red():
"""Mirror of dark_green — SELL side."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "dark_red", 1.0, notif, audit, first_accepted=True)
assert tr is not None
assert tr.next == State.PRIMED_SELL
assert len(notif.alerts) == 2
assert notif.alerts[0].direction == "SELL"
assert notif.alerts[1].direction == "SELL"
# ---------------------------------------------------------------------------
# 6. late-start: light_green from IDLE with first_accepted=True
# ---------------------------------------------------------------------------
def test_handle_tick_late_start():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "light_green", 1.0, notif, audit, first_accepted=True)
# FSM untouched
assert tr is None
assert fsm.state == State.IDLE
# Exactly one late_start alert
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "late_start"
assert notif.alerts[0].direction == "BUY"
# Audit has a late_start event
assert any(e.get("event") == "late_start" for e in audit.events)
def test_handle_tick_late_start_sell():
"""Mirror of light_green — light_red triggers SELL late_start."""
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "light_red", 1.0, notif, audit, first_accepted=True)
assert tr is None
assert fsm.state == State.IDLE
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "late_start"
assert notif.alerts[0].direction == "SELL"
# ---------------------------------------------------------------------------
# Extra guard: first_accepted=True with turquoise is a normal arm (decision 3A)
# ---------------------------------------------------------------------------
def test_handle_tick_first_turquoise_no_catchup_label():
fsm = StateMachine(lockout_s=60)
notif = FakeNotifier()
audit = FakeAudit()
tr = _handle_tick(fsm, "turquoise", 1.0, notif, audit, first_accepted=True)
assert tr is not None
assert tr.next == State.ARMED_BUY
assert len(notif.alerts) == 1
assert notif.alerts[0].kind == "arm"
# Decision 3A: cannot distinguish fresh arm from catchup-at-arm-phase
assert "catchup" not in notif.alerts[0].title.lower()
assert "catchup" not in notif.alerts[0].body.lower()