feat(signal_v2): pull_worker + FastAPI app + 2 integration tests

poll_loop: asyncio.gather parallel fetch of 3 endpoints (portfolio,
news_sentiment, screener_preview) + state update. main.py: FastAPI
lifespan creates StockClient/SignalDedup/shutdown.Event then spawns
poll_loop as background task. GET /health reports status, last poll
times, cache size.

Signal V2 test suite: 19/19 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 03:49:50 +09:00
parent 1a6d9fcb39
commit 94c684bab8
3 changed files with 164 additions and 0 deletions

70
signal_v2/main.py Normal file
View File

@@ -0,0 +1,70 @@
"""FastAPI app — Signal V2 Pull Worker."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from signal_v2 import state as state_mod
from signal_v2.config import get_settings
from signal_v2.pull_worker import poll_loop
from signal_v2.rate_limit import SignalDedup
from signal_v2.stock_client import StockClient
logger = logging.getLogger(__name__)
class AppContext:
client: StockClient | None = None
dedup: SignalDedup | None = None
shutdown: asyncio.Event | None = None
poll_task: asyncio.Task | None = None
_ctx = AppContext()
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if not settings.webai_api_key:
logger.warning(
"WEBAI_API_KEY not configured — stock API calls will fail with 401"
)
_ctx.client = StockClient(settings.stock_api_url, settings.webai_api_key)
_ctx.dedup = SignalDedup(settings.db_path)
_ctx.shutdown = asyncio.Event()
_ctx.poll_task = asyncio.create_task(
poll_loop(_ctx.client, state_mod.state, _ctx.shutdown)
)
yield
# Shutdown
if _ctx.shutdown is not None:
_ctx.shutdown.set()
if _ctx.poll_task is not None:
try:
await asyncio.wait_for(_ctx.poll_task, timeout=5.0)
except asyncio.TimeoutError:
_ctx.poll_task.cancel()
if _ctx.client is not None:
await _ctx.client.close()
app = FastAPI(
title="Signal V2 Pull Worker", version="0.1.0", lifespan=lifespan
)
@app.get("/health")
async def health():
settings = get_settings()
return {
"status": "online",
"stock_api_url": settings.stock_api_url,
"last_poll": state_mod.state.last_updated,
"cache_size": len(_ctx.client._cache) if _ctx.client is not None else 0,
}