Compare commits
2 Commits
e7369ca632
...
d7305fbbfc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7305fbbfc | ||
|
|
f4b9000100 |
@@ -371,7 +371,7 @@ def _handle_tick(
|
|||||||
# Drive FSM through a synthetic arm so the real PRIME transition fires a
|
# Drive FSM through a synthetic arm so the real PRIME transition fires a
|
||||||
# normal prime alert below. Audit entry is tagged catchup:true.
|
# normal prime alert below. Audit entry is tagged catchup:true.
|
||||||
catchup = False
|
catchup = False
|
||||||
if first_accepted and color in ("dark_green", "dark_red") and fsm.state == State.IDLE:
|
if color in ("dark_green", "dark_red") and fsm.state == State.IDLE:
|
||||||
assert fsm.state == State.IDLE, "synth-arm invariant: FSM must be IDLE"
|
assert fsm.state == State.IDLE, "synth-arm invariant: FSM must be IDLE"
|
||||||
arm_color = "turquoise" if color == "dark_green" else "yellow"
|
arm_color = "turquoise" if color == "dark_green" else "yellow"
|
||||||
direction = "BUY" if color == "dark_green" else "SELL"
|
direction = "BUY" if color == "dark_green" else "SELL"
|
||||||
@@ -447,6 +447,7 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
|
|||||||
fsm = StateMachine(lockout_s=cfg.lockout_s)
|
fsm = StateMachine(lockout_s=cfg.lockout_s)
|
||||||
canary = Canary(cfg, pause_flag_path=Path("logs/pause.flag"))
|
canary = Canary(cfg, pause_flag_path=Path("logs/pause.flag"))
|
||||||
audit = AuditLog(Path("logs"))
|
audit = AuditLog(Path("logs"))
|
||||||
|
detection_log = AuditLog(Path("logs/detections"))
|
||||||
backends = [
|
backends = [
|
||||||
DiscordNotifier(cfg.discord.webhook_url),
|
DiscordNotifier(cfg.discord.webhook_url),
|
||||||
TelegramNotifier(cfg.telegram.bot_token, cfg.telegram.chat_id),
|
TelegramNotifier(cfg.telegram.bot_token, cfg.telegram.chat_id),
|
||||||
@@ -503,6 +504,18 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
|
|||||||
continue
|
continue
|
||||||
# detection
|
# detection
|
||||||
res = detector.step(now)
|
res = detector.step(now)
|
||||||
|
detection_log.log({
|
||||||
|
"ts": now,
|
||||||
|
"event": "frame",
|
||||||
|
"window_found": res.window_found,
|
||||||
|
"dot_found": res.dot_found,
|
||||||
|
"rgb": list(res.rgb) if res.rgb is not None else None,
|
||||||
|
"match_name": res.match.name if res.match is not None else None,
|
||||||
|
"distance": round(res.match.distance, 2) if res.match is not None else None,
|
||||||
|
"confidence": round(res.match.confidence, 3) if res.match is not None else None,
|
||||||
|
"accepted": res.accepted,
|
||||||
|
"color": res.color,
|
||||||
|
})
|
||||||
if res.accepted and res.color:
|
if res.accepted and res.color:
|
||||||
is_first = first_accepted
|
is_first = first_accepted
|
||||||
first_accepted = False
|
first_accepted = False
|
||||||
@@ -569,6 +582,7 @@ def run_live(cfg, duration_s=None, capture_stub: bool = False) -> None:
|
|||||||
pass
|
pass
|
||||||
notifier.stop()
|
notifier.stop()
|
||||||
audit.close()
|
audit.close()
|
||||||
|
detection_log.close()
|
||||||
|
|
||||||
|
|
||||||
def _build_capture(cfg, capture_stub: bool = False):
|
def _build_capture(cfg, capture_stub: bool = False):
|
||||||
|
|||||||
@@ -218,3 +218,41 @@ def test_handle_tick_first_turquoise_no_catchup_label():
|
|||||||
# Decision 3A: cannot distinguish fresh arm from catchup-at-arm-phase
|
# 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].title.lower()
|
||||||
assert "catchup" not in notif.alerts[0].body.lower()
|
assert "catchup" not in notif.alerts[0].body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regression: user bug 2026-04-16. Catchup must fire whenever IDLE + dark_* is
|
||||||
|
# observed, regardless of first_accepted. Prior gray noise tick consumed
|
||||||
|
# first_accepted and the subsequent dark_red was gated out of catchup.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_handle_tick_catchup_dark_red_when_not_first_accepted():
|
||||||
|
fsm = StateMachine(lockout_s=60)
|
||||||
|
notif = FakeNotifier()
|
||||||
|
audit = FakeAudit()
|
||||||
|
|
||||||
|
tr = _handle_tick(fsm, "dark_red", 1.0, notif, audit, first_accepted=False)
|
||||||
|
|
||||||
|
assert tr is not None
|
||||||
|
assert tr.next == State.PRIMED_SELL
|
||||||
|
assert len(notif.alerts) == 2
|
||||||
|
assert notif.alerts[0].kind == "arm"
|
||||||
|
assert notif.alerts[0].direction == "SELL"
|
||||||
|
assert "catchup" in (notif.alerts[0].title + notif.alerts[0].body).lower()
|
||||||
|
assert notif.alerts[1].kind == "prime"
|
||||||
|
assert notif.alerts[1].direction == "SELL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_tick_catchup_dark_green_when_not_first_accepted():
|
||||||
|
"""Mirror — BUY side."""
|
||||||
|
fsm = StateMachine(lockout_s=60)
|
||||||
|
notif = FakeNotifier()
|
||||||
|
audit = FakeAudit()
|
||||||
|
|
||||||
|
tr = _handle_tick(fsm, "dark_green", 1.0, notif, audit, first_accepted=False)
|
||||||
|
|
||||||
|
assert tr is not None
|
||||||
|
assert tr.next == State.PRIMED_BUY
|
||||||
|
assert len(notif.alerts) == 2
|
||||||
|
assert notif.alerts[0].direction == "BUY"
|
||||||
|
assert notif.alerts[1].direction == "BUY"
|
||||||
|
|||||||
@@ -135,3 +135,123 @@ def test_run_live_dry(monkeypatch):
|
|||||||
|
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
assert calls[0]["duration_s"] == pytest.approx(0.0)
|
assert calls[0]["duration_s"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regression integration test — user bug 2026-04-16.
|
||||||
|
# Session starts with an accepted gray tick followed by dark_red. Catchup
|
||||||
|
# synth-arm must fire on dark_red (the previous gray consumed first_accepted),
|
||||||
|
# then light_red triggers SELL. Proves run_live wiring dispatches alerts for
|
||||||
|
# the user's exact scenario.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_run_live_catchup_sell_from_gray_then_dark_red(monkeypatch, tmp_path):
|
||||||
|
import numpy as np
|
||||||
|
import atm.main as _main
|
||||||
|
from atm.detector import DetectionResult
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class FakeFanout:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
def send(self, alert):
|
||||||
|
captured.append(alert)
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FakeCanaryResult:
|
||||||
|
distance = 0
|
||||||
|
drifted = False
|
||||||
|
paused = False
|
||||||
|
|
||||||
|
class FakeCanary:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
self.is_paused = False
|
||||||
|
def check(self, frame):
|
||||||
|
return FakeCanaryResult()
|
||||||
|
def resume(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _StopLoop(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ScriptedDetector:
|
||||||
|
_script = [
|
||||||
|
("gray", True),
|
||||||
|
("gray", True),
|
||||||
|
("dark_red", True),
|
||||||
|
("dark_red", True),
|
||||||
|
("light_red", True),
|
||||||
|
]
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
self._i = 0
|
||||||
|
def step(self, ts):
|
||||||
|
if self._i >= len(self._script):
|
||||||
|
raise _StopLoop
|
||||||
|
color, accepted = self._script[self._i]
|
||||||
|
self._i += 1
|
||||||
|
return DetectionResult(
|
||||||
|
ts=ts,
|
||||||
|
window_found=True,
|
||||||
|
dot_found=True,
|
||||||
|
rgb=(1, 1, 1),
|
||||||
|
match=None,
|
||||||
|
accepted=accepted,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_build_capture(cfg, capture_stub=False):
|
||||||
|
return lambda: np.zeros((50, 50, 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
cfg = MagicMock()
|
||||||
|
cfg.lockout_s = 60
|
||||||
|
cfg.heartbeat_min = 999
|
||||||
|
cfg.loop_interval_s = 0
|
||||||
|
cfg.config_version = "test"
|
||||||
|
cfg.dead_letter_path = str(tmp_path / "dl.jsonl")
|
||||||
|
cfg.canary.drift_threshold = 10
|
||||||
|
cfg.dot_roi.x = 0
|
||||||
|
cfg.dot_roi.y = 0
|
||||||
|
cfg.dot_roi.w = 10
|
||||||
|
cfg.dot_roi.h = 10
|
||||||
|
cfg.chart_window_region = None
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
class _Stub:
|
||||||
|
def __init__(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
def log(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
def close(self, *a, **kw):
|
||||||
|
pass
|
||||||
|
def step(self, *a, **kw):
|
||||||
|
return types.SimpleNamespace(status="pending", levels=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr("atm.detector.Detector", ScriptedDetector)
|
||||||
|
monkeypatch.setattr("atm.canary.Canary", FakeCanary)
|
||||||
|
monkeypatch.setattr("atm.notifier.fanout.FanoutNotifier", FakeFanout)
|
||||||
|
monkeypatch.setattr("atm.notifier.discord.DiscordNotifier", _Stub)
|
||||||
|
monkeypatch.setattr("atm.notifier.telegram.TelegramNotifier", _Stub)
|
||||||
|
monkeypatch.setattr("atm.audit.AuditLog", _Stub)
|
||||||
|
monkeypatch.setattr("atm.levels.LevelsExtractor", _Stub)
|
||||||
|
monkeypatch.setattr("atm.main._build_capture", fake_build_capture)
|
||||||
|
monkeypatch.setattr("atm.main.time.sleep", lambda s: None)
|
||||||
|
|
||||||
|
with pytest.raises(_StopLoop):
|
||||||
|
_main.run_live(cfg, duration_s=None)
|
||||||
|
|
||||||
|
arm = [a for a in captured if a.kind == "arm"]
|
||||||
|
prime = [a for a in captured if a.kind == "prime"]
|
||||||
|
trigger = [a for a in captured if a.kind == "trigger"]
|
||||||
|
|
||||||
|
assert len(arm) == 1, f"expected 1 arm alert, got {len(arm)} ({[a.title for a in captured]})"
|
||||||
|
assert arm[0].direction == "SELL"
|
||||||
|
assert "catchup" in (arm[0].title + arm[0].body).lower()
|
||||||
|
|
||||||
|
assert len(prime) == 1
|
||||||
|
assert prime[0].direction == "SELL"
|
||||||
|
assert "catchup" in (prime[0].title + prime[0].body).lower()
|
||||||
|
|
||||||
|
assert len(trigger) == 1
|
||||||
|
assert trigger[0].direction == "SELL"
|
||||||
|
|||||||
Reference in New Issue
Block a user