Initial AlphaPulse implementation: news crawler and Binance klines

- Backend (FastAPI, SQLAlchemy 2, Alembic): single-admin cookie auth,
  news/crawler/market APIs with SSE and WebSocket streaming
- Crawler (Scrapy + Scrapy-Redis): wscn_news, wscn_live, cls_depth,
  cls_telegraph spiders, idempotent upserts with revisions, raw response
  archiving, per-source subprocess supervision, restart and backoff
- Market worker: full-history Binance spot/USD-M kline backfill,
  realtime WS stream, hourly and on-reconnect catch-up
- Frontend (Vue 3, Element Plus, UnoCSS, KLineChart): news feed,
  crawler console with live logs, Binance-style chart page
- Deploy: docker compose, nginx site, migrate and daily backup scripts
This commit is contained in:
eezd committed 2026-10-13 13:32:55 +08:00
commit aabc81ba5b
88 files changed
+12603

No files matched your search

Whitespace-only changes.
+58
View File
@@ -0,0 +1,58 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from alphapulse.core.auth import COOKIE_NAME, create_token, require_admin, verify_password
from alphapulse.core.config import get_settings
from alphapulse.core.redis import KEY_PREFIX, redis
router = APIRouter(prefix="/api/auth", tags=["auth"])
MAX_FAILURES = 5
LOCK_SECONDS = 15 * 60
class LoginIn(BaseModel):
username: str
password: str
class MeOut(BaseModel):
username: str
@router.post("/login")
async def login(body: LoginIn, request: Request, response: Response) -> MeOut:
settings = get_settings()
if not settings.admin_password_hash:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "管理员密码未配置")
fail_key = f"{KEY_PREFIX}login:fail:{request.client.host}"
if int(await redis.get(fail_key) or 0) >= MAX_FAILURES:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "失败次数过多,请 15 分钟后再试")
if body.username != settings.admin_username or not verify_password(body.password, settings.admin_password_hash):
async with redis.pipeline() as pipe:
await pipe.incr(fail_key).expire(fail_key, LOCK_SECONDS).execute()
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码错误")
await redis.delete(fail_key)
response.set_cookie(
COOKIE_NAME,
create_token(body.username),
max_age=settings.jwt_expire_days * 86400,
httponly=True,
secure=True,
samesite="strict",
)
return MeOut(username=body.username)
@router.post("/logout")
async def logout(response: Response) -> None:
response.delete_cookie(COOKIE_NAME, httponly=True, secure=True, samesite="strict")
@router.get("/me")
async def me(username: Annotated[str, Depends(require_admin)]) -> MeOut:
return MeOut(username=username)
+180
View File
@@ -0,0 +1,180 @@
import json
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Annotated, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.crawler.events import CONTROL_CHANNEL, LOG_CHANNEL, STATUS_KEY
from alphapulse.models import CrawlLog, CrawlRun, NewsItem, Source
router = APIRouter(prefix="/api/crawler", tags=["crawler"], dependencies=[Depends(require_admin)])
# “今日”按北京时间计算
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
class ProcessStatus(BaseModel):
state: Literal["running", "stopped", "restarting"]
pid: int | None
started_at: datetime | None
next_run_at: datetime
class SourceOut(BaseModel):
code: str
name: str
list_url: str
enabled: bool
interval_seconds: int
backfill_hours: int
consecutive_failures: int
last_success_at: datetime | None
last_error: str | None
paused_until: datetime | None
# 管理进程未运行时为 None
process: ProcessStatus | None
today_new: int
today_revised: int
total_items: int
class SourcesOut(BaseModel):
manager_alive: bool
db_size_bytes: int
sources: list[SourceOut]
class SourcePatch(BaseModel):
enabled: bool | None = None
interval_seconds: Annotated[int | None, Field(ge=5, le=86400)] = None
class RunOut(BaseModel):
id: uuid.UUID
source_code: str
trigger: str
started_at: datetime
finished_at: datetime | None
status: str
requests: int
items_seen: int
items_new: int
items_revised: int
error: str | None
class LogOut(BaseModel):
id: int
source_code: str
run_id: uuid.UUID | None
ts: datetime
level: str
message: str
class RestartIn(BaseModel):
# 来源 code 或 "all"
source: str
@router.get("/sources")
async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -> SourcesOut:
sources = (await session.scalars(select(Source).order_by(Source.code))).all()
today = datetime.now(LOCAL_TZ).replace(hour=0, minute=0, second=0, microsecond=0)
today_counts = {
code: (new or 0, revised or 0)
for code, new, revised in await session.execute(
select(CrawlRun.source_code, func.sum(CrawlRun.items_new), func.sum(CrawlRun.items_revised))
.where(CrawlRun.started_at >= today)
.group_by(CrawlRun.source_code)
)
}
totals = dict((await session.execute(
select(NewsItem.source_code, func.count()).group_by(NewsItem.source_code)
)).all())
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
return SourcesOut(
manager_alive=bool(statuses),
db_size_bytes=db_size,
sources=[
SourceOut.model_validate({
**s.__dict__,
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
"today_new": today_counts.get(s.code, (0, 0))[0],
"today_revised": today_counts.get(s.code, (0, 0))[1],
"total_items": totals.get(s.code, 0),
})
for s in sources
],
)
@router.patch("/sources/{code}")
async def patch_source(code: str, body: SourcePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
source = await session.get(Source, code)
if source is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
for field, value in body.model_dump(exclude_none=True).items():
setattr(source, field, value)
await session.commit()
@router.get("/runs")
async def list_runs(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[RunOut]:
stmt = select(CrawlRun).order_by(CrawlRun.started_at.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlRun.source_code == source)
return [RunOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)]
@router.get("/logs")
async def list_logs(
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
before_id: int | None = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
) -> list[LogOut]:
"""按时间正序返回 before_id 之前最近的 limit 条"""
stmt = select(CrawlLog).order_by(CrawlLog.id.desc()).limit(limit)
if source:
stmt = stmt.where(CrawlLog.source_code == source)
if before_id:
stmt = stmt.where(CrawlLog.id < before_id)
rows = [LogOut.model_validate(r, from_attributes=True) for r in await session.scalars(stmt)]
return rows[::-1]
@router.get("/logs/stream", response_class=EventSourceResponse)
async def stream_logs(source: str | None = None) -> AsyncIterator[ServerSentEvent]:
async with redis.pubsub() as pubsub:
await pubsub.subscribe(LOG_CHANNEL)
async for message in pubsub.listen():
if message["type"] != "message":
continue
if source and json.loads(message["data"])["source_code"] != source:
continue
yield ServerSentEvent(raw_data=message["data"], event="log")
@router.post("/restart")
async def restart(body: RestartIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
if body.source != "all" and await session.get(Source, body.source) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "来源不存在")
receivers = await redis.publish(CONTROL_CHANNEL, json.dumps({"action": "restart", "source": body.source}))
if receivers == 0:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "采集管理进程未运行")
+29
View File
@@ -0,0 +1,29 @@
from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import HTMLResponse
from alphapulse.api import auth, crawler, market, news
from alphapulse.core.auth import require_admin
# 关闭默认文档路由,改为登录后才能访问
app = FastAPI(title="AlphaPulse", docs_url=None, redoc_url=None, openapi_url=None)
app.include_router(auth.router)
app.include_router(news.router)
app.include_router(crawler.router)
app.include_router(market.router)
app.include_router(market.ws_router)
@app.get("/api/health", include_in_schema=False)
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/openapi.json", include_in_schema=False, dependencies=[Depends(require_admin)])
async def openapi() -> dict:
return app.openapi()
@app.get("/api/docs", include_in_schema=False, dependencies=[Depends(require_admin)])
async def docs() -> HTMLResponse:
return get_swagger_ui_html(openapi_url="/api/openapi.json", title="AlphaPulse API")
+202
View File
@@ -0,0 +1,202 @@
import asyncio
import json
import time
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
from pydantic import BaseModel
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin, websocket_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import KEY_PREFIX, redis
from alphapulse.market.binance import BinanceClient
from alphapulse.market.channels import kline_channel, ticker_channel
from alphapulse.models import Kline, KlineSyncState, MarketSymbol
from alphapulse.models.market import INTERVALS
router = APIRouter(prefix="/api/market", tags=["market"], dependencies=[Depends(require_admin)])
# WebSocket 握手不能用基于 Request 的依赖,单独鉴权
ws_router = APIRouter(prefix="/api/market", tags=["market"])
Market = Literal["spot", "um"]
SYMBOLS_CACHE_SECONDS = 3600
MAX_KLINES = 1500
client = BinanceClient()
class IntervalProgress(BaseModel):
done: bool
percent: float
class SymbolOut(BaseModel):
id: int
market: str
symbol: str
base: str
quote: str
price_precision: int
volume_precision: int
status: str
is_default: bool
added_at: datetime
last_error: str | None
progress: dict[str, IntervalProgress]
class SymbolIn(BaseModel):
market: Market
symbol: str
class TradableSymbol(BaseModel):
symbol: str
base: str
quote: str
price_precision: int
volume_precision: int
async def tradable_symbols(market: str) -> list[dict]:
key = f"{KEY_PREFIX}market:symbols:{market}"
if cached := await redis.get(key):
return json.loads(cached)
symbols = await client.tradable_symbols(market)
await redis.set(key, json.dumps(symbols), ex=SYMBOLS_CACHE_SECONDS)
return symbols
@router.get("/symbols")
async def list_symbols(session: Annotated[AsyncSession, Depends(get_session)]) -> list[SymbolOut]:
symbols = (await session.scalars(select(MarketSymbol).order_by(MarketSymbol.is_default.desc(), MarketSymbol.added_at))).all()
states = {(s.market, s.symbol, s.interval): s for s in await session.scalars(select(KlineSyncState))}
now = int(time.time() * 1000)
def progress(s: MarketSymbol, interval: str) -> IntervalProgress:
state = states.get((s.market, s.symbol, interval))
if state is None or state.earliest_open_time is None:
return IntervalProgress(done=False, percent=0)
if state.backfill_done:
return IntervalProgress(done=True, percent=100)
span = max(now - state.earliest_open_time, 1)
return IntervalProgress(done=False, percent=round((state.latest_open_time - state.earliest_open_time) / span * 100, 1))
return [
SymbolOut.model_validate({**s.__dict__, "progress": {i: progress(s, i) for i in INTERVALS}})
for s in symbols
]
@router.post("/symbols", status_code=status.HTTP_201_CREATED)
async def add_symbol(body: SymbolIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
symbol = body.symbol.strip().upper()
match = next((s for s in await tradable_symbols(body.market) if s["symbol"] == symbol), None)
if match is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{body.market} 市场没有可交易的 {symbol}")
session.add(MarketSymbol(market=body.market, **match))
try:
await session.commit()
except IntegrityError:
raise HTTPException(status.HTTP_409_CONFLICT, f"{symbol} 已存在") from None
@router.delete("/symbols/{symbol_id}")
async def delete_symbol(symbol_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""删除交易对及其 K 线(K 线可随时从币安重新补齐)"""
s = await session.get(MarketSymbol, symbol_id)
if s is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "交易对不存在")
await session.execute(delete(Kline).where(Kline.market == s.market, Kline.symbol == s.symbol))
await session.execute(delete(KlineSyncState).where(KlineSyncState.market == s.market, KlineSyncState.symbol == s.symbol))
await session.delete(s)
await session.commit()
@router.get("/exchange-info")
async def exchange_info(market: Market) -> list[TradableSymbol]:
return [TradableSymbol.model_validate(s) for s in await tradable_symbols(market)]
@router.get("/klines")
async def klines(
session: Annotated[AsyncSession, Depends(get_session)],
market: Market,
symbol: str,
interval: str,
end: int | None = None,
limit: Annotated[int, Query(ge=1, le=MAX_KLINES)] = 500,
) -> list[list[float]]:
"""返回 open_time < end 的最近 limit 根,按时间正序:[open_time, open, high, low, close, volume, quote_volume]"""
if interval == "1s":
# 1s 不落库,直接取币安最近数据
if market != "spot":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "合约没有 1s 周期")
params = {"symbol": symbol, "interval": "1s", "limit": limit, **({"endTime": end - 1} if end else {})}
rows = await client.get("spot", "klines", params)
return [[r[0], float(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5]), float(r[7])] for r in rows]
if interval not in INTERVALS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"不支持的周期:{interval}")
stmt = (
select(Kline.open_time, Kline.open, Kline.high, Kline.low, Kline.close, Kline.volume, Kline.quote_volume)
.where(Kline.market == market, Kline.symbol == symbol, Kline.interval == interval)
.order_by(Kline.open_time.desc())
.limit(limit)
)
if end:
stmt = stmt.where(Kline.open_time < end)
rows = (await session.execute(stmt)).all()
return [[r[0], *(float(v) for v in r[1:])] for r in reversed(rows)]
@router.get("/ticker")
async def ticker(market: Market, symbol: str) -> dict:
return await client.ticker(market, symbol)
@ws_router.websocket("/ws")
async def market_ws(websocket: WebSocket) -> None:
"""客户端发送 {"market","symbol","interval"} 切换订阅;服务端推送 {"type":"kline"|"ticker", market, symbol, interval?, data}"""
if websocket_admin(websocket) is None:
await websocket.close(code=4401)
return
await websocket.accept()
pubsub = redis.pubsub()
subscribed = asyncio.Event()
async def forward() -> None:
await subscribed.wait()
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message is None:
continue
# 频道格式:ap:pub:kline:<market>:<symbol>:<interval> 或 ap:pub:ticker:<market>:<symbol>
parts = message["channel"].split(":")
await websocket.send_text(json.dumps({
"type": parts[2],
"market": parts[3],
"symbol": parts[4],
"interval": parts[5] if parts[2] == "kline" else None,
"data": json.loads(message["data"]),
}))
task = asyncio.create_task(forward())
try:
while True:
sub = await websocket.receive_json()
if subscribed.is_set():
await pubsub.unsubscribe()
await pubsub.subscribe(
kline_channel(sub["market"], sub["symbol"], sub["interval"]),
ticker_channel(sub["market"], sub["symbol"]),
)
subscribed.set()
except WebSocketDisconnect:
pass
finally:
task.cancel()
await pubsub.aclose()
+110
View File
@@ -0,0 +1,110 @@
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel
from sqlalchemy import func, or_, select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.core.redis import redis
from alphapulse.crawler.events import NEWS_CHANNEL
from alphapulse.models import NewsItem, NewsRevision
router = APIRouter(prefix="/api/news", tags=["news"], dependencies=[Depends(require_admin)])
EXCERPT_LENGTH = 300
class NewsBrief(BaseModel):
id: int
source_code: str
url: str
title: str
summary: str
excerpt: str
author: str | None
tags: list
is_paywalled: bool
published_at: datetime
first_seen_at: datetime
revision_no: int
class NewsPage(BaseModel):
items: list[NewsBrief]
# 形如 "<published_at ISO>|<id>",原样传回即可取下一页
next_cursor: str | None
class NewsDetail(NewsBrief):
content_html: str
source_updated_at: datetime | None
last_seen_at: datetime
extra: dict
class Revision(BaseModel):
revision_no: int
title: str
summary: str
content_html: str
captured_at: datetime
@router.get("")
async def list_news(
session: Annotated[AsyncSession, Depends(get_session)],
source: Annotated[list[str] | None, Query()] = None,
q: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
cursor: str | None = None,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
) -> NewsPage:
stmt = select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"))
if source:
stmt = stmt.where(NewsItem.source_code.in_(source))
if q:
pattern = f"%{q}%"
stmt = stmt.where(or_(NewsItem.title.ilike(pattern), NewsItem.content_text.ilike(pattern)))
if start:
stmt = stmt.where(NewsItem.published_at >= start)
if end:
stmt = stmt.where(NewsItem.published_at < end)
if cursor:
published_at, news_id = cursor.rsplit("|", 1)
stmt = stmt.where(tuple_(NewsItem.published_at, NewsItem.id) < (datetime.fromisoformat(published_at), int(news_id)))
rows = (await session.execute(stmt.order_by(NewsItem.published_at.desc(), NewsItem.id.desc()).limit(limit))).all()
items = [NewsBrief.model_validate({**news.__dict__, "excerpt": excerpt}) for news, excerpt in rows]
next_cursor = f"{items[-1].published_at.isoformat()}|{items[-1].id}" if len(items) == limit else None
return NewsPage(items=items, next_cursor=next_cursor)
@router.get("/stream", response_class=EventSourceResponse)
async def stream_news() -> AsyncIterator[ServerSentEvent]:
async with redis.pubsub() as pubsub:
await pubsub.subscribe(NEWS_CHANNEL)
async for message in pubsub.listen():
if message["type"] == "message":
yield ServerSentEvent(raw_data=message["data"], event="news")
@router.get("/{news_id}")
async def get_news(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> NewsDetail:
news = await session.get(NewsItem, news_id)
if news is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "新闻不存在")
return NewsDetail.model_validate({**news.__dict__, "excerpt": news.content_text[:EXCERPT_LENGTH]})
@router.get("/{news_id}/revisions")
async def list_revisions(news_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> list[Revision]:
rows = await session.scalars(
select(NewsRevision).where(NewsRevision.news_id == news_id).order_by(NewsRevision.revision_no.desc())
)
return [Revision.model_validate(r, from_attributes=True) for r in rows]