first commit

This commit is contained in:
k
2026-04-20 23:05:59 +08:00
commit 9a72566753
33 changed files with 2138 additions and 0 deletions
View File
View File
+65
View File
@@ -0,0 +1,65 @@
"""
Dev-only endpoint: manually insert a fake post to test the full pipeline.
"""
import asyncio
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, AsyncSessionLocal
from app.models import Post
from app.ws.manager import manager
import hashlib, time
router = APIRouter()
@router.post("/dev/fake-post")
async def insert_fake_post(
text: str = "BITCOIN is the future of money! We will make America the greatest crypto capital EVER!",
sentiment: str = "bullish",
db: AsyncSession = Depends(get_db),
):
external_id = hashlib.md5(f"fake-{time.time()}".encode()).hexdigest()
post = Post(
external_id=external_id,
text=text,
source="truth",
published_at=datetime.now(timezone.utc).replace(tzinfo=None),
sentiment=sentiment,
ai_confidence=88,
relevant=True,
price_impact_asset="BTC",
price_impact_m5=1.2,
price_impact_m15=2.4,
price_impact_m1h=3.1,
price_at_post=94230.0,
)
db.add(post)
await db.commit()
await db.refresh(post)
await manager.broadcast({
"type": "new_post",
"post": {
"id": post.id,
"text": post.text,
"source": post.source,
"published_at": post.published_at.isoformat(),
"sentiment": post.sentiment,
"ai_confidence": post.ai_confidence,
"relevant": post.relevant,
"price_impact": {
"asset": "BTC", "m5": 1.2, "m15": 2.4, "m1h": 3.1, "price_at_post": 94230.0
},
},
})
return {"id": post.id, "text": post.text[:80]}
@router.post("/dev/backfill-prices")
async def trigger_price_backfill(background_tasks: BackgroundTasks, asset: str = "BTC"):
"""触发历史帖子价格回溯(后台运行)"""
from app.services.price_backfill import backfill_price_impact
background_tasks.add_task(backfill_price_impact, AsyncSessionLocal, asset)
return {"status": "started", "asset": asset, "message": "后台回溯中,约需 1-2 分钟"}
+69
View File
@@ -0,0 +1,69 @@
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade
from app.schemas import BotPerformance
router = APIRouter()
logger = logging.getLogger(__name__)
PERIOD_DAYS = 30
@router.get("/performance", response_model=BotPerformance)
async def get_performance(db: AsyncSession = Depends(get_db)):
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=PERIOD_DAYS)
result = await db.execute(
select(BotTrade)
.where(BotTrade.closed_at.is_not(None))
.where(BotTrade.opened_at >= since)
.order_by(BotTrade.opened_at.asc())
)
trades = result.scalars().all()
total_trades = len(trades)
if total_trades == 0:
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=0,
win_rate=0.0,
net_pnl_usd=0.0,
avg_hold_seconds=0.0,
max_drawdown_pct=0.0,
)
winning = sum(1 for t in trades if (t.pnl_usd or 0) > 0)
win_rate = winning / total_trades
pnl_values = [(t.pnl_usd or 0.0) for t in trades]
net_pnl = sum(pnl_values)
hold_values = [(t.hold_seconds or 0) for t in trades]
avg_hold = sum(hold_values) / len(hold_values)
# Max drawdown: running peak → trough of cumulative PnL
cumulative = 0.0
peak = 0.0
max_drawdown = 0.0
for pnl in pnl_values:
cumulative += pnl
if cumulative > peak:
peak = cumulative
drawdown = (peak - cumulative) / peak * 100 if peak > 0 else 0.0
if drawdown > max_drawdown:
max_drawdown = drawdown
return BotPerformance(
period_days=PERIOD_DAYS,
total_trades=total_trades,
win_rate=round(win_rate, 4),
net_pnl_usd=round(net_pnl, 2),
avg_hold_seconds=round(avg_hold, 1),
max_drawdown_pct=round(max_drawdown, 4),
)
+63
View File
@@ -0,0 +1,63 @@
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Post
from app.schemas import PriceImpact, TrumpPost
router = APIRouter()
logger = logging.getLogger(__name__)
def _post_to_schema(post: Post) -> TrumpPost:
price_impact: Optional[PriceImpact] = None
if (
post.price_impact_asset
and post.price_at_post is not None
):
price_impact = PriceImpact(
asset=post.price_impact_asset,
m5=post.price_impact_m5 or 0.0,
m15=post.price_impact_m15 or 0.0,
m1h=post.price_impact_m1h or 0.0,
price_at_post=post.price_at_post,
)
return TrumpPost(
id=post.id,
text=post.text,
source=post.source,
published_at=post.published_at.isoformat(),
sentiment=post.sentiment,
signal=post.signal,
ai_confidence=post.ai_confidence,
ai_reasoning=post.ai_reasoning,
relevant=post.relevant,
price_impact=price_impact,
)
@router.get("/posts", response_model=List[TrumpPost])
async def get_posts(
limit: int = Query(default=20, ge=1, le=500),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
):
offset = (page - 1) * limit
result = await db.execute(
select(Post).order_by(Post.published_at.desc()).offset(offset).limit(limit)
)
posts = result.scalars().all()
return [_post_to_schema(p) for p in posts]
@router.get("/posts/{post_id}", response_model=TrumpPost)
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Post).where(Post.id == post_id))
post = result.scalar_one_or_none()
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
return _post_to_schema(post)
+72
View File
@@ -0,0 +1,72 @@
import logging
from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.schemas import Candle
from app.services.price_store import price_store
router = APIRouter()
logger = logging.getLogger(__name__)
VALID_ASSETS = {"BTC", "ETH"}
VALID_TIMEFRAMES = {"5m", "15m", "1H", "4H", "1D", "1W"}
BINANCE_INTERVAL = {
"5m": "5m",
"15m": "15m",
"1H": "1h",
"4H": "4h",
"1D": "1d",
"1W": "1w",
}
SYMBOL_MAP = {"BTC": "BTCUSDT", "ETH": "ETHUSDT"}
async def fetch_binance_candles(asset: str, tf: str, limit: int) -> List[Candle]:
symbol = SYMBOL_MAP[asset]
interval = BINANCE_INTERVAL[tf]
url = f"{settings.binance_rest_url}/api/v3/klines?symbol={symbol}&interval={interval}&limit={limit}"
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
rows = resp.json()
return [
Candle(
time=row[0] // 1000, # ms → seconds
open=float(row[1]),
high=float(row[2]),
low=float(row[3]),
close=float(row[4]),
volume=float(row[5]),
)
for row in rows
]
@router.get("/prices/{asset}", response_model=List[Candle])
async def get_prices(
asset: str,
tf: str = Query(default="4H"),
limit: int = Query(default=200, ge=1, le=1000),
):
asset = asset.upper()
if asset not in VALID_ASSETS:
raise HTTPException(status_code=400, detail=f"Asset must be one of {VALID_ASSETS}")
if tf not in VALID_TIMEFRAMES:
raise HTTPException(status_code=400, detail=f"Timeframe must be one of {VALID_TIMEFRAMES}")
# 1m: use in-memory store (updated in real-time)
if tf == "1m":
candles = price_store.get_candles(asset, "1m", limit)
return [Candle(**c) for c in candles]
# All other timeframes: fetch directly from Binance
try:
return await fetch_binance_candles(asset, tf, limit)
except Exception as exc:
logger.error("Binance REST fetch failed for %s %s: %s", asset, tf, exc)
raise HTTPException(status_code=502, detail="Failed to fetch price data")
+66
View File
@@ -0,0 +1,66 @@
import logging
from datetime import datetime, timezone
from eth_account import Account
from eth_account.messages import encode_defunct
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Subscription
from app.schemas import SubscribeRequest, SubscribeResponse
router = APIRouter()
logger = logging.getLogger(__name__)
SIGN_MESSAGE = "Sign in to TrumpSignal"
from typing import Optional
def _recover_signer(signature: str) -> Optional[str]:
"""Recover signer address from an EIP-191 personal_sign signature."""
try:
message = encode_defunct(text=SIGN_MESSAGE)
recovered = Account.recover_message(message, signature=signature)
return recovered.lower()
except Exception as exc:
logger.warning("Signature recovery failed: %s", exc)
return None
@router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
wallet = body.wallet.lower().strip()
# Verify EIP-191 signature
recovered = _recover_signer(body.signature)
if recovered is None or recovered != wallet:
raise HTTPException(
status_code=401,
detail="Signature verification failed: recovered address does not match wallet",
)
# Upsert subscription
result = await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)
sub = result.scalar_one_or_none()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if sub is None:
sub = Subscription(
wallet_address=wallet,
active=True,
subscribed_at=now,
)
db.add(sub)
else:
sub.active = True
if sub.subscribed_at is None:
sub.subscribed_at = now
await db.commit()
logger.info("Subscription activated for wallet %s", wallet)
return SubscribeResponse(status="ok", wallet=wallet)
+46
View File
@@ -0,0 +1,46 @@
import logging
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade
from app.schemas import BotTrade as BotTradeSchema
router = APIRouter()
logger = logging.getLogger(__name__)
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
exit_price=trade.exit_price or 0.0,
pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0,
opened_at=trade.opened_at.isoformat(),
closed_at=trade.closed_at.isoformat() if trade.closed_at else "",
)
@router.get("/trades", response_model=List[BotTradeSchema])
async def get_trades(
limit: int = Query(default=20, ge=1, le=100),
page: int = Query(default=1, ge=1),
db: AsyncSession = Depends(get_db),
):
offset = (page - 1) * limit
result = await db.execute(
select(BotTrade)
.where(BotTrade.closed_at.is_not(None))
.order_by(BotTrade.opened_at.desc())
.offset(offset)
.limit(limit)
)
trades = result.scalars().all()
return [_trade_to_schema(t) for t in trades]
+59
View File
@@ -0,0 +1,59 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BotTrade, Subscription
from app.schemas import BotTrade as BotTradeSchema, UserResponse
router = APIRouter()
logger = logging.getLogger(__name__)
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
return BotTradeSchema(
id=trade.id,
asset=trade.asset,
side=trade.side,
entry_price=trade.entry_price,
exit_price=trade.exit_price or 0.0,
pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0,
opened_at=trade.opened_at.isoformat(),
closed_at=trade.closed_at.isoformat() if trade.closed_at else "",
)
@router.get("/user/{wallet}", response_model=UserResponse)
async def get_user(wallet: str, db: AsyncSession = Depends(get_db)):
wallet = wallet.lower().strip()
result = await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)
sub = result.scalar_one_or_none()
if sub is None:
raise HTTPException(status_code=404, detail="Wallet not found")
# Personal trades (all — open and closed)
trades_result = await db.execute(
select(BotTrade)
.where(BotTrade.wallet_address == wallet)
.order_by(BotTrade.opened_at.desc())
.limit(50)
)
trades = trades_result.scalars().all()
# Mask hl_api_key: show only last 4 chars if set
hl_api_key_set = bool(sub.hl_api_key)
return UserResponse(
wallet_address=sub.wallet_address,
active=sub.active,
subscribed_at=sub.subscribed_at.isoformat() if sub.subscribed_at else None,
hl_api_key_set=hl_api_key_set,
trades=[_trade_to_schema(t) for t in trades],
)
+21
View File
@@ -0,0 +1,21 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379"
anthropic_api_key: str
frontend_url: str = "http://localhost:3001"
truth_social_rss: str = "https://truthsocial.com/@realDonaldTrump.rss"
truth_social_poll_seconds: int = 15
binance_ws_url: str = (
"wss://data-stream.binance.vision/stream"
"?streams=btcusdt@kline_1m/ethusdt@kline_1m"
)
binance_rest_url: str = "https://data-api.binance.vision"
environment: str = "development"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from app.config import settings
_url = settings.database_url
# SQLite for local dev — no pool settings
if _url.startswith("sqlite"):
engine = create_async_engine(_url, echo=False, connect_args={"check_same_thread": False})
else:
engine = create_async_engine(_url, echo=False, pool_pre_ping=True, pool_size=10, max_overflow=20)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+151
View File
@@ -0,0 +1,151 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import AsyncSessionLocal, engine
from app.models import Base
from app.scrapers.truth_social import poll_truth_social, backfill_history
from app.services.binance import run_binance_ws
from app.ws.manager import manager
# Import all routers
from app.api.posts import router as posts_router
from app.api.prices import router as prices_router
from app.api.trades import router as trades_router
from app.api.performance import router as performance_router
from app.api.subscribe import router as subscribe_router
from app.api.user import router as user_router
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
from typing import Optional
_binance_task: Optional[asyncio.Task] = None
_scheduler: Optional[AsyncIOScheduler] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _binance_task, _scheduler
# 1. Create DB tables (dev convenience; production uses Alembic)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured.")
# 2. Backfill historical posts on startup (fast, no Claude API call)
asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500))
# 3. Start Binance WebSocket background task
_binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws")
logger.info("Binance WebSocket task started.")
# 3. Start Truth Social poller via APScheduler
_scheduler = AsyncIOScheduler()
_scheduler.add_job(
poll_truth_social,
"interval",
seconds=settings.truth_social_poll_seconds,
args=[AsyncSessionLocal],
id="truth_social_poll",
max_instances=1,
coalesce=True,
)
_scheduler.start()
logger.info(
"Truth Social poller scheduled every %ds.", settings.truth_social_poll_seconds
)
yield
# Shutdown
logger.info("Shutting down background tasks...")
if _scheduler:
_scheduler.shutdown(wait=False)
if _binance_task and not _binance_task.done():
_binance_task.cancel()
try:
await _binance_task
except asyncio.CancelledError:
pass
await engine.dispose()
logger.info("Shutdown complete.")
app = FastAPI(
title="TrumpSignal API",
version="1.0.0",
description="Crypto trading signals derived from Trump's Truth Social posts.",
lifespan=lifespan,
)
# CORS
allowed_origins = [
settings.frontend_url,
"http://localhost:3001",
"http://localhost:3000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# REST routers
app.include_router(posts_router, prefix="/api")
app.include_router(prices_router, prefix="/api")
app.include_router(trades_router, prefix="/api")
app.include_router(performance_router, prefix="/api")
app.include_router(subscribe_router, prefix="/api")
app.include_router(user_router, prefix="/api")
@app.get("/api/health")
async def health():
return {"status": "ok"}
if settings.environment == "development":
from app.api.dev import router as dev_router
app.include_router(dev_router, prefix="/api")
# ── WebSocket endpoints ────────────────────────────────────────────────────
@app.websocket("/ws/prices")
async def ws_prices(websocket: WebSocket):
"""Subscribe to live price ticks (type: 'price')."""
await manager.connect(websocket)
try:
while True:
# Keep connection alive; messages are pushed via manager.broadcast
await websocket.receive_text()
except WebSocketDisconnect:
await manager.disconnect(websocket)
except Exception as exc:
logger.warning("WebSocket /ws/prices error: %s", exc)
await manager.disconnect(websocket)
@app.websocket("/ws/posts")
async def ws_posts(websocket: WebSocket):
"""Subscribe to new post events (type: 'new_post')."""
await manager.connect(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
await manager.disconnect(websocket)
except Exception as exc:
logger.warning("WebSocket /ws/posts error: %s", exc)
await manager.disconnect(websocket)
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
external_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str] = mapped_column(String(32), nullable=False, default="truth")
published_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
sentiment: Mapped[str] = mapped_column(String(16), nullable=False, default="neutral")
ai_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
relevant: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
price_impact_asset: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
price_impact_m5: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
price_impact_m15: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
price_impact_m1h: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
price_at_post: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
signal: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
ai_reasoning: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
trades: Mapped[List["BotTrade"]] = relationship("BotTrade", back_populates="trigger_post")
class Candle(Base):
__tablename__ = "candles"
__table_args__ = (UniqueConstraint("asset", "timeframe", "time", name="uq_candle_asset_tf_time"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
asset: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
timeframe: Mapped[str] = mapped_column(String(4), nullable=False)
time: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
open: Mapped[float] = mapped_column(Float, nullable=False)
high: Mapped[float] = mapped_column(Float, nullable=False)
low: Mapped[float] = mapped_column(Float, nullable=False)
close: Mapped[float] = mapped_column(Float, nullable=False)
volume: Mapped[float] = mapped_column(Float, nullable=False)
class BotTrade(Base):
__tablename__ = "bot_trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
asset: Mapped[str] = mapped_column(String(8), nullable=False)
side: Mapped[str] = mapped_column(String(8), nullable=False)
entry_price: Mapped[float] = mapped_column(Float, nullable=False)
exit_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
pnl_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
hold_seconds: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
trigger_post_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("posts.id"), nullable=True, index=True
)
wallet_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
hl_order_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
trigger_post: Mapped[Optional["Post"]] = relationship("Post", back_populates="trades")
class Subscription(Base):
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
wallet_address: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
hl_api_key: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
subscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
+77
View File
@@ -0,0 +1,77 @@
from typing import Optional
from pydantic import BaseModel
class PriceImpact(BaseModel):
asset: str
m5: float
m15: float
m1h: float
price_at_post: float
class TrumpPost(BaseModel):
id: int
text: str
source: str
published_at: str # ISO
sentiment: str
signal: Optional[str] = None # buy | sell | short | hold
ai_confidence: int
ai_reasoning: Optional[str] = None
relevant: bool
price_impact: Optional[PriceImpact] = None
model_config = {"from_attributes": True}
class Candle(BaseModel):
time: int
open: float
high: float
low: float
close: float
volume: float
class BotTrade(BaseModel):
id: int
asset: str
side: str
entry_price: float
exit_price: float
pnl_usd: float
hold_seconds: int
trigger_post_id: int
opened_at: str
closed_at: str
model_config = {"from_attributes": True}
class BotPerformance(BaseModel):
period_days: int
total_trades: int
win_rate: float
net_pnl_usd: float
avg_hold_seconds: float
max_drawdown_pct: float
class SubscribeRequest(BaseModel):
wallet: str
signature: str
class SubscribeResponse(BaseModel):
status: str
wallet: str
class UserResponse(BaseModel):
wallet_address: str
active: bool
subscribed_at: Optional[str] = None
hl_api_key_set: bool
trades: list[BotTrade]
View File
+202
View File
@@ -0,0 +1,202 @@
"""
Trump Truth Social scraper — CNN public archive
Source: https://ix.cnn.io/data/truth-social/truth_archive.json
Updated every ~5 minutes by CNN.
"""
import hashlib
import html
import logging
import re
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Post
from app.services.analysis import analyze_post
from app.services.price_store import price_store
from app.ws.manager import manager
logger = logging.getLogger(__name__)
ARCHIVE_URL = "https://ix.cnn.io/data/truth-social/truth_archive.json"
def _strip_html(text: str) -> str:
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
return re.sub(r"\s+", " ", text).strip()
def _parse_dt(iso: str) -> datetime:
try:
return datetime.fromisoformat(iso.replace("Z", "+00:00")).replace(tzinfo=None)
except Exception:
return datetime.now(timezone.utc).replace(tzinfo=None)
async def _fetch_archive() -> Optional[list]:
headers = {
"User-Agent": "Mozilla/5.0 (compatible; TrumpSignal/1.0)",
"Accept": "application/json",
}
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(ARCHIVE_URL, headers=headers)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.error("Failed to fetch CNN archive: %s", exc)
return None
async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
external_id = hashlib.md5(str(entry["id"]).encode()).hexdigest()
result = await db.execute(select(Post).where(Post.external_id == external_id))
if result.scalar_one_or_none():
return None
text = _strip_html(entry.get("content") or "").strip()
if not text:
return None
published_at = _parse_dt(entry.get("created_at", ""))
analysis = await analyze_post(text)
asset = analysis["asset"]
price_impact_m5 = price_impact_m15 = price_impact_m1h = price_at_post = None
if asset and analysis["relevant"]:
price_at_post = price_store.get_price_at(asset, published_at)
price_impact_m5 = price_store.get_pct_change(asset, published_at, 5)
price_impact_m15 = price_store.get_pct_change(asset, published_at, 15)
price_impact_m1h = price_store.get_pct_change(asset, published_at, 60)
post = Post(
external_id=external_id,
text=text,
source="truth",
published_at=published_at,
sentiment=analysis["sentiment"],
signal=analysis.get("signal"),
ai_confidence=analysis["confidence"],
ai_reasoning=analysis.get("reasoning"),
relevant=analysis["relevant"],
price_impact_asset=asset if analysis["relevant"] else None,
price_impact_m5=price_impact_m5,
price_impact_m15=price_impact_m15,
price_impact_m1h=price_impact_m1h,
price_at_post=price_at_post,
)
db.add(post)
await db.flush()
return post
def _post_to_ws_payload(post: Post) -> dict:
price_impact = None
if post.price_impact_asset and post.price_at_post is not None:
price_impact = {
"asset": post.price_impact_asset,
"m5": post.price_impact_m5 or 0.0,
"m15": post.price_impact_m15 or 0.0,
"m1h": post.price_impact_m1h or 0.0,
"price_at_post": post.price_at_post,
}
return {
"type": "new_post",
"post": {
"id": post.id,
"text": post.text,
"source": post.source,
"published_at": post.published_at.isoformat(),
"sentiment": post.sentiment,
"ai_confidence": post.ai_confidence,
"relevant": post.relevant,
"price_impact": price_impact,
},
}
async def poll_truth_social(db_session_factory) -> None:
logger.info("Polling CNN Truth Social archive...")
entries = await _fetch_archive()
if not entries:
return
# Only process the latest 50 entries each poll (archive has 30k+ posts)
recent = entries[:50]
logger.info("Checking %d recent entries...", len(recent))
async with db_session_factory() as db:
try:
new_posts = []
for entry in recent:
try:
post = await _process_entry(entry, db)
if post:
new_posts.append(post)
except Exception as exc:
logger.error("Error processing entry %s: %s", entry.get("id"), exc)
if new_posts:
await db.commit()
for post in new_posts:
await manager.broadcast(_post_to_ws_payload(post))
logger.info("Saved new post id=%d: %s", post.id, post.text[:60])
try:
from app.services.bot_engine import process_post
await process_post(post, db)
except Exception as exc:
logger.error("process_post failed for post %d: %s", post.id, exc)
else:
logger.info("No new posts found.")
except Exception as exc:
logger.error("Transaction error: %s", exc)
await db.rollback()
async def backfill_history(db_session_factory, limit: int = 500) -> None:
"""One-time backfill of historical posts (no Claude analysis, no price impact)."""
logger.info("Starting historical backfill (limit=%d)...", limit)
entries = await _fetch_archive()
if not entries:
logger.error("Backfill failed: could not fetch archive")
return
to_process = entries[:limit]
saved = 0
async with db_session_factory() as db:
try:
for entry in to_process:
external_id = hashlib.md5(str(entry["id"]).encode()).hexdigest()
result = await db.execute(select(Post).where(Post.external_id == external_id))
if result.scalar_one_or_none():
continue
text = _strip_html(entry.get("content") or "").strip()
if not text:
continue
post = Post(
external_id=external_id,
text=text,
source="truth",
published_at=_parse_dt(entry.get("created_at", "")),
sentiment="neutral",
ai_confidence=0,
relevant=False,
)
db.add(post)
saved += 1
await db.commit()
logger.info("Backfill complete: saved %d posts", saved)
except Exception as exc:
logger.error("Backfill error: %s", exc)
await db.rollback()
View File
+125
View File
@@ -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)
+102
View File
@@ -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)
+131
View File
@@ -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)
+176
View File
@@ -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 []
+144
View File
@@ -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)
+138
View File
@@ -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()
View File
+39
View File
@@ -0,0 +1,39 @@
import json
import logging
from typing import List
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active_connections.append(ws)
logger.info("WebSocket connected. Total connections: %d", len(self.active_connections))
async def disconnect(self, ws: WebSocket):
if ws in self.active_connections:
self.active_connections.remove(ws)
logger.info("WebSocket disconnected. Total connections: %d", len(self.active_connections))
async def broadcast(self, message: dict):
if not self.active_connections:
return
payload = json.dumps(message)
dead: List[WebSocket] = []
for connection in self.active_connections:
try:
await connection.send_text(payload)
except Exception as exc:
logger.warning("Failed to send to WebSocket: %s", exc)
dead.append(connection)
for ws in dead:
await self.disconnect(ws)
manager = ConnectionManager()