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
17 changes: 13 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,21 @@ terminal_port = 8194

## Real-Time Data

> **구현 예정** — WebSocket 실시간 데이터는 아직 구현되지 않았습니다. 현재는 REST polling만 지원합니다.
Real-time price (and news) streaming is provided by
`FinnhubWebSocketAdapter`, which implements the `StreamingProvider`
capability. It is enabled automatically by `qracer serve` when the
`finnhub` provider is enabled in `providers.toml` and the
`qracer[streaming]` extra is installed. Each trade message is
dispatched to `AlertMonitor.evaluate_price`, allowing threshold alerts
to trigger on the next tick instead of waiting for the next polling
interval.

For Live Mode, qracer needs sub-second price data and streaming news:

| Capability | Preferred Provider | Protocol | Fallback |
|---|---|---|---|
| Real-time quotes (구현 예정) | Finnhub | WebSocket | REST polling (5s interval) |
| Streaming news (구현 예정) | Finnhub | WebSocket | REST polling (30s interval) |
| Real-time quotes | Finnhub | WebSocket | REST polling (5s interval) |
| Streaming news | Finnhub | WebSocket | REST polling (30s interval) |
| Price/OHLCV | Finnhub | REST | yfinance |
| Fundamental | Finnhub | REST | FMP, yfinance |
| Macro | FRED | REST | World Bank |
Expand All @@ -163,7 +170,9 @@ For Live Mode, qracer needs sub-second price data and streaming news:
| Short interest (planned) | FINRA | REST | Ortex (plugin) |
| ETF flows (planned) | ETF.com | REST | — (plugin) |

WebSocket connections are opened on session start during market hours and closed on session end. REST fallback activates automatically if WebSocket disconnects.
WebSocket connections are opened when `qracer serve` starts and closed
on shutdown. If the initial handshake fails, the server transparently
falls back to REST polling via `AlertMonitor.check()`.

API key missing → adapter auto-skipped. Fallback kicks in transparently. Provider availability is controlled entirely by `providers.toml` — no code changes needed to toggle sources.

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ fred = ["fredapi>=0.5.0"]
all-llm = ["openai>=1.0.0", "google-generativeai>=0.8.0"]
web = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
pdf = ["fpdf2>=2.7.0"]
streaming = ["websockets>=12.0"]

[dependency-groups]
dev = [
Expand Down
19 changes: 19 additions & 0 deletions qracer/alert_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,22 @@ async def check(self) -> list[AlertResult]:
logger.info(msg)

return results

def evaluate_price(self, ticker: str, price: float) -> list[AlertResult]:
"""Evaluate alerts for *ticker* against a known *price*.

Intended for push-based flows — for example, a WebSocket trade
callback that already carries the latest price. Unlike
:meth:`check`, this method does not fetch from the data
registry and only touches alerts for the given ticker.
"""
results: list[AlertResult] = []
for alert in self._store.get_by_ticker(ticker):
if not alert.active:
continue
if alert.evaluate(price):
self._store.mark_triggered(alert.id, price)
msg = f"Alert triggered: {alert.describe()} (price: {price})"
results.append(AlertResult(alert=alert, triggered_price=price, message=msg))
logger.info(msg)
return results
34 changes: 34 additions & 0 deletions qracer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import click

if TYPE_CHECKING:
from qracer.config.models import QracerConfig
from qracer.data.providers import StreamingProvider
from qracer.data.registry import DataRegistry
from qracer.llm.registry import LLMRegistry

Expand Down Expand Up @@ -270,6 +272,33 @@ def _write_toml(path: Path, data: dict[str, object]) -> None:
# ---------------------------------------------------------------------------


def _build_streaming_adapter(config: QracerConfig) -> StreamingProvider | None:
"""Return a Finnhub streaming adapter if enabled and available.

The adapter is only constructed when the ``finnhub`` provider is
enabled in ``providers.toml`` *and* an API key is available *and*
the ``websockets`` package is importable. Any failure returns
``None`` so the caller falls back to REST polling.
"""
finnhub_cfg = config.providers.providers.get("finnhub")
if finnhub_cfg is None or not finnhub_cfg.enabled:
return None
api_key_env = finnhub_cfg.api_key_env or "FINNHUB_API_KEY"
api_key = config.credentials.get(api_key_env) or os.environ.get(api_key_env)
if not api_key:
return None
try:
from qracer.data.finnhub_ws import FinnhubWebSocketAdapter

return FinnhubWebSocketAdapter(api_key=api_key)
except ImportError:
logger.info("Streaming disabled — install 'qracer[streaming]' for WebSocket support")
return None
except Exception as exc:
logger.warning("Streaming adapter unavailable: %s", exc)
return None


def _build_registries() -> tuple[LLMRegistry, DataRegistry, list[str]]:
"""Build LLM and data registries from providers.toml + provider catalog.

Expand Down Expand Up @@ -1059,12 +1088,15 @@ def serve(check_interval: int) -> None:
cooldown_minutes=app_cfg.alert_cooldown_minutes,
)

streaming_adapter = _build_streaming_adapter(config)

server = Server(
alert_monitor,
task_executor,
notifications,
autonomous_monitor=autonomous_monitor,
telegram_poller=telegram_poller,
streaming_adapter=streaming_adapter,
tick_interval=1.0,
)

Expand All @@ -1086,6 +1118,8 @@ def _handle_signal(signum: int, _frame: object) -> None:
)
if telegram_poller is not None:
click.echo(" Telegram bot: receiving commands (try /help in chat)")
if streaming_adapter is not None:
click.echo(" Streaming: Finnhub WebSocket (real-time alerts)")
click.echo(" Press Ctrl+C to stop.\n")

try:
Expand Down
2 changes: 2 additions & 0 deletions qracer/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
NewsArticle,
NewsProvider,
PriceProvider,
StreamingProvider,
)
from qracer.data.registry import DataRegistry
from qracer.data.yfinance_adapter import YfinanceAdapter
Expand All @@ -25,5 +26,6 @@
"NewsArticle",
"NewsProvider",
"PriceProvider",
"StreamingProvider",
"YfinanceAdapter",
]
Loading
Loading