Files
OrderFlow-Analysis-Pro/orderflow_system/analytics/volume_profile.py
T
BlackboxAI 0206ef7cbb Initial commit: orderflow analysis system with 5 pattern detectors
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>
2026-03-08 21:38:25 +03:00

259 lines
9.0 KiB
Python

"""
Volume Profile Engine
Computes POC, VAH, VAL, LVN, and profile shape classification from tick data.
Implements Fabio's methodology:
- Cash session profiles (NY session only for US indices)
- Multi-day profile merging
- Profile shape: P-shape, b-shape, D-shape, double distribution
- 68% value area rule
"""
from __future__ import annotations
import numpy as np
from collections import defaultdict
from typing import Optional
from orderflow_system.data.models import Tick, VolumeProfileResult, Candle
from orderflow_system.config.settings import VolumeProfileConfig
class VolumeProfileEngine:
"""
Builds volume profiles from tick data or candles.
Core logic from Fabio's teaching:
- Volume at each price level → histogram
- POC = price with max volume
- Value Area = 68% of total volume, expanding from POC
- LVN = local minima in the histogram, below mean - 1.5*stddev
- Shape classification based on where POC sits and volume distribution
"""
def __init__(self, config: VolumeProfileConfig):
self.config = config
def compute_from_ticks(
self, ticks: list[Tick], session_date: str = ""
) -> VolumeProfileResult:
"""Build volume profile from raw tick data."""
if not ticks:
return VolumeProfileResult(session_date=session_date)
volume_at_price: dict[float, float] = defaultdict(float)
tick_size = self.config.tick_size
for t in ticks:
rounded = round(round(t.price / tick_size) * tick_size, 10)
volume_at_price[rounded] += t.size
return self._compute_profile(dict(volume_at_price), session_date)
def compute_from_candles(
self, candles: list[Candle], session_date: str = ""
) -> VolumeProfileResult:
"""Build volume profile from candle footprint data."""
if not candles:
return VolumeProfileResult(session_date=session_date)
volume_at_price: dict[float, float] = defaultdict(float)
tick_size = self.config.tick_size
for candle in candles:
if candle.footprint:
for price, fp in candle.footprint.items():
# Re-bucket footprint prices to VP tick_size
rounded = round(round(price / tick_size) * tick_size, 10)
volume_at_price[rounded] += fp.total_volume
else:
# Fallback: distribute candle volume evenly across OHLC range
low = round(round(candle.low / tick_size) * tick_size, 10)
high = round(round(candle.high / tick_size) * tick_size, 10)
n_levels = max(1, int((high - low) / tick_size) + 1)
vol_per_level = candle.volume / n_levels
price = low
while price <= high + tick_size / 2:
volume_at_price[round(price, 10)] += vol_per_level
price += tick_size
return self._compute_profile(dict(volume_at_price), session_date)
def merge_profiles(
self, profiles: list[VolumeProfileResult]
) -> VolumeProfileResult:
"""
Merge multiple daily profiles into a composite profile.
Fabio's technique: merge 2-3 overlapping days to refine VAL/VAH.
"""
if not profiles:
return VolumeProfileResult()
if len(profiles) == 1:
return profiles[0]
merged_vap: dict[float, float] = defaultdict(float)
dates = []
for vp in profiles:
dates.append(vp.session_date)
for price, vol in vp.volume_at_price.items():
merged_vap[price] += vol
result = self._compute_profile(
dict(merged_vap),
session_date=f"{dates[0]}_to_{dates[-1]}",
)
return result
def _compute_profile(
self, volume_at_price: dict[float, float], session_date: str
) -> VolumeProfileResult:
"""Core computation: POC, Value Area, LVN, shape."""
if not volume_at_price:
return VolumeProfileResult(session_date=session_date)
prices = sorted(volume_at_price.keys())
volumes = np.array([volume_at_price[p] for p in prices])
total_volume = float(volumes.sum())
if total_volume == 0:
return VolumeProfileResult(session_date=session_date)
# ── POC: price with maximum volume ──
poc_idx = int(np.argmax(volumes))
poc = prices[poc_idx]
# ── Value Area: expand from POC until 68% of volume ──
vah, val = self._compute_value_area(prices, volumes, poc_idx, total_volume)
# ── LVN: local minima below mean - 1.5*stddev ──
lvn_levels = self._detect_lvn(prices, volumes)
# ── Shape classification ──
shape, poc_pct = self._classify_shape(prices, volumes, poc_idx)
return VolumeProfileResult(
session_date=session_date,
poc=poc,
vah=vah,
val=val,
volume_at_price=volume_at_price,
total_volume=total_volume,
lvn_levels=lvn_levels,
shape=shape,
poc_position_pct=poc_pct,
)
def _compute_value_area(
self,
prices: list[float],
volumes: np.ndarray,
poc_idx: int,
total_volume: float,
) -> tuple[float, float]:
"""
Expand from POC one level at a time (up or down), adding the side
with higher volume, until 68% of total volume is enclosed.
"""
target = total_volume * self.config.value_area_pct
accumulated = float(volumes[poc_idx])
lo = poc_idx
hi = poc_idx
while accumulated < target:
can_go_up = hi + 1 < len(prices)
can_go_down = lo - 1 >= 0
if not can_go_up and not can_go_down:
break
vol_up = float(volumes[hi + 1]) if can_go_up else -1.0
vol_down = float(volumes[lo - 1]) if can_go_down else -1.0
if vol_up >= vol_down:
hi += 1
accumulated += vol_up
else:
lo -= 1
accumulated += vol_down
val = prices[lo]
vah = prices[hi]
return vah, val
def _detect_lvn(
self, prices: list[float], volumes: np.ndarray
) -> list[float]:
"""
Detect Low Volume Nodes — price levels with volume significantly
below the mean. These are inefficient delivery levels where price
tends to return for rebalancing before resuming trend.
"""
if len(volumes) < 5:
return []
mean_vol = float(np.mean(volumes))
std_vol = float(np.std(volumes))
threshold = mean_vol - self.config.lvn_stddev_factor * std_vol
threshold = max(threshold, mean_vol * 0.2) # Floor at 20% of mean
lvn = []
for i in range(1, len(volumes) - 1):
# Local minimum AND below threshold
if (
volumes[i] < volumes[i - 1]
and volumes[i] < volumes[i + 1]
and volumes[i] < threshold
):
lvn.append(prices[i])
return lvn
def _classify_shape(
self,
prices: list[float],
volumes: np.ndarray,
poc_idx: int,
) -> tuple[str, float]:
"""
Classify profile shape per Fabio's methodology:
- P-shape: POC above 50%, high volume at top → buyers in control
- b-shape: POC below 50%, high volume at bottom → sellers in control
- D-shape: POC near center, balanced volume → normal distribution
- Double distribution: bimodal — two clusters of high volume
"""
n = len(prices)
if n == 0:
return "unknown", 0.5
poc_pct = poc_idx / max(n - 1, 1) # 0 = bottom, 1 = top
# Check for double distribution (bimodal)
if n >= 10:
mid = n // 2
upper_max = int(np.argmax(volumes[mid:])) + mid
lower_max = int(np.argmax(volumes[:mid]))
upper_vol = float(volumes[upper_max])
lower_vol = float(volumes[lower_max])
mean_vol = float(np.mean(volumes))
# Both peaks must be significant and there's a valley between them
if (
upper_vol > mean_vol * 1.5
and lower_vol > mean_vol * 1.5
):
# Check for a valley between them
valley_start = min(lower_max, upper_max)
valley_end = max(lower_max, upper_max)
if valley_end - valley_start > 2:
valley_min = float(np.min(volumes[valley_start + 1 : valley_end]))
if valley_min < min(upper_vol, lower_vol) * 0.5:
return "double_dist", poc_pct
# Single distribution shapes
if poc_pct > 0.65:
return "p_shape", poc_pct # Buyers aggressive, POC at top
elif poc_pct < 0.35:
return "b_shape", poc_pct # Sellers aggressive, POC at bottom
else:
return "d_shape", poc_pct # Balanced / normal