145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""
|
||
历史帖子价格回溯
|
||
对 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)
|