Files
robinhood/backend/app/services/ai_context_builder.py
T
Krikorios 48e60d015f feat: Add Phase 4 advanced metrics and components
- Add advanced metrics dashboard with trade analytics
- Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.)
- Add strategy mode selector and trend confirmation
- Add risk automation panel and slippage correlation analysis
- Add daily trading plan enhancements with modal components
- Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics)
- Add broker service integration and trading API
- Add test setup and vitest configuration
- Include parquet data files for live market data
- Add comprehensive documentation in docs/ folder
2025-11-27 10:23:58 +02:00

406 lines
16 KiB
Python

from __future__ import annotations
from statistics import mean
from typing import List, Dict, Optional, Iterable
from app.schemas.schemas import AIAnalysisRequest, PriceData, PositionMetrics
from app.streaming.live_store import live_store
from app.services.candlestick_patterns import candlestick_detector
BB_LENGTH = 20
BB_MULT = 2.0
RSI_FAST_LENGTH = 3
ZLSMA_LENGTH = 50
CHAND_LENGTH = 22
CHAND_MULT = 2.0
MAX_PATTERN_SIGNALS = 10
class AIContextBuilder:
"""Builds enriched AIAnalysisRequest payloads from live store data."""
def __init__(self, default_symbol: str = "XAUUSD", default_timeframe: str = "1m") -> None:
self.default_symbol = default_symbol
self.default_timeframe = default_timeframe
def build_request(self, symbol: Optional[str] = None, timeframe: Optional[str] = None, limit: int = 400) -> AIAnalysisRequest:
sym = (symbol or self.default_symbol).upper().replace("/", "")
tf = timeframe or self.default_timeframe
bars = self._load_bars(sym, tf, limit)
if not bars:
raise ValueError(f"No live data available for {sym} {tf}")
trimmed = bars[-limit:]
price_data = [
PriceData(
time=int(row["time"]),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row.get("volume", 0.0)),
)
for row in trimmed
]
indicators = self._build_indicators(trimmed)
current_price = price_data[-1].close if price_data else float(trimmed[-1]["close"])
return AIAnalysisRequest(
price_data=price_data,
indicators=indicators,
current_price=current_price,
symbol=self._format_symbol(sym),
timeframe=tf,
)
def build_metrics(
self,
symbol: str,
timeframe: str,
price_data: Iterable[PriceData] | Iterable[Dict[str, float]]
) -> PositionMetrics:
rows = list(price_data)
if not rows:
raise ValueError("No price data available for metrics")
def _get(row, key: str) -> float:
if hasattr(row, key):
return float(getattr(row, key))
return float(row[key])
closes = [float(_get(row, "close")) for row in rows]
highs = [float(_get(row, "high")) for row in rows]
lows = [float(_get(row, "low")) for row in rows]
last = rows[-1]
prev = rows[-2] if len(rows) > 1 else None
change = None
change_pct = None
if prev is not None:
prev_close = _get(prev, "close")
last_close = _get(last, "close")
change = last_close - prev_close
if prev_close:
change_pct = (change / prev_close) * 100
recent_window = rows[-120:] if len(rows) > 120 else rows
support_levels = sorted({round(_get(row, "low"), 2) for row in recent_window})[:4]
resistance_levels = sorted({round(_get(row, "high"), 2) for row in recent_window}, reverse=True)[:4]
atr14 = self._atr(highs, lows, closes, 14)
rsi14 = self._rsi(closes, 14)
ema21 = self._ema(closes, 21)
sma55 = self._sma(closes, 55)
sma100 = self._sma(closes, 100)
sma200 = self._sma(closes, 200)
volatility = self._volatility(closes, 30)
momentum = self._momentum(closes, 12)
current_price = float(_get(last, "close"))
previous_close = float(_get(prev, "close")) if prev is not None else None
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
rsi_fast_prev = self._rsi(closes[:-1], RSI_FAST_LENGTH) if len(closes) > RSI_FAST_LENGTH + 1 else None
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
bb_prev = self._bollinger_bands(closes[:-1], BB_LENGTH, BB_MULT) if len(closes) > BB_LENGTH else (None, None, None)
bb_signal = None
if (
bb_basis is not None
and bb_upper is not None
and bb_lower is not None
and rsi_fast is not None
and rsi_fast_prev is not None
and bb_prev[0] is not None
):
close_prev = closes[-2]
bb_upper_prev = bb_prev[1]
bb_lower_prev = bb_prev[2]
if (
rsi_fast_prev < 30
and close_prev < bb_lower_prev
and rsi_fast > 30
and closes[-1] > bb_lower
and rsi_fast < 50
and closes[-1] < bb_basis
):
bb_signal = "LONG"
elif (
rsi_fast_prev > 70
and close_prev > bb_upper_prev
and rsi_fast < 70
and closes[-1] < bb_upper
and rsi_fast > 50
and closes[-1] > bb_basis
):
bb_signal = "SHORT"
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
)
chandelier_signal = None
if chandelier_long_stop is not None or chandelier_short_stop is not None:
if chandelier_long_stop is not None and chandelier_short_stop is not None:
if current_price > chandelier_short_stop and (zlsma is None or current_price >= zlsma):
chandelier_signal = "LONG"
elif current_price < chandelier_long_stop and (zlsma is None or current_price <= zlsma):
chandelier_signal = "SHORT"
else:
chandelier_signal = "NEUTRAL"
elif chandelier_long_stop is not None:
chandelier_signal = "LONG" if current_price > chandelier_long_stop else "SHORT"
else:
chandelier_signal = "SHORT" if current_price < chandelier_short_stop else "LONG"
symbol_fmt = self._format_symbol(symbol)
timestamp = int(_get(last, "time"))
pattern_signals = candlestick_detector.analyze(rows)
recent_pattern_signals = pattern_signals[-MAX_PATTERN_SIGNALS:]
return PositionMetrics(
symbol=symbol_fmt,
timeframe=timeframe,
timestamp=timestamp,
current_price=current_price,
previous_close=previous_close,
change=round(change, 4) if change is not None else None,
change_percent=round(change_pct, 4) if change_pct is not None else None,
high=round(max(_get(row, "high") for row in recent_window), 4) if recent_window else None,
low=round(min(_get(row, "low") for row in recent_window), 4) if recent_window else None,
atr14=round(atr14, 4) if atr14 is not None else None,
rsi14=round(rsi14, 2) if rsi14 is not None else None,
rsi3=round(rsi_fast, 2) if rsi_fast is not None else None,
ema21=round(ema21, 4) if ema21 is not None else None,
sma55=round(sma55, 4) if sma55 is not None else None,
sma100=round(sma100, 4) if sma100 is not None else None,
sma200=round(sma200, 4) if sma200 is not None else None,
bb_basis=round(bb_basis, 4) if bb_basis is not None else None,
bb_upper=round(bb_upper, 4) if bb_upper is not None else None,
bb_lower=round(bb_lower, 4) if bb_lower is not None else None,
bb_signal=bb_signal,
zlsma=round(zlsma, 4) if zlsma is not None else None,
chandelier_long_stop=round(chandelier_long_stop, 4) if chandelier_long_stop is not None else None,
chandelier_short_stop=round(chandelier_short_stop, 4) if chandelier_short_stop is not None else None,
chandelier_signal=chandelier_signal,
volatility30=round(volatility * 100, 2) if volatility is not None else None,
momentum12=round(momentum, 4) if momentum is not None else None,
support_levels=support_levels,
resistance_levels=resistance_levels,
pattern_signals=recent_pattern_signals,
bars_analyzed=len(rows),
)
def _load_bars(self, symbol: str, timeframe: str, limit: int) -> List[Dict[str, float]]:
bars = live_store.get_history(symbol, timeframe)
if not bars or len(bars) < limit:
try:
live_store.load_historical_data(symbol, timeframe, days_back=30)
bars = live_store.get_history(symbol, timeframe)
except Exception:
pass
return bars[-limit:] if bars else []
def _build_indicators(self, bars: List[Dict[str, float]]) -> List[Dict[str, float]]:
closes = [float(b["close"]) for b in bars]
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
indicators: List[Dict[str, float]] = []
for window in (8, 21, 55, 100, 200):
val = self._sma(closes, window)
if val is not None:
indicators.append({"name": f"SMA_{window}", "value": round(val, 4)})
ema21 = self._ema(closes, 21)
if ema21 is not None:
indicators.append({"name": "EMA_21", "value": round(ema21, 4)})
rsi14 = self._rsi(closes, 14)
if rsi14 is not None:
indicators.append({"name": "RSI_14", "value": round(rsi14, 2)})
atr14 = self._atr(highs, lows, closes, 14)
if atr14 is not None:
indicators.append({"name": "ATR_14", "value": round(atr14, 4)})
volatility = self._volatility(closes, 30)
if volatility is not None:
indicators.append({"name": "VOLATILITY_30", "value": round(volatility * 100, 2), "unit": "%"})
momentum = self._momentum(closes, 12)
if momentum is not None:
indicators.append({"name": "MOMENTUM_12", "value": round(momentum, 4)})
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
if rsi_fast is not None:
indicators.append({"name": f"RSI_{RSI_FAST_LENGTH}", "value": round(rsi_fast, 2)})
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
if bb_basis is not None:
indicators.append({"name": f"BB_{BB_LENGTH}_BASIS", "value": round(bb_basis, 4)})
indicators.append({"name": f"BB_{BB_LENGTH}_UPPER", "value": round(bb_upper, 4)})
indicators.append({"name": f"BB_{BB_LENGTH}_LOWER", "value": round(bb_lower, 4)})
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
if zlsma is not None:
indicators.append({"name": f"ZLSMA_{ZLSMA_LENGTH}", "value": round(zlsma, 4)})
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
)
if chandelier_long_stop is not None and chandelier_short_stop is not None:
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_LONG", "value": round(chandelier_long_stop, 4)})
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_SHORT", "value": round(chandelier_short_stop, 4)})
return indicators
@staticmethod
def _sma(values: List[float], window: int) -> Optional[float]:
if len(values) < window:
return None
return mean(values[-window:])
@staticmethod
def _ema(values: List[float], window: int) -> Optional[float]:
if len(values) < window:
return None
k = 2 / (window + 1)
ema = mean(values[:window])
for price in values[window:]:
ema = price * k + ema * (1 - k)
return ema
@staticmethod
def _rsi(values: List[float], window: int = 14) -> Optional[float]:
if len(values) <= window:
return None
gains = []
losses = []
for i in range(1, window + 1):
change = values[-i] - values[-i - 1]
if change >= 0:
gains.append(change)
else:
losses.append(abs(change))
avg_gain = mean(gains) if gains else 0
avg_loss = mean(losses) if losses else 0
if avg_loss == 0:
return 100.0
rs = avg_gain / avg_loss if avg_loss else 0
return 100 - (100 / (1 + rs))
@staticmethod
def _atr(highs: List[float], lows: List[float], closes: List[float], window: int = 14) -> Optional[float]:
if len(closes) <= window:
return None
true_ranges = []
for i in range(-window + 1, 0):
high = highs[i]
low = lows[i]
prev_close = closes[i - 1]
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
true_ranges.append(tr)
return mean(true_ranges) if true_ranges else None
@staticmethod
def _volatility(values: List[float], window: int) -> Optional[float]:
if len(values) < window:
return None
subset = values[-window:]
avg = mean(subset)
variance = mean([(p - avg) ** 2 for p in subset])
return (variance ** 0.5) / avg if avg else None
@staticmethod
def _momentum(values: List[float], lookback: int = 12) -> Optional[float]:
if len(values) <= lookback:
return None
return values[-1] - values[-lookback - 1]
@staticmethod
def _bollinger_bands(values: List[float], length: int, multiplier: float) -> tuple[Optional[float], Optional[float], Optional[float]]:
if len(values) < length:
return (None, None, None)
window = values[-length:]
basis = mean(window)
variance = mean([(price - basis) ** 2 for price in window])
deviation = variance ** 0.5
upper = basis + multiplier * deviation
lower = basis - multiplier * deviation
return (basis, upper, lower)
@staticmethod
def _linreg(values: List[float], length: int) -> Optional[float]:
if len(values) < length:
return None
window = values[-length:]
x = list(range(length))
sum_x = sum(x)
sum_y = sum(window)
sum_x2 = sum(i * i for i in x)
sum_xy = sum(i * y for i, y in zip(x, window))
denominator = length * sum_x2 - sum_x ** 2
if denominator == 0:
return window[-1]
slope = (length * sum_xy - sum_x * sum_y) / denominator
intercept = (sum_y - slope * sum_x) / length
return intercept + slope * (length - 1)
@classmethod
def _zlsma(cls, values: List[float], length: int) -> Optional[float]:
if len(values) < length:
return None
lsma_series: List[float] = []
for idx in range(length, len(values) + 1):
segment = values[idx - length : idx]
lsma_val = cls._linreg(segment, length)
if lsma_val is not None:
lsma_series.append(lsma_val)
if not lsma_series:
return None
lsma_last = lsma_series[-1]
if len(lsma_series) < length:
return lsma_last
lsma2_series: List[float] = []
for idx in range(length, len(lsma_series) + 1):
segment = lsma_series[idx - length : idx]
lsma2_val = cls._linreg(segment, length)
if lsma2_val is not None:
lsma2_series.append(lsma2_val)
if not lsma2_series:
return lsma_last
lsma2_last = lsma2_series[-1]
return lsma_last + (lsma_last - lsma2_last)
@classmethod
def _chandelier_exit(
cls, highs: List[float], lows: List[float], closes: List[float], length: int, multiplier: float
) -> tuple[Optional[float], Optional[float]]:
if len(closes) <= length:
return (None, None)
recent_high = max(highs[-length:])
recent_low = min(lows[-length:])
atr = cls._atr(highs, lows, closes, length)
if atr is None:
return (None, None)
long_stop = recent_high - multiplier * atr
short_stop = recent_low + multiplier * atr
return (long_stop, short_stop)
@staticmethod
def _format_symbol(symbol: str) -> str:
if len(symbol) == 6 and symbol.isalpha():
return f"{symbol[:3]}/{symbol[3:]}"
return symbol
ai_context_builder = AIContextBuilder()