0206ef7cbb
Real-time orderflow trading system with absorption, initiative, sweep, exhaustion, and divergence detection. Features volume profile framing, state machine trade lifecycle, MT5 + Bybit feeds, FastAPI dashboard, and Telegram alerts for 30+ instruments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
4.5 KiB
Python
134 lines
4.5 KiB
Python
"""
|
|
Candle Builder — aggregates ticks into OHLCV candles with footprint data.
|
|
Builds candles in real-time from the tick stream.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Callable, Optional
|
|
|
|
from orderflow_system.data.models import Tick, Candle, FootprintLevel, Side
|
|
|
|
|
|
class CandleBuilder:
|
|
"""
|
|
Builds time-based candles from a tick stream.
|
|
Each candle includes full footprint data (bid/ask volume at each price level).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
interval_seconds: int = 60,
|
|
tick_size: float = 0.1,
|
|
on_candle_close: Optional[Callable] = None,
|
|
):
|
|
self.interval_ms = interval_seconds * 1000
|
|
self.tick_size = tick_size
|
|
self.on_candle_close = on_candle_close
|
|
self._current_candle: Optional[Candle] = None
|
|
self._candle_history: list[Candle] = []
|
|
self._max_history = 5000
|
|
|
|
def _round_price(self, price: float) -> float:
|
|
"""Round price to tick size for footprint grouping."""
|
|
return round(round(price / self.tick_size) * self.tick_size, 10)
|
|
|
|
def _candle_start_ms(self, timestamp_ms: int) -> int:
|
|
"""Align timestamp to candle interval boundary."""
|
|
return (timestamp_ms // self.interval_ms) * self.interval_ms
|
|
|
|
async def process_tick(self, tick: Tick) -> Optional[Candle]:
|
|
"""
|
|
Feed a tick into the builder. Returns a closed candle if interval completed.
|
|
"""
|
|
candle_start = self._candle_start_ms(tick.timestamp_ms)
|
|
closed_candle = None
|
|
|
|
# Check if we need to close current candle and start new one
|
|
if self._current_candle is not None:
|
|
if candle_start > self._current_candle.timestamp_ms:
|
|
closed_candle = self._current_candle
|
|
# Deduplicate: replace last entry if same timestamp
|
|
if (self._candle_history
|
|
and self._candle_history[-1].timestamp_ms
|
|
== closed_candle.timestamp_ms):
|
|
self._candle_history[-1] = closed_candle
|
|
else:
|
|
self._candle_history.append(closed_candle)
|
|
if len(self._candle_history) > self._max_history:
|
|
self._candle_history = self._candle_history[-self._max_history:]
|
|
|
|
if self.on_candle_close:
|
|
await self.on_candle_close(closed_candle)
|
|
|
|
self._current_candle = None
|
|
|
|
# Start new candle if needed
|
|
if self._current_candle is None:
|
|
self._current_candle = Candle(
|
|
timestamp_ms=candle_start,
|
|
open=tick.price,
|
|
high=tick.price,
|
|
low=tick.price,
|
|
close=tick.price,
|
|
)
|
|
|
|
# Update OHLCV
|
|
c = self._current_candle
|
|
c.high = max(c.high, tick.price)
|
|
c.low = min(c.low, tick.price)
|
|
c.close = tick.price
|
|
c.volume += tick.size
|
|
c.tick_count += 1
|
|
|
|
if tick.is_buy:
|
|
c.buy_volume += tick.size
|
|
else:
|
|
c.sell_volume += tick.size
|
|
|
|
# Update footprint at this price level
|
|
fp_price = self._round_price(tick.price)
|
|
if fp_price not in c.footprint:
|
|
c.footprint[fp_price] = FootprintLevel(price=fp_price)
|
|
|
|
if tick.is_buy:
|
|
c.footprint[fp_price].ask_volume += tick.size
|
|
else:
|
|
c.footprint[fp_price].bid_volume += tick.size
|
|
|
|
return closed_candle
|
|
|
|
@property
|
|
def current_candle(self) -> Optional[Candle]:
|
|
return self._current_candle
|
|
|
|
@property
|
|
def history(self) -> list[Candle]:
|
|
return self._candle_history
|
|
|
|
def get_recent_candles(self, n: int) -> list[Candle]:
|
|
"""Return last N closed candles."""
|
|
return self._candle_history[-n:]
|
|
|
|
def load_historical_candles(self, candles: list[Candle]) -> int:
|
|
"""
|
|
Bulk-load historical candles (e.g. from MT5 bars).
|
|
Prepends them before any real-time candles, deduplicating by timestamp.
|
|
Returns the number of candles actually added.
|
|
"""
|
|
if not candles:
|
|
return 0
|
|
|
|
# Existing timestamps for dedup
|
|
existing_ts = {c.timestamp_ms for c in self._candle_history}
|
|
new_candles = [c for c in candles if c.timestamp_ms not in existing_ts]
|
|
|
|
if not new_candles:
|
|
return 0
|
|
|
|
# Merge: historical first, then real-time, sorted by time
|
|
merged = sorted(new_candles + self._candle_history, key=lambda c: c.timestamp_ms)
|
|
self._candle_history = merged[-self._max_history:]
|
|
return len(new_candles)
|