141 lines
5.7 KiB
Python
141 lines
5.7 KiB
Python
"""Support & Resistance strategy signal (§3.14).
|
|
|
|
Detects local min/max price levels from trailing 6-month daily data.
|
|
Identifies nearest support (below current price) and resistance (above)
|
|
for alert thresholds and entry/exit timing.
|
|
|
|
Reference: Kakushadze & Serur §3.14 — "Support and Resistance"
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import yfinance as yf
|
|
|
|
from tradingagents.strategies.base import BaseStrategy, StrategySignal
|
|
|
|
_LOOKBACK_DAYS = 180 # ~6 months of calendar days
|
|
_ORDER = 5 # local extrema window: point must be min/max within ±ORDER bars
|
|
|
|
|
|
def _find_local_extrema(close: pd.Series, order: int = _ORDER) -> tuple[list[float], list[float]]:
|
|
"""Find local minima (supports) and maxima (resistances) in price series."""
|
|
supports: list[float] = []
|
|
resistances: list[float] = []
|
|
values = close.values
|
|
|
|
for i in range(order, len(values) - order):
|
|
window = values[i - order : i + order + 1]
|
|
if values[i] == window.min():
|
|
supports.append(float(values[i]))
|
|
elif values[i] == window.max():
|
|
resistances.append(float(values[i]))
|
|
|
|
return supports, resistances
|
|
|
|
|
|
def _cluster_levels(levels: list[float], tolerance: float = 0.02) -> list[float]:
|
|
"""Cluster nearby price levels (within tolerance %) into single levels."""
|
|
if not levels:
|
|
return []
|
|
sorted_levels = sorted(levels)
|
|
clusters: list[list[float]] = [[sorted_levels[0]]]
|
|
for lvl in sorted_levels[1:]:
|
|
if (lvl - clusters[-1][-1]) / clusters[-1][-1] <= tolerance:
|
|
clusters[-1].append(lvl)
|
|
else:
|
|
clusters.append([lvl])
|
|
return [round(np.mean(c), 2) for c in clusters]
|
|
|
|
|
|
class SupportResistanceStrategy(BaseStrategy):
|
|
|
|
@property
|
|
def interpretation_guide(self) -> str:
|
|
return "Usage: Key levels for stop-loss placement and entry timing. Tips: Levels are approximate — use zones not exact prices. Breaks of major support on high volume are significant. Combine with RSI for 'oversold at support' high-conviction entries."
|
|
|
|
name = "support_resistance"
|
|
description = "Local min/max price levels for support/resistance"
|
|
target_analysts = ["technical"]
|
|
|
|
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
|
|
hist = kwargs.get("hist")
|
|
if hist is None:
|
|
end = pd.Timestamp(date)
|
|
start = end - pd.DateOffset(days=_LOOKBACK_DAYS)
|
|
hist = yf.Ticker(ticker).history(
|
|
start=start.strftime("%Y-%m-%d"),
|
|
end=(end + pd.DateOffset(days=1)).strftime("%Y-%m-%d"),
|
|
)
|
|
|
|
if hist.empty or len(hist) < _ORDER * 3:
|
|
return self._neutral(ticker, date)
|
|
|
|
close = hist["Close"]
|
|
current = float(close.iloc[-1])
|
|
|
|
raw_supports, raw_resistances = _find_local_extrema(close)
|
|
supports = _cluster_levels(raw_supports)
|
|
resistances = _cluster_levels(raw_resistances)
|
|
|
|
# Nearest support below current price
|
|
below = [s for s in supports if s < current]
|
|
nearest_support = max(below) if below else None
|
|
|
|
# Nearest resistance above current price
|
|
above = [r for r in resistances if r > current]
|
|
nearest_resistance = min(above) if above else None
|
|
|
|
# Distance to nearest levels (as % of current price)
|
|
support_dist = ((current - nearest_support) / current * 100) if nearest_support else None
|
|
resist_dist = ((nearest_resistance - current) / current * 100) if nearest_resistance else None
|
|
|
|
# Signal: near support = bullish (SUPPORTS), near resistance = bearish (CONTRADICTS)
|
|
if support_dist is not None and support_dist < 3:
|
|
signal, direction = "STRONG", "SUPPORTS"
|
|
label = f"near support ${nearest_support:.2f} ({support_dist:.1f}% below)"
|
|
elif resist_dist is not None and resist_dist < 3:
|
|
signal, direction = "STRONG", "CONTRADICTS"
|
|
label = f"near resistance ${nearest_resistance:.2f} ({resist_dist:.1f}% above)"
|
|
elif support_dist is not None and support_dist < 8:
|
|
signal, direction = "MODERATE", "SUPPORTS"
|
|
label = f"support ${nearest_support:.2f} ({support_dist:.1f}% below)"
|
|
elif resist_dist is not None and resist_dist < 8:
|
|
signal, direction = "MODERATE", "CONTRADICTS"
|
|
label = f"resistance ${nearest_resistance:.2f} ({resist_dist:.1f}% above)"
|
|
else:
|
|
signal, direction = "NEUTRAL", "NEUTRAL"
|
|
parts = []
|
|
if nearest_support:
|
|
parts.append(f"S=${nearest_support:.2f}")
|
|
if nearest_resistance:
|
|
parts.append(f"R=${nearest_resistance:.2f}")
|
|
label = ", ".join(parts) if parts else "no clear levels"
|
|
|
|
return StrategySignal(
|
|
name=self.name,
|
|
ticker=ticker,
|
|
date=date,
|
|
signal=signal,
|
|
value=round(support_dist or 0, 4),
|
|
value_label=label,
|
|
direction=direction,
|
|
detail={
|
|
"current_price": round(current, 2),
|
|
"nearest_support": nearest_support,
|
|
"nearest_resistance": nearest_resistance,
|
|
"support_distance_pct": round(support_dist, 2) if support_dist else None,
|
|
"resistance_distance_pct": round(resist_dist, 2) if resist_dist else None,
|
|
"all_supports": supports[-5:], # last 5
|
|
"all_resistances": resistances[-5:],
|
|
},
|
|
)
|
|
|
|
def _neutral(self, ticker: str, date: str) -> StrategySignal:
|
|
return StrategySignal(
|
|
name=self.name, ticker=ticker, date=date,
|
|
signal="NEUTRAL", value=0.0, value_label="N/A (insufficient data)",
|
|
direction="NEUTRAL", detail={},
|
|
)
|