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
+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],
)