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
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Sequence, Tuple, Union
|
||||
|
||||
from app.schemas.schemas import PatternSignal, PriceData
|
||||
|
||||
BarLike = Union[PriceData, dict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candle:
|
||||
time: int
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
|
||||
@property
|
||||
def hl2(self) -> float:
|
||||
return (self.high + self.low) / 2
|
||||
|
||||
|
||||
class CandlestickPatternDetector:
|
||||
"""Translated subset of the TradingView *All Candlestick Patterns* study.
|
||||
|
||||
The detector focuses on high-signal patterns that are most useful for
|
||||
risk automation and narrative building. The implementation is intentionally
|
||||
modular so additional patterns from the Pine script can be ported quickly.
|
||||
"""
|
||||
|
||||
BODY_AVG_EMA = 14
|
||||
SHADOW_PERCENT = 5.0
|
||||
SHADOW_EQUALS_PERCENT = 100.0
|
||||
DOJI_BODY_PERCENT = 5.0
|
||||
LONG_LOWER_PERCENT = 75.0
|
||||
LONG_UPPER_PERCENT = 75.0
|
||||
HAMMER_FACTOR = 2.0
|
||||
TREND_SMA = 50
|
||||
TREND_SMA_LONG = 200
|
||||
|
||||
def analyze(self, rows: Iterable[BarLike]) -> List[PatternSignal]:
|
||||
candles = self._normalize(rows)
|
||||
if len(candles) < 3:
|
||||
return []
|
||||
|
||||
opens = [c.open for c in candles]
|
||||
highs = [c.high for c in candles]
|
||||
lows = [c.low for c in candles]
|
||||
closes = [c.close for c in candles]
|
||||
times = [c.time for c in candles]
|
||||
|
||||
body_hi = [max(o, c) for o, c in zip(opens, closes)]
|
||||
body_lo = [min(o, c) for o, c in zip(opens, closes)]
|
||||
bodies = [hi - lo for hi, lo in zip(body_hi, body_lo)]
|
||||
ranges = [h - l for h, l in zip(highs, lows)]
|
||||
upper_shadows = [h - hi for h, hi in zip(highs, body_hi)]
|
||||
lower_shadows = [lo - l for lo, l in zip(body_lo, lows)]
|
||||
body_avg = self._ema_series(bodies, self.BODY_AVG_EMA)
|
||||
sma50 = self._sma_series(closes, self.TREND_SMA)
|
||||
sma200 = self._sma_series(closes, self.TREND_SMA_LONG)
|
||||
|
||||
up_trend = [False] * len(candles)
|
||||
down_trend = [False] * len(candles)
|
||||
for idx in range(len(candles)):
|
||||
if sma50[idx] is None:
|
||||
if idx > 0:
|
||||
up_trend[idx] = closes[idx] > closes[idx - 1]
|
||||
down_trend[idx] = closes[idx] < closes[idx - 1]
|
||||
continue
|
||||
close = closes[idx]
|
||||
s50 = sma50[idx]
|
||||
s200 = sma200[idx]
|
||||
up = close > s50
|
||||
down = close < s50
|
||||
if s200 is not None:
|
||||
up = up and s50 > s200
|
||||
down = down and s50 < s200
|
||||
up_trend[idx] = up
|
||||
down_trend[idx] = down
|
||||
|
||||
pattern_signals: List[PatternSignal] = []
|
||||
|
||||
for i in range(len(candles)):
|
||||
detected = self._detect_at(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
upper_shadows,
|
||||
lower_shadows,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
for pattern, classification in detected:
|
||||
pattern_signals.append(
|
||||
PatternSignal(
|
||||
pattern=pattern,
|
||||
classification=classification,
|
||||
price=closes[i],
|
||||
time=times[i],
|
||||
)
|
||||
)
|
||||
|
||||
return pattern_signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_at(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
upper_shadows: Sequence[float],
|
||||
lower_shadows: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
if i == 0:
|
||||
return signals
|
||||
|
||||
body = bodies[i]
|
||||
body_average = body_avg[i] or 0.0
|
||||
range_ = ranges[i]
|
||||
upper = upper_shadows[i]
|
||||
lower = lower_shadows[i]
|
||||
is_white = candles[i].close > candles[i].open
|
||||
is_black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
small_body = body_average > 0 and body < body_average
|
||||
long_body = body_average > 0 and body > body_average
|
||||
has_upper_shadow = upper > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
has_lower_shadow = lower > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
doji = self._is_doji(body, range_)
|
||||
|
||||
# Single-candle patterns -------------------------------------------------
|
||||
if doji:
|
||||
signals.append(("Doji", "NEUTRAL"))
|
||||
if upper <= body:
|
||||
signals.append(("Dragonfly Doji", "BULLISH"))
|
||||
if lower <= body:
|
||||
signals.append(("Gravestone Doji", "BEARISH"))
|
||||
|
||||
if body > 0:
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and down_trend[i]:
|
||||
signals.append(("Hammer", "BULLISH"))
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and up_trend[i]:
|
||||
signals.append(("Hanging Man", "BEARISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and down_trend[i]:
|
||||
signals.append(("Inverted Hammer", "BULLISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and up_trend[i]:
|
||||
signals.append(("Shooting Star", "BEARISH"))
|
||||
|
||||
if body > 0 and upper <= body * self.SHADOW_PERCENT / 100 and lower <= body * self.SHADOW_PERCENT / 100:
|
||||
if is_white:
|
||||
signals.append(("Marubozu White", "BULLISH"))
|
||||
if is_black:
|
||||
signals.append(("Marubozu Black", "BEARISH"))
|
||||
|
||||
if lower > range_ * self.LONG_LOWER_PERCENT / 100:
|
||||
signals.append(("Long Lower Shadow", "BULLISH"))
|
||||
if upper > range_ * self.LONG_UPPER_PERCENT / 100:
|
||||
signals.append(("Long Upper Shadow", "BEARISH"))
|
||||
|
||||
# Multi-candle patterns --------------------------------------------------
|
||||
signals.extend(
|
||||
self._two_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(
|
||||
self._three_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(self._soldiers_and_crows(i, candles, bodies, body_avg))
|
||||
|
||||
return signals
|
||||
|
||||
def _two_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 1:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
body = bodies[i]
|
||||
body_prev = bodies[i - 1]
|
||||
avg = body_avg[i] or 0.0
|
||||
avg_prev = body_avg[i - 1] or 0.0
|
||||
white = candles[i].close > candles[i].open
|
||||
black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
|
||||
tol = (avg + avg_prev) / 2 * 0.05 if (avg + avg_prev) > 0 else 0.0
|
||||
|
||||
# Tweezer patterns
|
||||
if abs(candles[i].high - candles[i - 1].high) <= tol and prev_white and black and up_trend[i - 1]:
|
||||
signals.append(("Tweezer Top", "BEARISH"))
|
||||
if abs(candles[i].low - candles[i - 1].low) <= tol and prev_black and white and down_trend[i - 1]:
|
||||
signals.append(("Tweezer Bottom", "BULLISH"))
|
||||
|
||||
# Engulfing
|
||||
if down_trend[i - 1] and prev_black and (avg_prev == 0 or body_prev <= avg_prev) and white:
|
||||
if candles[i].close >= candles[i - 1].open and candles[i].open <= candles[i - 1].close:
|
||||
signals.append(("Bullish Engulfing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and (avg_prev == 0 or body_prev <= avg_prev) and black:
|
||||
if candles[i].close <= candles[i - 1].open and candles[i].open >= candles[i - 1].close:
|
||||
signals.append(("Bearish Engulfing", "BEARISH"))
|
||||
|
||||
# Piercing / Dark Cloud Cover
|
||||
mid_prev = (candles[i - 1].open + candles[i - 1].close) / 2
|
||||
if down_trend[i - 1] and prev_black and white:
|
||||
if candles[i].open <= candles[i - 1].low and candles[i].close > mid_prev and candles[i].close < candles[i - 1].open:
|
||||
signals.append(("Piercing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and black:
|
||||
if candles[i].open >= candles[i - 1].high and candles[i].close < mid_prev and candles[i].close > candles[i - 1].open:
|
||||
signals.append(("Dark Cloud Cover", "BEARISH"))
|
||||
|
||||
# Doji Star variants
|
||||
if self._is_doji(body, ranges[i]) and up_trend[i - 1] and prev_white:
|
||||
if candles[i].open > candles[i - 1].high:
|
||||
signals.append(("Doji Star", "BEARISH"))
|
||||
if self._is_doji(body, ranges[i]) and down_trend[i - 1] and prev_black:
|
||||
if candles[i].open < candles[i - 1].low:
|
||||
signals.append(("Doji Star", "BULLISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _three_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
white2 = c2.close > c2.open
|
||||
black2 = c2.open > c2.close
|
||||
small1 = avg1 > 0 and body1 < avg1
|
||||
doji1 = self._is_doji(body1, c1.high - c1.low)
|
||||
|
||||
mid0 = (c0.open + c0.close) / 2
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and small1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and small1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Star", "BEARISH"))
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and doji1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Doji Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and doji1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Doji Star", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _soldiers_and_crows(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
|
||||
if all(b > a for b, a in zip((body0, body1, body2), (avg0, avg1, avg2))):
|
||||
if c0.close < c0.open and c1.close > c1.open and c2.close > c2.open:
|
||||
if c1.open > c0.close and c2.open > c1.close and c2.close > c1.close > c0.close:
|
||||
signals.append(("Three White Soldiers", "BULLISH"))
|
||||
if c0.close > c0.open and c1.close < c1.open and c2.close < c2.open:
|
||||
if c1.open < c0.close and c2.open < c1.close and c2.close < c1.close < c0.close:
|
||||
signals.append(("Three Black Crows", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize(self, rows: Iterable[BarLike]) -> List[Candle]:
|
||||
candles: List[Candle] = []
|
||||
for row in rows:
|
||||
if isinstance(row, PriceData):
|
||||
candles.append(Candle(time=row.time, open=row.open, high=row.high, low=row.low, close=row.close))
|
||||
else:
|
||||
candles.append(
|
||||
Candle(
|
||||
time=int(row.get("time", len(candles))),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
)
|
||||
)
|
||||
return candles
|
||||
|
||||
def _ema_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
ema_series: List[float | None] = [None] * len(values)
|
||||
if len(values) < length:
|
||||
return ema_series
|
||||
k = 2 / (length + 1)
|
||||
ema = sum(values[:length]) / length
|
||||
ema_series[length - 1] = ema
|
||||
for idx in range(length, len(values)):
|
||||
ema = values[idx] * k + ema * (1 - k)
|
||||
ema_series[idx] = ema
|
||||
return ema_series
|
||||
|
||||
def _sma_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
sma_series: List[float | None] = [None] * len(values)
|
||||
if length <= 0:
|
||||
return sma_series
|
||||
window_sum = 0.0
|
||||
for idx, value in enumerate(values):
|
||||
window_sum += value
|
||||
if idx >= length:
|
||||
window_sum -= values[idx - length]
|
||||
if idx >= length - 1:
|
||||
sma_series[idx] = window_sum / length
|
||||
return sma_series
|
||||
|
||||
def _is_doji(self, body: float, candle_range: float) -> bool:
|
||||
if candle_range <= 0:
|
||||
return False
|
||||
return body <= candle_range * self.DOJI_BODY_PERCENT / 100
|
||||
|
||||
|
||||
candlestick_detector = CandlestickPatternDetector()
|
||||
Reference in New Issue
Block a user