deploy: 2026-03-20 07:49

This commit is contained in:
ufo6849
2026-03-20 07:49:42 +09:00
commit d14a8bab04
73 changed files with 76534 additions and 0 deletions

0
core/__init__.py Normal file
View File

306
core/bot.py Normal file
View File

@@ -0,0 +1,306 @@
"""Main bot orchestrator.
Ties together all modules into the main trading loop:
DataFeed -> ICTEngine -> MTFAnalyzer -> Confluence -> Signal -> Risk -> Order -> Position
"""
from __future__ import annotations
import asyncio
import signal as sys_signal
from datetime import datetime
from typing import Optional
from loguru import logger
from config import settings
from core.data_feed import DataFeed
from core.event_bus import event_bus
from database.models import init_db
from database.repository import TradingRepository, PositionRecord
from execution.exchange_client import ExchangeClient
from execution.order_manager import OrderManager
from execution.position_manager import PositionManager
from indicators.ict_engine import ICTEngine
from indicators.multi_timeframe import MultiTimeframeAnalyzer
from indicators.confluence import ConfluenceChecker
from notification.alert_manager import AlertManager
from notification.telegram_bot import TelegramNotifier
from risk.risk_manager import RiskManager
from risk.drawdown_monitor import DrawdownMonitor
from strategy.entry_rules import EntryRules
from strategy.exit_rules import ExitRules
from strategy.signal_generator import SignalGenerator, TradeSignal
class ICTBot:
"""ICT Smart Money Concepts trading bot orchestrator.
Lifecycle:
1. Initialize all components
2. Connect to exchange
3. Enter main loop
4. Each iteration:
a. Fetch/update data for all symbols
b. Run ICT analysis per symbol
c. Generate signals
d. Execute approved trades
e. Monitor open positions
f. Send notifications
5. Graceful shutdown on SIGINT/SIGTERM
"""
def __init__(self, paper_mode: bool = False):
# Core - use paper exchange if in paper/sandbox mode
if paper_mode or settings.SANDBOX_MODE:
from execution.paper_exchange import PaperExchangeClient
self.exchange_client = PaperExchangeClient(initial_balance=300.0)
logger.info("Using PAPER exchange client (simulated orders)")
else:
self.exchange_client = ExchangeClient()
self.data_feed = DataFeed(self.exchange_client)
# Indicators
self.ict_engine = ICTEngine()
self.mtf_analyzer = MultiTimeframeAnalyzer(self.ict_engine)
self.confluence_checker = ConfluenceChecker()
# Strategy
self.entry_rules = EntryRules()
self.exit_rules = ExitRules()
self.signal_generator = SignalGenerator(
ict_engine=self.ict_engine,
mtf_analyzer=self.mtf_analyzer,
confluence_checker=self.confluence_checker,
entry_rules=self.entry_rules,
exit_rules=self.exit_rules,
)
# Risk
self.risk_manager = RiskManager()
self.drawdown_monitor = DrawdownMonitor(
max_drawdown_limit=settings.MAX_DRAWDOWN
)
# Execution
self.order_manager = OrderManager(self.exchange_client, self.risk_manager)
self.position_manager = PositionManager(
self.order_manager, self.risk_manager, self.exit_rules
)
# Database
self.repo = TradingRepository()
# Notification
self.notifier = TelegramNotifier()
self.alert_manager = AlertManager([self.notifier])
# State
self._running = False
self._loop_interval = 60 # seconds between analysis cycles
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def start(self) -> None:
"""Initialise all components and start the main loop."""
logger.info("=" * 60)
logger.info(" ICT Smart Money Concepts Trading Bot")
logger.info(" Exchange : {}", settings.EXCHANGE_ID)
logger.info(" Sandbox : {}", settings.SANDBOX_MODE)
logger.info(" Pairs : {}", settings.TRADING_PAIRS)
logger.info("=" * 60)
# Database
self.repo.connect()
# Exchange
await self.exchange_client.connect()
await self.data_feed.connect()
# Futures initialization: set leverage and margin mode per symbol
for symbol in settings.TRADING_PAIRS:
await self.exchange_client.set_leverage(symbol, settings.DEFAULT_LEVERAGE)
await self.exchange_client.set_margin_mode(symbol, "isolated")
# Warm up data
for symbol in settings.TRADING_PAIRS:
logger.info("Warming up data for {}", symbol)
await self.data_feed.fetch_multi_timeframe(symbol)
self._running = True
logger.info("Bot started -- entering main loop")
try:
await self._main_loop()
except asyncio.CancelledError:
logger.info("Bot cancelled")
except Exception as e:
logger.exception("Unhandled error in main loop: {}", e)
await self.alert_manager.notify_error(str(e))
self.risk_manager.emergency_stop()
finally:
await self.stop()
async def stop(self) -> None:
"""Graceful shutdown."""
self._running = False
logger.info("Shutting down...")
# Close all positions if emergency
if self.risk_manager.is_stopped:
closed = await self.position_manager.close_all("EMERGENCY")
for p in closed:
await self.alert_manager.notify_emergency(
f"Emergency close: {p.symbol} PnL={p.realized_pnl:.2f}"
)
await self.data_feed.disconnect()
self.repo.close()
logger.info("Bot stopped")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
async def _main_loop(self) -> None:
"""Core trading loop."""
while self._running:
try:
for symbol in settings.TRADING_PAIRS:
await self._process_symbol(symbol)
# Check drawdown
balance_info = await self.exchange_client.fetch_balance()
equity = float(balance_info.get("total", {}).get("USDT", 0))
dd_state = self.drawdown_monitor.update(equity)
self.risk_manager.update_equity(equity)
if self.drawdown_monitor.is_breached():
logger.critical("Max drawdown breached! Emergency stop.")
self.risk_manager.emergency_stop()
await self.alert_manager.notify_emergency(
f"Max drawdown {dd_state.max_drawdown:.2%} exceeded limit"
)
break
except Exception as e:
logger.error("Loop iteration error: {}", e)
await self.alert_manager.notify_error(str(e))
await asyncio.sleep(self._loop_interval)
async def _process_symbol(self, symbol: str) -> None:
"""Run the full analysis and execution pipeline for one symbol."""
# 1. Update data
await self.data_feed.fetch_multi_timeframe(symbol)
# 2. Check existing positions for exit
ltf = settings.LTF_TIMEFRAME
try:
ltf_df = self.data_feed.get_dataframe(symbol, ltf)
current_price = float(ltf_df["close"].iloc[-1])
ltf_signals = self.ict_engine.analyze(ltf_df)
closed = await self.position_manager.update_positions(
symbol, current_price, ltf_signals
)
for pos in closed:
await self._on_position_closed(pos)
except Exception as e:
logger.error("Position update error for {}: {}", symbol, e)
# 3. Generate new signals
if self.risk_manager.is_stopped:
return
try:
signal = await self.signal_generator.generate(symbol, self.data_feed)
if signal:
await self._on_signal(signal)
except Exception as e:
logger.exception("Signal generation error for {}: {}", symbol, e)
async def _on_signal(self, signal: TradeSignal) -> None:
"""Handle a new trade signal."""
# Notify
await self.alert_manager.notify_signal({
"symbol": signal.symbol,
"direction": signal.direction.value,
"entry_price": signal.entry_price,
"stop_loss": signal.stop_loss,
"take_profit": signal.take_profit,
"confluence": signal.confidence,
"reasons": signal.reasons,
})
# Execute
balance_info = await self.exchange_client.fetch_balance()
balance = float(balance_info.get("free", {}).get("USDT", 0))
order = await self.order_manager.execute_signal(signal, balance)
if order is None:
return
# Track position
position = self.position_manager.open_position(signal, order)
# Persist
self.repo.save_position(PositionRecord(
id=position.id,
symbol=position.symbol,
direction=position.direction.value,
entry_price=position.entry_price,
amount=position.amount,
stop_loss=position.stop_loss,
take_profit=position.take_profit,
status="OPEN",
opened_at=position.opened_at.isoformat(),
confluence_score=position.confluence_score,
entry_reasons=str(position.entry_reasons),
))
await self.alert_manager.notify_fill({
"symbol": signal.symbol,
"side": "BUY" if signal.direction.value == "LONG" else "SELL",
"amount": position.amount,
"price": position.entry_price,
})
async def _on_position_closed(self, position) -> None:
"""Handle a closed position -- persist and notify."""
self.repo.save_position(PositionRecord(
id=position.id,
symbol=position.symbol,
direction=position.direction.value,
entry_price=position.entry_price,
amount=position.amount,
stop_loss=position.stop_loss,
take_profit=position.take_profit,
realized_pnl=position.realized_pnl,
status="CLOSED",
opened_at=position.opened_at.isoformat(),
closed_at=position.closed_at.isoformat() if position.closed_at else None,
close_reason=position.close_reason,
confluence_score=position.confluence_score,
entry_reasons=str(position.entry_reasons),
))
self.repo.update_daily_performance(
pnl=position.realized_pnl,
is_win=position.realized_pnl > 0,
max_dd=self.drawdown_monitor.max_drawdown,
)
await self.alert_manager.notify_close({
"symbol": position.symbol,
"direction": position.direction.value,
"entry_price": position.entry_price,
"exit_price": position.current_price,
"pnl": position.realized_pnl,
"reason": position.close_reason or "UNKNOWN",
})
await event_bus.publish("position_closed", position)

142
core/data_feed.py Normal file
View File

@@ -0,0 +1,142 @@
"""Real-time and historical market data feed.
Wraps ExchangeClient to manage multi-timeframe DataFrames
and provide a streaming data interface for the strategy engine.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
import pandas as pd
from loguru import logger
from config import settings
from execution.exchange_client import ExchangeClient
class DataFeed:
"""Manages market data collection for one or more symbols/timeframes."""
def __init__(self, exchange_client: ExchangeClient):
self._client = exchange_client
# Cache: {(symbol, timeframe): pd.DataFrame}
self._dataframes: Dict[tuple, pd.DataFrame] = {}
self._running = False
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
async def connect(self) -> None:
"""Ensure the underlying exchange is connected."""
if not await self._client.is_connected():
await self._client.connect()
logger.info("DataFeed ready")
async def disconnect(self) -> None:
"""Stop feeds and disconnect."""
self._running = False
await self._client.disconnect()
logger.info("DataFeed disconnected")
# ------------------------------------------------------------------
# Historical data (REST)
# ------------------------------------------------------------------
async def fetch_ohlcv(
self,
symbol: str,
timeframe: str,
since: int | None = None,
limit: int = 500,
) -> pd.DataFrame:
"""Fetch historical OHLCV and cache the result."""
df = await self._client.fetch_ohlcv(symbol, timeframe, since=since, limit=limit)
self._dataframes[(symbol, timeframe)] = df
logger.debug("Fetched {} candles for {} {}", len(df), symbol, timeframe)
return df
async def fetch_multi_timeframe(
self, symbol: str, timeframes: List[str] | None = None, limit: int = 500
) -> Dict[str, pd.DataFrame]:
"""Fetch OHLCV for multiple timeframes concurrently."""
tfs = timeframes or [
settings.HTF_TIMEFRAME,
settings.MTF_TIMEFRAME,
settings.LTF_TIMEFRAME,
]
tasks = [self.fetch_ohlcv(symbol, tf, limit=limit) for tf in tfs]
results = await asyncio.gather(*tasks)
return dict(zip(tfs, results))
# ------------------------------------------------------------------
# Real-time data (WebSocket)
# ------------------------------------------------------------------
async def watch_ohlcv(self, symbol: str, timeframe: str) -> List:
"""Watch live OHLCV candles and update the internal DataFrame."""
candles = await self._client.watch_ohlcv(symbol, timeframe)
self._update_dataframe(symbol, timeframe, candles)
return candles
async def watch_ticker(self, symbol: str) -> Dict[str, Any]:
"""Watch the live ticker for a symbol."""
return await self._client.exchange.watch_ticker(symbol)
async def watch_order_book(self, symbol: str) -> Dict[str, Any]:
"""Watch the live order book for a symbol."""
return await self._client.exchange.watch_order_book(symbol)
async def start_streaming(
self, symbols: List[str], timeframe: str = "1m", callback=None
) -> None:
"""Continuously stream OHLCV data for multiple symbols."""
self._running = True
logger.info("Streaming started for {} on {}", symbols, timeframe)
while self._running:
for symbol in symbols:
try:
candles = await self.watch_ohlcv(symbol, timeframe)
if callback:
await callback(symbol, timeframe, candles)
except Exception as e:
logger.error("Streaming error for {}: {}", symbol, e)
await asyncio.sleep(1)
def stop_streaming(self) -> None:
"""Signal the streaming loop to stop."""
self._running = False
# ------------------------------------------------------------------
# DataFrame management
# ------------------------------------------------------------------
def get_dataframe(self, symbol: str, timeframe: str) -> pd.DataFrame:
"""Return the cached DataFrame for a symbol/timeframe pair."""
key = (symbol, timeframe)
if key not in self._dataframes:
raise KeyError(f"No data cached for {symbol} {timeframe}. Fetch first.")
return self._dataframes[key]
def _update_dataframe(
self, symbol: str, timeframe: str, candles: List
) -> None:
"""Merge incoming WebSocket candles into the cached DataFrame."""
if not candles:
return
new_df = pd.DataFrame(
candles, columns=["timestamp", "open", "high", "low", "close", "volume"]
)
new_df["timestamp"] = pd.to_datetime(new_df["timestamp"], unit="ms")
new_df.set_index("timestamp", inplace=True)
key = (symbol, timeframe)
if key in self._dataframes:
existing = self._dataframes[key]
combined = pd.concat([existing, new_df])
combined = combined[~combined.index.duplicated(keep="last")]
self._dataframes[key] = combined.sort_index()
else:
self._dataframes[key] = new_df

39
core/event_bus.py Normal file
View File

@@ -0,0 +1,39 @@
"""Simple async event bus for inter-module communication."""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Callable, Coroutine, Dict, List
from loguru import logger
class EventBus:
"""Publish-subscribe event bus using asyncio."""
def __init__(self):
self._subscribers: Dict[str, List[Callable]] = defaultdict(list)
def subscribe(self, event_type: str, handler: Callable[..., Coroutine]) -> None:
"""Register an async handler for an event type."""
self._subscribers[event_type].append(handler)
logger.debug("Subscribed to '{}': {}", event_type, handler.__name__)
def unsubscribe(self, event_type: str, handler: Callable) -> None:
"""Remove a handler from an event type."""
self._subscribers[event_type] = [
h for h in self._subscribers[event_type] if h != handler
]
async def publish(self, event_type: str, data: Any = None) -> None:
"""Publish an event, calling all registered handlers concurrently."""
handlers = self._subscribers.get(event_type, [])
if not handlers:
return
logger.debug("Publishing '{}' to {} handler(s)", event_type, len(handlers))
await asyncio.gather(*(h(data) for h in handlers), return_exceptions=True)
# Module-level singleton
event_bus = EventBus()