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
+2 -2
View File
@@ -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,
+63 -6
View File
@@ -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))
+11 -30
View File
@@ -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
+3 -3
View File
@@ -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 "",
)
+152 -12
View File
@@ -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 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):
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 (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"}
+5
View File
@@ -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.")
+21
View File
@@ -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)
+41 -1
View File
@@ -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
+6 -2
View File
@@ -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,
},
+151 -48
View File
@@ -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 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>,
"asset": "BTC" | "ETH" | null,
"relevant": <true if post contains ANY concrete macro/geopolitical/crypto information>,
"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": "<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:
- "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}")
+4
View File
@@ -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",
+192 -76
View File
@@ -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")
+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 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}
+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)