first commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import anthropic
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from typing import Optional
|
||||
_client: Optional[anthropic.AsyncAnthropic] = None
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a crypto trading signal analyst. Analyze Donald Trump's social media posts "
|
||||
"and determine their likely impact on cryptocurrency markets. "
|
||||
"Return only valid JSON with no additional text or markdown."
|
||||
)
|
||||
|
||||
USER_PROMPT_TEMPLATE = """Analyze this Trump post for cryptocurrency trading signals.
|
||||
|
||||
Post: {text}
|
||||
|
||||
Respond with JSON only:
|
||||
{{
|
||||
"relevant": <true if this post could affect crypto/macro markets>,
|
||||
"asset": "BTC" | "ETH" | null,
|
||||
"sentiment": "bullish" | "bearish" | "neutral",
|
||||
"signal": "buy" | "sell" | "short" | "hold",
|
||||
"confidence": <0-100>,
|
||||
"reasoning": "<1-2 sentences explaining the signal and why>"
|
||||
}}
|
||||
|
||||
Signal rules:
|
||||
- "buy": bullish crypto/macro signal → expect price rise → go long
|
||||
- "sell": bearish signal for existing longs → exit position
|
||||
- "short": strong bearish signal → expect price drop → go short
|
||||
- "hold": relevant but unclear direction, or neutral macro
|
||||
|
||||
Bullish examples: pro-crypto policy, BTC reserve, anti-regulation, dollar weakness, tariff deal, market optimism
|
||||
Bearish examples: SEC crackdowns, war escalation, economic sanctions, crypto ban threats, crisis
|
||||
Not relevant: personal attacks, sports, endorsements, unrelated politics
|
||||
|
||||
Be strict on relevance — most posts are NOT relevant to crypto."""
|
||||
|
||||
_FALLBACK = {
|
||||
"relevant": False,
|
||||
"asset": None,
|
||||
"sentiment": "neutral",
|
||||
"signal": "hold",
|
||||
"confidence": 0,
|
||||
"reasoning": "",
|
||||
}
|
||||
|
||||
|
||||
def _get_client() -> anthropic.AsyncAnthropic:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
return _client
|
||||
|
||||
|
||||
async def analyze_post(text: str) -> dict:
|
||||
"""Run Claude signal analysis on a Trump post.
|
||||
|
||||
Returns dict with keys: relevant, asset, sentiment, signal, confidence, reasoning.
|
||||
Falls back to neutral hold on any error.
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
message = await client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=300,
|
||||
system=SYSTEM_PROMPT,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": USER_PROMPT_TEMPLATE.format(text=text[:2000]),
|
||||
}
|
||||
],
|
||||
)
|
||||
raw = message.content[0].text.strip()
|
||||
|
||||
if raw.startswith("```"):
|
||||
lines = raw.split("\n")
|
||||
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||
|
||||
result = json.loads(raw)
|
||||
|
||||
sentiment = result.get("sentiment", "neutral")
|
||||
if sentiment not in ("bullish", "bearish", "neutral"):
|
||||
sentiment = "neutral"
|
||||
|
||||
signal = result.get("signal", "hold")
|
||||
if signal not in ("buy", "sell", "short", "hold"):
|
||||
signal = "hold"
|
||||
|
||||
confidence = int(result.get("confidence", 0))
|
||||
confidence = max(0, min(100, confidence))
|
||||
|
||||
relevant = bool(result.get("relevant", False))
|
||||
|
||||
asset = result.get("asset")
|
||||
if asset not in ("BTC", "ETH", None):
|
||||
asset = None
|
||||
|
||||
reasoning = str(result.get("reasoning", ""))[:500]
|
||||
|
||||
return {
|
||||
"relevant": relevant,
|
||||
"asset": asset,
|
||||
"sentiment": sentiment,
|
||||
"signal": signal,
|
||||
"confidence": confidence,
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
except anthropic.APIError as exc:
|
||||
logger.error("Anthropic API error: %s", exc)
|
||||
return dict(_FALLBACK)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("Failed to parse Claude JSON response: %s", exc)
|
||||
return dict(_FALLBACK)
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected error in analyze_post: %s", exc)
|
||||
return dict(_FALLBACK)
|
||||
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
from app.config import settings
|
||||
from app.services.price_store import price_store
|
||||
from app.ws.manager import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ASSET_MAP = {
|
||||
"btcusdt": "BTC",
|
||||
"ethusdt": "ETH",
|
||||
}
|
||||
|
||||
|
||||
async def _process_message(raw: str):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
# Combined stream wraps payload under "data"
|
||||
payload = data.get("data", data)
|
||||
if payload.get("e") != "kline":
|
||||
return
|
||||
|
||||
kline = payload["k"]
|
||||
symbol = kline["s"].lower()
|
||||
asset = ASSET_MAP.get(symbol)
|
||||
if asset is None:
|
||||
return
|
||||
|
||||
candle = {
|
||||
"time": kline["t"], # open time ms
|
||||
"open": float(kline["o"]),
|
||||
"high": float(kline["h"]),
|
||||
"low": float(kline["l"]),
|
||||
"close": float(kline["c"]),
|
||||
"volume": float(kline["v"]),
|
||||
}
|
||||
price_store.update(asset, candle)
|
||||
|
||||
# Broadcast live price tick
|
||||
await manager.broadcast({
|
||||
"type": "price",
|
||||
"asset": asset,
|
||||
"price": candle["close"],
|
||||
"time": candle["time"],
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("Error processing Binance message: %s", exc)
|
||||
|
||||
|
||||
async def fetch_historical(asset: str, symbol: str, interval: str = "1m", limit: int = 500):
|
||||
"""Fetch historical klines from Binance REST API to pre-fill price_store."""
|
||||
url = f"{settings.binance_rest_url}/api/v3/klines?symbol={symbol.upper()}&interval={interval}&limit={limit}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
for row in resp.json():
|
||||
candle = {
|
||||
"time": row[0], # open time ms
|
||||
"open": float(row[1]),
|
||||
"high": float(row[2]),
|
||||
"low": float(row[3]),
|
||||
"close": float(row[4]),
|
||||
"volume": float(row[5]),
|
||||
}
|
||||
price_store.update(asset, candle)
|
||||
logger.info("Loaded %d historical %s candles for %s", limit, interval, asset)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to fetch historical data for %s: %s", asset, exc)
|
||||
|
||||
|
||||
async def run_binance_ws():
|
||||
"""Connect to Binance kline stream with exponential back-off on disconnect."""
|
||||
# Pre-fill with historical data
|
||||
await fetch_historical("BTC", "btcusdt", limit=500)
|
||||
await fetch_historical("ETH", "ethusdt", limit=500)
|
||||
|
||||
backoff = 1
|
||||
while True:
|
||||
try:
|
||||
logger.info("Connecting to Binance WebSocket: %s", settings.binance_ws_url)
|
||||
async with websockets.connect(
|
||||
settings.binance_ws_url,
|
||||
ping_interval=20,
|
||||
ping_timeout=10,
|
||||
) as ws:
|
||||
backoff = 1 # reset on successful connection
|
||||
logger.info("Binance WebSocket connected.")
|
||||
async for message in ws:
|
||||
await _process_message(message)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Binance WebSocket task cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error("Binance WebSocket error: %s. Reconnecting in %ds.", exc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Trading decision loop.
|
||||
Called by the Truth Social scraper after each new post is analyzed and saved.
|
||||
Iterates all active subscribers and executes trades on their behalf.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models import Post, BotTrade, Subscription
|
||||
from app.services.hyperliquid import HyperliquidTrader
|
||||
from app.services.price_store import price_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thresholds
|
||||
MIN_CONFIDENCE = 70 # ai_confidence must be >= this to trade
|
||||
POSITION_SIZE_USD = 100 # per-trade size in USD (fixed for MVP)
|
||||
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour
|
||||
|
||||
|
||||
async def process_post(post: Post, db: AsyncSession) -> None:
|
||||
"""
|
||||
Entry point called by the scraper after a new post is saved.
|
||||
Skips non-relevant or low-confidence posts.
|
||||
"""
|
||||
if not post.relevant:
|
||||
return
|
||||
if (post.ai_confidence or 0) < MIN_CONFIDENCE:
|
||||
logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, MIN_CONFIDENCE)
|
||||
return
|
||||
if post.sentiment == 'neutral':
|
||||
return
|
||||
|
||||
asset = post.price_impact_asset or 'BTC'
|
||||
side = 'long' if post.sentiment == 'bullish' else 'short'
|
||||
|
||||
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
|
||||
|
||||
result = await db.execute(select(Subscription).where(Subscription.active == True))
|
||||
subscribers = result.scalars().all()
|
||||
|
||||
if not subscribers:
|
||||
logger.info("No active subscribers, skipping trade execution")
|
||||
return
|
||||
|
||||
tasks = [_execute_for_subscriber(sub, post, asset, side, db) for sub in subscribers]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def _execute_for_subscriber(
|
||||
sub: Subscription,
|
||||
post: Post,
|
||||
asset: str,
|
||||
side: str,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
if not sub.hl_api_key:
|
||||
logger.warning("Subscriber %s has no HL API key, skipping", sub.wallet_address)
|
||||
return
|
||||
|
||||
try:
|
||||
trader = HyperliquidTrader(sub.hl_api_key)
|
||||
|
||||
# Check for existing open position to avoid doubling up
|
||||
open_positions = await trader.get_open_positions()
|
||||
already_open = any(p.get('coin') == asset for p in open_positions)
|
||||
if already_open:
|
||||
logger.info("Subscriber %s already has open %s position, skipping", sub.wallet_address, asset)
|
||||
return
|
||||
|
||||
result = await trader.open_position(asset, side, POSITION_SIZE_USD)
|
||||
entry_price = result.get('fill_price', 0.0)
|
||||
|
||||
trade = BotTrade(
|
||||
asset=asset,
|
||||
side=side,
|
||||
entry_price=entry_price,
|
||||
wallet_address=sub.wallet_address,
|
||||
trigger_post_id=post.id,
|
||||
opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
hl_order_id=result.get('order_id'),
|
||||
)
|
||||
db.add(trade)
|
||||
await db.commit()
|
||||
await db.refresh(trade)
|
||||
|
||||
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", side, asset, sub.wallet_address, entry_price, trade.id)
|
||||
|
||||
# Schedule close after MAX_HOLD_SECONDS
|
||||
asyncio.create_task(_close_after_hold(trade.id, sub.hl_api_key, asset, sub.wallet_address))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Trade execution failed for %s: %s", sub.wallet_address, e)
|
||||
|
||||
|
||||
async def _close_after_hold(trade_id: int, api_key: str, asset: str, wallet: str) -> None:
|
||||
await asyncio.sleep(MAX_HOLD_SECONDS)
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(BotTrade).where(BotTrade.id == trade_id, BotTrade.closed_at.is_(None))
|
||||
)
|
||||
trade = result.scalar_one_or_none()
|
||||
if trade is None:
|
||||
return # already closed
|
||||
|
||||
trader = HyperliquidTrader(api_key)
|
||||
close_result = await trader.close_position(asset)
|
||||
exit_price = close_result.get('fill_price', 0.0)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
hold_secs = int((now - trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds())
|
||||
pnl = (exit_price - trade.entry_price) * (1 if trade.side == 'long' else -1)
|
||||
# Approximate PnL: size_usd * pct_change
|
||||
pnl_usd = POSITION_SIZE_USD * (pnl / trade.entry_price) if trade.entry_price else 0.0
|
||||
|
||||
trade.exit_price = exit_price
|
||||
trade.pnl_usd = round(pnl_usd, 2)
|
||||
trade.hold_seconds = hold_secs
|
||||
trade.closed_at = now
|
||||
|
||||
await db.commit()
|
||||
logger.info("Closed %s %s for %s @ %.2f PnL=%.2f", trade.side, asset, wallet, exit_price, pnl_usd)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to close trade %d: %s", trade_id, e)
|
||||
@@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from eth_account import Account
|
||||
from hyperliquid.exchange import Exchange
|
||||
from hyperliquid.info import Info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HL_MAINNET = "https://api.hyperliquid.xyz"
|
||||
|
||||
# Coin name mapping: our asset -> Hyperliquid coin
|
||||
COIN_MAP = {
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
}
|
||||
|
||||
|
||||
class HyperliquidTrader:
|
||||
def __init__(self, private_key: str):
|
||||
self.account = Account.from_key(private_key)
|
||||
self.exchange = Exchange(self.account, HL_MAINNET)
|
||||
self.info = Info(HL_MAINNET)
|
||||
self._loop = asyncio.get_event_loop()
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _run_sync(self, fn, *args, **kwargs):
|
||||
"""Run a blocking SDK call in the default thread pool."""
|
||||
return await self._loop.run_in_executor(None, partial(fn, *args, **kwargs))
|
||||
|
||||
def _wallet(self) -> str:
|
||||
return self.account.address
|
||||
|
||||
# ── public interface ─────────────────────────────────────────────────────
|
||||
|
||||
async def get_balance(self) -> float:
|
||||
"""Return USDC balance (withdrawable)."""
|
||||
try:
|
||||
state = await self._run_sync(
|
||||
self.info.user_state, self._wallet()
|
||||
)
|
||||
return float(state.get("withdrawable", 0))
|
||||
except Exception as exc:
|
||||
logger.error("get_balance error: %s", exc)
|
||||
return 0.0
|
||||
|
||||
async def open_position(self, asset: str, side: str, size_usd: float) -> dict:
|
||||
"""Place a market order.
|
||||
|
||||
Args:
|
||||
asset: "BTC" or "ETH"
|
||||
side: "long" (buy) or "short" (sell)
|
||||
size_usd: notional size in USD
|
||||
|
||||
Returns:
|
||||
{"order_id": str, "fill_price": float}
|
||||
"""
|
||||
coin = COIN_MAP.get(asset.upper(), asset.upper())
|
||||
is_buy = side.lower() == "long"
|
||||
|
||||
try:
|
||||
# Get current price to compute size in coins
|
||||
meta = await self._run_sync(self.info.meta)
|
||||
universe = {u["name"]: u for u in meta.get("universe", [])}
|
||||
coin_meta = universe.get(coin, {})
|
||||
sz_decimals = int(coin_meta.get("szDecimals", 3))
|
||||
|
||||
# Use mid-price approximation from user state
|
||||
all_mids = await self._run_sync(self.info.all_mids)
|
||||
mid_price = float(all_mids.get(coin, 0))
|
||||
if mid_price <= 0:
|
||||
raise ValueError(f"Could not get mid price for {coin}")
|
||||
|
||||
size_coins = round(size_usd / mid_price, sz_decimals)
|
||||
|
||||
# Slippage: 1% for longs (limit above mid), 1% for shorts (limit below mid)
|
||||
slippage = 0.01
|
||||
if is_buy:
|
||||
limit_px = round(mid_price * (1 + slippage), 1)
|
||||
else:
|
||||
limit_px = round(mid_price * (1 - slippage), 1)
|
||||
|
||||
order_result = await self._run_sync(
|
||||
self.exchange.order,
|
||||
coin,
|
||||
is_buy,
|
||||
size_coins,
|
||||
limit_px,
|
||||
{"limit": {"tif": "Ioc"}}, # IOC ~ market
|
||||
)
|
||||
|
||||
status = order_result.get("response", {}).get("data", {})
|
||||
filled = status.get("statuses", [{}])[0]
|
||||
fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price)
|
||||
order_id = str(filled.get("resting", {}).get("oid", ""))
|
||||
|
||||
logger.info(
|
||||
"Opened %s %s position: size=%.4f coins @ %.2f",
|
||||
side, coin, size_coins, fill_price,
|
||||
)
|
||||
return {"order_id": order_id, "fill_price": fill_price}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("open_position error: %s", exc)
|
||||
raise
|
||||
|
||||
async def close_position(self, asset: str) -> dict:
|
||||
"""Close all open positions for the given asset.
|
||||
|
||||
Returns:
|
||||
{"fill_price": float}
|
||||
"""
|
||||
coin = COIN_MAP.get(asset.upper(), asset.upper())
|
||||
try:
|
||||
positions = await self.get_open_positions()
|
||||
target = next(
|
||||
(p for p in positions if p.get("coin") == coin), None
|
||||
)
|
||||
if target is None:
|
||||
logger.warning("No open position found for %s", coin)
|
||||
return {"fill_price": 0.0}
|
||||
|
||||
szi = float(target.get("szi", 0))
|
||||
is_buy = szi < 0 # closing a short means buying
|
||||
|
||||
all_mids = await self._run_sync(self.info.all_mids)
|
||||
mid_price = float(all_mids.get(coin, 0))
|
||||
|
||||
slippage = 0.01
|
||||
if is_buy:
|
||||
limit_px = round(mid_price * (1 + slippage), 1)
|
||||
else:
|
||||
limit_px = round(mid_price * (1 - slippage), 1)
|
||||
|
||||
close_result = await self._run_sync(
|
||||
self.exchange.order,
|
||||
coin,
|
||||
is_buy,
|
||||
abs(szi),
|
||||
limit_px,
|
||||
{"limit": {"tif": "Ioc"}},
|
||||
reduce_only=True,
|
||||
)
|
||||
|
||||
status = close_result.get("response", {}).get("data", {})
|
||||
filled = status.get("statuses", [{}])[0]
|
||||
fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price)
|
||||
|
||||
logger.info("Closed %s position @ %.2f", coin, fill_price)
|
||||
return {"fill_price": fill_price}
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("close_position error: %s", exc)
|
||||
raise
|
||||
|
||||
async def get_open_positions(self) -> list:
|
||||
"""Return list of open positions from Hyperliquid."""
|
||||
try:
|
||||
state = await self._run_sync(self.info.user_state, self._wallet())
|
||||
positions = []
|
||||
for asset_pos in state.get("assetPositions", []):
|
||||
pos = asset_pos.get("position", {})
|
||||
szi = float(pos.get("szi", 0))
|
||||
if szi != 0:
|
||||
positions.append({
|
||||
"coin": pos.get("coin"),
|
||||
"szi": szi,
|
||||
"entry_px": float(pos.get("entryPx", 0) or 0),
|
||||
"unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0),
|
||||
})
|
||||
return positions
|
||||
except Exception as exc:
|
||||
logger.error("get_open_positions error: %s", exc)
|
||||
return []
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
历史帖子价格回溯
|
||||
对 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)
|
||||
@@ -0,0 +1,138 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 7 days of 1-minute candles
|
||||
MAX_CANDLES = 7 * 24 * 60 # 10080
|
||||
|
||||
TIMEFRAME_MINUTES: Dict[str, int] = {
|
||||
"1m": 1,
|
||||
"1H": 60,
|
||||
"4H": 240,
|
||||
"1D": 1440,
|
||||
"1W": 10080,
|
||||
}
|
||||
|
||||
|
||||
class PriceStore:
|
||||
def __init__(self):
|
||||
self._candles: Dict[str, deque] = {
|
||||
"BTC": deque(maxlen=MAX_CANDLES),
|
||||
"ETH": deque(maxlen=MAX_CANDLES),
|
||||
}
|
||||
|
||||
def update(self, asset: str, candle: dict):
|
||||
"""Add or replace the latest 1m candle for the asset."""
|
||||
asset = asset.upper()
|
||||
if asset not in self._candles:
|
||||
self._candles[asset] = deque(maxlen=MAX_CANDLES)
|
||||
buf = self._candles[asset]
|
||||
# If the last candle has the same timestamp, replace it (in-progress bar)
|
||||
if buf and buf[-1]["time"] == candle["time"]:
|
||||
buf[-1] = candle
|
||||
else:
|
||||
buf.append(candle)
|
||||
|
||||
def get_price_at(self, asset: str, timestamp: datetime) -> Optional[float]:
|
||||
"""Return the close price of the candle closest to timestamp."""
|
||||
asset = asset.upper()
|
||||
buf = self._candles.get(asset)
|
||||
if not buf:
|
||||
return None
|
||||
ts = timestamp.replace(tzinfo=None)
|
||||
target_unix = int(ts.timestamp()) * 1000 # candle times are ms
|
||||
best = None
|
||||
best_diff = float("inf")
|
||||
for candle in buf:
|
||||
diff = abs(candle["time"] - target_unix)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best = candle
|
||||
return best["close"] if best else None
|
||||
|
||||
def get_pct_change(
|
||||
self, asset: str, from_ts: datetime, minutes: int
|
||||
) -> Optional[float]:
|
||||
"""Return % change from from_ts to from_ts + minutes."""
|
||||
asset = asset.upper()
|
||||
buf = self._candles.get(asset)
|
||||
if not buf:
|
||||
return None
|
||||
|
||||
from_unix_ms = int(from_ts.replace(tzinfo=None).timestamp()) * 1000
|
||||
to_unix_ms = from_unix_ms + minutes * 60 * 1000
|
||||
|
||||
# Find candle at from_ts
|
||||
from_candle = self._closest_candle(buf, from_unix_ms)
|
||||
to_candle = self._closest_candle(buf, to_unix_ms)
|
||||
|
||||
if from_candle is None or to_candle is None:
|
||||
return None
|
||||
if from_candle["close"] == 0:
|
||||
return None
|
||||
|
||||
pct = (to_candle["close"] - from_candle["close"]) / from_candle["close"] * 100
|
||||
return round(pct, 4)
|
||||
|
||||
def _closest_candle(self, buf: deque, target_unix_ms: int) -> Optional[dict]:
|
||||
best = None
|
||||
best_diff = float("inf")
|
||||
for candle in buf:
|
||||
diff = abs(candle["time"] - target_unix_ms)
|
||||
if diff < best_diff:
|
||||
best_diff = diff
|
||||
best = candle
|
||||
return best
|
||||
|
||||
def get_candles(self, asset: str, timeframe: str = "1H", limit: int = 200) -> List[dict]:
|
||||
"""Aggregate 1m candles into the requested timeframe and return the last `limit` bars."""
|
||||
asset = asset.upper()
|
||||
buf = self._candles.get(asset)
|
||||
if not buf:
|
||||
return []
|
||||
|
||||
tf_minutes = TIMEFRAME_MINUTES.get(timeframe, 60)
|
||||
|
||||
if tf_minutes == 1:
|
||||
candles = list(buf)[-limit:]
|
||||
return [{**c, "time": c["time"] // 1000} for c in candles]
|
||||
|
||||
# Aggregate
|
||||
aggregated: Dict[int, dict] = {}
|
||||
tf_ms = tf_minutes * 60 * 1000
|
||||
|
||||
for candle in buf:
|
||||
bucket = (candle["time"] // tf_ms) * tf_ms
|
||||
if bucket not in aggregated:
|
||||
aggregated[bucket] = {
|
||||
"time": bucket,
|
||||
"open": candle["open"],
|
||||
"high": candle["high"],
|
||||
"low": candle["low"],
|
||||
"close": candle["close"],
|
||||
"volume": candle["volume"],
|
||||
}
|
||||
else:
|
||||
agg = aggregated[bucket]
|
||||
agg["high"] = max(agg["high"], candle["high"])
|
||||
agg["low"] = min(agg["low"], candle["low"])
|
||||
agg["close"] = candle["close"]
|
||||
agg["volume"] += candle["volume"]
|
||||
|
||||
sorted_candles = sorted(aggregated.values(), key=lambda c: c["time"])
|
||||
result = sorted_candles[-limit:]
|
||||
# Convert ms → seconds for lightweight-charts
|
||||
return [{**c, "time": c["time"] // 1000} for c in result]
|
||||
|
||||
def latest_price(self, asset: str) -> Optional[float]:
|
||||
asset = asset.upper()
|
||||
buf = self._candles.get(asset)
|
||||
if not buf:
|
||||
return None
|
||||
return buf[-1]["close"]
|
||||
|
||||
|
||||
price_store = PriceStore()
|
||||
Reference in New Issue
Block a user