| """ |
| Funding Rate and Order Flow Analyzer |
| |
| Features: |
| - Real-time funding rate analysis |
| - 3-layer order flow signal (CVD + Taker Ratio + Notable Orders) |
| - CVD (Cumulative Volume Delta) from OHLCV |
| - Large/Notable order detection with $5K threshold |
| """ |
|
|
| import numpy as np |
| import pandas as pd |
| import requests |
| from typing import Dict, Tuple, Optional, List |
| from dataclasses import dataclass |
| from collections import deque |
| import logging |
| import os |
| import time |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass |
| class FundingSignal: |
| """Funding rate signal.""" |
| rate: float |
| predicted_rate: float |
| signal: str |
| strength: float |
| payout_direction: str |
|
|
|
|
| @dataclass |
| class OrderFlowSignal: |
| """Order flow analysis signal.""" |
| cvd: float |
| cvd_trend: str |
| large_orders_bias: str |
| buy_pressure: float |
| signal_strength: float |
|
|
|
|
| class FundingRateAnalyzer: |
| """ |
| Analyze funding rates for trade timing. |
| |
| Strategy: |
| - When longs pay (positive funding), shorts are favored |
| - When shorts pay (negative funding), longs are favored |
| - Extreme funding often precedes reversals |
| """ |
|
|
| def __init__( |
| self, |
| symbol: str = "BTCUSDT", |
| extreme_threshold: float = 0.0003, |
| cache_duration: int = 60, |
| ): |
| self.symbol = symbol |
| self.extreme_threshold = extreme_threshold |
| self.cache_duration = cache_duration |
| self._last_fetch = 0 |
| self._cached_data = None |
| |
| |
| self._oi_history: deque = deque(maxlen=24) |
| self._last_oi_fetch = 0 |
| self._cached_oi = 0.0 |
|
|
| logger.info(f"💰 FundingRateAnalyzer initialized for {symbol}") |
|
|
| def _fetch_funding_rate(self) -> Dict: |
| """Fetch current funding rate exclusively from OKX (no proxy needed).""" |
| now = time.time() |
|
|
| |
| if self._cached_data and (now - self._last_fetch) < self.cache_duration: |
| return self._cached_data |
|
|
| rate = self._get_okx_funding() |
|
|
| self._cached_data = { |
| 'rate': rate, |
| 'predicted': rate, |
| 'mark_price': 0, |
| 'index_price': 0, |
| 'next_funding_time': 0, |
| 'history': [rate], |
| } |
| self._last_fetch = now |
| return self._cached_data |
|
|
| def _get_okx_funding(self) -> float: |
| """Fetch funding rate from OKX (no geo-blocking).""" |
| try: |
| |
| base = self.symbol.replace('USDT', '') |
| okx_inst = f"{base}-USDT-SWAP" |
| url = "https://www.okx.com/api/v5/public/funding-rate" |
| response = requests.get( |
| url, |
| params={"instId": okx_inst}, |
| timeout=10 |
| ) |
| response.raise_for_status() |
| data = response.json() |
| |
| if data.get('data'): |
| rate = float(data['data'][0].get('fundingRate', 0)) |
| logger.info(f"💰 OKX Funding Rate for {okx_inst}: {rate:.6f}") |
| return rate |
| except Exception as e: |
| logger.warning(f"OKX funding rate fetch failed: {e}") |
| |
| return 0.0 |
|
|
| def get_signal(self) -> FundingSignal: |
| """ |
| Get funding rate signal for trade filtering. |
| |
| Returns: |
| FundingSignal with recommendation |
| """ |
| data = self._fetch_funding_rate() |
|
|
| rate = data.get('rate', 0) |
| predicted = data.get('predicted', rate) |
|
|
| |
| if rate > 0: |
| payout = "longs_pay" |
| elif rate < 0: |
| payout = "shorts_pay" |
| else: |
| payout = "neutral" |
|
|
| |
| if rate > self.extreme_threshold: |
| signal = "short_favored" |
| strength = min(abs(rate) / (self.extreme_threshold * 3), 1.0) |
| elif rate < -self.extreme_threshold: |
| signal = "long_favored" |
| strength = min(abs(rate) / (self.extreme_threshold * 3), 1.0) |
| else: |
| if rate > 0: |
| signal = "slight_short_favored" |
| elif rate < 0: |
| signal = "slight_long_favored" |
| else: |
| signal = "neutral" |
| strength = abs(rate) / self.extreme_threshold if self.extreme_threshold else 0 |
|
|
| result = FundingSignal( |
| rate=rate, |
| predicted_rate=predicted, |
| signal=signal, |
| strength=strength, |
| payout_direction=payout |
| ) |
|
|
| logger.info(f"💰 Funding: {rate:.4%} → {signal} (strength={strength:.2f})") |
| return result |
|
|
| def should_trade(self, trade_type: str) -> Tuple[bool, str]: |
| """ |
| Check if funding rate favors the trade. |
| |
| Args: |
| trade_type: "long" or "short" |
| |
| Returns: |
| Tuple of (should_trade, reason) |
| """ |
| signal = self.get_signal() |
|
|
| if trade_type == "long" and signal.signal == "short_favored" and signal.strength > 0.5: |
| return False, f"Funding opposes LONG: {signal.rate:.4%} (longs paying heavily)" |
|
|
| if trade_type == "short" and signal.signal == "long_favored" and signal.strength > 0.5: |
| return False, f"Funding opposes SHORT: {signal.rate:.4%} (shorts paying heavily)" |
|
|
| if trade_type == "long" and "long_favored" in signal.signal: |
| return True, f"Funding favors LONG: {signal.rate:.4%} (shorts paying)" |
|
|
| if trade_type == "short" and "short_favored" in signal.signal: |
| return True, f"Funding favors SHORT: {signal.rate:.4%} (longs paying)" |
|
|
| return True, f"Funding neutral: {signal.rate:.4%}" |
|
|
| def _fetch_open_interest(self) -> float: |
| """Fetch current Open Interest from OKX.""" |
| now = time.time() |
| if self._cached_oi > 0 and (now - self._last_oi_fetch) < self.cache_duration: |
| return self._cached_oi |
| |
| try: |
| base = self.symbol.replace('USDT', '') |
| okx_inst = f"{base}-USDT-SWAP" |
| url = "https://www.okx.com/api/v5/public/open-interest" |
| response = requests.get( |
| url, |
| params={"instId": okx_inst}, |
| timeout=10 |
| ) |
| response.raise_for_status() |
| data = response.json() |
| |
| if data.get('data'): |
| oi = float(data['data'][0].get('oi', 0)) |
| self._cached_oi = oi |
| self._last_oi_fetch = now |
| |
| |
| self._oi_history.append(oi) |
| logger.info(f"📈 OKX Open Interest for {okx_inst}: {oi:,.0f} contracts") |
| return oi |
| except Exception as e: |
| logger.warning(f"OKX OI fetch failed: {e}") |
| |
| return self._cached_oi if self._cached_oi > 0 else 0.0 |
|
|
| def get_liquidation_danger(self) -> Dict: |
| """ |
| Compute a Liquidation Danger Score. |
| |
| Combines: |
| - Funding Rate (how greedy is retail?) |
| - Open Interest spike (how loaded is the system?) |
| |
| Returns dict with: |
| - danger_score: 0.0 (safe) to 1.0 (extremely dangerous) |
| - long_danger: True if conditions favor a long squeeze |
| - short_danger: True if conditions favor a short squeeze |
| - reason: Human-readable explanation |
| """ |
| funding_data = self._fetch_funding_rate() |
| rate = funding_data.get('rate', 0) |
| |
| oi = self._fetch_open_interest() |
| |
| |
| oi_spike = 0.0 |
| if len(self._oi_history) >= 3: |
| avg_oi = sum(list(self._oi_history)[:-1]) / (len(self._oi_history) - 1) |
| if avg_oi > 0: |
| oi_spike = (oi - avg_oi) / avg_oi |
| |
| |
| funding_danger = min(abs(rate) / 0.001, 1.0) |
| |
| |
| oi_danger = min(max(oi_spike, 0) / 0.15, 1.0) |
| |
| |
| danger_score = 0.6 * funding_danger + 0.4 * oi_danger |
| danger_score = min(danger_score, 1.0) |
| |
| |
| long_danger = rate > 0.00015 and danger_score > 0.5 |
| short_danger = rate < -0.00015 and danger_score > 0.5 |
| |
| reason_parts = [] |
| if funding_danger > 0.3: |
| reason_parts.append(f"Funding={rate:.4%}") |
| if oi_danger > 0.3: |
| reason_parts.append(f"OI_Spike={oi_spike:+.1%}") |
| reason = ", ".join(reason_parts) if reason_parts else "Normal" |
| |
| logger.info( |
| f"⚠️ Liquidation Danger [{self.symbol}]: score={danger_score:.2f} " |
| f"(funding={funding_danger:.2f}, oi={oi_danger:.2f}) " |
| f"long_danger={long_danger}, short_danger={short_danger}" |
| ) |
| |
| return { |
| 'danger_score': danger_score, |
| 'long_danger': long_danger, |
| 'short_danger': short_danger, |
| 'funding_rate': rate, |
| 'oi': oi, |
| 'oi_spike': oi_spike, |
| 'reason': reason, |
| } |
|
|
|
|
| class OrderFlowAnalyzer: |
| """ |
| Enhanced Order Flow Analyzer with 3-layer signal. |
| |
| Layer 1: CVD from OHLCV (most reliable, always available) — 50% weight |
| Layer 2: Taker buy/sell ratio from recent trades — 30% weight |
| Layer 3: Notable orders ($5K+ threshold) — 20% weight |
| |
| Combined signal provides [-1, +1] directional score. |
| """ |
|
|
| def __init__( |
| self, |
| symbol: str = "BTCUSDT", |
| notable_order_threshold: float = 5000, |
| lookback_minutes: int = 60, |
| ): |
| self.symbol = symbol |
| self.notable_order_threshold = notable_order_threshold |
| |
| self.large_order_threshold = notable_order_threshold |
| self.lookback_minutes = lookback_minutes |
|
|
| |
| self.recent_trades: deque = deque(maxlen=10000) |
|
|
| |
| self._cache_time = 0 |
| self._cache_result = None |
| self._cache_ttl = 30 |
|
|
| logger.info(f"📊 OrderFlowAnalyzer initialized (notable orders > ${notable_order_threshold:,.0f})") |
|
|
| def _fetch_recent_trades(self) -> List[Dict]: |
| """Fetch recent trades from Binance Spot API (with OKX fallback).""" |
| try: |
| url = "https://data-api.binance.vision/api/v3/trades" |
| response = requests.get( |
| url, |
| params={"symbol": self.symbol, "limit": 1000}, |
| timeout=10 |
| ) |
| response.raise_for_status() |
| trades = response.json() |
|
|
| if not trades: |
| return self._get_okx_trades() |
|
|
| return trades |
|
|
| except Exception as e: |
| logger.warning(f"Failed to fetch Binance Spot trades: {e}. Trying OKX fallback...") |
| return self._get_okx_trades() |
|
|
| def _get_okx_trades(self) -> List[Dict]: |
| """Fetch recent trades from OKX (no geo-blocking).""" |
| try: |
| base = self.symbol.replace('USDT', '') |
| okx_inst = f"{base}-USDT" |
| url = "https://www.okx.com/api/v5/market/trades" |
| response = requests.get( |
| url, |
| params={"instId": okx_inst, "limit": "500"}, |
| timeout=10 |
| ) |
| response.raise_for_status() |
| data = response.json() |
|
|
| if data.get('data'): |
| trades = [] |
| for t in data['data']: |
| trades.append({ |
| 'price': t.get('px', '0'), |
| 'qty': t.get('sz', '0'), |
| 'isBuyerMaker': t.get('side', 'buy') == 'sell', |
| }) |
| logger.info(f"📊 OKX trades fetched: {len(trades)} trades for {okx_inst}") |
| return trades |
| except Exception as e: |
| logger.warning(f"OKX trades fetch failed: {e}") |
|
|
| return [] |
|
|
| |
| |
| |
|
|
| def calculate_cvd(self, df: pd.DataFrame = None) -> Dict: |
| """ |
| Calculate Cumulative Volume Delta from OHLCV data. |
| |
| Uses candle body position to estimate buy/sell volume: |
| - Close > Open = buying pressure (bullish candle) |
| - Close < Open = selling pressure (bearish candle) |
| |
| Returns dict with raw CVD, normalized score [-1,+1], and trend. |
| """ |
| if df is None or len(df) < 10: |
| return {'cvd': 0, 'score': 0.0, 'trend': 'neutral'} |
|
|
| recent = df.tail(20) |
| body = recent['close'] - recent['open'] |
| range_size = recent['high'] - recent['low'] |
| body_ratio = body / (range_size + 1e-10) |
| volume_delta = body_ratio * recent['volume'] |
|
|
| cvd_raw = volume_delta.sum() |
|
|
| |
| avg_volume = recent['volume'].mean() |
| cvd_normalized = cvd_raw / (avg_volume * 5 + 1e-10) |
| cvd_score = float(np.clip(cvd_normalized, -1, 1)) |
|
|
| |
| if len(recent) >= 10: |
| recent_5 = volume_delta.tail(5).sum() |
| prev_5 = volume_delta.tail(10).head(5).sum() |
| if recent_5 > prev_5 * 1.2: |
| trend = 'bullish' |
| elif recent_5 < prev_5 * 0.8: |
| trend = 'bearish' |
| else: |
| trend = 'neutral' |
| else: |
| trend = 'neutral' |
|
|
| return { |
| 'cvd': float(cvd_raw), |
| 'score': cvd_score, |
| 'trend': trend, |
| } |
|
|
| |
| |
| |
|
|
| def calculate_taker_ratio(self, trades: List[Dict] = None) -> Dict: |
| """ |
| Calculate taker buy/sell volume ratio from ALL recent trades. |
| No threshold filtering — uses total volume to measure |
| aggregate buy vs sell pressure. |
| """ |
| if trades is None: |
| trades = self._fetch_recent_trades() |
|
|
| if not trades: |
| return { |
| 'buy_volume': 0, 'sell_volume': 0, |
| 'ratio': 0.5, 'score': 0.0, 'total_trades': 0 |
| } |
|
|
| buy_vol = 0 |
| sell_vol = 0 |
|
|
| for trade in trades: |
| price = float(trade.get('price', 0)) |
| qty = float(trade.get('qty', 0)) |
| is_maker = trade.get('isBuyerMaker', False) |
| value = price * qty |
|
|
| if is_maker: |
| sell_vol += value |
| else: |
| buy_vol += value |
|
|
| total = buy_vol + sell_vol |
| ratio = buy_vol / total if total > 0 else 0.5 |
|
|
| |
| score = float(np.clip((ratio - 0.5) * 4, -1, 1)) |
|
|
| return { |
| 'buy_volume': round(buy_vol, 2), |
| 'sell_volume': round(sell_vol, 2), |
| 'ratio': round(ratio, 3), |
| 'score': score, |
| 'total_trades': len(trades), |
| } |
|
|
| |
| |
| |
|
|
| def analyze_large_orders(self, trades: List[Dict] = None) -> Dict: |
| """ |
| Analyze recent trades for notable order patterns. |
| Uses $5K threshold (down from $50K) to capture meaningful activity. |
| """ |
| if trades is None: |
| trades = self._fetch_recent_trades() |
|
|
| if not trades: |
| return { |
| 'large_buys': 0, 'large_sells': 0, |
| 'large_buy_volume': 0, 'large_sell_volume': 0, |
| 'bias': 'neutral', 'total_large_volume': 0, 'score': 0.0 |
| } |
|
|
| large_buys = 0 |
| large_sells = 0 |
| large_buy_vol = 0 |
| large_sell_vol = 0 |
|
|
| for trade in trades: |
| price = float(trade.get('price', 0)) |
| qty = float(trade.get('qty', 0)) |
| is_maker = trade.get('isBuyerMaker', False) |
| value = price * qty |
|
|
| if value >= self.notable_order_threshold: |
| if is_maker: |
| large_sells += 1 |
| large_sell_vol += value |
| else: |
| large_buys += 1 |
| large_buy_vol += value |
|
|
| total_vol = large_buy_vol + large_sell_vol |
| if total_vol > 0: |
| score = float(np.clip((large_buy_vol - large_sell_vol) / total_vol, -1, 1)) |
| if large_buy_vol > large_sell_vol * 1.2: |
| bias = 'bullish' |
| elif large_sell_vol > large_buy_vol * 1.2: |
| bias = 'bearish' |
| else: |
| bias = 'neutral' |
| else: |
| score = 0.0 |
| bias = 'neutral' |
|
|
| return { |
| 'large_buys': large_buys, |
| 'large_sells': large_sells, |
| 'large_buy_volume': round(large_buy_vol, 2), |
| 'large_sell_volume': round(large_sell_vol, 2), |
| 'bias': bias, |
| 'total_large_volume': round(total_vol, 2), |
| 'score': score, |
| } |
|
|
| |
| |
| |
|
|
| def get_enhanced_signal(self, df: pd.DataFrame = None) -> Dict: |
| """ |
| Get blended order flow signal from all 3 layers. |
| |
| Weights: |
| - CVD (OHLCV): 50% — most reliable, always available |
| - Taker ratio: 30% — aggregate buy/sell pressure |
| - Notable orders: 20% — institutional activity |
| |
| Returns dict with composite score [-1,+1] and layer details. |
| """ |
| |
| now = time.time() |
| if self._cache_result and (now - self._cache_time) < self._cache_ttl: |
| return self._cache_result |
|
|
| |
| trades = self._fetch_recent_trades() |
|
|
| |
| cvd_data = self.calculate_cvd(df) |
|
|
| |
| taker_data = self.calculate_taker_ratio(trades) |
|
|
| |
| notable_data = self.analyze_large_orders(trades) |
|
|
| |
| composite_score = ( |
| 0.50 * cvd_data['score'] + |
| 0.30 * taker_data['score'] + |
| 0.20 * notable_data['score'] |
| ) |
| composite_score = float(np.clip(composite_score, -1, 1)) |
|
|
| |
| if composite_score > 0.15: |
| bias = 'bullish' |
| elif composite_score < -0.15: |
| bias = 'bearish' |
| else: |
| bias = 'neutral' |
|
|
| result = { |
| 'score': composite_score, |
| 'bias': bias, |
| |
| 'cvd': cvd_data, |
| 'taker': taker_data, |
| 'notable': notable_data, |
| |
| 'large_buys': notable_data['large_buys'], |
| 'large_sells': notable_data['large_sells'], |
| 'large_buy_volume': notable_data['large_buy_volume'], |
| 'large_sell_volume': notable_data['large_sell_volume'], |
| 'total_large_volume': notable_data['total_large_volume'], |
| } |
|
|
| logger.info( |
| f"📊 Order Flow [{self.symbol}]: score={composite_score:+.2f} ({bias}) | " |
| f"CVD={cvd_data['score']:+.2f}({cvd_data['trend']}) | " |
| f"Taker={taker_data['score']:+.2f}(buy={taker_data['ratio']:.1%}) | " |
| f"Notable={notable_data['score']:+.2f}(B:{notable_data['large_buys']}/S:{notable_data['large_sells']})" |
| ) |
|
|
| |
| self._cache_time = now |
| self._cache_result = result |
|
|
| return result |
|
|
| def detect_cvd_divergence(self, df: pd.DataFrame = None) -> Dict: |
| """ |
| Detect CVD Divergence: price going up while volume delta goes down. |
| This signals hidden selling (whales limit-selling into retail buying) |
| and often precedes a sudden dump. |
| |
| Returns: |
| dict with divergence_detected (bool), direction, and details. |
| """ |
| if df is None or len(df) < 20: |
| return {'divergence_detected': False, 'direction': 'none', 'price_trend': 'unknown', 'cvd_trend': 'unknown'} |
| |
| recent = df.tail(20) |
| |
| |
| price_recent = recent['close'].tail(5).mean() |
| price_prev = recent['close'].tail(10).head(5).mean() |
| price_change = (price_recent - price_prev) / price_prev |
| |
| if price_change > 0.002: |
| price_trend = 'bullish' |
| elif price_change < -0.002: |
| price_trend = 'bearish' |
| else: |
| price_trend = 'neutral' |
| |
| |
| body = recent['close'] - recent['open'] |
| range_size = recent['high'] - recent['low'] |
| body_ratio = body / (range_size + 1e-10) |
| volume_delta = body_ratio * recent['volume'] |
| |
| cvd_recent = volume_delta.tail(5).sum() |
| cvd_prev = volume_delta.tail(10).head(5).sum() |
| |
| if cvd_recent > cvd_prev * 1.1: |
| cvd_trend = 'bullish' |
| elif cvd_recent < cvd_prev * 0.9: |
| cvd_trend = 'bearish' |
| else: |
| cvd_trend = 'neutral' |
| |
| |
| bearish_divergence = price_trend == 'bullish' and cvd_trend == 'bearish' |
| |
| bullish_divergence = price_trend == 'bearish' and cvd_trend == 'bullish' |
| |
| divergence_detected = bearish_divergence or bullish_divergence |
| direction = 'bearish' if bearish_divergence else ('bullish' if bullish_divergence else 'none') |
| |
| if divergence_detected: |
| logger.warning( |
| f"⚡ CVD DIVERGENCE [{self.symbol}]: {direction.upper()} " |
| f"(price={price_trend}, cvd={cvd_trend}, Δprice={price_change:+.2%})" |
| ) |
| |
| return { |
| 'divergence_detected': divergence_detected, |
| 'direction': direction, |
| 'price_trend': price_trend, |
| 'cvd_trend': cvd_trend, |
| 'price_change': price_change, |
| } |
|
|
| def get_signal(self, df: pd.DataFrame = None) -> OrderFlowSignal: |
| """Get order flow signal (backward-compatible wrapper).""" |
| enhanced = self.get_enhanced_signal(df) |
|
|
| return OrderFlowSignal( |
| cvd=enhanced['cvd']['cvd'], |
| cvd_trend=enhanced['cvd']['trend'], |
| large_orders_bias=enhanced['notable']['bias'], |
| buy_pressure=enhanced['taker']['ratio'], |
| signal_strength=abs(enhanced['score']), |
| ) |
|
|
| def should_trade(self, trade_type: str, df: pd.DataFrame = None) -> Tuple[bool, str]: |
| """Check if order flow supports the trade.""" |
| enhanced = self.get_enhanced_signal(df) |
| score = enhanced['score'] |
|
|
| if trade_type == "long" and score < -0.4: |
| return False, f"Order flow opposing LONG: score={score:+.2f} (bearish)" |
|
|
| if trade_type == "short" and score > 0.4: |
| return False, f"Order flow opposing SHORT: score={score:+.2f} (bullish)" |
|
|
| return True, f"Order flow: {enhanced['bias']}, score={score:+.2f}" |
|
|