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