132 lines
4.7 KiB
Python
132 lines
4.7 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 sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from app.models import Post, BotTrade, Subscription
|
|
from app.services.hyperliquid import HyperliquidTrader
|
|
from app.services.price_store import price_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Thresholds
|
|
MIN_CONFIDENCE = 70 # ai_confidence must be >= this to trade
|
|
POSITION_SIZE_USD = 100 # per-trade size in USD (fixed for MVP)
|
|
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour
|
|
|
|
|
|
async def process_post(post: Post, db: AsyncSession) -> None:
|
|
"""
|
|
Entry point called by the scraper after a new post is saved.
|
|
Skips non-relevant or low-confidence posts.
|
|
"""
|
|
if not post.relevant:
|
|
return
|
|
if (post.ai_confidence or 0) < MIN_CONFIDENCE:
|
|
logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, MIN_CONFIDENCE)
|
|
return
|
|
if post.sentiment == 'neutral':
|
|
return
|
|
|
|
asset = post.price_impact_asset or 'BTC'
|
|
side = 'long' if post.sentiment == 'bullish' 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))
|
|
subscribers = result.scalars().all()
|
|
|
|
if not subscribers:
|
|
logger.info("No active subscribers, skipping trade execution")
|
|
return
|
|
|
|
tasks = [_execute_for_subscriber(sub, post, asset, side, db) for sub in subscribers]
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
async def _execute_for_subscriber(
|
|
sub: Subscription,
|
|
post: Post,
|
|
asset: str,
|
|
side: str,
|
|
db: AsyncSession,
|
|
) -> None:
|
|
if not sub.hl_api_key:
|
|
logger.warning("Subscriber %s has no HL API key, skipping", sub.wallet_address)
|
|
return
|
|
|
|
try:
|
|
trader = HyperliquidTrader(sub.hl_api_key)
|
|
|
|
# Check for existing open position to avoid doubling up
|
|
open_positions = await trader.get_open_positions()
|
|
already_open = any(p.get('coin') == asset for p in open_positions)
|
|
if already_open:
|
|
logger.info("Subscriber %s already has open %s position, skipping", sub.wallet_address, asset)
|
|
return
|
|
|
|
result = await trader.open_position(asset, side, POSITION_SIZE_USD)
|
|
entry_price = result.get('fill_price', 0.0)
|
|
|
|
trade = BotTrade(
|
|
asset=asset,
|
|
side=side,
|
|
entry_price=entry_price,
|
|
wallet_address=sub.wallet_address,
|
|
trigger_post_id=post.id,
|
|
opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
|
hl_order_id=result.get('order_id'),
|
|
)
|
|
db.add(trade)
|
|
await db.commit()
|
|
await db.refresh(trade)
|
|
|
|
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", side, asset, sub.wallet_address, entry_price, trade.id)
|
|
|
|
# Schedule close after MAX_HOLD_SECONDS
|
|
asyncio.create_task(_close_after_hold(trade.id, sub.hl_api_key, asset, sub.wallet_address))
|
|
|
|
except Exception as e:
|
|
logger.error("Trade execution failed for %s: %s", sub.wallet_address, e)
|
|
|
|
|
|
async def _close_after_hold(trade_id: int, api_key: str, asset: str, wallet: str) -> None:
|
|
await asyncio.sleep(MAX_HOLD_SECONDS)
|
|
|
|
from app.database import AsyncSessionLocal
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
result = await db.execute(
|
|
select(BotTrade).where(BotTrade.id == trade_id, BotTrade.closed_at.is_(None))
|
|
)
|
|
trade = result.scalar_one_or_none()
|
|
if trade is None:
|
|
return # already closed
|
|
|
|
trader = HyperliquidTrader(api_key)
|
|
close_result = await trader.close_position(asset)
|
|
exit_price = close_result.get('fill_price', 0.0)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
hold_secs = int((now - trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds())
|
|
pnl = (exit_price - trade.entry_price) * (1 if trade.side == 'long' else -1)
|
|
# Approximate PnL: size_usd * pct_change
|
|
pnl_usd = POSITION_SIZE_USD * (pnl / trade.entry_price) if trade.entry_price else 0.0
|
|
|
|
trade.exit_price = exit_price
|
|
trade.pnl_usd = round(pnl_usd, 2)
|
|
trade.hold_seconds = hold_secs
|
|
trade.closed_at = now
|
|
|
|
await db.commit()
|
|
logger.info("Closed %s %s for %s @ %.2f PnL=%.2f", trade.side, asset, wallet, exit_price, pnl_usd)
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to close trade %d: %s", trade_id, e)
|