mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-07-27 18:47:51 +00:00
228 lines
8.4 KiB
Python
228 lines
8.4 KiB
Python
from typing import Dict, List, Optional, Tuple
|
||
from collections import deque
|
||
import statistics
|
||
import config
|
||
|
||
|
||
class TechnicalAnalyzer:
|
||
"""Real-time technical analysis with bar-anchored statistics (Task 1.1).
|
||
|
||
Maintains two data streams:
|
||
1. Bar history (M1/M5 candles) — the multi-hour statistical anchor
|
||
for μ and σ (config.BAR_LOOKBACK_BARS, default 288 M5 bars = 24h).
|
||
2. Live tick/poll deques — fast recent movement for display.
|
||
|
||
Z-score formula (priority):
|
||
If bar history has >= 2 bars: Z = (tick - μ_bars) / σ_bars
|
||
Otherwise (fallback): Z = (tick - μ_ticks) / σ_ticks
|
||
|
||
μ and σ prefer the multi-hour bar frame, but fall back to tick-based
|
||
statistics when bars haven't been seeded yet.
|
||
"""
|
||
|
||
def __init__(self, lookback: int = None):
|
||
if lookback is None:
|
||
lookback = config.BAR_LOOKBACK_BARS
|
||
self.bar_lookback = lookback
|
||
self.tick_lookback = 20
|
||
|
||
self.bar_history: Dict[str, deque] = {}
|
||
self.price_history: Dict[str, deque] = {}
|
||
self.volume_history: Dict[str, deque] = {}
|
||
self.z_scores: Dict[str, float] = {}
|
||
self.extremes: Dict[str, bool] = {}
|
||
|
||
for base in config.CURRENCIES:
|
||
for quote in config.CURRENCIES:
|
||
if base != quote:
|
||
pair = f"{base}_{quote}"
|
||
self.bar_history[pair] = deque(maxlen=self.bar_lookback)
|
||
self.price_history[pair] = deque(maxlen=self.tick_lookback)
|
||
self.volume_history[pair] = deque(maxlen=self.tick_lookback)
|
||
self.z_scores[pair] = 0.0
|
||
self.extremes[pair] = False
|
||
|
||
def add_bar(self, currency_pair: str, close: float, high: float = None,
|
||
low: float = None, volume: int = 0):
|
||
"""Add a completed M1/M5 bar to the multi-hour historical frame."""
|
||
if currency_pair not in self.bar_history:
|
||
return
|
||
self.bar_history[currency_pair].append(close)
|
||
|
||
def add_price_data(self, currency_pair: str, close_price: float,
|
||
volume: float = 0):
|
||
"""Add tick/poll price."""
|
||
if currency_pair not in self.price_history:
|
||
return
|
||
|
||
self.price_history[currency_pair].append(close_price)
|
||
if volume > 0:
|
||
self.volume_history[currency_pair].append(volume)
|
||
|
||
self._update_z_score(currency_pair)
|
||
|
||
def _get_mean_std(self, currency_pair: str) -> Tuple[float, float]:
|
||
"""Compute μ and σ, preferring bar history over tick history.
|
||
|
||
Falls back to tick data when bars haven't been seeded yet,
|
||
so the system works immediately from the first price update.
|
||
"""
|
||
bars = list(self.bar_history[currency_pair])
|
||
if len(bars) >= 2:
|
||
try:
|
||
return (statistics.mean(bars), statistics.stdev(bars))
|
||
except (ValueError, statistics.StatisticsError):
|
||
pass
|
||
|
||
ticks = list(self.price_history[currency_pair])
|
||
if len(ticks) >= 2:
|
||
try:
|
||
return (statistics.mean(ticks), statistics.stdev(ticks))
|
||
except (ValueError, statistics.StatisticsError):
|
||
pass
|
||
|
||
return (0.0, 0.0)
|
||
|
||
def _update_z_score(self, currency_pair: str):
|
||
prices = list(self.price_history[currency_pair])
|
||
if len(prices) < 1:
|
||
self.z_scores[currency_pair] = 0.0
|
||
self.extremes[currency_pair] = False
|
||
return
|
||
|
||
mu, sigma = self._get_mean_std(currency_pair)
|
||
if sigma == 0.0:
|
||
self.z_scores[currency_pair] = 0.0
|
||
self.extremes[currency_pair] = False
|
||
return
|
||
|
||
current_price = prices[-1]
|
||
z_score = (current_price - mu) / sigma
|
||
self.z_scores[currency_pair] = z_score
|
||
self.extremes[currency_pair] = abs(z_score) >= config.Z_SCORE_THRESHOLD
|
||
|
||
def get_z_score(self, currency_pair: str) -> float:
|
||
return self.z_scores.get(currency_pair, 0.0)
|
||
|
||
def is_extreme(self, currency_pair: str) -> bool:
|
||
return self.extremes.get(currency_pair, False)
|
||
|
||
def get_overbought_pairs(self) -> List[str]:
|
||
return [pair for pair, z in self.z_scores.items() if z >= config.Z_SCORE_THRESHOLD]
|
||
|
||
def get_oversold_pairs(self) -> List[str]:
|
||
return [pair for pair, z in self.z_scores.items() if z <= -config.Z_SCORE_THRESHOLD]
|
||
|
||
def get_volatility(self, currency_pair: str) -> float:
|
||
"""Volatility from bar history, falling back to ticks."""
|
||
bars = list(self.bar_history[currency_pair])
|
||
if len(bars) >= 2:
|
||
try:
|
||
return statistics.stdev(bars)
|
||
except (ValueError, statistics.StatisticsError):
|
||
pass
|
||
ticks = list(self.price_history[currency_pair])
|
||
if len(ticks) >= 2:
|
||
try:
|
||
return statistics.stdev(ticks)
|
||
except (ValueError, statistics.StatisticsError):
|
||
pass
|
||
return 0.0
|
||
|
||
def get_mean_price(self, currency_pair: str) -> float:
|
||
"""Mean from bar history, falling back to ticks."""
|
||
bars = list(self.bar_history[currency_pair])
|
||
if len(bars) >= 1:
|
||
return statistics.mean(bars)
|
||
ticks = list(self.price_history[currency_pair])
|
||
if len(ticks) >= 1:
|
||
return statistics.mean(ticks)
|
||
return 0.0
|
||
|
||
def is_mean_reverting(self, currency_pair: str, threshold: float = 0.5) -> bool:
|
||
z = self.get_z_score(currency_pair)
|
||
return abs(z) < threshold
|
||
|
||
def get_last_price(self, currency_pair: str) -> Optional[float]:
|
||
prices = self.price_history.get(currency_pair)
|
||
if prices and len(prices) > 0:
|
||
return prices[-1]
|
||
return None
|
||
|
||
def get_all_z_scores(self) -> Dict[str, float]:
|
||
return self.z_scores.copy()
|
||
|
||
def get_status_for_pair(self, currency_pair: str) -> Dict:
|
||
z_score = self.get_z_score(currency_pair)
|
||
volatility = self.get_volatility(currency_pair)
|
||
mean_price = self.get_mean_price(currency_pair)
|
||
is_extreme = self.is_extreme(currency_pair)
|
||
|
||
if z_score > 2.5:
|
||
status = "SEVERELY OVERBOUGHT"
|
||
elif z_score > 2.0:
|
||
status = "OVERBOUGHT"
|
||
elif z_score > 0.5:
|
||
status = "Moderately Overbought"
|
||
elif z_score < -2.5:
|
||
status = "SEVERELY OVERSOLD"
|
||
elif z_score < -2.0:
|
||
status = "OVERSOLD"
|
||
elif z_score < -0.5:
|
||
status = "Moderately Oversold"
|
||
else:
|
||
status = "Neutral"
|
||
|
||
return {
|
||
'pair': currency_pair,
|
||
'z_score': z_score,
|
||
'volatility': volatility,
|
||
'mean_price': mean_price,
|
||
'is_extreme': is_extreme,
|
||
'status': status
|
||
}
|
||
|
||
def seed_bars(self, historical_bars: Dict[str, List[float]]):
|
||
"""Seed bar_history with 288 M5 bars (24h) of historical close prices.
|
||
|
||
Args:
|
||
historical_bars: dict mapping pair -> list of close prices (oldest first)
|
||
"""
|
||
for pair, closes in historical_bars.items():
|
||
if pair in self.bar_history:
|
||
self.bar_history[pair].clear()
|
||
for c in closes[-self.bar_lookback:]:
|
||
self.bar_history[pair].append(c)
|
||
if len(self.bar_history[pair]) >= 2:
|
||
mu, sigma = self._get_mean_std(pair)
|
||
ticks = list(self.price_history[pair])
|
||
if ticks and sigma > 0:
|
||
z = (ticks[-1] - mu) / sigma
|
||
self.z_scores[pair] = z
|
||
self.extremes[pair] = abs(z) >= config.Z_SCORE_THRESHOLD
|
||
|
||
def clear_history(self):
|
||
for pair in self.bar_history:
|
||
self.bar_history[pair].clear()
|
||
self.price_history[pair].clear()
|
||
self.volume_history[pair].clear()
|
||
self.z_scores[pair] = 0.0
|
||
self.extremes[pair] = False
|
||
|
||
|
||
class TechnicalSignal:
|
||
"""Generates technical entry/exit signals based on Z-scores."""
|
||
|
||
def __init__(self, analyzer: TechnicalAnalyzer):
|
||
self.analyzer = analyzer
|
||
|
||
def should_enter_on_extreme(self, currency_pair: str) -> bool:
|
||
return self.analyzer.is_extreme(currency_pair)
|
||
|
||
def should_exit_on_mean_reversion(self, currency_pair: str) -> bool:
|
||
return self.analyzer.is_mean_reverting(currency_pair, threshold=0.5)
|
||
|
||
def get_signal_strength(self, currency_pair: str) -> float:
|
||
z = self.analyzer.get_z_score(currency_pair)
|
||
return min(abs(z) / 3.0 * 100, 100.0)
|