- 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
388 lines
15 KiB
Python
388 lines
15 KiB
Python
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()
|