747158b5ed
Why
v4 was firing buy/short on 13% of posts, but only 9% of those had a
≥1% move within the hour. Median move on 'actionable' was 0.298% vs
0.258% on 'hold' — a 1.15× signal-to-noise ratio (random would be 1.0).
The model was confabulating transmission chains to please the user
rather than holding when uncertain.
Separately: 'sell' meant 'close longs / de-risk' in the prompt but
was traded as 'open short' by bot_engine.py, producing systematically
negative results on sell signals (27% win rate vs 57% on real shorts).
What changed
• analysis.py rewritten as v5-extreme-alpha:
- Asymmetric error costs framing (false positive = -$30, FN = $0)
- 7-item checklist that MUST all pass before buy/short
- Only 4 named transmission paths (a/b/c/d); anything else = HOLD
- 5 positive + 5 negative few-shot examples
- UTC hour injected with liquidity context (Asia thin → stricter)
- Adversarial steelman self-check before final output
- confidence < 80 + checklist failure both force-collapse to HOLD
in code, regardless of what the model returns (defense-in-depth)
- 'sell' removed from output schema entirely
• bot_engine.py: stop trading 'sell' signals (treat as hold)
• Case-insensitive normalization on checklist values so model
returning 'None'/'True' (capitalized) doesn't slip through
Expected impact (to validate over next 2-3 weeks of new posts)
• actionable rate: 13% → 2-4%
• signal/hold MFE ratio: 1.15× → 3-5×
• ≥1% hit rate among actionable: 9% → 40-60%
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""
|
||
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 0–100 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
|
||
# v5: "sell" is no longer emitted by the AI. Old DB rows might still have
|
||
# it; treat as hold (do not trade) — see analysis.py docstring for why
|
||
# the previous "sell→short" treatment was a semantic bug.
|
||
if post.signal not in ('buy', 'short'):
|
||
logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal)
|
||
return
|
||
|
||
asset = post.price_impact_asset or 'BTC'
|
||
side = 'long' if post.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")
|