""" 历史帖子价格回溯 对 DB 中没有价格数据的帖子,从 Binance 拉历史 K 线计算涨跌幅 """ 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: """ 对 DB 中 relevant=True 但没有价格数据的帖子补充价格回溯。 对 relevant=False 的帖子,做简单判断(标题含 crypto/bitcoin/btc 关键词则标记为相关)。 """ symbol = "BTCUSDT" if asset == "BTC" else "ETHUSDT" async with db_session_factory() as db: # 拿所有没有价格数据的帖子 result = await db.execute( select(Post) .where(Post.price_at_post == None) .order_by(Post.published_at.asc()) ) posts = result.scalars().all() logger.info("找到 %d 条帖子需要价格回溯 (asset=%s)", len(posts), asset) if not posts: return # 关键词判断是否与加密相关(快速,不用 Claude API) crypto_keywords = [ "bitcoin", "btc", "crypto", "cryptocurrency", "blockchain", "ethereum", "eth", "digital currency", "defi", "coinbase", "sec", "regulation", "tariff", "dollar", "inflation", "fed", "economy", "market", "trade", "sanctions", "iran", "china", ] def is_relevant(text: str) -> bool: t = text.lower() return any(kw in t for kw in crypto_keywords) 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) # 判断相关性(用关键词,不消耗 Claude API) relevant = is_relevant(post.text) sentiment = "neutral" if relevant: t = post.text.lower() bullish_kw = ["great", "win", "winning", "strong", "best", "love", "beautiful", "tremendous", "amazing", "pro-crypto", "bitcoin reserve"] bearish_kw = ["bad", "terrible", "war", "crisis", "sanction", "ban", "regulate", "crack", "fraud", "scam"] if any(k in t for k in bullish_kw): sentiment = "bullish" elif any(k in t for k in bearish_kw): sentiment = "bearish" # 拉 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.relevant = relevant p.sentiment = sentiment p.price_impact_asset = asset if relevant else None p.price_at_post = price_at_post p.price_impact_m5 = m5 if relevant else None p.price_impact_m15 = m15 if relevant else None p.price_impact_m1h = m1h if relevant else None 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)