""" Historical price backfill for already-relevant posts. Important safety rule: This job only fills missing price fields. It must NEVER rewrite the post's semantic classification (`relevant`, `sentiment`, signal direction) from a keyword heuristic, otherwise historical truth rows become a mixed dataset of AI-scored rows + script-guessed rows. """ import asyncio import logging from datetime import datetime, timezone, timedelta from typing import Optional import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models import Post logger = logging.getLogger(__name__) # 每次从 Binance 拉多少分钟的 1m K 线(覆盖 1h 涨跌幅计算需要至少 60 根) FETCH_WINDOW_MINUTES = 90 async def _fetch_klines(symbol: str, start_ms: int, limit: int = 90) -> list: url = ( f"{settings.binance_rest_url}/api/v3/klines" f"?symbol={symbol}&interval=1m&startTime={start_ms}&limit={limit}" ) async with httpx.AsyncClient(timeout=15) as client: resp = await client.get(url) resp.raise_for_status() return resp.json() def _price_at(klines: list, target_ms: int) -> Optional[float]: """找最接近 target_ms 的收盘价""" best = None best_diff = float("inf") for row in klines: diff = abs(row[0] - target_ms) if diff < best_diff: best_diff = diff best = float(row[4]) # close return best def _pct_change(klines: list, from_ms: int, delta_minutes: int) -> Optional[float]: from_price = _price_at(klines, from_ms) to_price = _price_at(klines, from_ms + delta_minutes * 60 * 1000) if from_price and to_price and from_price != 0: return round((to_price - from_price) / from_price * 100, 4) return None async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None: """Fill price-impact fields for posts that are already relevant. The semantic label must come from the original analyzer, not from this backfill job. This task only enriches rows with price-at-post and subsequent returns. """ symbol = "BTCUSDT" if asset == "BTC" else "ETHUSDT" async with db_session_factory() as db: # Only touch rows whose semantic classification already exists. result = await db.execute( select(Post) .where(Post.relevant == True) .where(Post.price_at_post == None) .where(Post.price_impact_asset == asset) .order_by(Post.published_at.asc()) ) posts = result.scalars().all() logger.info("找到 %d 条帖子需要价格回溯 (asset=%s)", len(posts), asset) if not posts: return saved = 0 errors = 0 for i, post in enumerate(posts): try: published_at = post.published_at if published_at.tzinfo is None: published_at = published_at.replace(tzinfo=timezone.utc) start_ms = int(published_at.timestamp() * 1000) # 拉 Binance 历史价格 klines = await _fetch_klines(symbol, start_ms, limit=FETCH_WINDOW_MINUTES) price_at_post = _price_at(klines, start_ms) m5 = _pct_change(klines, start_ms, 5) m15 = _pct_change(klines, start_ms, 15) m1h = _pct_change(klines, start_ms, 60) # 更新帖子 async with db_session_factory() as db: result = await db.execute(select(Post).where(Post.id == post.id)) p = result.scalar_one_or_none() if p: p.price_at_post = price_at_post p.price_impact_m5 = m5 p.price_impact_m15 = m15 p.price_impact_m1h = m1h await db.commit() saved += 1 if (i + 1) % 20 == 0: logger.info("进度: %d/%d 已处理,已保存 %d 条", i + 1, len(posts), saved) # 避免触发 Binance 限速(1200 requests/min) await asyncio.sleep(0.1) except Exception as exc: errors += 1 logger.error("帖子 id=%d 回溯失败: %s", post.id, exc) await asyncio.sleep(1) logger.info("✅ 价格回溯完成: 共处理 %d 条,成功 %d 条,失败 %d 条", len(posts), saved, errors)