d6c802ef26
Batch of the pre-launch audit campaign (BUG-01…14 plus three new features): Pricing / TP-SL protection - Add app/services/hl_price_feed.py: supplemental HL allMids poller for HL-native assets (HYPE, PURR) not listed on Binance. Pumps price_store + tp_sl_monitor.on_price_tick so bot trades on these assets keep full stop-loss / take-profit / trailing protection instead of max-hold only. - Wire feed into main.py lifespan (startup task + graceful shutdown cancel). Telegram - Add format_trump_mention + PATH B in _dispatch: crypto-relevant Trump posts with no directional signal (relevant=True, signal=hold) now alert the public channel only (no per-subscriber noise). - Rate limiter (slowapi) on the API; assorted bot/digest fixes. KOL on-chain - seed_kol_wallets.py: KOL_FEEDS coverage cross-check; reversibly deactivate orphaned wallets (handle not in KOL_FEEDS → can never produce divergence) so the scanner stops burning cycles on them. Tests / misc - Fix brittle test_macro_ahr999_uses_same_formula_as_scanner: mock now uses realistic ms timestamps so the in-progress-day drop fires, matching the fetcher's bar count (was 0.3179 vs 0.3178 off-by-one). - Refresh stale notify_signal comment in truth_social.py. Frontend reduce-action type fix lives in the sibling repo. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
127 lines
4.3 KiB
Python
127 lines
4.3 KiB
Python
"""
|
||
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)
|