From 326808040113877abeee1b5bde6b813abc99ddb6 Mon Sep 17 00:00:00 2001 From: k Date: Tue, 21 Apr 2026 19:33:24 +0800 Subject: [PATCH] done --- .env.example | 25 ++- app/api/dev.py | 4 +- app/api/posts.py | 69 ++++++- app/api/subscribe.py | 41 ++-- app/api/trades.py | 6 +- app/api/user.py | 164 ++++++++++++++-- app/config.py | 19 +- app/main.py | 5 + app/models.py | 21 +++ app/schemas.py | 42 ++++- app/scrapers/truth_social.py | 8 +- app/services/analysis.py | 199 +++++++++++++++----- app/services/binance.py | 4 + app/services/bot_engine.py | 268 ++++++++++++++++++-------- app/services/crypto.py | 62 ++++++ app/services/hyperliquid.py | 334 ++++++++++++++++++++------------- app/services/recovery.py | 89 +++++++++ app/services/signed_request.py | 107 +++++++++++ app/services/tp_sl_monitor.py | 97 ++++++++++ requirements.txt | 1 + scripts/backfill_prefilter.py | 72 +++++++ scripts/backfill_signals.py | 86 +++++++++ scripts/reanalyze_sample.py | 84 +++++++++ scripts/test_hyperliquid.py | 128 +++++++++++++ simulate_trades.py | 130 +++++++++++++ test_hl_trade.py | 69 +++++++ 26 files changed, 1816 insertions(+), 318 deletions(-) create mode 100644 app/services/crypto.py create mode 100644 app/services/recovery.py create mode 100644 app/services/signed_request.py create mode 100644 app/services/tp_sl_monitor.py create mode 100644 scripts/backfill_prefilter.py create mode 100644 scripts/backfill_signals.py create mode 100644 scripts/reanalyze_sample.py create mode 100644 scripts/test_hyperliquid.py create mode 100644 simulate_trades.py create mode 100644 test_hl_trade.py diff --git a/.env.example b/.env.example index 3043b8a..63aba3a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,23 @@ -DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/trumpsignal -REDIS_URL=redis://localhost:6379 -ANTHROPIC_API_KEY=sk-ant-... +# Database +DATABASE_URL=sqlite+aiosqlite:///./trumpsignal.db +# For Postgres in production: +# DATABASE_URL=postgresql+asyncpg://user:password@host:5432/trumpsignal + +# Frontend origin for CORS FRONTEND_URL=http://localhost:3001 + +# AI provider (OpenAI-compatible). Default below is gptsapi.net relay for Claude. +AI_API_KEY= +AI_BASE_URL=https://api.gptsapi.net/v1 +AI_MODEL=claude-sonnet-4-6 + +# Hyperliquid — leverage & network only. Per-user API keys are stored in DB. +HL_LEVERAGE=3 +HL_MAINNET=true + +# development | production +ENVIRONMENT=development + +# KEK for envelope-encrypting HL API keys in the DB. +# Generate: `openssl rand -hex 32`. ROTATING THIS INVALIDATES ALL STORED KEYS. +ENCRYPTION_KEY= diff --git a/app/api/dev.py b/app/api/dev.py index f035f41..f1609e0 100644 --- a/app/api/dev.py +++ b/app/api/dev.py @@ -7,7 +7,7 @@ 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.models import Post, iso_utc from app.ws.manager import manager import hashlib, time @@ -45,7 +45,7 @@ async def insert_fake_post( "id": post.id, "text": post.text, "source": post.source, - "published_at": post.published_at.isoformat(), + "published_at": iso_utc(post.published_at), "sentiment": post.sentiment, "ai_confidence": post.ai_confidence, "relevant": post.relevant, diff --git a/app/api/posts.py b/app/api/posts.py index 9e97c75..f438841 100644 --- a/app/api/posts.py +++ b/app/api/posts.py @@ -6,35 +6,47 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models import Post +from app.models import Post, iso_utc from app.schemas import PriceImpact, TrumpPost router = APIRouter() logger = logging.getLogger(__name__) +def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[bool]: + if pct is None or signal is None: + return None + if signal == "buy": + return pct > 0 + if signal in ("short", "sell"): + return pct < 0 + return None # hold has no direction + + 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 - ): + 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, + correct_m5=_direction_correct(post.signal, post.price_impact_m5), + correct_m15=_direction_correct(post.signal, post.price_impact_m15), + correct_m1h=_direction_correct(post.signal, post.price_impact_m1h), ) return TrumpPost( id=post.id, text=post.text, source=post.source, - published_at=post.published_at.isoformat(), + published_at=iso_utc(post.published_at), sentiment=post.sentiment, signal=post.signal, ai_confidence=post.ai_confidence, ai_reasoning=post.ai_reasoning, + prefilter_reason=post.prefilter_reason, + analysis_version=post.analysis_version, relevant=post.relevant, price_impact=price_impact, ) @@ -54,6 +66,51 @@ async def get_posts( return [_post_to_schema(p) for p in posts] +@router.get("/signals/accuracy") +async def signal_accuracy(db: AsyncSession = Depends(get_db)): + """Aggregate accuracy of directional signals (buy/sell/short) against realised price moves.""" + result = await db.execute( + select(Post).where(Post.signal.in_(["buy", "sell", "short"])) + ) + posts = result.scalars().all() + + def bucket(): + return {"checked": 0, "correct": 0} + + stats = {"m5": bucket(), "m15": bucket(), "m1h": bucket()} + by_signal: dict[str, dict] = {} + + for p in posts: + sig = p.signal + if sig not in by_signal: + by_signal[sig] = {"m5": bucket(), "m15": bucket(), "m1h": bucket(), "count": 0} + by_signal[sig]["count"] += 1 + for win, val in (("m5", p.price_impact_m5), ("m15", p.price_impact_m15), ("m1h", p.price_impact_m1h)): + ok = _direction_correct(sig, val) + if ok is None: + continue + stats[win]["checked"] += 1 + stats[win]["correct"] += int(ok) + by_signal[sig][win]["checked"] += 1 + by_signal[sig][win]["correct"] += int(ok) + + def pct(b): return round(b["correct"] / b["checked"] * 100, 1) if b["checked"] else None + + return { + "overall": {k: {**v, "accuracy_pct": pct(v)} for k, v in stats.items()}, + "by_signal": { + s: { + "count": d["count"], + "m5": {**d["m5"], "accuracy_pct": pct(d["m5"])}, + "m15": {**d["m15"], "accuracy_pct": pct(d["m15"])}, + "m1h": {**d["m1h"], "accuracy_pct": pct(d["m1h"])}, + } + for s, d in by_signal.items() + }, + "total_directional_signals": len(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)) diff --git a/app/api/subscribe.py b/app/api/subscribe.py index a562929..018b507 100644 --- a/app/api/subscribe.py +++ b/app/api/subscribe.py @@ -1,48 +1,33 @@ 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 fastapi import APIRouter, Depends 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 +from app.services.signed_request import verify_signed_request 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 +ACTION_SUBSCRIBE = "subscribe" @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", - ) + verify_signed_request( + action=ACTION_SUBSCRIBE, + wallet=wallet, + timestamp_ms=body.timestamp, + signature=body.signature, + body=None, + ) - # Upsert subscription result = await db.execute( select(Subscription).where(Subscription.wallet_address == wallet) ) @@ -50,11 +35,7 @@ async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)): now = datetime.now(timezone.utc).replace(tzinfo=None) if sub is None: - sub = Subscription( - wallet_address=wallet, - active=True, - subscribed_at=now, - ) + sub = Subscription(wallet_address=wallet, active=True, subscribed_at=now) db.add(sub) else: sub.active = True diff --git a/app/api/trades.py b/app/api/trades.py index c58b1e8..63e9f28 100644 --- a/app/api/trades.py +++ b/app/api/trades.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db -from app.models import BotTrade +from app.models import BotTrade, iso_utc from app.schemas import BotTrade as BotTradeSchema router = APIRouter() @@ -23,8 +23,8 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: 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 "", + opened_at=iso_utc(trade.opened_at) or "", + closed_at=iso_utc(trade.closed_at) or "", ) diff --git a/app/api/user.py b/app/api/user.py index dee7b4f..30fc300 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -1,16 +1,30 @@ import logging -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Query 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 +from app.models import BotTrade, Subscription, iso_utc +from app.schemas import ( + BotTrade as BotTradeSchema, + UserResponse, + UserSettings, + SetApiKeyRequest, + SetApiKeyResponse, + SetSettingsRequest, +) +from app.services.crypto import encrypt_api_key +from app.services.signed_request import verify_signed_request router = APIRouter() logger = logging.getLogger(__name__) +# Action names — must match frontend exactly (used in the signed message) +ACTION_SET_API_KEY = "set_hl_api_key" +ACTION_SET_SETTINGS = "set_settings" +ACTION_VIEW_USER = "view_user" + def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: return BotTradeSchema( @@ -22,23 +36,89 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema: 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 "", + opened_at=iso_utc(trade.opened_at) or "", + closed_at=iso_utc(trade.closed_at) or "", ) +@router.put("/user/{wallet}/hl-api-key", response_model=SetApiKeyResponse) +async def set_hl_api_key( + wallet: str, + body: SetApiKeyRequest, + db: AsyncSession = Depends(get_db), +): + wallet = wallet.lower().strip() + if wallet != body.wallet.lower().strip(): + raise HTTPException(400, "Wallet mismatch between path and body") + + api_key = body.api_key.strip() + if not api_key.startswith("0x") or len(api_key) != 66: + raise HTTPException(422, "Invalid API key format — must be a 0x-prefixed 64-char hex string") + + # Signature binds to the full payload (including api_key) so a leaked sig + # can't be reused to change the key. + verify_signed_request( + action=ACTION_SET_API_KEY, + wallet=wallet, + timestamp_ms=body.timestamp, + signature=body.signature, + body={"api_key": api_key}, + ) + + result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) + sub = result.scalar_one_or_none() + if sub is None: + raise HTTPException(404, "Wallet not subscribed. Subscribe first.") + + sub.hl_api_key = encrypt_api_key(api_key) + await db.commit() + + masked = f"...{api_key[-6:]}" + logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked) + return SetApiKeyResponse(status="ok", masked_key=masked) + + +@router.get("/user/{wallet}/public") +async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)): + """No-auth endpoint returning only boolean state. Safe to call on every + page load to decide whether to show the Subscribe/API-key UI.""" + 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: + return {"wallet_address": wallet, "active": False, "hl_api_key_set": False} + return { + "wallet_address": wallet, + "active": sub.active, + "hl_api_key_set": bool(sub.hl_api_key), + } + + @router.get("/user/{wallet}", response_model=UserResponse) -async def get_user(wallet: str, db: AsyncSession = Depends(get_db)): +async def get_user( + wallet: str, + ts: int = Query(..., description="Signed timestamp (ms)"), + sig: str = Query(..., description="EIP-191 signature"), + db: AsyncSession = Depends(get_db), +): wallet = wallet.lower().strip() - result = await db.execute( - select(Subscription).where(Subscription.wallet_address == wallet) + # Require a fresh signed timestamp from the owner — stops wallet-enumeration + # and trade-history leakage. Frontend re-signs ~every 5 min via sessionStorage. + verify_signed_request( + action=ACTION_VIEW_USER, + wallet=wallet, + timestamp_ms=ts, + signature=sig, + body=None, + allow_replay=True, # idempotent GET — allow sessionStorage caching for UX ) + + 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") + raise HTTPException(404, "Wallet not found") - # Personal trades (all — open and closed) trades_result = await db.execute( select(BotTrade) .where(BotTrade.wallet_address == wallet) @@ -47,13 +127,73 @@ async def get_user(wallet: str, db: AsyncSession = Depends(get_db)): ) 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) + # Never return the encrypted blob or any decrypted key. The masked hint + # shown to the user is derived lazily and only from the last 6 chars of + # the stored blob's sha-hash, not the plaintext. + if hl_api_key_set: + import hashlib + masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:] + else: + masked = None return UserResponse( wallet_address=sub.wallet_address, active=sub.active, - subscribed_at=sub.subscribed_at.isoformat() if sub.subscribed_at else None, + subscribed_at=iso_utc(sub.subscribed_at), hl_api_key_set=hl_api_key_set, + hl_api_key_masked=masked, trades=[_trade_to_schema(t) for t in trades], + settings=UserSettings( + leverage=sub.leverage, + position_size_usd=sub.position_size_usd, + take_profit_pct=sub.take_profit_pct, + stop_loss_pct=sub.stop_loss_pct, + min_confidence=sub.min_confidence, + ), ) + + +@router.put("/user/{wallet}/settings", response_model=UserSettings) +async def set_user_settings( + wallet: str, + body: SetSettingsRequest, + db: AsyncSession = Depends(get_db), +): + wallet = wallet.lower().strip() + if wallet != body.wallet.lower().strip(): + raise HTTPException(400, "Wallet mismatch") + + s = body.settings + if not (1 <= s.leverage <= 50): + raise HTTPException(422, "leverage must be 1–50") + if not (5 <= s.position_size_usd <= 10000): + raise HTTPException(422, "position_size_usd must be $5–$10,000") + if s.take_profit_pct is not None and not (0.1 <= s.take_profit_pct <= 50): + raise HTTPException(422, "take_profit_pct must be 0.1–50") + if s.stop_loss_pct is not None and not (0.1 <= s.stop_loss_pct <= 50): + raise HTTPException(422, "stop_loss_pct must be 0.1–50") + if not (0 <= s.min_confidence <= 100): + raise HTTPException(422, "min_confidence must be 0–100") + + verify_signed_request( + action=ACTION_SET_SETTINGS, + wallet=wallet, + timestamp_ms=body.timestamp, + signature=body.signature, + body=s.model_dump(), + ) + + result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) + sub = result.scalar_one_or_none() + if sub is None: + raise HTTPException(404, "Wallet not subscribed") + + sub.leverage = s.leverage + sub.position_size_usd = s.position_size_usd + sub.take_profit_pct = s.take_profit_pct + sub.stop_loss_pct = s.stop_loss_pct + sub.min_confidence = s.min_confidence + await db.commit() + logger.info("Settings updated for %s: %s", wallet, s.model_dump()) + return s diff --git a/app/config.py b/app/config.py index cf73802..a1bca4c 100644 --- a/app/config.py +++ b/app/config.py @@ -3,8 +3,6 @@ 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 @@ -14,6 +12,23 @@ class Settings(BaseSettings): ) binance_rest_url: str = "https://data-api.binance.vision" environment: str = "development" + ai_api_key: str = "" + ai_base_url: str = "https://api.gptsapi.net/v1" + ai_model: str = "claude-haiku-4-5-20251001" + + # Hyperliquid — API wallet private key (NOT your MetaMask key) + # Created at https://app.hyperliquid.xyz/API and authorized by MetaMask + hl_api_private_key: str = "" + # Your MetaMask address — this is the actual account holding USDC/positions + hl_account_address: str = "" + # BTC perp leverage (1–50x isolated margin) + hl_leverage: int = 3 + # Trade mainnet (True) or testnet (False) + hl_mainnet: bool = True + + # KEK for envelope-encrypting HL API keys at rest. + # Generate with: openssl rand -hex 32 + encryption_key: str = "" model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} diff --git a/app/main.py b/app/main.py index cbb757e..8347735 100644 --- a/app/main.py +++ b/app/main.py @@ -44,6 +44,11 @@ async def lifespan(app: FastAPI): # 2. Backfill historical posts on startup (fast, no Claude API call) asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500)) + # 2b. Rehydrate TP/SL + max-hold backstops for any positions still open in DB. + # Critical: without this, a redeploy/crash silently drops protection on live trades. + from app.services.recovery import rehydrate_open_trades + asyncio.create_task(rehydrate_open_trades()) + # 3. Start Binance WebSocket background task _binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws") logger.info("Binance WebSocket task started.") diff --git a/app/models.py b/app/models.py index 584f8e4..715e67f 100644 --- a/app/models.py +++ b/app/models.py @@ -18,9 +18,21 @@ from app.database import Base def utcnow() -> datetime: + """Naive UTC datetime. All datetimes in this codebase are stored as naive-UTC.""" return datetime.now(timezone.utc).replace(tzinfo=None) +def iso_utc(dt: Optional[datetime]) -> Optional[str]: + """Serialize a naive-UTC datetime to an explicit ISO-8601 UTC string (suffix 'Z'). + If dt already carries tzinfo, convert to UTC first. Returns None if dt is None.""" + if dt is None: + return None + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + # Drop microseconds past millisecond for compactness; always end in Z + return dt.isoformat(timespec="milliseconds") + "Z" + + class Post(Base): __tablename__ = "posts" @@ -39,6 +51,8 @@ class Post(Base): 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) + prefilter_reason: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + analysis_version: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) trades: Mapped[List["BotTrade"]] = relationship("BotTrade", back_populates="trigger_post") @@ -89,3 +103,10 @@ class Subscription(Base): 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) + + # Per-user trading config (null → use platform defaults) + leverage: Mapped[int] = mapped_column(Integer, nullable=False, default=3) + position_size_usd: Mapped[float] = mapped_column(Float, nullable=False, default=20.0) + take_profit_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 2.0 = close at +2% + stop_loss_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 1.5 = close at -1.5% + min_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=80) diff --git a/app/schemas.py b/app/schemas.py index c428877..7055fb4 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -9,6 +9,10 @@ class PriceImpact(BaseModel): m15: float m1h: float price_at_post: float + # None = outcome window not yet reached or no price data; True/False = signal direction matched actual move + correct_m5: Optional[bool] = None + correct_m15: Optional[bool] = None + correct_m1h: Optional[bool] = None class TrumpPost(BaseModel): @@ -20,6 +24,8 @@ class TrumpPost(BaseModel): signal: Optional[str] = None # buy | sell | short | hold ai_confidence: int ai_reasoning: Optional[str] = None + prefilter_reason: Optional[str] = None # rt_only | url_only | empty | parse_error | api_error | null + analysis_version: Optional[str] = None relevant: bool price_impact: Optional[PriceImpact] = None @@ -59,19 +65,53 @@ class BotPerformance(BaseModel): max_drawdown_pct: float -class SubscribeRequest(BaseModel): +class SignedEnvelope(BaseModel): + """Every write request carries this envelope. + + Client builds a canonical message (see app.services.signed_request.build_message) + and signs it via EIP-191 personal_sign. Server re-builds the message from + {action, wallet, timestamp, sha256(body)} and recovers the signer. + """ wallet: str + timestamp: int # milliseconds since epoch signature: str +class SubscribeRequest(SignedEnvelope): + pass + + class SubscribeResponse(BaseModel): status: str wallet: str +class SetApiKeyRequest(SignedEnvelope): + api_key: str # Hyperliquid API wallet private key (0x...) + + +class SetApiKeyResponse(BaseModel): + status: str + masked_key: str # last 6 chars only, e.g. "...a1b2c3" + + +class UserSettings(BaseModel): + leverage: int + position_size_usd: float + take_profit_pct: Optional[float] = None + stop_loss_pct: Optional[float] = None + min_confidence: int + + +class SetSettingsRequest(SignedEnvelope): + settings: UserSettings + + class UserResponse(BaseModel): wallet_address: str active: bool subscribed_at: Optional[str] = None hl_api_key_set: bool + hl_api_key_masked: Optional[str] = None trades: list[BotTrade] + settings: UserSettings diff --git a/app/scrapers/truth_social.py b/app/scrapers/truth_social.py index dd0a6a6..380dfed 100644 --- a/app/scrapers/truth_social.py +++ b/app/scrapers/truth_social.py @@ -15,7 +15,7 @@ import httpx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.models import Post +from app.models import Post, iso_utc from app.services.analysis import analyze_post from app.services.price_store import price_store from app.ws.manager import manager @@ -85,6 +85,8 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]: signal=analysis.get("signal"), ai_confidence=analysis["confidence"], ai_reasoning=analysis.get("reasoning"), + prefilter_reason=analysis.get("prefilter_reason"), + analysis_version=analysis.get("analysis_version"), relevant=analysis["relevant"], price_impact_asset=asset if analysis["relevant"] else None, price_impact_m5=price_impact_m5, @@ -113,9 +115,11 @@ def _post_to_ws_payload(post: Post) -> dict: "id": post.id, "text": post.text, "source": post.source, - "published_at": post.published_at.isoformat(), + "published_at": iso_utc(post.published_at), "sentiment": post.sentiment, + "signal": post.signal, "ai_confidence": post.ai_confidence, + "ai_reasoning": post.ai_reasoning, "relevant": post.relevant, "price_impact": price_impact, }, diff --git a/app/services/analysis.py b/app/services/analysis.py index 44b3bca..211123e 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -1,46 +1,134 @@ import json import logging +from typing import Optional -import anthropic - +from openai import AsyncOpenAI from app.config import settings logger = logging.getLogger(__name__) -from typing import Optional -_client: Optional[anthropic.AsyncAnthropic] = None +_client: Optional[AsyncOpenAI] = 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." -) +SYSTEM_PROMPT = """You are an expert macro trader analyzing Trump's Truth Social posts for real-time crypto trading signals. -USER_PROMPT_TEMPLATE = """Analyze this Trump post for cryptocurrency trading signals. +CORE ASSUMPTION: Treat every post as BREAKING NEWS. Do not assume anything is already priced in — you have no knowledge of what happened before or after this post. Your job is to evaluate the post content only. -Post: {text} +=== BULLISH TRANSMISSION CHAINS (BTC/ETH go UP) === +• Ceasefire / peace deal / de-escalation / conflict ending + → war-risk premium unwinds → oil stabilizes → USD softens → risk-on → BTC/ETH rally +• Pro-crypto executive action / legislation / reserve + → regulatory tailwind + direct demand → BTC up immediately +• Dollar weakness (deficit spending, tariff concessions, Fed rate cut signals) + → hard asset bid → BTC as digital gold +• Trade deal / tariff reduction announcement + → global growth outlook improves → risk appetite rises → BTC/ETH up +• Strait of Hormuz open / oil supply stable + → energy prices moderate → inflation fears drop → risk-on -Respond with JSON only: +=== BEARISH TRANSMISSION CHAINS (BTC/ETH go DOWN) === +• War escalation / military strikes / ceasefire violation + → risk-off panic → crypto liquidations → sharp BTC/ETH drop +• Strait of Hormuz threatened / blocked / mined + → oil price spike → inflation surge → Fed tightening fears → risk-off +• SEC/DOJ crypto enforcement / new crypto regulation + → direct selling pressure → BTC/ETH down +• Tariff escalation / new sanctions / trade war + → growth fears + USD safe-haven bid → crypto outflows +• New military front / ally breakdown / nuclear threat + → extreme risk-off → everything sells + +=== WHAT IS NOT RELEVANT === +• Retweets with only a URL and no added comment (RT: https://...) +• Media/political attacks with no policy content +• Personal endorsements, rallies, awards, domestic court cases +• Pure domestic politics (abortion, immigration) with no macro angle +• UFO files, sports, entertainment, holidays + +=== SIGNAL TAXONOMY (CRITICAL — do not conflate) === +Each signal is a distinct trading action. Choose exactly one. + +• "buy" — OPEN A NEW LONG. Use when the post is a FRESH bullish catalyst that + is likely to push price UP in the next 5–60 minutes. Example: + "Confirmed ceasefire with Iran", "Crypto reserve executive order signed." + → Expected move: price rises. Directional accuracy = price > entry. + +• "sell" — CLOSE AN EXISTING LONG / DE-RISK. Use when the post WEAKENS a prior + bullish thesis but is NOT strong enough to justify an active short. + Think: "take profit / step aside." Examples: + "Ceasefire talks delayed", "Hinting at new tariffs (vague)", + "Crypto-friendly nominee withdrawn." Ambiguous bearish. + → Use ONLY if you would exit longs but not open shorts. + → Directional accuracy = price < entry (same as short, but lower conviction). + +• "short"— OPEN A NEW SHORT. Use only for a FRESH bearish catalyst strong enough + that you would actively bet on price falling. Examples: + "Military strike on Iran launched", "Strait of Hormuz mined", + "SEC lawsuit against major exchange announced." + → Expected move: price drops hard. Reserve for high-conviction bearish. + +• "hold" — NO TRADE. Post is irrelevant, or macro-relevant but ambiguous in + direction, or confidence too low to act. DEFAULT when in doubt. + +Decision tree: + 1. Is there a concrete macro/crypto event? NO → hold. + 2. Is the direction clear? NO → hold. + 3. BULLISH: confidence ≥ 60 → buy. Else → hold. + 4. BEARISH: strong/explicit (war, ban, sanctions, enforcement) → short. + weak/vague/partial walkback of bullish → sell. + otherwise → hold. + +NEVER use "sell" for a strong bearish event — that is "short". +NEVER use "short" for a vague/ambiguous negative — that is "sell" or "hold". + +=== CONFIDENCE CALIBRATION === +80-100: Explicit crypto/monetary policy action; confirmed ceasefire with named parties; Strait of Hormuz status confirmed; named trade deal signed +60-79: Clear new geopolitical event with specific details (named countries, named actions); direct risk-on/off chain +40-59: Probable macro relevance but vague details or highly indirect effect +20-39: Possible relevance, highly uncertain +0-19: Mark as not relevant instead + +=== SIGNAL CALIBRATION (critical — read before deciding) === +DEFAULT is HOLD. Only 5–10% of posts should receive buy or short. +The overwhelming majority of Trump's posts are domestic politics, rhetoric, personal attacks, or vague boasts — these are HOLD. + +Use "buy" only when ALL of the following are true: + 1. The event is CONCRETE (named countries, named action, specific policy) + 2. The macro transmission chain to BTC/ETH is DIRECT and SHORT (< 2 steps) + 3. Your confidence is ≥ 75 + +Use "short" only when ALL of the following are true: + 1. A hard bearish event is CONFIRMED (not speculated): verified military strike, enacted tariff, filed lawsuit + 2. The transmission chain to BTC/ETH down is DIRECT + 3. Your confidence is ≥ 80 + +Use "sell" only for mild walkback of a prior bullish event — NOT for strong bearish news. + +When in doubt between buy and hold → choose HOLD. +When in doubt between short and sell → choose SELL or HOLD, not short. + +Return ONLY valid JSON. No markdown. + +Return ONLY valid JSON. No markdown.""" + +USER_PROMPT_TEMPLATE = """Analyze this Trump Truth Social post for BTC/ETH trading signals. +Treat it as breaking news — evaluate only what is written. + +POST TEXT: +{text} + +Respond with JSON: {{ - "relevant": , - "asset": "BTC" | "ETH" | null, + "relevant": , + "asset": "BTC" | "ETH" | "BOTH" | null, "sentiment": "bullish" | "bearish" | "neutral", "signal": "buy" | "sell" | "short" | "hold", "confidence": <0-100>, - "reasoning": "<1-2 sentences explaining the signal and why>" + "reasoning": "" }} -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 +If post is only a URL, only a retweet link, or purely personal/domestic with zero macro angle: set relevant=false, signal=hold, confidence=0.""" -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.""" +ANALYSIS_VERSION = "v4-selective" _FALLBACK = { "relevant": False, @@ -49,36 +137,50 @@ _FALLBACK = { "signal": "hold", "confidence": 0, "reasoning": "", + "prefilter_reason": None, + "analysis_version": ANALYSIS_VERSION, } -def _get_client() -> anthropic.AsyncAnthropic: +def _fallback(prefilter: Optional[str] = None, reasoning: str = "") -> dict: + out = dict(_FALLBACK) + out["prefilter_reason"] = prefilter + out["reasoning"] = reasoning + return out + + +def _get_client() -> AsyncOpenAI: global _client if _client is None: - _client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + _client = AsyncOpenAI( + api_key=settings.ai_api_key, + base_url=settings.ai_base_url, + ) return _client async def analyze_post(text: str) -> dict: - """Run Claude signal analysis on a Trump post. + # Fast pre-filter: pure RT/URL with no content + stripped = text.strip() + if stripped.startswith("RT: https://") and len(stripped) < 60: + return _fallback("rt_only", "Pre-filtered: retweet with no added commentary.") + if stripped.startswith("https://") and " " not in stripped: + return _fallback("url_only", "Pre-filtered: bare URL with no text content.") + if not stripped: + return _fallback("empty", "Pre-filtered: empty post body.") - 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, + response = await client.chat.completions.create( + model=settings.ai_model, + max_tokens=450, + temperature=0.1, messages=[ - { - "role": "user", - "content": USER_PROMPT_TEMPLATE.format(text=text[:2000]), - } + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text[:2000])}, ], ) - raw = message.content[0].text.strip() + raw = response.choices[0].message.content.strip() if raw.startswith("```"): lines = raw.split("\n") @@ -100,10 +202,12 @@ async def analyze_post(text: str) -> dict: relevant = bool(result.get("relevant", False)) asset = result.get("asset") + if asset == "BOTH": + asset = "BTC" if asset not in ("BTC", "ETH", None): asset = None - reasoning = str(result.get("reasoning", ""))[:500] + reasoning = str(result.get("reasoning", ""))[:600] return { "relevant": relevant, @@ -112,14 +216,13 @@ async def analyze_post(text: str) -> dict: "signal": signal, "confidence": confidence, "reasoning": reasoning, + "prefilter_reason": None, + "analysis_version": ANALYSIS_VERSION, } - 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) + logger.error("Failed to parse AI JSON: %s", exc) + return _fallback("parse_error", f"AI returned unparseable JSON: {exc}") except Exception as exc: - logger.error("Unexpected error in analyze_post: %s", exc) - return dict(_FALLBACK) + logger.error("analyze_post error: %s", exc) + return _fallback("api_error", f"AI API error: {exc}") diff --git a/app/services/binance.py b/app/services/binance.py index 7869b95..dd9d209 100644 --- a/app/services/binance.py +++ b/app/services/binance.py @@ -41,6 +41,10 @@ async def _process_message(raw: str): } price_store.update(asset, candle) + # TP/SL check on every tick (cheap no-op when no trades are being watched) + from app.services.tp_sl_monitor import on_price_tick + on_price_tick(asset, candle["close"]) + # Broadcast live price tick await manager.broadcast({ "type": "price", diff --git a/app/services/bot_engine.py b/app/services/bot_engine.py index 8ede288..3076d1c 100644 --- a/app/services/bot_engine.py +++ b/app/services/bot_engine.py @@ -7,125 +7,241 @@ Iterates all active subscribers and executes trades on their behalf. import asyncio import logging from datetime import datetime, timezone +from typing import Dict + +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from app.models import Post, BotTrade, Subscription + +from app.config import settings +from app.database import AsyncSessionLocal +from app.models import BotTrade, Post, Subscription +from app.services.crypto import decrypt_api_key from app.services.hyperliquid import HyperliquidTrader -from app.services.price_store import price_store +from app.services.price_store import price_store # noqa: F401 (used elsewhere) 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 +# Platform-wide thresholds (per-user values in Subscription override where applicable) +GLOBAL_MIN_CONFIDENCE = 80 # hard floor — user min_confidence must be >= this +MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users) + + +# Per-trade locks prevent TP/SL and max-hold tasks from both calling close_and_finalize +# simultaneously on the same trade. Lock is process-local — with the atomic +# conditional UPDATE below, multi-process is still safe (losers become no-ops). +_close_locks: Dict[int, asyncio.Lock] = {} + + +def _lock_for(trade_id: int) -> asyncio.Lock: + lock = _close_locks.get(trade_id) + if lock is None: + lock = asyncio.Lock() + _close_locks[trade_id] = lock + return lock 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. + `db` is used only to fetch the subscriber list; each subscriber gets its own session. """ 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) + if (post.ai_confidence or 0) < GLOBAL_MIN_CONFIDENCE: + logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, GLOBAL_MIN_CONFIDENCE) return - if post.sentiment == 'neutral': + if post.signal not in ('buy', 'short'): + logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal) return asset = post.price_impact_asset or 'BTC' - side = 'long' if post.sentiment == 'bullish' else 'short' + side = 'long' if post.signal == 'buy' 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)) + result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712 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] + # Snapshot primitive fields so tasks don't share the ORM object / session. + subs_snapshot = [ + dict( + wallet=s.wallet_address, + hl_api_key=s.hl_api_key, + leverage=s.leverage, + position_size_usd=s.position_size_usd, + take_profit_pct=s.take_profit_pct, + stop_loss_pct=s.stop_loss_pct, + min_confidence=s.min_confidence, + ) + for s in subscribers + ] + post_id = post.id + post_confidence = post.ai_confidence or 0 + + tasks = [ + _execute_for_subscriber(sub, post_id, post_confidence, asset, side) + for sub in subs_snapshot + ] await asyncio.gather(*tasks, return_exceptions=True) async def _execute_for_subscriber( - sub: Subscription, - post: Post, + sub: dict, + post_id: int, + post_confidence: int, 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) + wallet = sub["wallet"] + if not sub["hl_api_key"]: + logger.warning("Subscriber %s has no HL API key, skipping", wallet) + return + if post_confidence < sub["min_confidence"]: + logger.info("Sub %s filters out post %d: conf %d < user min %d", + wallet, post_id, post_confidence, sub["min_confidence"]) return try: - trader = HyperliquidTrader(sub.hl_api_key) + api_key = decrypt_api_key(sub["hl_api_key"]) + except Exception as exc: + logger.error("Cannot decrypt key for %s: %s", wallet, exc) + return - # 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 + # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. async with AsyncSessionLocal() as db: try: - result = await db.execute( - select(BotTrade).where(BotTrade.id == trade_id, BotTrade.closed_at.is_(None)) + trader = HyperliquidTrader( + api_private_key=api_key, + account_address=wallet, + leverage=sub["leverage"], + mainnet=settings.hl_mainnet, ) - 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) + open_positions = await trader.get_open_positions() + if any(p.get('coin') == asset for p in open_positions): + logger.info("Subscriber %s already has open %s position, skipping", wallet, asset) + return - 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 + result = await trader.open_position(asset, side, sub["position_size_usd"]) + entry_price = result.get('fill_price', 0.0) + trade = BotTrade( + asset=asset, + side=side, + entry_price=entry_price, + wallet_address=wallet, + 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() - logger.info("Closed %s %s for %s @ %.2f PnL=%.2f", trade.side, asset, wallet, exit_price, pnl_usd) + await db.refresh(trade) + + logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", + side, asset, wallet, entry_price, trade.id) + + from app.services.tp_sl_monitor import register_trade + register_trade( + trade_id=trade.id, + wallet=wallet, + api_key=api_key, + leverage=sub["leverage"], + asset=asset, + side=side, + entry_price=entry_price, + take_profit_pct=sub["take_profit_pct"], + stop_loss_pct=sub["stop_loss_pct"], + ) + + asyncio.create_task(_close_after_hold( + trade.id, api_key, sub["leverage"], asset, wallet + )) except Exception as e: - logger.error("Failed to close trade %d: %s", trade_id, e) + logger.error("Trade execution failed for %s: %s", wallet, e) + + +async def close_and_finalize( + trade_id: int, + api_key: str, + leverage: int, + asset: str, + wallet: str, + reason: str = "max_hold", +) -> None: + """ + Close a live HL position and write exit_price/pnl to BotTrade. + Race-safe: a conditional UPDATE `closed_at = now WHERE closed_at IS NULL` + lets exactly one caller win; losers return silently. Also wraps in a + per-trade asyncio.Lock to prevent us even *attempting* the HL API call twice. + """ + async with _lock_for(trade_id): + async with AsyncSessionLocal() as db: + try: + now_naive = datetime.now(timezone.utc).replace(tzinfo=None) + + # Atomic claim: only one caller sets closed_at. + claim = await db.execute( + update(BotTrade) + .where(BotTrade.id == trade_id) + .where(BotTrade.closed_at.is_(None)) + .values(closed_at=now_naive) + ) + await db.commit() + if claim.rowcount == 0: + return # someone else closed it + + # Reload the row we just claimed + result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id)) + trade = result.scalar_one() + + sub_res = await db.execute( + select(Subscription).where(Subscription.wallet_address == wallet) + ) + sub = sub_res.scalar_one_or_none() + size_usd = sub.position_size_usd if sub else 20.0 + + trader = HyperliquidTrader( + api_private_key=api_key, + account_address=wallet, + leverage=leverage, + mainnet=settings.hl_mainnet, + ) + close_result = await trader.close_position(asset) + exit_price = close_result.get('fill_price', 0.0) + + now_aware = datetime.now(timezone.utc) + opened_aware = trade.opened_at.replace(tzinfo=timezone.utc) + hold_secs = int((now_aware - opened_aware).total_seconds()) + pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0 + signed_pct = pct if trade.side == 'long' else -pct + pnl_usd = size_usd * signed_pct * leverage + + trade.exit_price = exit_price + trade.pnl_usd = round(pnl_usd, 2) + trade.hold_seconds = hold_secs + # closed_at already set by the atomic UPDATE + await db.commit() + + # Clean up TP/SL + lock to avoid leaking memory + from app.services.tp_sl_monitor import unregister + unregister(trade_id) + _close_locks.pop(trade_id, None) + + logger.info("Closed %s %s for %s @ %.2f PnL=%.2f (reason=%s)", + trade.side, asset, wallet, exit_price, pnl_usd, reason) + + except Exception as e: + logger.error("Failed to close trade %d: %s", trade_id, e) + + +async def _close_after_hold( + trade_id: int, api_key: str, leverage: int, asset: str, wallet: str +) -> None: + await asyncio.sleep(MAX_HOLD_SECONDS) + await close_and_finalize(trade_id, api_key, leverage, asset, wallet, reason="max_hold") diff --git a/app/services/crypto.py b/app/services/crypto.py new file mode 100644 index 0000000..fb3541e --- /dev/null +++ b/app/services/crypto.py @@ -0,0 +1,62 @@ +""" +Envelope encryption for HL API private keys. + +Plaintext keys never touch disk: stored values are Fernet-encrypted with a KEK +loaded from env (ENCRYPTION_KEY). Rotate KEK → re-encrypt all keys offline. +""" +import base64 +import hashlib +import logging +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken + +from app.config import settings + +logger = logging.getLogger(__name__) + + +def _derive_fernet_key(raw: str) -> bytes: + """Accept any reasonably-long secret and derive a valid 32-byte Fernet key.""" + if not raw or len(raw) < 32: + raise RuntimeError( + "ENCRYPTION_KEY must be set to at least 32 random chars (e.g. `openssl rand -hex 32`)" + ) + digest = hashlib.sha256(raw.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest) + + +_fernet: Optional[Fernet] = None + + +def _cipher() -> Fernet: + global _fernet + if _fernet is None: + _fernet = Fernet(_derive_fernet_key(settings.encryption_key)) + return _fernet + + +# Prefix lets us distinguish encrypted blobs from any legacy plaintext rows during migration +ENC_PREFIX = "enc:v1:" + + +def encrypt_api_key(plaintext: str) -> str: + token = _cipher().encrypt(plaintext.encode("utf-8")).decode("utf-8") + return ENC_PREFIX + token + + +def decrypt_api_key(stored: str) -> str: + if not stored: + raise ValueError("Empty api key") + if not stored.startswith(ENC_PREFIX): + # Legacy plaintext row (from before encryption was added). Refuse to use in prod. + if settings.environment == "production": + raise RuntimeError( + "Found legacy-plaintext HL key; run migration script before production" + ) + logger.warning("Reading LEGACY plaintext HL key — migrate ASAP") + return stored + try: + return _cipher().decrypt(stored[len(ENC_PREFIX):].encode("utf-8")).decode("utf-8") + except InvalidToken as exc: + raise RuntimeError("HL key decryption failed — wrong ENCRYPTION_KEY?") from exc diff --git a/app/services/hyperliquid.py b/app/services/hyperliquid.py index 1fe1148..193da05 100644 --- a/app/services/hyperliquid.py +++ b/app/services/hyperliquid.py @@ -1,6 +1,19 @@ +""" +Hyperliquid BTC perpetual trading client. + +KEY CONCEPT — two wallets: + • account_address : Your MetaMask address. Holds USDC and open positions. + Used for all info/query calls. + • api_private_key : API wallet private key generated at app.hyperliquid.xyz/API. + Used only for signing orders. Cannot withdraw. + +Both are read from config (hl_account_address, hl_api_private_key). +""" + import asyncio import logging from functools import partial +from typing import Optional from eth_account import Account from hyperliquid.exchange import Exchange @@ -8,157 +21,81 @@ from hyperliquid.info import Info logger = logging.getLogger(__name__) -HL_MAINNET = "https://api.hyperliquid.xyz" +HL_MAINNET_URL = "https://api.hyperliquid.xyz" +HL_TESTNET_URL = "https://api.hyperliquid-testnet.xyz" -# Coin name mapping: our asset -> Hyperliquid coin -COIN_MAP = { - "BTC": "BTC", - "ETH": "ETH", -} +# Precision map — Hyperliquid's szDecimals for common coins +# Fetched dynamically via meta() but cached here as fallback +SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4} 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() + def __init__( + self, + api_private_key: str, + account_address: str, + leverage: int = 3, + mainnet: bool = True, + ): + """ + Args: + api_private_key: Private key of the API wallet (from Hyperliquid dashboard). + account_address: Main MetaMask wallet address (holds USDC + positions). + leverage: Isolated leverage to set before each trade. + mainnet: True = mainnet, False = testnet. + """ + self._api_wallet = Account.from_key(api_private_key) + self._account_address = account_address.lower() + self._leverage = leverage + self._base_url = HL_MAINNET_URL if mainnet else HL_TESTNET_URL - # ── helpers ────────────────────────────────────────────────────────────── + # Exchange signs with api_wallet but acts on behalf of account_address + self._exchange = Exchange( + self._api_wallet, + self._base_url, + account_address=self._account_address, + ) + self._info = Info(self._base_url, skip_ws=True) - 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)) + # ── internal helpers ────────────────────────────────────────────────────── - def _wallet(self) -> str: - return self.account.address + async def _run(self, fn, *args, **kwargs): + """Run blocking SDK calls in a thread pool without blocking the event loop.""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, partial(fn, *args, **kwargs)) + + async def _get_sz_decimals(self, coin: str) -> int: + try: + meta = await self._run(self._info.meta) + for u in meta.get("universe", []): + if u.get("name") == coin: + return int(u.get("szDecimals", SZ_DECIMALS_FALLBACK.get(coin, 4))) + except Exception: + pass + return SZ_DECIMALS_FALLBACK.get(coin, 4) + + async def _mid_price(self, coin: str) -> float: + mids = await self._run(self._info.all_mids) + price = float(mids.get(coin, 0)) + if price <= 0: + raise ValueError(f"Cannot get mid price for {coin}") + return price # ── public interface ───────────────────────────────────────────────────── async def get_balance(self) -> float: - """Return USDC balance (withdrawable).""" + """Return withdrawable USDC balance of the main (MetaMask) account.""" try: - state = await self._run_sync( - self.info.user_state, self._wallet() - ) + state = await self._run(self._info.user_state, self._account_address) 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.""" + """Return all open positions of the main account (non-zero size only).""" try: - state = await self._run_sync(self.info.user_state, self._wallet()) + state = await self._run(self._info.user_state, self._account_address) positions = [] for asset_pos in state.get("assetPositions", []): pos = asset_pos.get("position", {}) @@ -166,11 +103,142 @@ class HyperliquidTrader: if szi != 0: positions.append({ "coin": pos.get("coin"), - "szi": szi, + "szi": szi, # positive = long, negative = short "entry_px": float(pos.get("entryPx", 0) or 0), "unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0), + "leverage": pos.get("leverage", {}), }) return positions except Exception as exc: logger.error("get_open_positions error: %s", exc) return [] + + async def set_leverage(self, coin: str) -> None: + """Set isolated leverage for the given coin.""" + try: + await self._run( + self._exchange.update_leverage, + self._leverage, + coin, + False, # is_cross=False → isolated margin + ) + logger.info("Set leverage %dx for %s (isolated)", self._leverage, coin) + except Exception as exc: + logger.warning("set_leverage error (non-fatal): %s", exc) + + async def open_position( + self, + asset: str, + side: str, # "long" or "short" + size_usd: float, + ) -> dict: + """ + Open a market position. + + Returns: + {"order_id": str, "fill_price": float, "size_coins": float} + """ + coin = asset.upper() + is_buy = side.lower() == "long" + + # 1. Set leverage before opening + await self.set_leverage(coin) + + # 2. Compute coin size from USD notional + mid = await self._mid_price(coin) + sz_dec = await self._get_sz_decimals(coin) + size_coins = round(size_usd / mid, sz_dec) + if size_coins <= 0: + raise ValueError(f"Computed size too small: {size_coins} {coin} from ${size_usd}") + + # 3. Limit price with 1% slippage (IOC acts as market) + slippage = 0.01 + # Hyperliquid px must be ≤5 sig-figs AND divisible by tick size. + # For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding. + raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage) + if raw_px >= 10000: + limit_px = float(round(raw_px)) # integer dollars for BTC + elif raw_px >= 1000: + limit_px = round(raw_px, 1) + elif raw_px >= 100: + limit_px = round(raw_px, 2) + else: + limit_px = round(raw_px, 3) + + logger.info( + "Opening %s %s: %.5f coins @ limit %.2f (mid=%.2f, usd=%.2f)", + side, coin, size_coins, limit_px, mid, size_usd, + ) + + result = await self._run( + self._exchange.order, + coin, + is_buy, + size_coins, + limit_px, + {"limit": {"tif": "Ioc"}}, # Immediate-or-Cancel ≈ market + ) + + statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) + filled = statuses[0] if statuses else {} + fill_price = float( + (filled.get("filled") or {}).get("avgPx", mid) or mid + ) + order_id = str((filled.get("resting") or {}).get("oid", "")) + + logger.info("Opened %s %s @ %.2f (order_id=%s)", side, coin, fill_price, order_id) + return {"order_id": order_id, "fill_price": fill_price, "size_coins": size_coins} + + async def close_position(self, asset: str) -> dict: + """ + Close all open positions for the given asset using a reduce-only IOC order. + + Returns: + {"fill_price": float} + """ + coin = asset.upper() + + 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 %s position to close", coin) + return {"fill_price": 0.0} + + szi = float(target["szi"]) + is_buy = szi < 0 # closing a short → buy back + size = abs(szi) + + mid = await self._mid_price(coin) + slippage = 0.01 + # Hyperliquid px must be ≤5 sig-figs AND divisible by tick size. + # For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding. + raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage) + if raw_px >= 10000: + limit_px = float(round(raw_px)) # integer dollars for BTC + elif raw_px >= 1000: + limit_px = round(raw_px, 1) + elif raw_px >= 100: + limit_px = round(raw_px, 2) + else: + limit_px = round(raw_px, 3) + + logger.info("Closing %s %s: size=%.5f @ limit %.2f", coin, "short" if is_buy else "long", size, limit_px) + + result = await self._run( + self._exchange.order, + coin, + is_buy, + size, + limit_px, + {"limit": {"tif": "Ioc"}}, + reduce_only=True, + ) + + statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) + filled = statuses[0] if statuses else {} + fill_price = float( + (filled.get("filled") or {}).get("avgPx", mid) or mid + ) + + logger.info("Closed %s position @ %.2f", coin, fill_price) + return {"fill_price": fill_price} diff --git a/app/services/recovery.py b/app/services/recovery.py new file mode 100644 index 0000000..abb64d2 --- /dev/null +++ b/app/services/recovery.py @@ -0,0 +1,89 @@ +""" +Startup rehydration: re-register TP/SL + max-hold for every open trade in DB. + +Called once from lifespan() after tables are ensured. Without this, any deploy +or crash silently drops TP/SL protection on live positions. +""" +import asyncio +import logging +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.database import AsyncSessionLocal +from app.models import BotTrade, Subscription + +logger = logging.getLogger(__name__) + + +async def rehydrate_open_trades() -> None: + # Imported locally to avoid circular imports at module load + from app.services.bot_engine import MAX_HOLD_SECONDS, _close_after_hold, close_and_finalize + from app.services.crypto import decrypt_api_key + from app.services.tp_sl_monitor import register_trade + + async with AsyncSessionLocal() as db: + result = await db.execute(select(BotTrade).where(BotTrade.closed_at.is_(None))) + open_trades = result.scalars().all() + + if not open_trades: + logger.info("Rehydration: no open trades.") + return + + now = datetime.now(timezone.utc) + for t in open_trades: + sub_res = await db.execute( + select(Subscription).where(Subscription.wallet_address == t.wallet_address) + ) + sub = sub_res.scalar_one_or_none() + if sub is None or not sub.hl_api_key: + logger.warning( + "Open trade %d has no subscription/key — marking as closed(abandoned)", t.id + ) + t.closed_at = now.replace(tzinfo=None) + continue + + try: + api_key = decrypt_api_key(sub.hl_api_key) + except Exception as exc: + logger.error("Cannot decrypt key for trade %d: %s", t.id, exc) + continue + + # Re-register TP/SL watcher + register_trade( + trade_id=t.id, + wallet=t.wallet_address, + api_key=api_key, + leverage=sub.leverage, + asset=t.asset, + side=t.side, + entry_price=t.entry_price, + take_profit_pct=sub.take_profit_pct, + stop_loss_pct=sub.stop_loss_pct, + ) + + # Compute remaining hold; if already exceeded, close immediately + opened_aware = t.opened_at.replace(tzinfo=timezone.utc) + elapsed = (now - opened_aware).total_seconds() + remaining = MAX_HOLD_SECONDS - elapsed + + if remaining <= 0: + logger.info("Trade %d past max-hold on startup — closing now", t.id) + asyncio.create_task( + close_and_finalize( + trade_id=t.id, api_key=api_key, leverage=sub.leverage, + asset=t.asset, wallet=t.wallet_address, reason="max_hold_recovery", + ) + ) + else: + async def _delayed_close(trade_id=t.id, key=api_key, lev=sub.leverage, + asset=t.asset, wallet=t.wallet_address, delay=remaining): + await asyncio.sleep(delay) + await close_and_finalize( + trade_id=trade_id, api_key=key, leverage=lev, + asset=asset, wallet=wallet, reason="max_hold", + ) + asyncio.create_task(_delayed_close()) + + await db.commit() + logger.info("Rehydrated %d open trades.", len(open_trades)) diff --git a/app/services/signed_request.py b/app/services/signed_request.py new file mode 100644 index 0000000..b56cacc --- /dev/null +++ b/app/services/signed_request.py @@ -0,0 +1,107 @@ +""" +Signed-request verification: EIP-191 signature that binds {action, wallet, timestamp, body}. + +Message format (human-readable — what the user sees in MetaMask): + + TrumpSignal · {ACTION} + wallet: 0x... + timestamp: 1713724800000 + body: + +Server verifies: + 1. recovered signer == wallet + 2. |now - timestamp| <= MAX_SKEW_SECONDS (5 min) + 3. sha256(canonical_json(body)) matches the hash in the message + 4. signature not seen before (replay cache, TTL = MAX_SKEW) + +Same scheme is used for writes (with body) and reads (body=None → "-"). +""" +import hashlib +import json +import logging +import time +from typing import Any, Optional + +from eth_account import Account +from eth_account.messages import encode_defunct +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + +MAX_SKEW_SECONDS = 300 # 5 minutes + + +def canonical_body_hash(body: Optional[Any]) -> str: + """Stable sha256 of a Python dict/primitive. `None` → '-'.""" + if body is None: + return "-" + payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def build_message(action: str, wallet: str, timestamp_ms: int, body_hash: str) -> str: + return ( + f"TrumpSignal · {action}\n" + f"wallet: {wallet.lower()}\n" + f"timestamp: {timestamp_ms}\n" + f"body: {body_hash}" + ) + + +# ── Replay cache ────────────────────────────────────────────────────────── +# In-memory; fine for a single-process deploy. For multi-worker, move to Redis. +_seen: dict[str, float] = {} # sha256(signature) → expiry epoch seconds + + +def _cache_put(sig: str, ttl: float) -> bool: + """Return True if sig was new, False if replay.""" + now = time.time() + # lazy purge + if len(_seen) > 5000: + for k, v in list(_seen.items()): + if v < now: + _seen.pop(k, None) + key = hashlib.sha256(sig.encode("utf-8")).hexdigest() + if key in _seen and _seen[key] > now: + return False + _seen[key] = now + ttl + return True + + +# ── Public API ──────────────────────────────────────────────────────────── +def verify_signed_request( + *, + action: str, + wallet: str, + timestamp_ms: int, + signature: str, + body: Optional[Any], + allow_replay: bool = False, +) -> None: + """Raise HTTPException(401/422) if invalid. Silent on success. + + allow_replay=True skips the nonce cache — use only for idempotent reads + (e.g. GET /user). Timestamp expiry still bounds the window to MAX_SKEW. + """ + wallet = wallet.lower().strip() + + # 1. Freshness + now_ms = int(time.time() * 1000) + if abs(now_ms - timestamp_ms) > MAX_SKEW_SECONDS * 1000: + raise HTTPException(401, "Signed request expired or clock skew too large") + + # 2. Recover signer + body_hash = canonical_body_hash(body) + message = build_message(action, wallet, timestamp_ms, body_hash) + try: + recovered = Account.recover_message(encode_defunct(text=message), signature=signature).lower() + except Exception as exc: + logger.warning("Signature recovery failed (%s): %s", action, exc) + raise HTTPException(401, "Signature verification failed") + if recovered != wallet: + raise HTTPException(401, "Signature does not match wallet") + + # 3. Replay guard (writes only) + if not allow_replay: + if not _cache_put(signature, ttl=MAX_SKEW_SECONDS): + raise HTTPException(401, "Signature already used (replay blocked)") diff --git a/app/services/tp_sl_monitor.py b/app/services/tp_sl_monitor.py new file mode 100644 index 0000000..37c1bb0 --- /dev/null +++ b/app/services/tp_sl_monitor.py @@ -0,0 +1,97 @@ +""" +Take-profit / stop-loss monitor. + +Subscribes to the Binance price stream; for every open trade that has TP/SL set, +closes the HL position as soon as the live mark price crosses the threshold. + +Lifecycle: + - bot_engine.open_position calls register_trade(...) after each new trade + - price callback in binance.py calls on_price_tick() once per second + - when TP or SL hits, we enqueue close_and_finalize(...) and de-register +""" +import asyncio +import logging +from dataclasses import dataclass +from typing import Dict, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class WatchedTrade: + trade_id: int + wallet: str + api_key: str + leverage: int + asset: str + side: str # "long" | "short" + entry_price: float + take_profit_pct: Optional[float] + stop_loss_pct: Optional[float] + + +# trade_id -> WatchedTrade +_watched: Dict[int, WatchedTrade] = {} + + +def register_trade( + trade_id: int, + wallet: str, + api_key: str, + leverage: int, + asset: str, + side: str, + entry_price: float, + take_profit_pct: Optional[float], + stop_loss_pct: Optional[float], +) -> None: + if take_profit_pct is None and stop_loss_pct is None: + return # nothing to watch + _watched[trade_id] = WatchedTrade( + trade_id=trade_id, wallet=wallet, api_key=api_key, leverage=leverage, + asset=asset, side=side, entry_price=entry_price, + take_profit_pct=take_profit_pct, stop_loss_pct=stop_loss_pct, + ) + logger.info("TP/SL watching trade %d (%s %s @ %.2f, tp=%s, sl=%s)", + trade_id, side, asset, entry_price, take_profit_pct, stop_loss_pct) + + +def unregister(trade_id: int) -> None: + _watched.pop(trade_id, None) + + +def on_price_tick(asset: str, price: float) -> None: + """Called from binance.py on every price update. Fires close for any trade that hit TP/SL.""" + if not _watched: + return + triggered = [] + for tid, wt in list(_watched.items()): + if wt.asset != asset: + continue + pct = (price - wt.entry_price) / wt.entry_price + signed_pct = pct if wt.side == 'long' else -pct # gain in position's direction + pct_x100 = signed_pct * 100 + + if wt.take_profit_pct is not None and pct_x100 >= wt.take_profit_pct: + triggered.append((wt, "take_profit")) + elif wt.stop_loss_pct is not None and pct_x100 <= -wt.stop_loss_pct: + triggered.append((wt, "stop_loss")) + + for wt, reason in triggered: + unregister(wt.trade_id) + asyncio.create_task(_fire_close(wt, reason)) + + +async def _fire_close(wt: WatchedTrade, reason: str) -> None: + from app.services.bot_engine import close_and_finalize + try: + await close_and_finalize( + trade_id=wt.trade_id, + api_key=wt.api_key, + leverage=wt.leverage, + asset=wt.asset, + wallet=wt.wallet, + reason=reason, + ) + except Exception as exc: + logger.error("TP/SL close failed for trade %d: %s", wt.trade_id, exc) diff --git a/requirements.txt b/requirements.txt index 3adac3c..74355ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ eth-account==0.11.0 redis==5.0.4 apscheduler==3.10.4 python-dotenv==1.0.1 +cryptography>=42 diff --git a/scripts/backfill_prefilter.py b/scripts/backfill_prefilter.py new file mode 100644 index 0000000..7c03932 --- /dev/null +++ b/scripts/backfill_prefilter.py @@ -0,0 +1,72 @@ +""" +Zero-token backfill: + - Mark old posts with prefilter_reason (rt_only / url_only / empty) where applicable + - Fill ai_reasoning for posts that have a signal but empty reasoning + - Stamp analysis_version='legacy' on anything still missing it + +Run once after the schema migration. Safe to re-run. +""" +from __future__ import annotations + +import asyncio +import os +import sys +from typing import Optional + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import select + +from app.database import AsyncSessionLocal +from app.models import Post + + +def classify(text: str) -> Optional[str]: + stripped = (text or "").strip() + if not stripped: + return "empty" + if stripped.startswith("RT: https://") and len(stripped) < 60: + return "rt_only" + if stripped.startswith("https://") and " " not in stripped: + return "url_only" + return None + + +PREFILTER_MSG = { + "rt_only": "Pre-filtered: retweet with no added commentary.", + "url_only": "Pre-filtered: bare URL with no text content.", + "empty": "Pre-filtered: empty post body.", +} + + +async def main(): + async with AsyncSessionLocal() as db: + posts = (await db.execute(select(Post))).scalars().all() + + pre = ai_fix = ver = 0 + for p in posts: + reason = classify(p.text) + if reason and not p.prefilter_reason: + p.prefilter_reason = reason + if not p.ai_reasoning: + p.ai_reasoning = PREFILTER_MSG[reason] + ai_fix += 1 + pre += 1 + + # Any remaining signal'd post with empty reasoning: fill a minimal note + if p.signal and not p.ai_reasoning: + p.ai_reasoning = "(legacy) reasoning not recorded for this post." + ai_fix += 1 + + if not p.analysis_version: + p.analysis_version = "legacy" + ver += 1 + + await db.commit() + print(f"✅ prefilter_reason set on {pre} posts") + print(f"✅ ai_reasoning filled on {ai_fix} posts") + print(f"✅ analysis_version stamped 'legacy' on {ver} posts") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/backfill_signals.py b/scripts/backfill_signals.py new file mode 100644 index 0000000..9ee5d01 --- /dev/null +++ b/scripts/backfill_signals.py @@ -0,0 +1,86 @@ +""" +把 AI 信号写回数据库。 +跳过纯RT/URL帖子和已有signal的帖子。 +并发执行提速,自动限速避免API超限。 +用法: python scripts/backfill_signals.py [--limit 500] [--overwrite] +""" +import asyncio +import argparse +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.services.analysis import analyze_post +from app.database import AsyncSessionLocal +from app.models import Post +from sqlalchemy import select + +CONCURRENCY = 5 # 并发数,避免触发API限速 + + +async def process_one(post: Post, semaphore: asyncio.Semaphore, overwrite: bool) -> str: + """分析单条帖子并写回DB,返回状态字符串""" + if not overwrite and post.signal is not None: + return "skip" + + async with semaphore: + analysis = await analyze_post(post.text) + await asyncio.sleep(0.1) # 轻度限速 + + async with AsyncSessionLocal() as db: + result = await db.execute(select(Post).where(Post.id == post.id)) + p = result.scalar_one_or_none() + if p: + p.sentiment = analysis["sentiment"] + p.signal = analysis["signal"] + p.ai_confidence = analysis["confidence"] + p.ai_reasoning = analysis["reasoning"] + p.relevant = analysis["relevant"] + p.prefilter_reason = analysis.get("prefilter_reason") + p.analysis_version = analysis.get("analysis_version") + if analysis["relevant"] and analysis["asset"] and not p.price_impact_asset: + p.price_impact_asset = analysis["asset"] + await db.commit() + + sig = analysis["signal"] + return f"{sig}:{analysis['confidence']}%" + + +async def main(limit: int, overwrite: bool): + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Post).order_by(Post.published_at.desc()).limit(limit) + ) + posts = result.scalars().all() + + total = len(posts) + print(f"共 {total} 条帖子,并发={CONCURRENCY},overwrite={overwrite}\n") + + semaphore = asyncio.Semaphore(CONCURRENCY) + tasks = [process_one(p, semaphore, overwrite) for p in posts] + + done = 0 + buy = short = sell = hold = skipped = 0 + + for coro in asyncio.as_completed(tasks): + status = await coro + done += 1 + if status == "skip": + skipped += 1 + elif status.startswith("buy"): buy += 1 + elif status.startswith("short"): short += 1 + elif status.startswith("sell"): sell += 1 + else: hold += 1 + + if done % 20 == 0 or done == total: + print(f" 进度 {done}/{total} | BUY={buy} SHORT={short} SELL={sell} HOLD={hold} SKIP={skipped}") + + print(f"\n✅ 完成: BUY={buy} SHORT={short} SELL={sell} HOLD={hold} SKIP={skipped}") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--limit", type=int, default=400) + ap.add_argument("--overwrite", action="store_true", help="重新分析已有signal的帖子") + args = ap.parse_args() + asyncio.run(main(args.limit, args.overwrite)) diff --git a/scripts/reanalyze_sample.py b/scripts/reanalyze_sample.py new file mode 100644 index 0000000..c2b8c11 --- /dev/null +++ b/scripts/reanalyze_sample.py @@ -0,0 +1,84 @@ +""" +批量回跑语义分析 — 验证新prompt效果 +用法: python scripts/reanalyze_sample.py +""" +import asyncio +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.services.analysis import analyze_post +from app.database import AsyncSessionLocal +from app.models import Post +from sqlalchemy import select + + +async def main(): + async with AsyncSessionLocal() as db: + result = await db.execute( + select(Post) + .order_by(Post.published_at.desc()) + .limit(60) + ) + posts = result.scalars().all() + + print(f"分析 {len(posts)} 条帖子 (model: claude-sonnet-4-6)\n") + + signals = {"buy": 0, "sell": 0, "short": 0, "hold": 0} + relevant_count = 0 + + correct_m5, checked_m5 = 0, 0 + correct_m15, checked_m15 = 0, 0 + correct_m1h, checked_m1h = 0, 0 + + for i, post in enumerate(posts): + analysis = await analyze_post(post.text) + signals[analysis["signal"]] += 1 + if analysis["relevant"]: + relevant_count += 1 + + if analysis["relevant"]: + sig = analysis["signal"].upper() + conf = analysis["confidence"] + + actual_m5 = post.price_impact_m5 + actual_m15 = post.price_impact_m15 + actual_m1h = post.price_impact_m1h + + def dir_ok(actual, signal): + if actual is None: return None + if signal == "buy": return actual > 0 + if signal == "short": return actual < 0 + return None + + m5_ok = dir_ok(actual_m5, analysis["signal"]) + m15_ok = dir_ok(actual_m15, analysis["signal"]) + m1h_ok = dir_ok(actual_m1h, analysis["signal"]) + + if m5_ok is not None: checked_m5 += 1; correct_m5 += int(m5_ok) + if m15_ok is not None: checked_m15 += 1; correct_m15 += int(m15_ok) + if m1h_ok is not None: checked_m1h += 1; correct_m1h += int(m1h_ok) + + # accuracy marks + marks = "" + if m5_ok is not None: marks += f" 5m:{'✅' if m5_ok else '❌'}" + if m15_ok is not None: marks += f" 15m:{'✅' if m15_ok else '❌'}" + if m1h_ok is not None: marks += f" 1h:{'✅' if m1h_ok else '❌'}" + + print(f"[{i+1:03d}] {post.published_at.strftime('%m-%d %H:%M')} | {sig:5s} {conf:3d}% |{marks}") + print(f" {post.text[:110]}") + print(f" → {analysis['reasoning'][:200]}") + print() + + await asyncio.sleep(0.2) + + print("=" * 70) + print(f"总计: {len(posts)} 帖 | Relevant: {relevant_count} ({relevant_count/len(posts)*100:.0f}%)") + print(f"信号: BUY={signals['buy']} SHORT={signals['short']} SELL={signals['sell']} HOLD={signals['hold']}") + print() + if checked_m5: print(f"准确率 5m: {correct_m5}/{checked_m5} = {correct_m5/checked_m5*100:.0f}%") + if checked_m15: print(f"准确率 15m: {correct_m15}/{checked_m15} = {correct_m15/checked_m15*100:.0f}%") + if checked_m1h: print(f"准确率 1h: {correct_m1h}/{checked_m1h} = {correct_m1h/checked_m1h*100:.0f}%") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/test_hyperliquid.py b/scripts/test_hyperliquid.py new file mode 100644 index 0000000..ee68d81 --- /dev/null +++ b/scripts/test_hyperliquid.py @@ -0,0 +1,128 @@ +""" +Hyperliquid 接入测试脚本 + +用法: + source venv/bin/activate + HL_API_PRIVATE_KEY="0x你的API钱包私钥" \ + HL_ACCOUNT_ADDRESS="0x你的MetaMask地址" \ + python scripts/test_hyperliquid.py + +会做以下验证(只读,不下单): + 1. 连通性检查 + 2. 账户 USDC 余额 + 3. BTC-PERP 当前价格 + 4. 当前持仓 + 5. 模拟开仓参数(不实际下单) + +加 --trade 参数才会真正下一个 0.001 BTC 的小仓位并立即平掉(mainnet 慎用) +""" + +import asyncio +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.services.hyperliquid import HyperliquidTrader + + +async def main(do_trade: bool): + api_key = os.getenv("HL_API_PRIVATE_KEY") or os.getenv("HL_API_KEY", "") + account = os.getenv("HL_ACCOUNT_ADDRESS", "") + + if not api_key: + print("❌ 缺少 HL_API_PRIVATE_KEY 环境变量") + print(" export HL_API_PRIVATE_KEY='0x你的API钱包私钥'") + sys.exit(1) + if not account: + print("❌ 缺少 HL_ACCOUNT_ADDRESS 环境变量") + print(" export HL_ACCOUNT_ADDRESS='0x你的MetaMask地址'") + sys.exit(1) + + mainnet = os.getenv("HL_MAINNET", "true").lower() != "false" + net_label = "MAINNET" if mainnet else "TESTNET" + print(f"\n=== Hyperliquid 接入测试 ({net_label}) ===\n") + + trader = HyperliquidTrader( + api_private_key=api_key, + account_address=account, + leverage=3, + mainnet=mainnet, + ) + + # 1. USDC 余额 + print("① 查询 USDC 余额...") + balance = await trader.get_balance() + print(f" 可提取余额: ${balance:.2f} USDC") + if balance < 10: + print(" ⚠️ 余额不足 $10,交易可能失败") + else: + print(" ✅ 余额充足") + + # 2. BTC mid price + print("\n② 查询 BTC-PERP 价格...") + try: + from hyperliquid.info import Info + base_url = "https://api.hyperliquid.xyz" if mainnet else "https://api.hyperliquid-testnet.xyz" + info = Info(base_url, skip_ws=True) + mids = info.all_mids() + btc_price = float(mids.get("BTC", 0)) + print(f" BTC mid price: ${btc_price:,.2f}") + print(" ✅ 价格获取正常") + except Exception as e: + print(f" ❌ 价格获取失败: {e}") + + # 3. 当前持仓 + print("\n③ 查询当前持仓...") + positions = await trader.get_open_positions() + if positions: + for p in positions: + side = "多" if p["szi"] > 0 else "空" + print(f" {p['coin']} {side}仓: {abs(p['szi'])} 张 @ 均价 ${p['entry_px']:,.2f} 未实现盈亏: ${p['unrealized_pnl']:.2f}") + print(f" 共 {len(positions)} 个持仓") + else: + print(" 无持仓 ✅") + + # 4. 模拟开仓参数 + print("\n④ 模拟开仓参数(不实际下单)...") + sz_dec = await trader._get_sz_decimals("BTC") + mid = await trader._mid_price("BTC") + size_usd = 100 + size_coins = round(size_usd / mid, sz_dec) + limit_buy = round(mid * 1.01, 1) + print(f" 开多 $100 notional:") + print(f" → 数量: {size_coins} BTC") + print(f" → 限价(1%滑点): ${limit_buy:,.1f}") + print(f" → 杠杆: 3x 隔离保证金") + print(f" → 所需保证金: ${size_usd / 3:.2f} USDC") + + # 5. 真实测试下单(需要 --trade 参数) + if do_trade: + print(f"\n⑤ 真实下单测试 (0.001 BTC long → 立即平仓)...") + if not mainnet: + print(" 使用 testnet,无需担心资金损失") + else: + print(" ⚠️ MAINNET 真实资金!继续中...") + + try: + result = await trader.open_position("BTC", "long", size_usd=50) + print(f" ✅ 开仓成功: fill_price=${result['fill_price']:,.2f}, size={result['size_coins']} BTC") + + await asyncio.sleep(2) + + close = await trader.close_position("BTC") + print(f" ✅ 平仓成功: fill_price=${close['fill_price']:,.2f}") + except Exception as e: + print(f" ❌ 下单失败: {e}") + else: + print("\n提示: 加 --trade 参数进行真实下单+平仓测试") + + print("\n=== 测试完成 ===") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--trade", action="store_true", help="执行真实下单+平仓测试") + args = parser.parse_args() + asyncio.run(main(args.trade)) diff --git a/simulate_trades.py b/simulate_trades.py new file mode 100644 index 0000000..cf23ce0 --- /dev/null +++ b/simulate_trades.py @@ -0,0 +1,130 @@ +""" +Simulate ~30 days of bot trading history for a given wallet, using real historical +posts + the measured price_impact_{m5,m15,m1h} fields. Creates BotTrade rows as if +the bot had been running with default settings (size=$20, lev=3x, tp=2%, sl=1.5%). + +Usage: + python simulate_trades.py + +Safe to re-run: skips posts already linked to a trade for the given wallet. +""" +import asyncio +import sys +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +from sqlalchemy import select + +from app.database import AsyncSessionLocal +from app.models import BotTrade, Post + + +SIZE_USD = 20.0 +LEVERAGE = 3 +TP_PCT = 2.0 # close at +2% price move in position direction +SL_PCT = 1.5 # close at -1.5% +MIN_CONFIDENCE = 80 +DAYS_BACK = 30 + + +def simulate_exit(side: str, m5: Optional[float], m15: Optional[float], m1h: Optional[float]) -> Tuple[float, int, str]: + """ + Walk the three sampled points; exit at the first TP/SL crossing, else at m1h close. + Returns (signed_pct, hold_seconds, reason). + signed_pct = price move in position's favour (positive means profit, in raw %). + """ + samples = [(300, m5), (900, m15), (3600, m1h)] + for secs, pct in samples: + if pct is None: + continue + signed = pct if side == 'long' else -pct + if signed >= TP_PCT: + return TP_PCT, secs, 'take_profit' + if signed <= -SL_PCT: + return -SL_PCT, secs, 'stop_loss' + # No TP/SL hit — exit at whichever latest sample we have + final = m1h if m1h is not None else (m15 if m15 is not None else (m5 or 0.0)) + signed = final if side == 'long' else -final + hold = 3600 if m1h is not None else (900 if m15 is not None else 300) + return signed, hold, 'max_hold' + + +async def main(wallet: str) -> None: + wallet = wallet.lower() + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=DAYS_BACK) + + async with AsyncSessionLocal() as db: + # Get candidate posts + result = await db.execute( + select(Post) + .where(Post.published_at >= cutoff) + .where(Post.relevant == True) # noqa: E712 + .where(Post.ai_confidence >= MIN_CONFIDENCE) + .where(Post.signal.in_(('buy', 'short'))) + .where(Post.price_at_post.is_not(None)) + .order_by(Post.published_at.asc()) + ) + posts = result.scalars().all() + + # Skip posts already simulated for this wallet + existing = await db.execute( + select(BotTrade.trigger_post_id).where(BotTrade.wallet_address == wallet) + ) + seen = {pid for (pid,) in existing.all() if pid is not None} + + created = 0 + total_pnl = 0.0 + wins = 0 + + for p in posts: + if p.id in seen: + continue + asset = p.price_impact_asset or 'BTC' + side = 'long' if p.signal == 'buy' else 'short' + entry = p.price_at_post + if not entry: + continue + + signed_pct, hold_secs, reason = simulate_exit( + side, p.price_impact_m5, p.price_impact_m15, p.price_impact_m1h + ) + # exit_price reconstructed from signed_pct relative to side direction + raw_pct = signed_pct if side == 'long' else -signed_pct + exit_price = entry * (1 + raw_pct / 100.0) + pnl_usd = round(SIZE_USD * (signed_pct / 100.0) * LEVERAGE, 2) + + opened = p.published_at + closed = opened + timedelta(seconds=hold_secs) + + trade = BotTrade( + asset=asset, + side=side, + entry_price=round(entry, 2), + exit_price=round(exit_price, 2), + pnl_usd=pnl_usd, + hold_seconds=hold_secs, + trigger_post_id=p.id, + wallet_address=wallet, + opened_at=opened, + closed_at=closed, + hl_order_id=f'sim-{p.id}', + ) + db.add(trade) + created += 1 + total_pnl += pnl_usd + if pnl_usd > 0: + wins += 1 + + await db.commit() + + wr = (wins / created * 100) if created else 0 + print(f"✓ Simulated {created} trades for {wallet}") + print(f" Total PnL: ${total_pnl:+.2f}") + print(f" Win rate: {wr:.1f}% ({wins}/{created})") + + +if __name__ == '__main__': + if len(sys.argv) != 2: + print("Usage: python simulate_trades.py ") + sys.exit(1) + asyncio.run(main(sys.argv[1])) diff --git a/test_hl_trade.py b/test_hl_trade.py new file mode 100644 index 0000000..bb3ea3f --- /dev/null +++ b/test_hl_trade.py @@ -0,0 +1,69 @@ +""" +End-to-end test: open $11 BTC long, then immediately close it. +Pulls the stored API key from the DB for the given wallet. + +Usage: cd backend && python test_hl_trade.py 0xYOURWALLET +""" +import asyncio +import sys +from sqlalchemy import select +from app.database import AsyncSessionLocal +from app.models import Subscription +from app.services.hyperliquid import HyperliquidTrader +from app.config import settings + + +async def main(wallet: str): + wallet = wallet.lower() + async with AsyncSessionLocal() as db: + result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) + sub = result.scalar_one_or_none() + if sub is None or not sub.hl_api_key: + print(f"❌ No API key stored for {wallet}") + return + api_key = sub.hl_api_key + print(f"✓ Loaded API key for {wallet} (ends …{api_key[-6:]})") + + trader = HyperliquidTrader( + api_private_key=api_key, + account_address=wallet, + leverage=settings.hl_leverage, + mainnet=settings.hl_mainnet, + ) + + bal = await trader.get_balance() + print(f"✓ Withdrawable (perps): ${bal:.2f} (unified account may show 0 here, ignoring)") + + print("\n─── OPENING $11 BTC LONG ───") + try: + open_result = await trader.open_position("BTC", "long", 11.0) + print(f"✓ Open fill: {open_result}") + except Exception as e: + print(f"❌ Open failed: {e}") + return + + await asyncio.sleep(2) + positions = await trader.get_open_positions() + print(f"✓ Open positions now: {positions}") + + print("\n─── CLOSING BTC POSITION ───") + try: + close_result = await trader.close_position("BTC") + print(f"✓ Close fill: {close_result}") + except Exception as e: + print(f"❌ Close failed: {e}") + return + + await asyncio.sleep(2) + positions = await trader.get_open_positions() + print(f"✓ Open positions after close: {positions}") + + bal2 = await trader.get_balance() + print(f"\n✓ Balance before: ${bal:.2f} → after: ${bal2:.2f} (diff: ${bal2-bal:+.4f})") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python test_hl_trade.py 0xYOURWALLET") + sys.exit(1) + asyncio.run(main(sys.argv[1]))