Files
trade/app/main.py
T

411 строки
18 KiB
Python

import asyncio
from pathlib import Path
from typing import Any
import httpx
from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from app.ai_bot import AiTradingBot, decision_to_dict, signal_to_dict, trade_marker_from_record
from app.auth import require_auth
from app.coinex import CoinExClient, CoinExLiveStream
from app.core import get_settings
from app.db import SessionLocal, get_db, init_db
from app.demo_account import DemoAccountService
from app.legacy_bot import LegacyBotService
from app.market_rules import MarketRuleService
from app.models import DemoTradeRecord, MarketRule
from app.monitor import SignalMonitor
settings = get_settings()
coinex = CoinExClient(settings.coinex_api_base, settings.coinex_access_id, settings.coinex_secret_key)
live_stream = CoinExLiveStream(settings.coinex_ws_spot, coinex)
demo_service = DemoAccountService(settings.demo_initial_balance, settings.demo_quote_asset)
ai_bot = AiTradingBot(coinex, settings.markets)
legacy_bot = LegacyBotService()
market_rules = MarketRuleService(coinex)
monitor = SignalMonitor(ai_bot, demo_service, legacy_bot, market_rules)
monitor_task: asyncio.Task | None = None
app = FastAPI(title=settings.app_name)
static_dir = Path(__file__).parent / 'static'
app.mount('/static', StaticFiles(directory=static_dir), name='static')
def ensure_monitor_running() -> None:
global monitor_task
if monitor_task is None or monitor_task.done():
monitor_task = asyncio.create_task(monitor.loop(SessionLocal, interval_seconds=10))
def current_markets(db: Session) -> list[str]:
return market_rules.active_markets(db, fallback=ai_bot.markets or settings.markets)
def _float(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
async def coinex_wallets_public_valuation(db: Session) -> dict[str, Any]:
"""Temporary live balance adapter.
It avoids showing demo money in LIVE mode. Real private CoinEx balances need
the signed account adapter, but this endpoint now returns a clear Russian
status and can value any known wallet rows once the signed balance source is
connected.
"""
if not settings.coinex_access_id or not settings.coinex_secret_key:
return {
'available': False,
'balance': 0.0,
'equity': 0.0,
'realized_pnl': 0.0,
'unrealized_pnl': 0.0,
'total_pnl': 0.0,
'quote_asset': 'USDT',
'message': 'Live баланс CoinEx недоступен: API ключи не заданы в .env.',
'wallets': [],
}
return {
'available': False,
'balance': 0.0,
'equity': 0.0,
'realized_pnl': 0.0,
'unrealized_pnl': 0.0,
'total_pnl': 0.0,
'quote_asset': 'USDT',
'message': 'Live баланс CoinEx пока не подключен к приватному API. Demo баланс в LIVE больше не показывается.',
'wallets': [],
}
@app.on_event('startup')
async def on_startup() -> None:
init_db()
with SessionLocal() as db:
legacy_bot.ensure_defaults(db)
active = current_markets(db)
if active:
ai_bot.markets = active
state = ai_bot.get_or_create_state(db)
if state.enabled:
ensure_monitor_running()
@app.get('/')
async def dashboard(_: str = Depends(require_auth)) -> FileResponse:
return FileResponse(static_dir / 'index.html')
@app.get('/health')
async def health() -> dict:
return {'status': 'ok', 'app': settings.app_name, 'env': settings.app_env, 'default_market': settings.default_market}
@app.get('/api/v1/app/bootstrap')
async def app_bootstrap(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.get_or_create_state(db)
account = demo_service.snapshot(db)
legacy_bot.ensure_defaults(db)
markets = current_markets(db)
return {'app': settings.app_name, 'default_market': settings.default_market, 'markets': markets, 'bot': ai_bot.state_to_dict(state), 'account': account.model_dump(mode='json'), 'legacy': legacy_bot.dashboard(db), 'monitor': monitor.status()}
@app.get('/api/v1/demo/account')
async def demo_account(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return demo_service.snapshot(db).model_dump(mode='json')
@app.get('/api/v1/live/account')
async def live_account(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return await coinex_wallets_public_valuation(db)
@app.post('/api/v1/demo/reset')
async def reset_demo_account(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return demo_service.reset(db).model_dump(mode='json')
@app.post('/api/v1/demo/trades')
async def create_demo_trade(market: str, side: str, price: float, amount: float, reason: str = 'manual demo trade', _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
trade = demo_service.add_demo_trade(db=db, market=market, side=side, price=price, amount=amount, reason=reason)
legacy_bot.snapshot_history(db)
return trade.model_dump(mode='json')
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/v1/demo/close-full')
async def close_demo_full_market(market: str, price: float, reason: str = 'manual full close', _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
trade = demo_service.close_full_market(db, market, price, reason)
legacy_bot.snapshot_history(db)
monitor.write_log(db, 'success', 'manual_full_close', f'Вручную закрыта вся позиция {market.upper()}: объем {trade.amount:g}, цена {trade.price:.8f}.', market=market.upper(), action='sell')
return trade.model_dump(mode='json')
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/v1/market/kline')
async def market_kline(market: str | None = None, period: str = '1min', limit: int = 100, _: str = Depends(require_auth)) -> dict:
return await coinex.get_kline(market or settings.default_market, period, limit)
@app.get('/api/v1/market/ticker')
async def market_ticker(market: str | None = None, _: str = Depends(require_auth)) -> dict:
return await coinex.get_ticker(market or settings.default_market)
@app.post('/api/v1/market/rules/sync')
async def sync_market_rules(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
result = await market_rules.sync(db, None)
active = market_rules.active_markets(db, fallback=settings.markets)
if active:
ai_bot.markets = active
monitor.rules_synced = True
monitor.write_log(db, 'success', 'manual_coinex_rules_synced', f'Вручную синхронизированы лимиты и комиссии CoinEx: {result["synced"]} рынков. Активных рынков: {len(active)}.')
return {'synced': result['synced'], 'active_markets': len(active), 'markets': active[:200]}
@app.get('/api/v1/market/rules')
async def list_market_rules(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
rows = db.query(MarketRule).order_by(MarketRule.market.asc()).all()
return {'items': [{'market': r.market, 'min_amount': r.min_amount, 'min_quote_amount': r.min_quote_amount, 'amount_precision': r.amount_precision, 'price_precision': r.price_precision, 'maker_fee_rate': r.maker_fee_rate, 'taker_fee_rate': r.taker_fee_rate, 'synced_at': r.synced_at.isoformat()} for r in rows]}
@app.get('/api/v1/bot/settings')
async def bot_settings(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.get_or_create_state(db)
data = ai_bot.state_to_dict(state)
data['markets'] = current_markets(db)
return data
@app.post('/api/v1/bot/settings')
async def update_bot_settings(enabled: bool | None = None, trade_mode: str | None = None, trade_style_mode: str | None = None, min_signal_score: float | None = None, max_open_positions: int | None = None, max_quote_per_trade: float | None = None, emergency_stop: bool | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
if trade_mode is not None and trade_mode not in {'demo', 'live'}:
raise HTTPException(status_code=400, detail='trade_mode must be demo or live')
state = ai_bot.update_state(db, enabled=enabled, trade_mode=trade_mode, trade_style_mode=trade_style_mode, min_signal_score=min_signal_score, max_open_positions=max_open_positions, max_quote_per_trade=max_quote_per_trade, emergency_stop=emergency_stop)
if state.enabled:
ensure_monitor_running()
return ai_bot.state_to_dict(state)
@app.get('/api/v1/monitor/status')
async def monitor_status(_: str = Depends(require_auth)) -> dict:
return monitor.status()
@app.get('/api/v1/monitor/logs')
async def monitor_logs(limit: int = 80, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': monitor.recent_logs(db, limit)}
@app.post('/api/v1/monitor/start')
async def monitor_start(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.update_state(db, enabled=True)
ensure_monitor_running()
data = monitor.status()
data['bot'] = ai_bot.state_to_dict(state)
return data
@app.post('/api/v1/monitor/stop')
async def monitor_stop(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.update_state(db, enabled=False)
monitor.stop()
data = monitor.status()
data['bot'] = ai_bot.state_to_dict(state)
return data
@app.post('/api/v1/bot/live/enable')
async def enable_live_mode(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.update_state(db, trade_mode='live', emergency_stop=False)
return {'trade_mode': 'live', 'settings': ai_bot.state_to_dict(state)}
@app.post('/api/v1/bot/live/disable')
async def disable_live_mode(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.update_state(db, trade_mode='demo')
return {'trade_mode': 'demo', 'settings': ai_bot.state_to_dict(state)}
@app.get('/api/v1/bot/analyze')
async def analyze_market(market: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
pack = await ai_bot.analyze_market(db, market or settings.default_market)
return pack.as_dict()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/v1/bot/best-market')
async def best_market(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
pack = await ai_bot.choose_best_market(db)
return pack.as_dict()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/v1/bot/decide')
async def bot_decide(market: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
decision = await ai_bot.make_decision(db, market)
return decision_to_dict(decision)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _force_demo_trade(db: Session, decision) -> dict:
state = ai_bot.get_or_create_state(db)
decision_data = decision_to_dict(decision)
price = float(decision_data['indicators']['last_price'])
side = decision.action if decision.action in {'buy', 'sell'} else 'buy'
if side == 'buy':
amount, rule_message = market_rules.ensure_amount(db, decision.market, max(1.0, state.max_quote_per_trade), price)
trade = demo_service.add_demo_trade(db, decision.market, 'buy', price, amount, f'{decision.reason}; {rule_message}')
else:
trade = demo_service.close_full_market(db, decision.market, price, decision.reason)
decision.executed = True
db.add(decision)
db.commit()
legacy_bot.snapshot_history(db)
monitor.write_log(db, 'success', 'manual_auto_order', f'Открыта/закрыта сделка вручную: {trade.side.upper()} {trade.market}, сумма {trade.quote_amount:.2f} USDT, цена {trade.price:.8f}.', market=trade.market, action=trade.side, score=decision.score)
return {'executed': True, 'mode': 'demo', 'decision': decision_to_dict(decision), 'trade': trade.model_dump(mode='json')}
@app.post('/api/v1/bot/auto-demo-trade')
async def auto_demo_trade(market: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
decision = await ai_bot.make_decision(db, market)
return _force_demo_trade(db, decision)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post('/api/v1/bot/auto-trade')
async def auto_trade(market: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
state = ai_bot.get_or_create_state(db)
decision = await ai_bot.make_decision(db, market)
if state.trade_mode == 'live':
monitor.write_log(db, 'warning', 'live_order_ready_blocked', f'Live-сигнал {decision.market} найден. Live-исполнение ожидает подключения приватного CoinEx adapter.', market=decision.market, action=decision.action, score=decision.score)
return {'executed': False, 'mode': 'live', 'decision': decision_to_dict(decision), 'message': 'Live сигнал найден. Live-исполнение ожидает подключения приватного CoinEx adapter.'}
return _force_demo_trade(db, decision)
@app.post('/api/v1/signals/manual')
async def create_manual_signal(title: str, market: str, sentiment: str = 'neutral', score: float = 50.0, source: str = 'manual', url: str = '', _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
signal = ai_bot.record_manual_signal(db, title=title, market=market, sentiment=sentiment, score=score, source=source, url=url)
return signal_to_dict(signal)
@app.get('/api/v1/signals/recent')
async def recent_signals(limit: int = 50, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': [signal_to_dict(item) for item in ai_bot.recent_signals(db, limit)]}
@app.get('/api/v1/decisions/recent')
async def recent_decisions(limit: int = 50, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': [decision_to_dict(item) for item in ai_bot.recent_decisions(db, limit)]}
@app.get('/api/v1/dashboard/markers')
async def dashboard_markers(market: str | None = None, limit: int = 100, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
query = db.query(DemoTradeRecord)
if market:
query = query.filter(DemoTradeRecord.market == market.upper())
trades = query.order_by(DemoTradeRecord.id.desc()).limit(limit).all()
return {'items': [trade_marker_from_record(item) for item in reversed(trades)]}
@app.get('/api/v1/legacy/dashboard')
async def legacy_dashboard(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.dashboard(db)
@app.get('/api/v1/legacy/open-trades')
async def legacy_open_trades(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': legacy_bot.open_positions(db)}
@app.get('/api/v1/legacy/closed-trades')
async def legacy_closed_trades(limit: int = 50, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': legacy_bot.closed_positions(db, limit)}
@app.get('/api/v1/legacy/chart-history')
async def legacy_chart_history(limit: int = 100, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return {'items': legacy_bot.chart_history(db, limit)}
@app.get('/api/v1/legacy/strategies')
async def legacy_strategies(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
legacy_bot.ensure_defaults(db)
return {'items': legacy_bot.dashboard(db)['strategies']}
@app.post('/api/v1/legacy/strategies/{strategy_id}')
async def legacy_update_strategy(strategy_id: int, enabled: bool | None = None, dry_run: bool | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
try:
return legacy_bot.update_strategy(db, strategy_id, enabled, dry_run)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get('/api/v1/legacy/coinex')
async def legacy_coinex(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.dashboard(db)['coinex']
@app.post('/api/v1/legacy/coinex')
async def legacy_update_coinex(enabled: bool | None = None, access_id: str | None = None, secret_key: str | None = None, mode: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.update_coinex(db, enabled, access_id, secret_key, mode)
@app.post('/api/v1/legacy/telegram')
async def legacy_update_telegram(enabled: bool | None = None, chat_id: str | None = None, message_format: str | None = None, _: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.update_telegram(db, enabled, chat_id, message_format)
@app.get('/api/v1/legacy/updater/status')
async def legacy_updater_status(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.updater_status(db)
@app.post('/api/v1/legacy/terminal')
async def legacy_terminal(command: str, _: str = Depends(require_auth)) -> dict:
return legacy_bot.terminal_command(command)
@app.get('/api/v1/android/status')
async def android_status(_: str = Depends(require_auth), db: Session = Depends(get_db)) -> dict:
return legacy_bot.android_status(db)
@app.websocket('/ws/market/{market}')
async def market_stream(websocket: WebSocket, market: str) -> None:
await websocket.accept()
try:
async for live_event in live_stream.trades(market):
with SessionLocal() as db:
account = demo_service.snapshot(db).model_dump(mode='json')
state = ai_bot.state_to_dict(ai_bot.get_or_create_state(db))
price = live_event.get('price')
if price:
demo_service.reprice_positions(db, {market: float(price)})
db.commit()
live_event['demo_account'] = account
live_event['bot_state'] = state
await websocket.send_json(live_event)
except WebSocketDisconnect:
return