first commit
This commit is contained in:
@@ -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 分钟"}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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],
|
||||
)
|
||||
Reference in New Issue
Block a user