This commit is contained in:
k
2026-04-21 19:33:24 +08:00
parent 9a72566753
commit 3268080401
26 changed files with 1816 additions and 318 deletions
+22 -3
View File
@@ -1,4 +1,23 @@
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/trumpsignal # Database
REDIS_URL=redis://localhost:6379 DATABASE_URL=sqlite+aiosqlite:///./trumpsignal.db
ANTHROPIC_API_KEY=sk-ant-... # For Postgres in production:
# DATABASE_URL=postgresql+asyncpg://user:password@host:5432/trumpsignal
# Frontend origin for CORS
FRONTEND_URL=http://localhost:3001 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=
+2 -2
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, AsyncSessionLocal 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 from app.ws.manager import manager
import hashlib, time import hashlib, time
@@ -45,7 +45,7 @@ async def insert_fake_post(
"id": post.id, "id": post.id,
"text": post.text, "text": post.text,
"source": post.source, "source": post.source,
"published_at": post.published_at.isoformat(), "published_at": iso_utc(post.published_at),
"sentiment": post.sentiment, "sentiment": post.sentiment,
"ai_confidence": post.ai_confidence, "ai_confidence": post.ai_confidence,
"relevant": post.relevant, "relevant": post.relevant,
+63 -6
View File
@@ -6,35 +6,47 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import Post from app.models import Post, iso_utc
from app.schemas import PriceImpact, TrumpPost from app.schemas import PriceImpact, TrumpPost
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) 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: def _post_to_schema(post: Post) -> TrumpPost:
price_impact: Optional[PriceImpact] = None price_impact: Optional[PriceImpact] = None
if ( if post.price_impact_asset and post.price_at_post is not None:
post.price_impact_asset
and post.price_at_post is not None
):
price_impact = PriceImpact( price_impact = PriceImpact(
asset=post.price_impact_asset, asset=post.price_impact_asset,
m5=post.price_impact_m5 or 0.0, m5=post.price_impact_m5 or 0.0,
m15=post.price_impact_m15 or 0.0, m15=post.price_impact_m15 or 0.0,
m1h=post.price_impact_m1h or 0.0, m1h=post.price_impact_m1h or 0.0,
price_at_post=post.price_at_post, 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( return TrumpPost(
id=post.id, id=post.id,
text=post.text, text=post.text,
source=post.source, source=post.source,
published_at=post.published_at.isoformat(), published_at=iso_utc(post.published_at),
sentiment=post.sentiment, sentiment=post.sentiment,
signal=post.signal, signal=post.signal,
ai_confidence=post.ai_confidence, ai_confidence=post.ai_confidence,
ai_reasoning=post.ai_reasoning, ai_reasoning=post.ai_reasoning,
prefilter_reason=post.prefilter_reason,
analysis_version=post.analysis_version,
relevant=post.relevant, relevant=post.relevant,
price_impact=price_impact, price_impact=price_impact,
) )
@@ -54,6 +66,51 @@ async def get_posts(
return [_post_to_schema(p) for p in 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) @router.get("/posts/{post_id}", response_model=TrumpPost)
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)): async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Post).where(Post.id == post_id)) result = await db.execute(select(Post).where(Post.id == post_id))
+10 -29
View File
@@ -1,48 +1,33 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from eth_account import Account from fastapi import APIRouter, Depends
from eth_account.messages import encode_defunct
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import Subscription from app.models import Subscription
from app.schemas import SubscribeRequest, SubscribeResponse from app.schemas import SubscribeRequest, SubscribeResponse
from app.services.signed_request import verify_signed_request
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SIGN_MESSAGE = "Sign in to TrumpSignal" ACTION_SUBSCRIBE = "subscribe"
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) @router.post("/subscribe", response_model=SubscribeResponse)
async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)): async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
wallet = body.wallet.lower().strip() wallet = body.wallet.lower().strip()
# Verify EIP-191 signature verify_signed_request(
recovered = _recover_signer(body.signature) action=ACTION_SUBSCRIBE,
if recovered is None or recovered != wallet: wallet=wallet,
raise HTTPException( timestamp_ms=body.timestamp,
status_code=401, signature=body.signature,
detail="Signature verification failed: recovered address does not match wallet", body=None,
) )
# Upsert subscription
result = await db.execute( result = await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet) 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) now = datetime.now(timezone.utc).replace(tzinfo=None)
if sub is None: if sub is None:
sub = Subscription( sub = Subscription(wallet_address=wallet, active=True, subscribed_at=now)
wallet_address=wallet,
active=True,
subscribed_at=now,
)
db.add(sub) db.add(sub)
else: else:
sub.active = True sub.active = True
+3 -3
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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 from app.schemas import BotTrade as BotTradeSchema
router = APIRouter() router = APIRouter()
@@ -23,8 +23,8 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
pnl_usd=trade.pnl_usd or 0.0, pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0, hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0, trigger_post_id=trade.trigger_post_id or 0,
opened_at=trade.opened_at.isoformat(), opened_at=iso_utc(trade.opened_at) or "",
closed_at=trade.closed_at.isoformat() if trade.closed_at else "", closed_at=iso_utc(trade.closed_at) or "",
) )
+152 -12
View File
@@ -1,16 +1,30 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import BotTrade, Subscription from app.models import BotTrade, Subscription, iso_utc
from app.schemas import BotTrade as BotTradeSchema, UserResponse 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() router = APIRouter()
logger = logging.getLogger(__name__) 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: def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
return BotTradeSchema( return BotTradeSchema(
@@ -22,23 +36,89 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
pnl_usd=trade.pnl_usd or 0.0, pnl_usd=trade.pnl_usd or 0.0,
hold_seconds=trade.hold_seconds or 0, hold_seconds=trade.hold_seconds or 0,
trigger_post_id=trade.trigger_post_id or 0, trigger_post_id=trade.trigger_post_id or 0,
opened_at=trade.opened_at.isoformat(), opened_at=iso_utc(trade.opened_at) or "",
closed_at=trade.closed_at.isoformat() if trade.closed_at else "", 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) @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() wallet = wallet.lower().strip()
result = await db.execute( # Require a fresh signed timestamp from the owner — stops wallet-enumeration
select(Subscription).where(Subscription.wallet_address == wallet) # 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() sub = result.scalar_one_or_none()
if sub is 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( trades_result = await db.execute(
select(BotTrade) select(BotTrade)
.where(BotTrade.wallet_address == wallet) .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() 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) 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( return UserResponse(
wallet_address=sub.wallet_address, wallet_address=sub.wallet_address,
active=sub.active, 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_set=hl_api_key_set,
hl_api_key_masked=masked,
trades=[_trade_to_schema(t) for t in trades], 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 150")
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.150")
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.150")
if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100")
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
+17 -2
View File
@@ -3,8 +3,6 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: str database_url: str
redis_url: str = "redis://localhost:6379"
anthropic_api_key: str
frontend_url: str = "http://localhost:3001" frontend_url: str = "http://localhost:3001"
truth_social_rss: str = "https://truthsocial.com/@realDonaldTrump.rss" truth_social_rss: str = "https://truthsocial.com/@realDonaldTrump.rss"
truth_social_poll_seconds: int = 15 truth_social_poll_seconds: int = 15
@@ -14,6 +12,23 @@ class Settings(BaseSettings):
) )
binance_rest_url: str = "https://data-api.binance.vision" binance_rest_url: str = "https://data-api.binance.vision"
environment: str = "development" 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 (150x 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"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+5
View File
@@ -44,6 +44,11 @@ async def lifespan(app: FastAPI):
# 2. Backfill historical posts on startup (fast, no Claude API call) # 2. Backfill historical posts on startup (fast, no Claude API call)
asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500)) 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 # 3. Start Binance WebSocket background task
_binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws") _binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws")
logger.info("Binance WebSocket task started.") logger.info("Binance WebSocket task started.")
+21
View File
@@ -18,9 +18,21 @@ from app.database import Base
def utcnow() -> datetime: def utcnow() -> datetime:
"""Naive UTC datetime. All datetimes in this codebase are stored as naive-UTC."""
return datetime.now(timezone.utc).replace(tzinfo=None) 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): class Post(Base):
__tablename__ = "posts" __tablename__ = "posts"
@@ -39,6 +51,8 @@ class Post(Base):
price_at_post: Mapped[Optional[float]] = mapped_column(Float, nullable=True) price_at_post: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
signal: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) signal: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
ai_reasoning: Mapped[Optional[str]] = mapped_column(Text, 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) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
trades: Mapped[List["BotTrade"]] = relationship("BotTrade", back_populates="trigger_post") 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) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
subscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) subscribed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) 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)
+41 -1
View File
@@ -9,6 +9,10 @@ class PriceImpact(BaseModel):
m15: float m15: float
m1h: float m1h: float
price_at_post: 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): class TrumpPost(BaseModel):
@@ -20,6 +24,8 @@ class TrumpPost(BaseModel):
signal: Optional[str] = None # buy | sell | short | hold signal: Optional[str] = None # buy | sell | short | hold
ai_confidence: int ai_confidence: int
ai_reasoning: Optional[str] = None 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 relevant: bool
price_impact: Optional[PriceImpact] = None price_impact: Optional[PriceImpact] = None
@@ -59,19 +65,53 @@ class BotPerformance(BaseModel):
max_drawdown_pct: float 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 wallet: str
timestamp: int # milliseconds since epoch
signature: str signature: str
class SubscribeRequest(SignedEnvelope):
pass
class SubscribeResponse(BaseModel): class SubscribeResponse(BaseModel):
status: str status: str
wallet: 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): class UserResponse(BaseModel):
wallet_address: str wallet_address: str
active: bool active: bool
subscribed_at: Optional[str] = None subscribed_at: Optional[str] = None
hl_api_key_set: bool hl_api_key_set: bool
hl_api_key_masked: Optional[str] = None
trades: list[BotTrade] trades: list[BotTrade]
settings: UserSettings
+6 -2
View File
@@ -15,7 +15,7 @@ import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.analysis import analyze_post
from app.services.price_store import price_store from app.services.price_store import price_store
from app.ws.manager import manager from app.ws.manager import manager
@@ -85,6 +85,8 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
signal=analysis.get("signal"), signal=analysis.get("signal"),
ai_confidence=analysis["confidence"], ai_confidence=analysis["confidence"],
ai_reasoning=analysis.get("reasoning"), ai_reasoning=analysis.get("reasoning"),
prefilter_reason=analysis.get("prefilter_reason"),
analysis_version=analysis.get("analysis_version"),
relevant=analysis["relevant"], relevant=analysis["relevant"],
price_impact_asset=asset if analysis["relevant"] else None, price_impact_asset=asset if analysis["relevant"] else None,
price_impact_m5=price_impact_m5, price_impact_m5=price_impact_m5,
@@ -113,9 +115,11 @@ def _post_to_ws_payload(post: Post) -> dict:
"id": post.id, "id": post.id,
"text": post.text, "text": post.text,
"source": post.source, "source": post.source,
"published_at": post.published_at.isoformat(), "published_at": iso_utc(post.published_at),
"sentiment": post.sentiment, "sentiment": post.sentiment,
"signal": post.signal,
"ai_confidence": post.ai_confidence, "ai_confidence": post.ai_confidence,
"ai_reasoning": post.ai_reasoning,
"relevant": post.relevant, "relevant": post.relevant,
"price_impact": price_impact, "price_impact": price_impact,
}, },
+151 -48
View File
@@ -1,46 +1,134 @@
import json import json
import logging import logging
from typing import Optional
import anthropic from openai import AsyncOpenAI
from app.config import settings from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from typing import Optional _client: Optional[AsyncOpenAI] = None
_client: Optional[anthropic.AsyncAnthropic] = None
SYSTEM_PROMPT = ( SYSTEM_PROMPT = """You are an expert macro trader analyzing Trump's Truth Social posts for real-time crypto trading signals.
"You are a crypto trading signal analyst. Analyze Donald Trump's social media posts "
"and determine their likely impact on cryptocurrency markets. "
"Return only valid JSON with no additional text or markdown."
)
USER_PROMPT_TEMPLATE = """Analyze this Trump post for cryptocurrency trading signals. 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 560 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 510% 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": <true if this post could affect crypto/macro markets>, "relevant": <true if post contains ANY concrete macro/geopolitical/crypto information>,
"asset": "BTC" | "ETH" | null, "asset": "BTC" | "ETH" | "BOTH" | null,
"sentiment": "bullish" | "bearish" | "neutral", "sentiment": "bullish" | "bearish" | "neutral",
"signal": "buy" | "sell" | "short" | "hold", "signal": "buy" | "sell" | "short" | "hold",
"confidence": <0-100>, "confidence": <0-100>,
"reasoning": "<1-2 sentences explaining the signal and why>" "reasoning": "<What specific event is described → macro transmission chain → expected market direction → WHY this signal and NOT the adjacent one (e.g. 'short not sell because strike is confirmed', 'sell not short because only a walkback of prior bullish headline')>"
}} }}
Signal rules: 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."""
- "buy": bullish crypto/macro signal → expect price rise → go long
- "sell": bearish signal for existing longs → exit position
- "short": strong bearish signal → expect price drop → go short
- "hold": relevant but unclear direction, or neutral macro
Bullish examples: pro-crypto policy, BTC reserve, anti-regulation, dollar weakness, tariff deal, market optimism ANALYSIS_VERSION = "v4-selective"
Bearish examples: SEC crackdowns, war escalation, economic sanctions, crypto ban threats, crisis
Not relevant: personal attacks, sports, endorsements, unrelated politics
Be strict on relevance — most posts are NOT relevant to crypto."""
_FALLBACK = { _FALLBACK = {
"relevant": False, "relevant": False,
@@ -49,36 +137,50 @@ _FALLBACK = {
"signal": "hold", "signal": "hold",
"confidence": 0, "confidence": 0,
"reasoning": "", "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 global _client
if _client is None: 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 return _client
async def analyze_post(text: str) -> dict: 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: try:
client = _get_client() client = _get_client()
message = await client.messages.create( response = await client.chat.completions.create(
model="claude-haiku-4-5-20251001", model=settings.ai_model,
max_tokens=300, max_tokens=450,
system=SYSTEM_PROMPT, temperature=0.1,
messages=[ messages=[
{ {"role": "system", "content": SYSTEM_PROMPT},
"role": "user", {"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text[:2000])},
"content": USER_PROMPT_TEMPLATE.format(text=text[:2000]),
}
], ],
) )
raw = message.content[0].text.strip() raw = response.choices[0].message.content.strip()
if raw.startswith("```"): if raw.startswith("```"):
lines = raw.split("\n") lines = raw.split("\n")
@@ -100,10 +202,12 @@ async def analyze_post(text: str) -> dict:
relevant = bool(result.get("relevant", False)) relevant = bool(result.get("relevant", False))
asset = result.get("asset") asset = result.get("asset")
if asset == "BOTH":
asset = "BTC"
if asset not in ("BTC", "ETH", None): if asset not in ("BTC", "ETH", None):
asset = None asset = None
reasoning = str(result.get("reasoning", ""))[:500] reasoning = str(result.get("reasoning", ""))[:600]
return { return {
"relevant": relevant, "relevant": relevant,
@@ -112,14 +216,13 @@ async def analyze_post(text: str) -> dict:
"signal": signal, "signal": signal,
"confidence": confidence, "confidence": confidence,
"reasoning": reasoning, "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: except json.JSONDecodeError as exc:
logger.error("Failed to parse Claude JSON response: %s", exc) logger.error("Failed to parse AI JSON: %s", exc)
return dict(_FALLBACK) return _fallback("parse_error", f"AI returned unparseable JSON: {exc}")
except Exception as exc: except Exception as exc:
logger.error("Unexpected error in analyze_post: %s", exc) logger.error("analyze_post error: %s", exc)
return dict(_FALLBACK) return _fallback("api_error", f"AI API error: {exc}")
+4
View File
@@ -41,6 +41,10 @@ async def _process_message(raw: str):
} }
price_store.update(asset, candle) 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 # Broadcast live price tick
await manager.broadcast({ await manager.broadcast({
"type": "price", "type": "price",
+168 -52
View File
@@ -7,79 +7,134 @@ Iterates all active subscribers and executes trades on their behalf.
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession 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.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__) logger = logging.getLogger(__name__)
# Thresholds # Platform-wide thresholds (per-user values in Subscription override where applicable)
MIN_CONFIDENCE = 70 # ai_confidence must be >= this to trade GLOBAL_MIN_CONFIDENCE = 80 # hard floor — user min_confidence must be >= this
POSITION_SIZE_USD = 100 # per-trade size in USD (fixed for MVP) MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users)
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour
# 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: async def process_post(post: Post, db: AsyncSession) -> None:
""" """
Entry point called by the scraper after a new post is saved. 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: if not post.relevant:
return return
if (post.ai_confidence or 0) < MIN_CONFIDENCE: if (post.ai_confidence or 0) < GLOBAL_MIN_CONFIDENCE:
logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, MIN_CONFIDENCE) logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, GLOBAL_MIN_CONFIDENCE)
return 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 return
asset = post.price_impact_asset or 'BTC' 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) 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() subscribers = result.scalars().all()
if not subscribers: if not subscribers:
logger.info("No active subscribers, skipping trade execution") logger.info("No active subscribers, skipping trade execution")
return 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) await asyncio.gather(*tasks, return_exceptions=True)
async def _execute_for_subscriber( async def _execute_for_subscriber(
sub: Subscription, sub: dict,
post: Post, post_id: int,
post_confidence: int,
asset: str, asset: str,
side: str, side: str,
db: AsyncSession,
) -> None: ) -> None:
if not sub.hl_api_key: wallet = sub["wallet"]
logger.warning("Subscriber %s has no HL API key, skipping", sub.wallet_address) 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 return
try: try:
trader = HyperliquidTrader(sub.hl_api_key) api_key = decrypt_api_key(sub["hl_api_key"])
except Exception as exc:
# Check for existing open position to avoid doubling up logger.error("Cannot decrypt key for %s: %s", wallet, exc)
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 return
result = await trader.open_position(asset, side, POSITION_SIZE_USD) # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe.
async with AsyncSessionLocal() as db:
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=sub["leverage"],
mainnet=settings.hl_mainnet,
)
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
result = await trader.open_position(asset, side, sub["position_size_usd"])
entry_price = result.get('fill_price', 0.0) entry_price = result.get('fill_price', 0.0)
trade = BotTrade( trade = BotTrade(
asset=asset, asset=asset,
side=side, side=side,
entry_price=entry_price, entry_price=entry_price,
wallet_address=sub.wallet_address, wallet_address=wallet,
trigger_post_id=post.id, trigger_post_id=post_id,
opened_at=datetime.now(timezone.utc).replace(tzinfo=None), opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
hl_order_id=result.get('order_id'), hl_order_id=result.get('order_id'),
) )
@@ -87,45 +142,106 @@ async def _execute_for_subscriber(
await db.commit() await db.commit()
await db.refresh(trade) await db.refresh(trade)
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", side, asset, sub.wallet_address, entry_price, trade.id) logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)",
side, asset, wallet, entry_price, trade.id)
# Schedule close after MAX_HOLD_SECONDS from app.services.tp_sl_monitor import register_trade
asyncio.create_task(_close_after_hold(trade.id, sub.hl_api_key, asset, sub.wallet_address)) 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: except Exception as e:
logger.error("Trade execution failed for %s: %s", sub.wallet_address, e) logger.error("Trade execution failed for %s: %s", wallet, e)
async def _close_after_hold(trade_id: int, api_key: str, asset: str, wallet: str) -> None: async def close_and_finalize(
await asyncio.sleep(MAX_HOLD_SECONDS) trade_id: int,
api_key: str,
from app.database import AsyncSessionLocal 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: async with AsyncSessionLocal() as db:
try: try:
result = await db.execute( now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
select(BotTrade).where(BotTrade.id == trade_id, BotTrade.closed_at.is_(None))
)
trade = result.scalar_one_or_none()
if trade is None:
return # already closed
trader = HyperliquidTrader(api_key) # 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) close_result = await trader.close_position(asset)
exit_price = close_result.get('fill_price', 0.0) exit_price = close_result.get('fill_price', 0.0)
now = datetime.now(timezone.utc) now_aware = datetime.now(timezone.utc)
hold_secs = int((now - trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds()) opened_aware = trade.opened_at.replace(tzinfo=timezone.utc)
pnl = (exit_price - trade.entry_price) * (1 if trade.side == 'long' else -1) hold_secs = int((now_aware - opened_aware).total_seconds())
# Approximate PnL: size_usd * pct_change pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
pnl_usd = POSITION_SIZE_USD * (pnl / 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.exit_price = exit_price
trade.pnl_usd = round(pnl_usd, 2) trade.pnl_usd = round(pnl_usd, 2)
trade.hold_seconds = hold_secs trade.hold_seconds = hold_secs
trade.closed_at = now # closed_at already set by the atomic UPDATE
await db.commit() await db.commit()
logger.info("Closed %s %s for %s @ %.2f PnL=%.2f", trade.side, asset, wallet, exit_price, pnl_usd)
# 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: except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, 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")
+62
View File
@@ -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
+201 -133
View File
@@ -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 asyncio
import logging import logging
from functools import partial from functools import partial
from typing import Optional
from eth_account import Account from eth_account import Account
from hyperliquid.exchange import Exchange from hyperliquid.exchange import Exchange
@@ -8,157 +21,81 @@ from hyperliquid.info import Info
logger = logging.getLogger(__name__) 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 # Precision map — Hyperliquid's szDecimals for common coins
COIN_MAP = { # Fetched dynamically via meta() but cached here as fallback
"BTC": "BTC", SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4}
"ETH": "ETH",
}
class HyperliquidTrader: class HyperliquidTrader:
def __init__(self, private_key: str): def __init__(
self.account = Account.from_key(private_key) self,
self.exchange = Exchange(self.account, HL_MAINNET) api_private_key: str,
self.info = Info(HL_MAINNET) account_address: str,
self._loop = asyncio.get_event_loop() 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): # ── internal helpers ──────────────────────────────────────────────────────
"""Run a blocking SDK call in the default thread pool."""
return await self._loop.run_in_executor(None, partial(fn, *args, **kwargs))
def _wallet(self) -> str: async def _run(self, fn, *args, **kwargs):
return self.account.address """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 ───────────────────────────────────────────────────── # ── public interface ─────────────────────────────────────────────────────
async def get_balance(self) -> float: async def get_balance(self) -> float:
"""Return USDC balance (withdrawable).""" """Return withdrawable USDC balance of the main (MetaMask) account."""
try: try:
state = await self._run_sync( state = await self._run(self._info.user_state, self._account_address)
self.info.user_state, self._wallet()
)
return float(state.get("withdrawable", 0)) return float(state.get("withdrawable", 0))
except Exception as exc: except Exception as exc:
logger.error("get_balance error: %s", exc) logger.error("get_balance error: %s", exc)
return 0.0 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: 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: try:
state = await self._run_sync(self.info.user_state, self._wallet()) state = await self._run(self._info.user_state, self._account_address)
positions = [] positions = []
for asset_pos in state.get("assetPositions", []): for asset_pos in state.get("assetPositions", []):
pos = asset_pos.get("position", {}) pos = asset_pos.get("position", {})
@@ -166,11 +103,142 @@ class HyperliquidTrader:
if szi != 0: if szi != 0:
positions.append({ positions.append({
"coin": pos.get("coin"), "coin": pos.get("coin"),
"szi": szi, "szi": szi, # positive = long, negative = short
"entry_px": float(pos.get("entryPx", 0) or 0), "entry_px": float(pos.get("entryPx", 0) or 0),
"unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0), "unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0),
"leverage": pos.get("leverage", {}),
}) })
return positions return positions
except Exception as exc: except Exception as exc:
logger.error("get_open_positions error: %s", exc) logger.error("get_open_positions error: %s", exc)
return [] 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}
+89
View File
@@ -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))
+107
View File
@@ -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: <sha256_hex_of_canonical_json, or "-" for empty>
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)")
+97
View File
@@ -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)
+1
View File
@@ -14,3 +14,4 @@ eth-account==0.11.0
redis==5.0.4 redis==5.0.4
apscheduler==3.10.4 apscheduler==3.10.4
python-dotenv==1.0.1 python-dotenv==1.0.1
cryptography>=42
+72
View File
@@ -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())
+86
View File
@@ -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))
+84
View File
@@ -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())
+128
View File
@@ -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))
+130
View File
@@ -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 <wallet_address>
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 <wallet_address>")
sys.exit(1)
asyncio.run(main(sys.argv[1]))
+69
View File
@@ -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]))