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