Files
trumpsignal-backend/app/services/bot_engine.py
T
k 4ffcb442fe feat: add daily budget, active window, trade snapshots, and price impact monitor
- New migrations for daily_budget, active_window, and bottrade snapshot
- Add trumpstruth scraper and price_impact_monitor service
- Expand bot_engine, hyperliquid, recovery, and tp_sl_monitor logic
- Update API/schemas/models for new features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:04:49 +08:00

373 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Trading decision loop.
Called by the Truth Social scraper after each new post is analyzed and saved.
Iterates all active subscribers and executes trades on their behalf.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import BotTrade, Post, Subscription
from app.services.crypto import decrypt_api_key
from app.services.hyperliquid import HyperliquidTrader
from app.services.price_store import price_store # noqa: F401 (used elsewhere)
logger = logging.getLogger(__name__)
# Platform-wide thresholds (per-user values in Subscription override where applicable)
# No global confidence floor — users pick their own 0100 threshold via settings.
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users)
# Hyperliquid perp fees (mainnet, base tier).
# IOC orders always cross the book → taker fee both on open and close.
# Ref: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees
HL_TAKER_FEE_RATE = 0.00045 # 4.5 bps per side → 9 bps round-trip
# 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] = {}
# Strong references to background close tasks. Python's GC can collect
# unreferenced asyncio.Task objects before they finish — keeping them here
# prevents silent task cancellation. Entries are removed when tasks complete.
_background_tasks: set = set()
# Per-wallet locks for the open-position critical section.
# Prevents two concurrent signals from both passing the daily-budget check
# before either trade is written to DB (TOCTOU race).
_wallet_open_locks: Dict[str, asyncio.Lock] = {}
def _wallet_lock(wallet: str) -> asyncio.Lock:
lock = _wallet_open_locks.get(wallet)
if lock is None:
lock = asyncio.Lock()
_wallet_open_locks[wallet] = lock
return lock
def _lock_for(trade_id: int) -> asyncio.Lock:
lock = _close_locks.get(trade_id)
if lock is None:
lock = asyncio.Lock()
_close_locks[trade_id] = lock
return lock
async def process_post(post: Post, db: AsyncSession) -> None:
"""
Entry point called by the scraper after a new post is saved.
`db` is used only to fetch the subscriber list; each subscriber gets its own session.
"""
if not post.relevant:
return
if post.signal not in ('buy', 'short', 'sell'):
logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal)
return
asset = post.price_impact_asset or 'BTC'
# "sell" is treated as a short signal (bearish directional trade)
side = 'long' if post.signal == 'buy' else 'short'
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
result = await db.execute(select(Subscription).where(Subscription.active == True)) # noqa: E712
subscribers = result.scalars().all()
if not subscribers:
logger.info("No active subscribers, skipping trade execution")
return
# 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,
daily_budget_usd=s.daily_budget_usd,
active_from=s.active_from,
active_until=s.active_until,
)
for s in subscribers
]
post_id = post.id
post_confidence = post.ai_confidence or 0
tasks = [
_execute_for_subscriber(sub, post_id, post_confidence, asset, side)
for sub in subs_snapshot
]
await asyncio.gather(*tasks, return_exceptions=True)
async def _execute_for_subscriber(
sub: dict,
post_id: int,
post_confidence: int,
asset: str,
side: str,
) -> None:
wallet = sub["wallet"]
if not sub["hl_api_key"]:
logger.warning("Subscriber %s has no HL API key, skipping", wallet)
return
# Required-setup guard: the bot is only allowed to trade when the user
# has explicitly set take-profit, stop-loss, and a daily budget. Prevents
# accidental trading on partially-configured accounts.
missing = [k for k in ("take_profit_pct", "stop_loss_pct", "daily_budget_usd")
if sub.get(k) is None]
if missing:
logger.info("Sub %s skipped post %d: setup incomplete (missing %s)",
wallet, post_id, ", ".join(missing))
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
# Active-window check. Both values stored as naive-UTC.
af = sub.get("active_from")
au = sub.get("active_until")
if af is not None and au is not None:
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if now_utc_naive < af or now_utc_naive >= au:
logger.info("Sub %s skipped post %d: outside active window [%s, %s)",
wallet, post_id, af, au)
return
try:
api_key = decrypt_api_key(sub["hl_api_key"])
except Exception as exc:
logger.error("Cannot decrypt key for %s: %s", wallet, exc)
return
# Per-wallet lock wraps BOTH the budget re-check and the open, so two
# concurrent signals can't both pass the daily-budget gate before either
# trade is written to DB (TOCTOU race under asyncio.gather).
async with _wallet_lock(wallet):
# Re-check daily budget INSIDE the lock so it's atomic with the open.
daily_cap = sub.get("daily_budget_usd")
if daily_cap is not None and daily_cap > 0:
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
async with AsyncSessionLocal() as budget_db:
spent_result = await budget_db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.opened_at >= start_of_day,
)
)
spent_trades = spent_result.scalars().all()
spent = sum(
(t.size_usd if t.size_usd is not None else sub["position_size_usd"])
for t in spent_trades
)
if spent + sub["position_size_usd"] > daily_cap:
logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f",
wallet, spent, sub["position_size_usd"], daily_cap)
return
# Each subscriber gets its own session — AsyncSession is NOT concurrency-safe.
async with AsyncSessionLocal() as db:
try:
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=sub["leverage"],
mainnet=settings.hl_mainnet,
)
open_positions = await trader.get_open_positions()
if any(p.get('coin') == asset for p in open_positions):
logger.info("Subscriber %s already has open %s position, skipping", wallet, asset)
return
result = await trader.open_position(asset, side, sub["position_size_usd"])
entry_price = result.get('fill_price', 0.0)
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'),
size_usd=sub["position_size_usd"],
leverage=sub["leverage"],
)
db.add(trade)
await db.commit()
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"],
)
task = asyncio.create_task(_close_after_hold(
trade.id, api_key, sub["leverage"], asset, wallet
))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
except Exception as 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()
# Prefer the historical snapshot stamped on the trade row;
# fall back to current Subscription or caller's leverage for
# legacy rows.
if trade.size_usd is not None:
size_usd = trade.size_usd
else:
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
trade_leverage = trade.leverage if trade.leverage is not None else leverage
trader = HyperliquidTrader(
api_private_key=api_key,
account_address=wallet,
leverage=trade_leverage,
mainnet=settings.hl_mainnet,
)
close_result = await trader.close_position(asset)
# Detect "position was already closed externally" before we even tried
if close_result.get("already_closed"):
logger.warning(
"Trade %d: no open %s position found on HL — "
"user likely closed it manually. Marking trade closed with no PnL.",
trade_id, asset,
)
trade.exit_price = None
trade.pnl_usd = None
trade.hold_seconds = int(
(datetime.now(timezone.utc) -
trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds()
)
await db.commit()
from app.services.tp_sl_monitor import unregister
unregister(trade_id)
_close_locks.pop(trade_id, None)
return
exit_price = close_result.get("fill_price")
if not exit_price:
raise ValueError(f"close_position returned no fill_price for trade {trade_id}")
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
# size_usd is the NOTIONAL value — leverage affects margin, not PnL.
gross_pnl = size_usd * signed_pct
# Deduct round-trip taker fees (IOC crosses book on both open + close).
# Without this, displayed PnL overstates real returns by ~9 bps per trade.
fees_usd = size_usd * HL_TAKER_FEE_RATE * 2
pnl_usd = gross_pnl - fees_usd
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 gross=%.2f fees=%.2f net=%.2f (reason=%s)",
trade.side, asset, wallet, exit_price,
gross_pnl, fees_usd, pnl_usd, reason,
)
except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, e)
# Rollback closed_at so recovery can retry this trade on next restart.
# Without this, the trade is "closed" in DB but position still open on HL.
try:
async with AsyncSessionLocal() as rb_db:
await rb_db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.values(closed_at=None)
)
await rb_db.commit()
logger.info("Rolled back closed_at for trade %d — will retry on recovery", trade_id)
except Exception as rb_exc:
logger.error("Failed to rollback closed_at for trade %d: %s", trade_id, rb_exc)
finally:
_close_locks.pop(trade_id, None)
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")