Add monolithic AI trading bot service
Этот коммит содержится в:
@@ -0,0 +1,268 @@
|
|||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.coinex import CoinExClient
|
||||||
|
from app.models import AiDecision, BotState, DemoPosition, DemoTradeRecord, NewsSignal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IndicatorPack:
|
||||||
|
market: str
|
||||||
|
last_price: float
|
||||||
|
sma_fast: float
|
||||||
|
sma_slow: float
|
||||||
|
momentum_pct: float
|
||||||
|
volatility_pct: float
|
||||||
|
news_score: float
|
||||||
|
score: float
|
||||||
|
action: str
|
||||||
|
confidence: float
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'market': self.market,
|
||||||
|
'last_price': self.last_price,
|
||||||
|
'sma_fast': self.sma_fast,
|
||||||
|
'sma_slow': self.sma_slow,
|
||||||
|
'momentum_pct': self.momentum_pct,
|
||||||
|
'volatility_pct': self.volatility_pct,
|
||||||
|
'news_score': self.news_score,
|
||||||
|
'score': self.score,
|
||||||
|
'action': self.action,
|
||||||
|
'confidence': self.confidence,
|
||||||
|
'reason': self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AiTradingBot:
|
||||||
|
def __init__(self, coinex: CoinExClient, markets: list[str]) -> None:
|
||||||
|
self.coinex = coinex
|
||||||
|
self.markets = markets
|
||||||
|
|
||||||
|
def get_or_create_state(self, db: Session) -> BotState:
|
||||||
|
state = db.get(BotState, 1)
|
||||||
|
if state is None:
|
||||||
|
state = BotState(id=1)
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def update_state(self, db: Session, **kwargs: Any) -> BotState:
|
||||||
|
state = self.get_or_create_state(db)
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if value is not None and hasattr(state, key):
|
||||||
|
setattr(state, key, value)
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def analyze_market(self, db: Session, market: str) -> IndicatorPack:
|
||||||
|
market = market.upper()
|
||||||
|
raw = await self.coinex.get_kline(market, period='1min', limit=120)
|
||||||
|
candles = self._parse_candles(raw)
|
||||||
|
closes = [c['close'] for c in candles]
|
||||||
|
if len(closes) < 20:
|
||||||
|
raise ValueError(f'not enough candles for {market}')
|
||||||
|
|
||||||
|
last_price = closes[-1]
|
||||||
|
sma_fast = statistics.fmean(closes[-9:])
|
||||||
|
sma_slow = statistics.fmean(closes[-30:]) if len(closes) >= 30 else statistics.fmean(closes)
|
||||||
|
momentum_pct = ((last_price - closes[-10]) / closes[-10]) * 100 if closes[-10] else 0.0
|
||||||
|
returns = [((closes[i] - closes[i - 1]) / closes[i - 1]) * 100 for i in range(1, len(closes)) if closes[i - 1]]
|
||||||
|
volatility_pct = statistics.pstdev(returns[-30:]) if len(returns) >= 2 else 0.0
|
||||||
|
news_score = self._latest_news_score(db, market)
|
||||||
|
|
||||||
|
trend_bonus = 18 if sma_fast > sma_slow else -12
|
||||||
|
momentum_bonus = max(-20, min(20, momentum_pct * 4))
|
||||||
|
news_bonus = (news_score - 50) * 0.35
|
||||||
|
volatility_penalty = min(18, volatility_pct * 1.8)
|
||||||
|
score = max(0, min(100, 50 + trend_bonus + momentum_bonus + news_bonus - volatility_penalty))
|
||||||
|
|
||||||
|
if score >= 70:
|
||||||
|
action = 'buy'
|
||||||
|
elif score <= 35:
|
||||||
|
action = 'sell'
|
||||||
|
else:
|
||||||
|
action = 'hold'
|
||||||
|
|
||||||
|
confidence = max(0, min(100, abs(score - 50) * 1.8))
|
||||||
|
reason = (
|
||||||
|
f'AI-bot: {market} score={score:.1f}. '
|
||||||
|
f'SMA fast {sma_fast:.8f} vs slow {sma_slow:.8f}; '
|
||||||
|
f'momentum {momentum_pct:.2f}%; volatility {volatility_pct:.2f}%; news_score {news_score:.1f}. '
|
||||||
|
f'Action: {action}.'
|
||||||
|
)
|
||||||
|
return IndicatorPack(
|
||||||
|
market=market,
|
||||||
|
last_price=last_price,
|
||||||
|
sma_fast=sma_fast,
|
||||||
|
sma_slow=sma_slow,
|
||||||
|
momentum_pct=momentum_pct,
|
||||||
|
volatility_pct=volatility_pct,
|
||||||
|
news_score=news_score,
|
||||||
|
score=score,
|
||||||
|
action=action,
|
||||||
|
confidence=confidence,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def choose_best_market(self, db: Session) -> IndicatorPack:
|
||||||
|
packs: list[IndicatorPack] = []
|
||||||
|
for market in self.markets:
|
||||||
|
try:
|
||||||
|
packs.append(await self.analyze_market(db, market))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not packs:
|
||||||
|
raise ValueError('no market could be analyzed')
|
||||||
|
return max(packs, key=lambda item: item.score)
|
||||||
|
|
||||||
|
async def make_decision(self, db: Session, market: str | None = None, persist: bool = True) -> AiDecision:
|
||||||
|
pack = await self.analyze_market(db, market) if market else await self.choose_best_market(db)
|
||||||
|
decision = AiDecision(
|
||||||
|
market=pack.market,
|
||||||
|
action=pack.action,
|
||||||
|
score=pack.score,
|
||||||
|
confidence=pack.confidence,
|
||||||
|
reason=pack.reason,
|
||||||
|
indicators_json=json.dumps(pack.as_dict(), ensure_ascii=False),
|
||||||
|
executed=False,
|
||||||
|
)
|
||||||
|
if persist:
|
||||||
|
db.add(decision)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(decision)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
def risk_check(self, db: Session, decision: AiDecision, quote_amount: float) -> dict[str, Any]:
|
||||||
|
state = self.get_or_create_state(db)
|
||||||
|
open_positions = db.query(DemoPosition).filter(DemoPosition.is_open.is_(True), DemoPosition.amount > 0).count()
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if state.emergency_stop:
|
||||||
|
reasons.append('emergency stop is active')
|
||||||
|
if not state.enabled:
|
||||||
|
reasons.append('bot is disabled')
|
||||||
|
if decision.score < state.min_signal_score and decision.action == 'buy':
|
||||||
|
reasons.append('signal score below minimum')
|
||||||
|
if quote_amount > state.max_quote_per_trade:
|
||||||
|
reasons.append('quote amount exceeds max per trade')
|
||||||
|
if open_positions >= state.max_open_positions and decision.action == 'buy':
|
||||||
|
reasons.append('max open positions reached')
|
||||||
|
if decision.action == 'hold':
|
||||||
|
reasons.append('decision is hold')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'allowed': not reasons,
|
||||||
|
'reasons': reasons,
|
||||||
|
'state': self.state_to_dict(state),
|
||||||
|
}
|
||||||
|
|
||||||
|
def record_manual_signal(self, db: Session, title: str, market: str, sentiment: str, score: float, source: str = 'manual', url: str = '') -> NewsSignal:
|
||||||
|
signal = NewsSignal(
|
||||||
|
title=title,
|
||||||
|
market=market.upper(),
|
||||||
|
sentiment=sentiment.lower(),
|
||||||
|
score=max(0, min(100, score)),
|
||||||
|
source=source,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
db.add(signal)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(signal)
|
||||||
|
return signal
|
||||||
|
|
||||||
|
def recent_decisions(self, db: Session, limit: int = 50) -> list[AiDecision]:
|
||||||
|
return db.query(AiDecision).order_by(AiDecision.id.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def recent_signals(self, db: Session, limit: int = 50) -> list[NewsSignal]:
|
||||||
|
return db.query(NewsSignal).order_by(NewsSignal.id.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def state_to_dict(self, state: BotState) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'enabled': state.enabled,
|
||||||
|
'trade_mode': state.trade_mode,
|
||||||
|
'trade_style_mode': state.trade_style_mode,
|
||||||
|
'min_signal_score': state.min_signal_score,
|
||||||
|
'max_open_positions': state.max_open_positions,
|
||||||
|
'max_quote_per_trade': state.max_quote_per_trade,
|
||||||
|
'emergency_stop': state.emergency_stop,
|
||||||
|
'live_acknowledged': state.live_acknowledged,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_candles(self, raw: dict[str, Any]) -> list[dict[str, float]]:
|
||||||
|
rows = raw.get('data') or []
|
||||||
|
candles: list[dict[str, float]] = []
|
||||||
|
for row in rows:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
close = row.get('close') or row.get('closing_price')
|
||||||
|
open_ = row.get('open') or row.get('opening_price')
|
||||||
|
high = row.get('high') or row.get('highest_price')
|
||||||
|
low = row.get('low') or row.get('lowest_price')
|
||||||
|
created_at = row.get('created_at') or row.get('time') or 0
|
||||||
|
else:
|
||||||
|
created_at, open_, close, high, low = row[0], row[1], row[2], row[3], row[4]
|
||||||
|
try:
|
||||||
|
candles.append({
|
||||||
|
'time': float(created_at),
|
||||||
|
'open': float(open_),
|
||||||
|
'high': float(high),
|
||||||
|
'low': float(low),
|
||||||
|
'close': float(close),
|
||||||
|
})
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
candles.sort(key=lambda item: item['time'])
|
||||||
|
return candles
|
||||||
|
|
||||||
|
def _latest_news_score(self, db: Session, market: str) -> float:
|
||||||
|
signals = db.query(NewsSignal).filter(NewsSignal.market == market).order_by(NewsSignal.id.desc()).limit(10).all()
|
||||||
|
if not signals:
|
||||||
|
return 50.0
|
||||||
|
return statistics.fmean(signal.score for signal in signals)
|
||||||
|
|
||||||
|
|
||||||
|
def decision_to_dict(decision: AiDecision) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': decision.id,
|
||||||
|
'market': decision.market,
|
||||||
|
'action': decision.action,
|
||||||
|
'score': decision.score,
|
||||||
|
'confidence': decision.confidence,
|
||||||
|
'reason': decision.reason,
|
||||||
|
'indicators': json.loads(decision.indicators_json or '{}'),
|
||||||
|
'executed': decision.executed,
|
||||||
|
'created_at': decision.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signal_to_dict(signal: NewsSignal) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': signal.id,
|
||||||
|
'source': signal.source,
|
||||||
|
'title': signal.title,
|
||||||
|
'market': signal.market,
|
||||||
|
'sentiment': signal.sentiment,
|
||||||
|
'score': signal.score,
|
||||||
|
'url': signal.url,
|
||||||
|
'created_at': signal.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def trade_marker_from_record(trade: DemoTradeRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': trade.id,
|
||||||
|
'time': int(trade.created_at.timestamp()),
|
||||||
|
'market': trade.market,
|
||||||
|
'side': trade.side,
|
||||||
|
'price': trade.price,
|
||||||
|
'amount': trade.amount,
|
||||||
|
'text': f'{trade.side.upper()} {trade.amount:g} @ {trade.price:g}',
|
||||||
|
}
|
||||||
Ссылка в новой задаче
Block a user