Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/autonomous-mode.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
145 changes: 144 additions & 1 deletion qracer/autonomous.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
18 changes: 15 additions & 3 deletions qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions qracer/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions qracer/config/schema/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 43 additions & 3 deletions qracer/conversation/quickpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]] = []
Expand Down
11 changes: 10 additions & 1 deletion qracer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}",
Expand Down
Loading
Loading