diff --git a/docs/autonomous-mode.md b/docs/autonomous-mode.md index d245a27..1cdff1a 100644 --- a/docs/autonomous-mode.md +++ b/docs/autonomous-mode.md @@ -1,6 +1,10 @@ # Autonomous Mode -> **구현 예정** — 자율 모니터링 모드는 아직 구현되지 않았습니다. +> **Status** — implemented for `qracer serve`. Price-move and breaking-news +> triggers, cooldowns, notification routing (Telegram/macOS), persistence of +> overnight findings, and the session-start briefing recap are live. Volume +> spikes, portfolio P&L triggers, cross-market signals, and the scheduled +> deep-analysis slots remain on the roadmap. qracer runs autonomously during market hours — monitoring watchlists, detecting significant events, and proactively alerting users without being asked. diff --git a/qracer/autonomous.py b/qracer/autonomous.py index b5daf5c..be3c127 100644 --- a/qracer/autonomous.py +++ b/qracer/autonomous.py @@ -7,11 +7,14 @@ from __future__ import annotations +import json import logging import time -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from enum import Enum +from pathlib import Path +from typing import Any from zoneinfo import ZoneInfo from qracer.data.providers import NewsProvider, PriceProvider @@ -229,3 +232,143 @@ def _is_cooling_down(self, ticker: str) -> bool: def _set_cooldown(self, ticker: str) -> None: """Record the current time as the last alert for *ticker*.""" self._cooldowns[ticker] = time.monotonic() + + +class AutonomousAlertStore: + """File-backed storage for triggered autonomous alerts. + + Persists every :class:`AutonomousAlert` produced by + :class:`AutonomousMonitor` so overnight findings can be surfaced on the + next :command:`qracer repl` start via the session briefing. + + Usage:: + + store = AutonomousAlertStore(Path("~/.qracer/autonomous_alerts.json")) + store.save(alert) + overnight = store.get_since(last_session) + """ + + # Keep the on-disk file bounded so a long-running ``qracer serve`` doesn't + # grow the log indefinitely. New alerts push the oldest ones out once the + # cap is reached. + MAX_ALERTS = 500 + + def __init__(self, path: Path) -> None: + self._path = path + self._mtime: float = 0.0 + self._alerts: list[AutonomousAlert] = self._load() + + @property + def alerts(self) -> list[AutonomousAlert]: + """Return a copy of all persisted alerts.""" + self._maybe_reload() + return list(self._alerts) + + def save(self, alert: AutonomousAlert) -> None: + """Append *alert* to the store and flush to disk.""" + self._maybe_reload() + self._alerts.append(alert) + if len(self._alerts) > self.MAX_ALERTS: + # Drop the oldest entries; ``created_at`` is monotonic per-process. + self._alerts = self._alerts[-self.MAX_ALERTS :] + self._save() + + def get_since(self, since: datetime) -> list[AutonomousAlert]: + """Return alerts with ``created_at`` strictly after *since*. + + Alerts with a malformed or missing timestamp are skipped. The + returned list is ordered newest first, mirroring briefing output. + """ + self._maybe_reload() + out: list[tuple[datetime, AutonomousAlert]] = [] + for alert in self._alerts: + dt = _parse_isoformat(alert.created_at) + if dt is None: + continue + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + if dt > since: + out.append((dt, alert)) + out.sort(key=lambda item: item[0], reverse=True) + return [alert for _, alert in out] + + def clear(self) -> None: + """Remove all persisted alerts.""" + self._alerts.clear() + self._save() + + def __len__(self) -> int: + return len(self._alerts) + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + def _maybe_reload(self) -> None: + """Re-read from disk if another process modified the file.""" + if not self._path.exists(): + return + try: + current_mtime = self._path.stat().st_mtime + except OSError: + return + if current_mtime != self._mtime: + self._alerts = self._load() + + def _load(self) -> list[AutonomousAlert]: + if not self._path.exists(): + return [] + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + self._mtime = self._path.stat().st_mtime + except (json.JSONDecodeError, OSError): + logger.warning("Failed to load autonomous alerts from %s", self._path) + return [] + if not isinstance(raw, list): + return [] + out: list[AutonomousAlert] = [] + for item in raw: + if not isinstance(item, dict): + continue + try: + out.append(self._deserialize(item)) + except (KeyError, ValueError): + logger.debug("Skipping malformed autonomous alert record: %r", item) + return out + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + payload = [self._serialize(a) for a in self._alerts] + self._path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + try: + self._mtime = self._path.stat().st_mtime + except OSError: + self._mtime = 0.0 + + @staticmethod + def _serialize(alert: AutonomousAlert) -> dict[str, Any]: + d = asdict(alert) + d["trigger_type"] = alert.trigger_type.value + d["severity"] = alert.severity.value + return d + + @staticmethod + def _deserialize(data: dict[str, Any]) -> AutonomousAlert: + return AutonomousAlert( + ticker=str(data["ticker"]), + trigger_type=TriggerType(data["trigger_type"]), + summary=str(data["summary"]), + severity=Severity(data["severity"]), + data=dict(data.get("data", {}) or {}), + created_at=str(data.get("created_at", "")), + ) + + +def _parse_isoformat(value: str) -> datetime | None: + """Return ``datetime.fromisoformat(value)`` or ``None`` on failure.""" + if not value: + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None diff --git a/qracer/cli.py b/qracer/cli.py index 7b394c7..df296ca 100644 --- a/qracer/cli.py +++ b/qracer/cli.py @@ -385,6 +385,7 @@ async def _repl_loop( sessions_dir: Path | None = None, current_session: Path | None = None, fact_store: object | None = None, + autonomous_alert_store: object | None = None, ) -> None: """Run the interactive read-eval-print loop.""" from qracer.alert_monitor import AlertMonitor @@ -414,6 +415,7 @@ async def _repl_loop( sessions_dir, current_session=current_session, fact_store=fact_store, # type: ignore[arg-type] + autonomous_alert_store=autonomous_alert_store, # type: ignore[arg-type] ) except Exception: logger.debug("Session briefing generation failed", exc_info=True) @@ -962,6 +964,11 @@ def repl() -> None: alert_store = AlertStore(_user_dir() / "alerts.json") alert_monitor = AlertMonitor(alert_store, data_registry) + # Overnight autonomous findings (persisted by ``qracer serve``). + from qracer.autonomous import AutonomousAlertStore + + autonomous_alert_store = AutonomousAlertStore(_user_dir() / "autonomous_alerts.json") + # Task scheduler from qracer.task_executor import TaskExecutor from qracer.tasks import TaskStore @@ -993,6 +1000,7 @@ def repl() -> None: sessions_dir=sessions_dir, current_session=session_logger.path, fact_store=fact_store, + autonomous_alert_store=autonomous_alert_store, ) ) @@ -1056,25 +1064,28 @@ def serve(check_interval: int) -> None: telegram_poller = build_telegram_poller(config.credentials) # Autonomous market monitoring - from qracer.autonomous import AutonomousMonitor + from qracer.autonomous import AutonomousAlertStore, AutonomousMonitor from qracer.watchlist import Watchlist autonomous_monitor: AutonomousMonitor | None = None + autonomous_alert_store: AutonomousAlertStore | None = None if app_cfg.autonomous_enabled: watchlist = Watchlist(_user_dir() / "watchlist.json") autonomous_monitor = AutonomousMonitor( watchlist, data_registry, - check_interval=check_interval, + check_interval=app_cfg.autonomous_check_interval_seconds, price_threshold_pct=app_cfg.price_move_threshold_pct, cooldown_minutes=app_cfg.alert_cooldown_minutes, ) + autonomous_alert_store = AutonomousAlertStore(_user_dir() / "autonomous_alerts.json") server = Server( alert_monitor, task_executor, notifications, autonomous_monitor=autonomous_monitor, + autonomous_alert_store=autonomous_alert_store, telegram_poller=telegram_poller, tick_interval=1.0, ) @@ -1092,7 +1103,8 @@ def _handle_signal(signum: int, _frame: object) -> None: click.echo(f" Notifications: {', '.join(channels)}") if autonomous_monitor: click.echo( - f" Autonomous monitoring: threshold={app_cfg.price_move_threshold_pct}%," + f" Autonomous monitoring: every {app_cfg.autonomous_check_interval_seconds}s," + f" threshold={app_cfg.price_move_threshold_pct}%," f" cooldown={app_cfg.alert_cooldown_minutes}m" ) if telegram_poller is not None: diff --git a/qracer/config/models.py b/qracer/config/models.py index de7ba16..7e9b1ca 100644 --- a/qracer/config/models.py +++ b/qracer/config/models.py @@ -25,6 +25,7 @@ class AppConfig(BaseModel): # Autonomous monitoring autonomous_enabled: bool = True + autonomous_check_interval_seconds: int = 60 price_move_threshold_pct: float = 2.0 alert_cooldown_minutes: int = 30 diff --git a/qracer/config/schema/config.toml b/qracer/config/schema/config.toml index 6ba5950..edf4cc3 100644 --- a/qracer/config/schema/config.toml +++ b/qracer/config/schema/config.toml @@ -15,6 +15,7 @@ lookback_days = 30 # days of price history to fetch staleness_hours = 24 # hours before data is considered stale # Autonomous monitoring (qracer serve) -autonomous_enabled = true # enable watchlist monitoring during market hours -price_move_threshold_pct = 2.0 # % move to trigger an alert -alert_cooldown_minutes = 30 # minutes before re-alerting the same ticker +autonomous_enabled = true # enable watchlist monitoring during market hours +autonomous_check_interval_seconds = 60 # seconds between autonomous scans +price_move_threshold_pct = 2.0 # % move to trigger an alert +alert_cooldown_minutes = 30 # minutes before re-alerting the same ticker diff --git a/qracer/conversation/quickpath.py b/qracer/conversation/quickpath.py index 82b60b2..8f058ec 100644 --- a/qracer/conversation/quickpath.py +++ b/qracer/conversation/quickpath.py @@ -11,6 +11,7 @@ from pathlib import Path from qracer.alerts import Alert, AlertStore +from qracer.autonomous import AutonomousAlertStore from qracer.conversation.intent import Intent, IntentType from qracer.data.providers import PriceProvider from qracer.data.registry import DataRegistry @@ -216,13 +217,15 @@ async def generate_briefing( sessions_dir: Path, current_session: Path | None = None, fact_store: FactStore | None = None, + autonomous_alert_store: AutonomousAlertStore | None = None, ) -> str | None: """Generate a session-start briefing. Summarises activity since the previous session: current watchlist - prices, alerts that triggered while away, and any pending scheduled - tasks. Returns ``None`` when there is no previous session on disk - or when nothing noteworthy is available. + prices, alerts that triggered while away, any overnight autonomous + findings produced by ``qracer serve``, and pending scheduled tasks. + Returns ``None`` when there is no previous session on disk or when + nothing noteworthy is available. Args: watchlist: User's ticker watchlist. @@ -233,6 +236,10 @@ async def generate_briefing( current_session: Path of the current session log; excluded from "last session" detection so the briefing reflects activity since the previous run, not the current one. + fact_store: Optional FactStore for open-thesis recap. + autonomous_alert_store: Optional store of autonomous alerts + produced by ``qracer serve``; when provided, alerts fired + since the previous session are included in the briefing. """ last_session = _find_last_session(sessions_dir, current_session=current_session) if last_session is None: @@ -260,6 +267,17 @@ async def generate_briefing( lines.append("") has_content = True + # Autonomous findings surfaced by ``qracer serve`` while away + if autonomous_alert_store is not None: + autonomous_lines, total_auto = _briefing_autonomous_lines( + autonomous_alert_store, since=last_session + ) + if autonomous_lines: + lines.append(f"Overnight Autonomous Findings ({total_auto}):") + lines.extend(autonomous_lines) + lines.append("") + has_content = True + # Pending tasks task_lines, pending_count = _briefing_task_lines(task_store) if task_lines: @@ -370,6 +388,28 @@ async def _briefing_price_lines( return lines +def _briefing_autonomous_lines( + store: AutonomousAlertStore, since: datetime, limit: int = 10 +) -> tuple[list[str], int]: + """Return formatted lines and total count for autonomous alerts after ``since``. + + The store returns alerts newest-first; the briefing is capped at + ``limit`` entries with a trailing "... and N more" summary when the + overnight batch exceeds the cap. The returned ``total`` is the count + of matching alerts *before* truncation, so callers can show the full + count in their section header. + """ + alerts = store.get_since(since) + if not alerts: + return [], 0 + shown = alerts[:limit] + lines = [f" [{a.severity.value.upper()}] {a.summary}" for a in shown] + remainder = len(alerts) - len(shown) + if remainder > 0: + lines.append(f" ... and {remainder} more") + return lines, len(alerts) + + def _briefing_alert_lines(alert_store: AlertStore, since: datetime) -> list[str]: """Return formatted lines for alerts triggered after ``since``.""" triggered: list[tuple[datetime, Alert]] = [] diff --git a/qracer/server.py b/qracer/server.py index c9fcb8f..147c47c 100644 --- a/qracer/server.py +++ b/qracer/server.py @@ -12,7 +12,7 @@ from qracer.alert_monitor import AlertMonitor from qracer.alerts import AlertCondition -from qracer.autonomous import AutonomousMonitor +from qracer.autonomous import AutonomousAlertStore, AutonomousMonitor from qracer.notifications.providers import Notification, NotificationCategory from qracer.notifications.registry import NotificationRegistry from qracer.notifications.telegram_poller import BotCommand, TelegramBotPoller @@ -39,12 +39,14 @@ def __init__( notifications: NotificationRegistry | None = None, *, autonomous_monitor: AutonomousMonitor | None = None, + autonomous_alert_store: AutonomousAlertStore | None = None, telegram_poller: TelegramBotPoller | None = None, tick_interval: float = 1.0, ) -> None: self._alert_monitor = alert_monitor self._task_executor = task_executor self._autonomous_monitor = autonomous_monitor + self._autonomous_alert_store = autonomous_alert_store self._notifications = notifications or NotificationRegistry() self._telegram_poller = telegram_poller self._tick_interval = tick_interval @@ -104,6 +106,13 @@ async def _tick(self) -> None: auto_alerts = await self._autonomous_monitor.check() for alert in auto_alerts: logger.info("Autonomous alert: %s", alert.summary) + if self._autonomous_alert_store is not None: + try: + self._autonomous_alert_store.save(alert) + except Exception: + # Persistence failure shouldn't block the alert + # from being delivered — log and continue. + logger.debug("Failed to persist autonomous alert", exc_info=True) await self._notify( NotificationCategory.AUTONOMOUS_MODE, f"[{alert.severity.value.upper()}] {alert.ticker}", diff --git a/tests/conversation/test_quickpath.py b/tests/conversation/test_quickpath.py index 1438b4c..2d01590 100644 --- a/tests/conversation/test_quickpath.py +++ b/tests/conversation/test_quickpath.py @@ -8,6 +8,12 @@ from pathlib import Path from qracer.alerts import AlertCondition, AlertStore +from qracer.autonomous import ( + AutonomousAlert, + AutonomousAlertStore, + Severity, + TriggerType, +) from qracer.conversation.intent import Intent, IntentType from qracer.conversation.quickpath import format_quickpath, generate_briefing from qracer.data.providers import PriceProvider @@ -374,3 +380,101 @@ async def test_truncates_pending_tasks_over_five(self, tmp_path: Path) -> None: assert result is not None assert "Pending Tasks (7)" in result assert "and 2 more" in result + + async def test_includes_overnight_autonomous_findings(self, tmp_path: Path) -> None: + sessions_dir = tmp_path / "sessions" + _make_previous_session(sessions_dir) + watchlist = Watchlist(tmp_path / "watchlist.json") + registry = _make_price_registry({}) + alert_store = AlertStore(tmp_path / "alerts.json") + task_store = TaskStore(tmp_path / "tasks.json") + autonomous_store = AutonomousAlertStore(tmp_path / "autonomous.json") + + # Recent alert — created_at defaults to now(UTC) which is after the + # previous session's (now-1h) mtime. + autonomous_store.save( + AutonomousAlert( + ticker="AAPL", + trigger_type=TriggerType.PRICE_MOVE, + summary="AAPL moved up 5.0% ($200.00 -> $210.00)", + severity=Severity.CRITICAL, + ) + ) + # Stale alert pre-dating the session — must be filtered out. + autonomous_store.save( + AutonomousAlert( + ticker="TSLA", + trigger_type=TriggerType.BREAKING_NEWS, + summary="Old news about TSLA", + severity=Severity.INFO, + created_at=(datetime.now(timezone.utc) - timedelta(days=2)).isoformat(), + ) + ) + + result = await generate_briefing( + watchlist, + registry, + alert_store, + task_store, + sessions_dir, + autonomous_alert_store=autonomous_store, + ) + assert result is not None + assert "Overnight Autonomous Findings (1)" in result + assert "[CRITICAL]" in result + assert "AAPL moved up 5.0%" in result + assert "Old news about TSLA" not in result + + async def test_autonomous_findings_truncated_over_limit(self, tmp_path: Path) -> None: + sessions_dir = tmp_path / "sessions" + _make_previous_session(sessions_dir) + watchlist = Watchlist(tmp_path / "watchlist.json") + registry = _make_price_registry({}) + alert_store = AlertStore(tmp_path / "alerts.json") + task_store = TaskStore(tmp_path / "tasks.json") + autonomous_store = AutonomousAlertStore(tmp_path / "autonomous.json") + + for i in range(13): + autonomous_store.save( + AutonomousAlert( + ticker=f"T{i}", + trigger_type=TriggerType.PRICE_MOVE, + summary=f"T{i} moved up {i}%", + severity=Severity.INFO, + ) + ) + + result = await generate_briefing( + watchlist, + registry, + alert_store, + task_store, + sessions_dir, + autonomous_alert_store=autonomous_store, + ) + assert result is not None + assert "Overnight Autonomous Findings (13)" in result + assert "and 3 more" in result + + async def test_autonomous_findings_none_when_store_empty(self, tmp_path: Path) -> None: + sessions_dir = tmp_path / "sessions" + _make_previous_session(sessions_dir) + watchlist = Watchlist(tmp_path / "watchlist.json") + watchlist.add("AAPL") + registry = _make_price_registry({"AAPL": 100.0}) + alert_store = AlertStore(tmp_path / "alerts.json") + task_store = TaskStore(tmp_path / "tasks.json") + autonomous_store = AutonomousAlertStore(tmp_path / "autonomous.json") + + result = await generate_briefing( + watchlist, + registry, + alert_store, + task_store, + sessions_dir, + autonomous_alert_store=autonomous_store, + ) + assert result is not None + # Briefing still rendered (watchlist is non-empty) but the + # autonomous section is omitted entirely. + assert "Overnight Autonomous Findings" not in result diff --git a/tests/test_autonomous.py b/tests/test_autonomous.py index fd96bc5..992b7d9 100644 --- a/tests/test_autonomous.py +++ b/tests/test_autonomous.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import patch from zoneinfo import ZoneInfo @@ -10,6 +10,7 @@ from qracer.autonomous import ( AutonomousAlert, + AutonomousAlertStore, AutonomousMonitor, Severity, TriggerType, @@ -442,3 +443,134 @@ def test_alert_fields(self) -> None: assert alert.severity == Severity.CRITICAL assert alert.data["pct_change"] == 5.0 assert alert.created_at # has a timestamp + + +# --------------------------------------------------------------------------- +# AutonomousAlertStore +# --------------------------------------------------------------------------- + + +def _alert( + ticker: str = "AAPL", + *, + summary: str = "moved", + severity: Severity = Severity.INFO, + trigger_type: TriggerType = TriggerType.PRICE_MOVE, + created_at: str | None = None, + data: dict | None = None, +) -> AutonomousAlert: + """Build an AutonomousAlert with optional overrides for timestamp/data.""" + kwargs: dict = { + "ticker": ticker, + "trigger_type": trigger_type, + "summary": summary, + "severity": severity, + "data": data or {}, + } + if created_at is not None: + kwargs["created_at"] = created_at + return AutonomousAlert(**kwargs) + + +class TestAutonomousAlertStore: + def test_save_and_roundtrip(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + alert = _alert("AAPL", summary="AAPL up 5%", severity=Severity.CRITICAL) + store.save(alert) + + reloaded = AutonomousAlertStore(tmp_path / "auto.json") + assert len(reloaded) == 1 + restored = reloaded.alerts[0] + assert restored.ticker == "AAPL" + assert restored.summary == "AAPL up 5%" + assert restored.severity is Severity.CRITICAL + assert restored.trigger_type is TriggerType.PRICE_MOVE + + def test_get_since_filters_by_timestamp(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + now = datetime.now(timezone.utc) + old = _alert( + "OLD", + summary="old alert", + created_at=(now - timedelta(days=1)).isoformat(), + ) + recent = _alert( + "NEW", + summary="recent alert", + created_at=(now - timedelta(minutes=5)).isoformat(), + ) + store.save(old) + store.save(recent) + + since = now - timedelta(hours=1) + results = store.get_since(since) + assert len(results) == 1 + assert results[0].ticker == "NEW" + + def test_get_since_orders_newest_first(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + now = datetime.now(timezone.utc) + oldest = _alert("A", summary="first", created_at=(now - timedelta(minutes=30)).isoformat()) + middle = _alert("B", summary="second", created_at=(now - timedelta(minutes=20)).isoformat()) + newest = _alert("C", summary="third", created_at=(now - timedelta(minutes=10)).isoformat()) + # Save out of order to verify the store sorts on retrieval. + store.save(middle) + store.save(oldest) + store.save(newest) + + results = store.get_since(now - timedelta(hours=1)) + assert [a.ticker for a in results] == ["C", "B", "A"] + + def test_get_since_skips_malformed_timestamp(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + store.save(_alert("BAD", created_at="not-a-date")) + store.save(_alert("GOOD")) + + results = store.get_since(datetime.now(timezone.utc) - timedelta(hours=1)) + tickers = {a.ticker for a in results} + assert "GOOD" in tickers + assert "BAD" not in tickers + + def test_max_alerts_cap(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + store.MAX_ALERTS = 3 # type: ignore[misc] # override for the test + for i in range(5): + store.save(_alert(f"T{i}")) + + tickers = [a.ticker for a in store.alerts] + assert tickers == ["T2", "T3", "T4"] + + def test_clear(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "auto.json") + store.save(_alert()) + store.save(_alert()) + store.clear() + assert len(store) == 0 + # Clear is persisted. + reloaded = AutonomousAlertStore(tmp_path / "auto.json") + assert len(reloaded) == 0 + + def test_load_handles_missing_file(self, tmp_path) -> None: + store = AutonomousAlertStore(tmp_path / "missing.json") + assert len(store) == 0 + assert store.get_since(datetime.now(timezone.utc)) == [] + + def test_load_handles_malformed_file(self, tmp_path) -> None: + path = tmp_path / "auto.json" + path.write_text("not valid json", encoding="utf-8") + store = AutonomousAlertStore(path) + assert len(store) == 0 + + def test_load_skips_malformed_entries(self, tmp_path) -> None: + path = tmp_path / "auto.json" + # One valid and one invalid entry — the valid one should still load. + path.write_text( + '[{"ticker": "OK", "trigger_type": "price_move", ' + '"summary": "ok", "severity": "info", "data": {}, ' + '"created_at": "2026-04-01T00:00:00+00:00"}, ' + '{"ticker": "BAD"}]', + encoding="utf-8", + ) + store = AutonomousAlertStore(path) + assert len(store) == 1 + assert store.alerts[0].ticker == "OK" diff --git a/tests/test_server.py b/tests/test_server.py index ded6641..698ba1a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -133,6 +133,95 @@ async def test_tick_handles_task_error_gracefully(self) -> None: await server._tick() # Should not raise +class TestServerAutonomousPersistence: + async def test_tick_persists_autonomous_alerts_to_store(self) -> None: + from qracer.autonomous import AutonomousAlert, Severity, TriggerType + + alert = AutonomousAlert( + ticker="AAPL", + trigger_type=TriggerType.PRICE_MOVE, + summary="AAPL moved up 5%", + severity=Severity.CRITICAL, + ) + autonomous = MagicMock() + autonomous.should_check.return_value = True + autonomous.check = AsyncMock(return_value=[alert]) + + store = MagicMock() + monitor = _make_monitor() + executor = _make_executor() + + server = Server( + monitor, + executor, + autonomous_monitor=autonomous, + autonomous_alert_store=store, + ) + await server._tick() + + store.save.assert_called_once_with(alert) + + async def test_tick_no_store_still_notifies(self) -> None: + from qracer.autonomous import AutonomousAlert, Severity, TriggerType + + alert = AutonomousAlert( + ticker="AAPL", + trigger_type=TriggerType.PRICE_MOVE, + summary="AAPL moved up 5%", + severity=Severity.INFO, + ) + autonomous = MagicMock() + autonomous.should_check.return_value = True + autonomous.check = AsyncMock(return_value=[alert]) + notifications = MagicMock() + notifications.channels = ["telegram"] + notifications.notify = AsyncMock(return_value={"telegram": True}) + + monitor = _make_monitor() + executor = _make_executor() + server = Server( + monitor, + executor, + notifications, + autonomous_monitor=autonomous, + ) + await server._tick() + + notifications.notify.assert_called_once() + + async def test_tick_continues_when_store_save_fails(self) -> None: + from qracer.autonomous import AutonomousAlert, Severity, TriggerType + + alert = AutonomousAlert( + ticker="AAPL", + trigger_type=TriggerType.PRICE_MOVE, + summary="AAPL moved up 5%", + severity=Severity.INFO, + ) + autonomous = MagicMock() + autonomous.should_check.return_value = True + autonomous.check = AsyncMock(return_value=[alert]) + store = MagicMock() + store.save.side_effect = RuntimeError("disk full") + notifications = MagicMock() + notifications.channels = ["telegram"] + notifications.notify = AsyncMock(return_value={"telegram": True}) + + monitor = _make_monitor() + executor = _make_executor() + server = Server( + monitor, + executor, + notifications, + autonomous_monitor=autonomous, + autonomous_alert_store=store, + ) + await server._tick() # Should not raise. + + # Notification still fires despite the store failure. + notifications.notify.assert_called_once() + + # --------------------------------------------------------------------------- # Telegram bot command integration # ---------------------------------------------------------------------------