From 0eee0a35ee46214f1fea494f3eed8e66d7fe68f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= <78488229+viktor138irk@users.noreply.github.com> Date: Fri, 8 May 2026 03:44:23 +0900 Subject: [PATCH] Add monolithic AI trading bot service --- app/ai_bot.py | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 app/ai_bot.py diff --git a/app/ai_bot.py b/app/ai_bot.py new file mode 100644 index 0000000..0aa59bb --- /dev/null +++ b/app/ai_bot.py @@ -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}', + }