Replace RSI divergence with a 70/30 crossing
Browse filesThe divergence rule needed a pivot pair, a three-bar confirmation lag
and separate 60/40 bounds on the weekly leg, so it fired weeks after the
move and almost never on a real series. It is replaced, not supplemented:
RSI(14) closing at or beyond 70 / 30 on a bar whose predecessor was
outside that zone, the same thresholds daily and weekly.
The crossing is the signal, so a name parked beyond the threshold fires
once rather than on every bar it stays there. `reversal.py` is deleted
and `meanrev.py` takes its place in the school list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- docs/analytics.md +1 -1
- skills/terrafin/SKILL.md +3 -4
- src/TerraFin/agent/runtime/capability.py +7 -5
- src/TerraFin/analytics/analysis/patterns/__init__.py +3 -3
- src/TerraFin/analytics/analysis/patterns/catalog.py +7 -7
- src/TerraFin/analytics/analysis/patterns/meanrev.py +92 -0
- src/TerraFin/analytics/analysis/patterns/reversal.py +0 -127
- tests/agent/test_pattern_scan.py +1 -1
- tests/analytics/test_pattern_catalog.py +20 -21
- tests/analytics/test_weekly_patterns.py +116 -87
docs/analytics.md
CHANGED
|
@@ -186,7 +186,7 @@ Modules are split by methodology so a new pattern lands in an obvious file.
|
|
| 186 |
| `breakout` | Bollinger / Donchian (50, weekly 52) breakout, BB squeeze release, swing-pivot break, Darvas box, NR7 / Inside Bar, Keltner channel, 52-week high proximity, Wyckoff Spring / Upthrust |
|
| 187 |
| `meanrev` | RSI overbought / oversold, Connors RSI(2) dip in uptrend |
|
| 188 |
| `momentum` | MACD signal-line cross, Coppock curve (monthly) |
|
| 189 |
-
| `
|
| 190 |
| `volume` | Capitulation bottom (Wyckoff selling climax), OBV divergence, Chaikin Money Flow, Money Flow Index |
|
| 191 |
|
| 192 |
### Public API
|
|
|
|
| 186 |
| `breakout` | Bollinger / Donchian (50, weekly 52) breakout, BB squeeze release, swing-pivot break, Darvas box, NR7 / Inside Bar, Keltner channel, 52-week high proximity, Wyckoff Spring / Upthrust |
|
| 187 |
| `meanrev` | RSI overbought / oversold, Connors RSI(2) dip in uptrend |
|
| 188 |
| `momentum` | MACD signal-line cross, Coppock curve (monthly) |
|
| 189 |
+
| `meanrev` | RSI(14) crossing into overbought / oversold, daily and weekly |
|
| 190 |
| `volume` | Capitulation bottom (Wyckoff selling climax), OBV divergence, Chaikin Money Flow, Money Flow Index |
|
| 191 |
|
| 192 |
### Public API
|
skills/terrafin/SKILL.md
CHANGED
|
@@ -479,15 +479,14 @@ Parameters:
|
|
| 479 |
Patterns evaluated per symbol:
|
| 480 |
|
| 481 |
- close-vs-MA cross grid — `MA20/60/120/200_{GOLDEN,DEATH}_CROSS` (daily) and `MA20/60/120W_{GOLDEN,DEATH}_CROSS` (weekly)
|
| 482 |
-
- `52W_NEW_HIGH`, `52W_NEW_LOW`, `WEEKLY_NEW_HIGH`, `WEEKLY_NEW_LOW`, `MINERVINI_TEMPLATE`, `
|
| 483 |
|
| 484 |
Severity, as the catalogue actually emits it:
|
| 485 |
|
| 486 |
- no pattern emits `low`, so `severity_min="low"` and `"medium"` return the same set
|
| 487 |
-
- `"high"` narrows to exactly `52W_NEW_HIGH`, `52W_NEW_LOW`, `WEEKLY_NEW_HIGH`, `WEEKLY_NEW_LOW`, `MINERVINI_TEMPLATE`, `
|
| 488 |
-
- a weekly divergence re-fires for about three consecutive weekly bars, so `"high"` is not a one-shot bucket
|
| 489 |
|
| 490 |
-
|
| 491 |
|
| 492 |
Coverage fields — check these before concluding "nothing is triggering":
|
| 493 |
|
|
|
|
| 479 |
Patterns evaluated per symbol:
|
| 480 |
|
| 481 |
- close-vs-MA cross grid — `MA20/60/120/200_{GOLDEN,DEATH}_CROSS` (daily) and `MA20/60/120W_{GOLDEN,DEATH}_CROSS` (weekly)
|
| 482 |
+
- `52W_NEW_HIGH`, `52W_NEW_LOW`, `WEEKLY_NEW_HIGH`, `WEEKLY_NEW_LOW`, `MINERVINI_TEMPLATE`, `RSI_OVERBOUGHT` and `RSI_OVERSOLD` with their `WEEKLY_` counterparts, `WEEKLY_VOLUME_DRYUP`
|
| 483 |
|
| 484 |
Severity, as the catalogue actually emits it:
|
| 485 |
|
| 486 |
- no pattern emits `low`, so `severity_min="low"` and `"medium"` return the same set
|
| 487 |
+
- `"high"` narrows to exactly `52W_NEW_HIGH`, `52W_NEW_LOW`, `WEEKLY_NEW_HIGH`, `WEEKLY_NEW_LOW`, `MINERVINI_TEMPLATE`, `WEEKLY_RSI_OVERBOUGHT`, `WEEKLY_RSI_OVERSOLD`
|
|
|
|
| 488 |
|
| 489 |
+
Expect roughly 1–2 signals per symbol per scan — not a flood.
|
| 490 |
|
| 491 |
Coverage fields — check these before concluding "nothing is triggering":
|
| 492 |
|
src/TerraFin/agent/runtime/capability.py
CHANGED
|
@@ -300,16 +300,18 @@ def build_default_capability_registry(
|
|
| 300 |
"\n"
|
| 301 |
"Patterns evaluated per symbol: the close-vs-MA cross grid "
|
| 302 |
"(MA20/60/120/200 daily and MA20/60/120 weekly, golden and "
|
| 303 |
-
"death), 52W_NEW_HIGH, 52W_NEW_LOW,
|
| 304 |
-
"
|
|
|
|
| 305 |
"WEEKLY_VOLUME_DRYUP.\n"
|
| 306 |
"\n"
|
| 307 |
"SEVERITY: no pattern currently emits 'low', so "
|
| 308 |
"`severity_min='low'` and `'medium'` return the same set. "
|
| 309 |
"`'high'` narrows to exactly {52W_NEW_HIGH, 52W_NEW_LOW, "
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
"symbol
|
|
|
|
| 313 |
"\n"
|
| 314 |
"COVERAGE: `requested` is the symbol count asked for, "
|
| 315 |
"`scanned` how many were actually fetched and evaluated, and "
|
|
|
|
| 300 |
"\n"
|
| 301 |
"Patterns evaluated per symbol: the close-vs-MA cross grid "
|
| 302 |
"(MA20/60/120/200 daily and MA20/60/120 weekly, golden and "
|
| 303 |
+
"death), 52W_NEW_HIGH, 52W_NEW_LOW, WEEKLY_NEW_HIGH, "
|
| 304 |
+
"WEEKLY_NEW_LOW, MINERVINI_TEMPLATE, RSI_OVERBOUGHT, "
|
| 305 |
+
"RSI_OVERSOLD and their WEEKLY_ counterparts, and "
|
| 306 |
"WEEKLY_VOLUME_DRYUP.\n"
|
| 307 |
"\n"
|
| 308 |
"SEVERITY: no pattern currently emits 'low', so "
|
| 309 |
"`severity_min='low'` and `'medium'` return the same set. "
|
| 310 |
"`'high'` narrows to exactly {52W_NEW_HIGH, 52W_NEW_LOW, "
|
| 311 |
+
"WEEKLY_NEW_HIGH, WEEKLY_NEW_LOW, MINERVINI_TEMPLATE, "
|
| 312 |
+
"WEEKLY_RSI_OVERBOUGHT, WEEKLY_RSI_OVERSOLD}. "
|
| 313 |
+
"Expect roughly 1-2 signals per symbol per scan, "
|
| 314 |
+
"not a flood.\n"
|
| 315 |
"\n"
|
| 316 |
"COVERAGE: `requested` is the symbol count asked for, "
|
| 317 |
"`scanned` how many were actually fetched and evaluated, and "
|
src/TerraFin/analytics/analysis/patterns/__init__.py
CHANGED
|
@@ -24,7 +24,7 @@ Split by methodology so a new pattern lands in an obvious file:
|
|
| 24 |
|
| 25 |
- ``trend`` — MA crosses, Minervini template
|
| 26 |
- ``breakout`` — 52-week high/low, weekly volume dry-up
|
| 27 |
-
- ``
|
| 28 |
|
| 29 |
Each school module exposes ``evaluate(ticker, ohlc) -> list[Signal]``;
|
| 30 |
the package-level ``evaluate`` aggregates them.
|
|
@@ -32,12 +32,12 @@ the package-level ``evaluate`` aggregates them.
|
|
| 32 |
|
| 33 |
from TerraFin.data.contracts.dataframes import TimeSeriesDataFrame
|
| 34 |
|
| 35 |
-
from . import breakout,
|
| 36 |
from ._base import _OHLCV_CACHE_KEY, Severity, Signal
|
| 37 |
from .catalog import PATTERN_TIMEFRAMES, signal_key
|
| 38 |
|
| 39 |
|
| 40 |
-
_ACTIVE_SCHOOLS = (trend, breakout,
|
| 41 |
|
| 42 |
|
| 43 |
def _preload_ohlcv(ohlc) -> None:
|
|
|
|
| 24 |
|
| 25 |
- ``trend`` — MA crosses, Minervini template
|
| 26 |
- ``breakout`` — 52-week high/low, weekly volume dry-up
|
| 27 |
+
- ``meanrev`` — RSI crossing into overbought / oversold
|
| 28 |
|
| 29 |
Each school module exposes ``evaluate(ticker, ohlc) -> list[Signal]``;
|
| 30 |
the package-level ``evaluate`` aggregates them.
|
|
|
|
| 32 |
|
| 33 |
from TerraFin.data.contracts.dataframes import TimeSeriesDataFrame
|
| 34 |
|
| 35 |
+
from . import breakout, meanrev, trend
|
| 36 |
from ._base import _OHLCV_CACHE_KEY, Severity, Signal
|
| 37 |
from .catalog import PATTERN_TIMEFRAMES, signal_key
|
| 38 |
|
| 39 |
|
| 40 |
+
_ACTIVE_SCHOOLS = (trend, breakout, meanrev)
|
| 41 |
|
| 42 |
|
| 43 |
def _preload_ohlcv(ohlc) -> None:
|
src/TerraFin/analytics/analysis/patterns/catalog.py
CHANGED
|
@@ -33,11 +33,11 @@ PATTERN_TIMEFRAMES: dict[str, str] = {
|
|
| 33 |
"WEEKLY_VOLUME_DRYUP": "weekly",
|
| 34 |
"WEEKLY_NEW_HIGH": "weekly",
|
| 35 |
"WEEKLY_NEW_LOW": "weekly",
|
| 36 |
-
|
| 37 |
-
"
|
| 38 |
-
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
}
|
| 42 |
|
| 43 |
|
|
@@ -45,8 +45,8 @@ def signal_key(signal: Signal) -> str | None:
|
|
| 45 |
"""What identifies one finding across every bar it re-fires on.
|
| 46 |
|
| 47 |
The trigger bar wins: it names the exact bar the finding is about, so a
|
| 48 |
-
|
| 49 |
-
|
| 50 |
belongs to. Both are facts the detector put in the snapshot.
|
| 51 |
|
| 52 |
The week only keys a pattern this map calls weekly. A daily pattern that
|
|
|
|
| 33 |
"WEEKLY_VOLUME_DRYUP": "weekly",
|
| 34 |
"WEEKLY_NEW_HIGH": "weekly",
|
| 35 |
"WEEKLY_NEW_LOW": "weekly",
|
| 36 |
+
# Mean reversion — RSI crossing into an extreme
|
| 37 |
+
"RSI_OVERBOUGHT": "daily",
|
| 38 |
+
"RSI_OVERSOLD": "daily",
|
| 39 |
+
"WEEKLY_RSI_OVERBOUGHT": "weekly",
|
| 40 |
+
"WEEKLY_RSI_OVERSOLD": "weekly",
|
| 41 |
}
|
| 42 |
|
| 43 |
|
|
|
|
| 45 |
"""What identifies one finding across every bar it re-fires on.
|
| 46 |
|
| 47 |
The trigger bar wins: it names the exact bar the finding is about, so a
|
| 48 |
+
detector that re-confirms the same event on several later bars stays a
|
| 49 |
+
single finding. `week_ending` is next, naming the data week the fire
|
| 50 |
belongs to. Both are facts the detector put in the snapshot.
|
| 51 |
|
| 52 |
The week only keys a pattern this map calls weekly. A daily pattern that
|
src/TerraFin/analytics/analysis/patterns/meanrev.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mean-reversion conditions — RSI crossing into an extreme.
|
| 2 |
+
|
| 3 |
+
One rule: RSI(14) closing at or beyond 70 / 30 on a bar that was not there the
|
| 4 |
+
bar before. No pivots and no confirmation lag, so the signal lands on the bar
|
| 5 |
+
it describes rather than weeks later.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from ._base import (
|
| 9 |
+
Signal,
|
| 10 |
+
bar_date,
|
| 11 |
+
entered_extreme,
|
| 12 |
+
week_ending,
|
| 13 |
+
weekly_bars,
|
| 14 |
+
wilder_rsi,
|
| 15 |
+
)
|
| 16 |
+
from ._base import (
|
| 17 |
+
closes as _closes,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
RSI_PERIOD = 14
|
| 22 |
+
OVERBOUGHT = 70.0
|
| 23 |
+
OVERSOLD = 30.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def evaluate(ticker: str, ohlc) -> list[Signal]:
|
| 27 |
+
out: list[Signal] = []
|
| 28 |
+
out.extend(_rsi_extreme(ticker, ohlc))
|
| 29 |
+
out.extend(_weekly_rsi_extreme(ticker, ohlc))
|
| 30 |
+
return out
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _rsi_extreme(
|
| 34 |
+
ticker: str,
|
| 35 |
+
ohlc,
|
| 36 |
+
*,
|
| 37 |
+
name_prefix: str = "",
|
| 38 |
+
severity: str = "medium",
|
| 39 |
+
snapshot_extra: dict | None = None,
|
| 40 |
+
) -> list[Signal]:
|
| 41 |
+
cs = _closes(ohlc)
|
| 42 |
+
if len(cs) < RSI_PERIOD + 2:
|
| 43 |
+
return []
|
| 44 |
+
rsi = wilder_rsi(cs, n=RSI_PERIOD)
|
| 45 |
+
if len(rsi) < 2:
|
| 46 |
+
return []
|
| 47 |
+
# lookback=1: the previous bar must have been outside the zone, so a name
|
| 48 |
+
# parked above 70 for weeks fires once, on the crossing.
|
| 49 |
+
if entered_extreme(rsi, threshold=OVERBOUGHT, low=False, lookback=1):
|
| 50 |
+
name, direction, word = "RSI_OVERBOUGHT", "overbought", "above"
|
| 51 |
+
elif entered_extreme(rsi, threshold=OVERSOLD, low=True, lookback=1):
|
| 52 |
+
name, direction, word = "RSI_OVERSOLD", "oversold", "below"
|
| 53 |
+
else:
|
| 54 |
+
return []
|
| 55 |
+
return [
|
| 56 |
+
Signal(
|
| 57 |
+
name=f"{name_prefix}{name}",
|
| 58 |
+
ticker=ticker,
|
| 59 |
+
severity=severity,
|
| 60 |
+
message=(
|
| 61 |
+
f"RSI({RSI_PERIOD}) crossed {word} "
|
| 62 |
+
f"{OVERBOUGHT if direction == 'overbought' else OVERSOLD:.0f} "
|
| 63 |
+
f"to {rsi[-1]:.1f} (close {cs[-1]:.2f})."
|
| 64 |
+
),
|
| 65 |
+
snapshot={
|
| 66 |
+
"rsi": rsi[-1],
|
| 67 |
+
"rsi_prev": rsi[-2],
|
| 68 |
+
"close": cs[-1],
|
| 69 |
+
"trigger_bar": bar_date(ohlc, len(ohlc) - 1),
|
| 70 |
+
**(snapshot_extra or {}),
|
| 71 |
+
},
|
| 72 |
+
)
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _weekly_rsi_extreme(ticker: str, ohlc) -> list[Signal]:
|
| 77 |
+
"""The same rule on weekly bars, at the same 70 / 30.
|
| 78 |
+
|
| 79 |
+
Severity is `high`, as for every other weekly signal: a weekly RSI(14)
|
| 80 |
+
reaches an extreme rarely enough to be worth pushing uncurated.
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
weekly = weekly_bars(ohlc, ticker)
|
| 84 |
+
except Exception:
|
| 85 |
+
return []
|
| 86 |
+
return _rsi_extreme(
|
| 87 |
+
ticker,
|
| 88 |
+
weekly,
|
| 89 |
+
name_prefix="WEEKLY_",
|
| 90 |
+
severity="high",
|
| 91 |
+
snapshot_extra={"week_ending": week_ending(weekly)},
|
| 92 |
+
)
|
src/TerraFin/analytics/analysis/patterns/reversal.py
DELETED
|
@@ -1,127 +0,0 @@
|
|
| 1 |
-
"""Reversal conditions — RSI/price divergence."""
|
| 2 |
-
|
| 3 |
-
from ._base import (
|
| 4 |
-
Signal,
|
| 5 |
-
bar_date,
|
| 6 |
-
swing_pivots,
|
| 7 |
-
week_ending,
|
| 8 |
-
weekly_bars,
|
| 9 |
-
wilder_rsi,
|
| 10 |
-
)
|
| 11 |
-
from ._base import (
|
| 12 |
-
closes as _closes,
|
| 13 |
-
)
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def evaluate(ticker: str, ohlc) -> list[Signal]:
|
| 17 |
-
out: list[Signal] = []
|
| 18 |
-
out.extend(_rsi_divergence(ticker, ohlc))
|
| 19 |
-
out.extend(_weekly_rsi_divergence(ticker, ohlc))
|
| 20 |
-
return out
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
# ─── RSI / price divergence (fractal pivot based) ────────────────────────────
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def _rsi_divergence(
|
| 27 |
-
ticker: str,
|
| 28 |
-
ohlc,
|
| 29 |
-
*,
|
| 30 |
-
rsi_period: int = 14,
|
| 31 |
-
half_window: int = 3,
|
| 32 |
-
rsi_high: float = 70.0,
|
| 33 |
-
rsi_low: float = 30.0,
|
| 34 |
-
name_prefix: str = "",
|
| 35 |
-
severity: str = "medium",
|
| 36 |
-
snapshot_extra: dict | None = None,
|
| 37 |
-
) -> list[Signal]:
|
| 38 |
-
cs = _closes(ohlc)
|
| 39 |
-
if len(cs) < rsi_period + half_window * 4 + 5:
|
| 40 |
-
return []
|
| 41 |
-
rsi_series = wilder_rsi(cs, n=rsi_period)
|
| 42 |
-
if len(rsi_series) < half_window * 4:
|
| 43 |
-
return []
|
| 44 |
-
# Align: RSI starts at index `rsi_period` in cs.
|
| 45 |
-
rsi_offset = len(cs) - len(rsi_series)
|
| 46 |
-
cs_aligned = cs[rsi_offset:]
|
| 47 |
-
pp = swing_pivots(cs_aligned, half=half_window)
|
| 48 |
-
rp = swing_pivots(rsi_series, half=half_window)
|
| 49 |
-
p_highs = [p for p in pp if p.side == +1]
|
| 50 |
-
p_lows = [p for p in pp if p.side == -1]
|
| 51 |
-
r_highs = [p for p in rp if p.side == +1]
|
| 52 |
-
r_lows = [p for p in rp if p.side == -1]
|
| 53 |
-
|
| 54 |
-
last_idx = len(cs_aligned) - 1
|
| 55 |
-
# Fire only if the most recent confirmed pivot is recent (within 2*half).
|
| 56 |
-
fire_window = half_window + 2
|
| 57 |
-
|
| 58 |
-
if len(p_highs) >= 2 and len(r_highs) >= 2 and last_idx - p_highs[-1].bar_index <= fire_window:
|
| 59 |
-
if p_highs[-1].price > p_highs[-2].price and r_highs[-1].price < r_highs[-2].price:
|
| 60 |
-
if r_highs[-2].price >= rsi_high:
|
| 61 |
-
return [
|
| 62 |
-
Signal(
|
| 63 |
-
name=f"{name_prefix}RSI_BEAR_DIVERGENCE",
|
| 64 |
-
ticker=ticker,
|
| 65 |
-
severity=severity,
|
| 66 |
-
message=(
|
| 67 |
-
f"RSI bearish divergence "
|
| 68 |
-
f"(price HH {p_highs[-1].price:.2f}, RSI lower-high "
|
| 69 |
-
f"{r_highs[-1].price:.1f})."
|
| 70 |
-
),
|
| 71 |
-
snapshot={
|
| 72 |
-
"price_high": p_highs[-1].price,
|
| 73 |
-
"rsi_high": r_highs[-1].price,
|
| 74 |
-
"trigger_bar": bar_date(ohlc, rsi_offset + p_highs[-1].bar_index),
|
| 75 |
-
**(snapshot_extra or {}),
|
| 76 |
-
},
|
| 77 |
-
)
|
| 78 |
-
]
|
| 79 |
-
if len(p_lows) >= 2 and len(r_lows) >= 2 and last_idx - p_lows[-1].bar_index <= fire_window:
|
| 80 |
-
if p_lows[-1].price < p_lows[-2].price and r_lows[-1].price > r_lows[-2].price:
|
| 81 |
-
if r_lows[-2].price <= rsi_low:
|
| 82 |
-
return [
|
| 83 |
-
Signal(
|
| 84 |
-
name=f"{name_prefix}RSI_BULL_DIVERGENCE",
|
| 85 |
-
ticker=ticker,
|
| 86 |
-
severity=severity,
|
| 87 |
-
message=(
|
| 88 |
-
f"RSI bullish divergence "
|
| 89 |
-
f"(price LL {p_lows[-1].price:.2f}, RSI higher-low "
|
| 90 |
-
f"{r_lows[-1].price:.1f})."
|
| 91 |
-
),
|
| 92 |
-
snapshot={
|
| 93 |
-
"price_low": p_lows[-1].price,
|
| 94 |
-
"rsi_low": r_lows[-1].price,
|
| 95 |
-
"trigger_bar": bar_date(ohlc, rsi_offset + p_lows[-1].bar_index),
|
| 96 |
-
**(snapshot_extra or {}),
|
| 97 |
-
},
|
| 98 |
-
)
|
| 99 |
-
]
|
| 100 |
-
return []
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def _weekly_rsi_divergence(ticker: str, ohlc) -> list[Signal]:
|
| 104 |
-
"""Same divergence rule on weekly bars.
|
| 105 |
-
|
| 106 |
-
Severity is `high`, not the daily `medium`: a divergence that takes months
|
| 107 |
-
of weekly pivots to form is rare and is the class of signal worth pushing
|
| 108 |
-
for a name the user has not curated.
|
| 109 |
-
"""
|
| 110 |
-
try:
|
| 111 |
-
weekly = weekly_bars(ohlc, ticker)
|
| 112 |
-
except Exception:
|
| 113 |
-
return []
|
| 114 |
-
# Looser bounds than the daily 70/30: weekly RSI(14) rarely reaches either
|
| 115 |
-
# extreme, and at 30 the bull side never fired on any name tested.
|
| 116 |
-
# `week_ending` is what keys the weekly dedup. Without it these fall back to
|
| 117 |
-
# the run date's ISO week, which double-ships on the KRX schedule and then
|
| 118 |
-
# swallows the following week.
|
| 119 |
-
return _rsi_divergence(
|
| 120 |
-
ticker,
|
| 121 |
-
weekly,
|
| 122 |
-
rsi_high=60.0,
|
| 123 |
-
rsi_low=40.0,
|
| 124 |
-
name_prefix="WEEKLY_",
|
| 125 |
-
severity="high",
|
| 126 |
-
snapshot_extra={"week_ending": week_ending(weekly)},
|
| 127 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/agent/test_pattern_scan.py
CHANGED
|
@@ -23,7 +23,7 @@ def _stub_scan_inputs(monkeypatch):
|
|
| 23 |
# emits "low", so a stub must not invent one.
|
| 24 |
return [
|
| 25 |
Signal(name="MA20_GOLDEN_CROSS", ticker=ticker, severity="medium", message="stub medium"),
|
| 26 |
-
Signal(name="
|
| 27 |
Signal(name="52W_NEW_HIGH", ticker=ticker, severity="high", message="stub high"),
|
| 28 |
]
|
| 29 |
|
|
|
|
| 23 |
# emits "low", so a stub must not invent one.
|
| 24 |
return [
|
| 25 |
Signal(name="MA20_GOLDEN_CROSS", ticker=ticker, severity="medium", message="stub medium"),
|
| 26 |
+
Signal(name="RSI_OVERSOLD", ticker=ticker, severity="medium", message="stub medium 2"),
|
| 27 |
Signal(name="52W_NEW_HIGH", ticker=ticker, severity="high", message="stub high"),
|
| 28 |
]
|
| 29 |
|
tests/analytics/test_pattern_catalog.py
CHANGED
|
@@ -23,15 +23,13 @@ def _sig(name="WEEKLY_NEW_HIGH", ticker="NVDA", **snapshot):
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_trigger_bar_outranks_the_data_week():
|
| 26 |
-
"""A
|
| 27 |
-
|
| 28 |
-
Keyed on `week_ending` that is three findings; keyed on the pivot bar it is
|
| 29 |
-
one, which is what it is.
|
| 30 |
"""
|
| 31 |
keys = {
|
| 32 |
signal_key(
|
| 33 |
_sig(
|
| 34 |
-
name="
|
| 35 |
trigger_bar="2026-08-21",
|
| 36 |
week_ending=week,
|
| 37 |
)
|
|
@@ -67,7 +65,7 @@ def _emittable_pattern_names() -> set[str]:
|
|
| 67 |
if f.name in ("__init__.py", "_base.py", "catalog.py"):
|
| 68 |
continue
|
| 69 |
names |= set(re.findall(r'name="([A-Z0-9_]+)"', f.read_text()))
|
| 70 |
-
# The MA-cross grid and the
|
| 71 |
# so expand them from the lists that drive those loops.
|
| 72 |
from TerraFin.analytics.analysis.patterns import trend
|
| 73 |
|
|
@@ -76,7 +74,7 @@ def _emittable_pattern_names() -> set[str]:
|
|
| 76 |
for p in trend._WEEKLY_MA_PERIODS:
|
| 77 |
names |= {f"MA{p}W_GOLDEN_CROSS", f"MA{p}W_DEATH_CROSS"}
|
| 78 |
for prefix in ("", "WEEKLY_"):
|
| 79 |
-
names |= {f"{prefix}
|
| 80 |
return names
|
| 81 |
|
| 82 |
|
|
@@ -102,8 +100,8 @@ def test_declared_timeframes_are_pinned():
|
|
| 102 |
"WEEKLY_VOLUME_DRYUP",
|
| 103 |
"WEEKLY_NEW_HIGH",
|
| 104 |
"WEEKLY_NEW_LOW",
|
| 105 |
-
"
|
| 106 |
-
"
|
| 107 |
}
|
| 108 |
assert set(PATTERN_TIMEFRAMES.values()) == {"daily", "weekly"}
|
| 109 |
|
|
@@ -137,19 +135,20 @@ def test_a_real_detector_emits_a_trigger_bar_on_a_production_frame():
|
|
| 137 |
import numpy as np
|
| 138 |
import pandas as pd
|
| 139 |
|
| 140 |
-
from TerraFin.analytics.analysis.patterns import
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
# Crash, bounce, then a lower price low on weaker momentum — a bull
|
| 148 |
-
# divergence whose pivot lands inside the fire window.
|
| 149 |
-
closes = [100.0] + seg(100, 100.5, 39) + seg(100.5, 55, 25) + seg(55, 80, 15) + seg(80, 52, 30) + seg(52, 62, 5)
|
| 150 |
frame = pd.DataFrame(
|
| 151 |
{
|
| 152 |
-
"time": pd.date_range("2020-01-
|
| 153 |
"open": closes,
|
| 154 |
"high": np.array(closes) * 1.01,
|
| 155 |
"low": np.array(closes) * 0.99,
|
|
@@ -162,7 +161,7 @@ def test_a_real_detector_emits_a_trigger_bar_on_a_production_frame():
|
|
| 162 |
# Pinned to the pair, not to "some ISO string": the weekly branch keys off
|
| 163 |
# `week_ending`, which never touches `bar_date`, so a substituted weekly
|
| 164 |
# emission would satisfy a looser assertion with the bug reinstated.
|
| 165 |
-
keyed = [(s.name, signal_key(s)) for s in
|
| 166 |
-
assert keyed == [("
|
| 167 |
-
"if the series stopped
|
| 168 |
)
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_trigger_bar_outranks_the_data_week():
|
| 26 |
+
"""A weekly signal carries both fields. The trigger bar names the exact bar
|
| 27 |
+
the finding is about, so re-reports across later weeks stay one finding.
|
|
|
|
|
|
|
| 28 |
"""
|
| 29 |
keys = {
|
| 30 |
signal_key(
|
| 31 |
_sig(
|
| 32 |
+
name="WEEKLY_RSI_OVERSOLD",
|
| 33 |
trigger_bar="2026-08-21",
|
| 34 |
week_ending=week,
|
| 35 |
)
|
|
|
|
| 65 |
if f.name in ("__init__.py", "_base.py", "catalog.py"):
|
| 66 |
continue
|
| 67 |
names |= set(re.findall(r'name="([A-Z0-9_]+)"', f.read_text()))
|
| 68 |
+
# The MA-cross grid and the RSI extreme pair build their names by f-string,
|
| 69 |
# so expand them from the lists that drive those loops.
|
| 70 |
from TerraFin.analytics.analysis.patterns import trend
|
| 71 |
|
|
|
|
| 74 |
for p in trend._WEEKLY_MA_PERIODS:
|
| 75 |
names |= {f"MA{p}W_GOLDEN_CROSS", f"MA{p}W_DEATH_CROSS"}
|
| 76 |
for prefix in ("", "WEEKLY_"):
|
| 77 |
+
names |= {f"{prefix}RSI_OVERBOUGHT", f"{prefix}RSI_OVERSOLD"}
|
| 78 |
return names
|
| 79 |
|
| 80 |
|
|
|
|
| 100 |
"WEEKLY_VOLUME_DRYUP",
|
| 101 |
"WEEKLY_NEW_HIGH",
|
| 102 |
"WEEKLY_NEW_LOW",
|
| 103 |
+
"WEEKLY_RSI_OVERBOUGHT",
|
| 104 |
+
"WEEKLY_RSI_OVERSOLD",
|
| 105 |
}
|
| 106 |
assert set(PATTERN_TIMEFRAMES.values()) == {"daily", "weekly"}
|
| 107 |
|
|
|
|
| 135 |
import numpy as np
|
| 136 |
import pandas as pd
|
| 137 |
|
| 138 |
+
from TerraFin.analytics.analysis.patterns import meanrev
|
| 139 |
|
| 140 |
+
# A zigzag that leaves RSI(14) mid-range, then four down bars: RSI crosses
|
| 141 |
+
# below 30 on the last one. Too short for a weekly RSI, so only daily fires.
|
| 142 |
+
closes, price = [], 100.0
|
| 143 |
+
for i in range(20):
|
| 144 |
+
price *= 1.01 if i % 2 == 0 else 0.99
|
| 145 |
+
closes.append(price)
|
| 146 |
+
tail = closes[-1]
|
| 147 |
+
closes.extend(tail * 0.98**k for k in range(1, 5))
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
frame = pd.DataFrame(
|
| 150 |
{
|
| 151 |
+
"time": pd.date_range("2020-01-06", periods=len(closes), freq="B"),
|
| 152 |
"open": closes,
|
| 153 |
"high": np.array(closes) * 1.01,
|
| 154 |
"low": np.array(closes) * 0.99,
|
|
|
|
| 161 |
# Pinned to the pair, not to "some ISO string": the weekly branch keys off
|
| 162 |
# `week_ending`, which never touches `bar_date`, so a substituted weekly
|
| 163 |
# emission would satisfy a looser assertion with the bug reinstated.
|
| 164 |
+
keyed = [(s.name, signal_key(s)) for s in meanrev.evaluate("AAA", frame)]
|
| 165 |
+
assert keyed == [("RSI_OVERSOLD", "2020-02-06")], (
|
| 166 |
+
"if the series stopped crossing here, adjust the fixture, not the assertion"
|
| 167 |
)
|
tests/analytics/test_weekly_patterns.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Weekly-timeframe patterns: `WEEKLY_NEW_HIGH/LOW` and weekly RSI
|
| 2 |
|
| 3 |
The first version of `_weekly_new_extreme` compared `cs[-2]` to its OWN trailing
|
| 4 |
window, which is true throughout any steady advance — so the detector never
|
|
@@ -10,7 +10,7 @@ import numpy as np
|
|
| 10 |
import pandas as pd
|
| 11 |
import pytest
|
| 12 |
|
| 13 |
-
from TerraFin.analytics.analysis.patterns import breakout,
|
| 14 |
from TerraFin.analytics.analysis.patterns._base import is_krx_ticker, weekly_bars
|
| 15 |
|
| 16 |
|
|
@@ -28,6 +28,25 @@ def _frame(closes, *, start="2020-01-01", volume=1e6):
|
|
| 28 |
).set_index("time")
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def _ramp(a, b, n):
|
| 32 |
return list(np.linspace(a, b, n))
|
| 33 |
|
|
@@ -215,16 +234,39 @@ def test_is_krx_ticker():
|
|
| 215 |
assert not is_krx_ticker("")
|
| 216 |
|
| 217 |
|
| 218 |
-
# ── weekly RSI
|
| 219 |
|
| 220 |
|
| 221 |
-
def
|
| 222 |
-
"""
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
keeps the 70/30 that was asked for.
|
| 227 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
captured = {}
|
| 229 |
|
| 230 |
def spy(ticker, ohlc, **kwargs):
|
|
@@ -233,12 +275,10 @@ def test_weekly_rsi_delegates_with_weekly_bars_and_looser_bounds(monkeypatch):
|
|
| 233 |
captured.update(kwargs)
|
| 234 |
return []
|
| 235 |
|
| 236 |
-
monkeypatch.setattr(
|
| 237 |
daily = _frame(_ramp(100, 300, 600))
|
| 238 |
-
|
| 239 |
|
| 240 |
-
assert captured["rsi_high"] == 60.0
|
| 241 |
-
assert captured["rsi_low"] == 40.0
|
| 242 |
assert captured["name_prefix"] == "WEEKLY_"
|
| 243 |
assert captured["severity"] == "high"
|
| 244 |
# It was handed weekly bars, not the daily frame.
|
|
@@ -246,16 +286,6 @@ def test_weekly_rsi_delegates_with_weekly_bars_and_looser_bounds(monkeypatch):
|
|
| 246 |
assert captured["rows"] < len(daily)
|
| 247 |
|
| 248 |
|
| 249 |
-
def test_daily_rsi_keeps_its_own_thresholds():
|
| 250 |
-
import inspect
|
| 251 |
-
|
| 252 |
-
params = inspect.signature(reversal._rsi_divergence).parameters
|
| 253 |
-
assert params["rsi_high"].default == 70.0
|
| 254 |
-
assert params["rsi_low"].default == 30.0
|
| 255 |
-
assert params["name_prefix"].default == ""
|
| 256 |
-
assert params["severity"].default == "medium"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
def test_weekly_signals_carry_the_data_week_for_dedup():
|
| 260 |
"""`week_ending` is what makes a weekly fire unique per week rather than
|
| 261 |
per run day; without it the EOD scan falls back to the run date's ISO week,
|
|
@@ -278,7 +308,7 @@ def test_weekly_signals_carry_the_data_week_for_dedup():
|
|
| 278 |
def test_flat_series_fires_nothing():
|
| 279 |
flat = [100.0] * 700
|
| 280 |
assert breakout._weekly_new_extreme("AAA", _frame(flat)) == []
|
| 281 |
-
assert
|
| 282 |
|
| 283 |
|
| 284 |
def test_every_weekly_pattern_emits_week_ending():
|
|
@@ -297,61 +327,71 @@ def test_every_weekly_pattern_emits_week_ending():
|
|
| 297 |
assert signal.snapshot.get("week_ending"), signal.name
|
| 298 |
|
| 299 |
|
| 300 |
-
def test_weekly_rsi_snapshot_extra_reaches_the_signal(
|
| 301 |
-
"""
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
"""
|
| 306 |
-
captured = {}
|
| 307 |
-
|
| 308 |
-
def spy(ticker, ohlc, **kwargs):
|
| 309 |
-
captured.update(kwargs)
|
| 310 |
-
return []
|
| 311 |
-
|
| 312 |
-
monkeypatch.setattr(reversal, "_rsi_divergence", spy)
|
| 313 |
-
daily = _frame(_ramp(100, 300, 600))
|
| 314 |
-
reversal._weekly_rsi_divergence("AAA", daily)
|
| 315 |
-
|
| 316 |
-
extra = captured.get("snapshot_extra") or {}
|
| 317 |
-
assert extra.get("week_ending") == weekly_bars(daily).index[-1].date().isoformat()
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
def _divergence_fixture(n: int = 49):
|
| 321 |
-
"""A damped sine on a rising drift, which produces the pivot pair the
|
| 322 |
-
divergence rule needs. Fires at n=49; the fire window is narrow, so the
|
| 323 |
-
length matters."""
|
| 324 |
-
import math
|
| 325 |
|
| 326 |
-
|
|
|
|
|
|
|
| 327 |
|
| 328 |
|
| 329 |
-
def
|
| 330 |
-
"""
|
| 331 |
-
|
| 332 |
-
fired =
|
| 333 |
"AAA",
|
| 334 |
-
|
| 335 |
-
rsi_high=60.0,
|
| 336 |
-
rsi_low=40.0,
|
| 337 |
name_prefix="WEEKLY_",
|
| 338 |
severity="high",
|
| 339 |
snapshot_extra={"week_ending": "2026-09-04"},
|
| 340 |
)
|
| 341 |
-
assert [x.name for x in fired] == ["
|
| 342 |
assert fired[0].severity == "high"
|
| 343 |
assert fired[0].snapshot["week_ending"] == "2026-09-04"
|
| 344 |
-
|
| 345 |
-
assert "rsi_high" in fired[0].snapshot
|
| 346 |
|
| 347 |
|
| 348 |
-
def
|
| 349 |
-
fired =
|
| 350 |
-
assert [x.name for x in fired] == ["
|
| 351 |
assert fired[0].severity == "medium"
|
| 352 |
assert "week_ending" not in fired[0].snapshot
|
| 353 |
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
def test_weekly_volume_dryup_carries_the_data_week():
|
| 356 |
import numpy as np
|
| 357 |
|
|
@@ -534,15 +574,15 @@ def test_holiday_week_survives_end_to_end_through_a_detector():
|
|
| 534 |
("breakout", lambda m: m._weekly_new_extreme("005930.KS", _krx_frame())),
|
| 535 |
("breakout", lambda m: m._weekly_volume_dryup_signal("005930.KS", _krx_frame())),
|
| 536 |
("breakout", lambda m: m.detect_weekly_volume_dryup(_krx_frame(), ticker="005930.KS")),
|
| 537 |
-
("
|
| 538 |
("trend", lambda m: m._ma_cross_grid("005930.KS", _krx_frame())),
|
| 539 |
],
|
| 540 |
)
|
| 541 |
def test_every_weekly_entry_point_forwards_the_ticker(monkeypatch, module_name, call):
|
| 542 |
"""`weekly_bars` needs the ticker to pick the right session calendar."""
|
| 543 |
-
from TerraFin.analytics.analysis.patterns import breakout,
|
| 544 |
|
| 545 |
-
module = {"breakout": breakout, "
|
| 546 |
seen = []
|
| 547 |
real = weekly_bars
|
| 548 |
|
|
@@ -557,31 +597,20 @@ def test_every_weekly_entry_point_forwards_the_ticker(monkeypatch, module_name,
|
|
| 557 |
assert all(t == "005930.KS" for t in seen), seen
|
| 558 |
|
| 559 |
|
| 560 |
-
def
|
| 561 |
-
"""
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
does not exercise the bull one.
|
| 565 |
-
"""
|
| 566 |
-
import math
|
| 567 |
-
|
| 568 |
-
return [max(1.0, 200 - 0.5 * i + 20 * (0.995**i) * math.sin(2 * math.pi * i / 16)) for i in range(n)]
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
def test_bull_branch_also_merges_snapshot_extra():
|
| 572 |
-
fired = reversal._rsi_divergence(
|
| 573 |
"AAA",
|
| 574 |
-
|
| 575 |
-
rsi_high=60.0,
|
| 576 |
-
rsi_low=40.0,
|
| 577 |
name_prefix="WEEKLY_",
|
| 578 |
severity="high",
|
| 579 |
snapshot_extra={"week_ending": "2026-09-04"},
|
| 580 |
)
|
| 581 |
-
assert [x.name for x in fired] == ["
|
| 582 |
assert fired[0].severity == "high"
|
| 583 |
assert fired[0].snapshot["week_ending"] == "2026-09-04"
|
| 584 |
-
assert "
|
| 585 |
|
| 586 |
|
| 587 |
def test_weekly_new_high_does_not_refire_while_the_close_ties_the_max():
|
|
@@ -625,15 +654,15 @@ def test_evaluate_emits_the_weekly_breakout_and_rsi_families():
|
|
| 625 |
names = {s.name for s in evaluate("AAA", advance)}
|
| 626 |
assert "WEEKLY_NEW_HIGH" in names
|
| 627 |
|
| 628 |
-
#
|
| 629 |
-
#
|
| 630 |
|
| 631 |
|
| 632 |
def test_evaluate_reaches_the_weekly_rsi_leg(monkeypatch):
|
| 633 |
-
from TerraFin.analytics.analysis.patterns import evaluate,
|
| 634 |
|
| 635 |
called = []
|
| 636 |
-
monkeypatch.setattr(
|
| 637 |
evaluate("AAA", _frame(_ramp(100, 200, 400)))
|
| 638 |
assert called == ["AAA"]
|
| 639 |
|
|
|
|
| 1 |
+
"""Weekly-timeframe patterns: `WEEKLY_NEW_HIGH/LOW` and the weekly RSI extreme.
|
| 2 |
|
| 3 |
The first version of `_weekly_new_extreme` compared `cs[-2]` to its OWN trailing
|
| 4 |
window, which is true throughout any steady advance — so the detector never
|
|
|
|
| 10 |
import pandas as pd
|
| 11 |
import pytest
|
| 12 |
|
| 13 |
+
from TerraFin.analytics.analysis.patterns import breakout, meanrev
|
| 14 |
from TerraFin.analytics.analysis.patterns._base import is_krx_ticker, weekly_bars
|
| 15 |
|
| 16 |
|
|
|
|
| 28 |
).set_index("time")
|
| 29 |
|
| 30 |
|
| 31 |
+
def _prod_frame(closes, *, start="2020-01-06", volume=1e6):
|
| 32 |
+
"""The shape the monitor passes: `time` is a COLUMN under a `RangeIndex`.
|
| 33 |
+
|
| 34 |
+
A frame indexed by time makes `bar_date` succeed for the wrong reason, so
|
| 35 |
+
anything asserting `trigger_bar` has to use this one.
|
| 36 |
+
"""
|
| 37 |
+
closes = list(closes)
|
| 38 |
+
return pd.DataFrame(
|
| 39 |
+
{
|
| 40 |
+
"time": pd.date_range(start, periods=len(closes), freq="B"),
|
| 41 |
+
"open": closes,
|
| 42 |
+
"high": np.array(closes) * 1.01,
|
| 43 |
+
"low": np.array(closes) * 0.99,
|
| 44 |
+
"close": closes,
|
| 45 |
+
"volume": [volume] * len(closes),
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
def _ramp(a, b, n):
|
| 51 |
return list(np.linspace(a, b, n))
|
| 52 |
|
|
|
|
| 234 |
assert not is_krx_ticker("")
|
| 235 |
|
| 236 |
|
| 237 |
+
# ── weekly RSI extreme ───────────────────────────────────────────────────────
|
| 238 |
|
| 239 |
|
| 240 |
+
def _rsi_series(step, n):
|
| 241 |
+
"""A zigzag that leaves RSI(14) mid-range, then `n` bars of `step` drift.
|
| 242 |
|
| 243 |
+
`n` picks how far past the threshold the last bar lands, so the same
|
| 244 |
+
builder gives both the crossing bar and the bar after it.
|
|
|
|
| 245 |
"""
|
| 246 |
+
out, price = [], 100.0
|
| 247 |
+
for i in range(20):
|
| 248 |
+
price *= 1.01 if i % 2 == 0 else 0.99
|
| 249 |
+
out.append(price)
|
| 250 |
+
tail = out[-1]
|
| 251 |
+
out.extend(tail * step**k for k in range(1, n + 1))
|
| 252 |
+
return out
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _weekly_prod_frame(weekly_closes, *, start="2020-01-06"):
|
| 256 |
+
"""Five identical daily bars per week, so each weekly close is the input."""
|
| 257 |
+
return _prod_frame([c for c in weekly_closes for _ in range(5)], start=start)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# RSI(14) on `_rsi_series`: 68.75 → 72.36 at 5 up bars, 31.40 → 28.01 at 4 down
|
| 261 |
+
# bars. One bar further and the previous bar is already in the zone.
|
| 262 |
+
_CROSS_UP = 1.02, 5
|
| 263 |
+
_PARKED_UP = 1.02, 6
|
| 264 |
+
_CROSS_DOWN = 0.98, 4
|
| 265 |
+
_PARKED_DOWN = 0.98, 5
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_weekly_rsi_delegates_with_weekly_bars(monkeypatch):
|
| 269 |
+
"""The weekly pass reuses the daily rule, on weekly bars, at high severity."""
|
| 270 |
captured = {}
|
| 271 |
|
| 272 |
def spy(ticker, ohlc, **kwargs):
|
|
|
|
| 275 |
captured.update(kwargs)
|
| 276 |
return []
|
| 277 |
|
| 278 |
+
monkeypatch.setattr(meanrev, "_rsi_extreme", spy)
|
| 279 |
daily = _frame(_ramp(100, 300, 600))
|
| 280 |
+
meanrev._weekly_rsi_extreme("AAA", daily)
|
| 281 |
|
|
|
|
|
|
|
| 282 |
assert captured["name_prefix"] == "WEEKLY_"
|
| 283 |
assert captured["severity"] == "high"
|
| 284 |
# It was handed weekly bars, not the daily frame.
|
|
|
|
| 286 |
assert captured["rows"] < len(daily)
|
| 287 |
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
def test_weekly_signals_carry_the_data_week_for_dedup():
|
| 290 |
"""`week_ending` is what makes a weekly fire unique per week rather than
|
| 291 |
per run day; without it the EOD scan falls back to the run date's ISO week,
|
|
|
|
| 308 |
def test_flat_series_fires_nothing():
|
| 309 |
flat = [100.0] * 700
|
| 310 |
assert breakout._weekly_new_extreme("AAA", _frame(flat)) == []
|
| 311 |
+
assert meanrev._weekly_rsi_extreme("AAA", _frame(flat)) == []
|
| 312 |
|
| 313 |
|
| 314 |
def test_every_weekly_pattern_emits_week_ending():
|
|
|
|
| 327 |
assert signal.snapshot.get("week_ending"), signal.name
|
| 328 |
|
| 329 |
|
| 330 |
+
def test_weekly_rsi_snapshot_extra_reaches_the_signal():
|
| 331 |
+
"""A real fire: `_weekly_rsi_extreme` injects the data week into the
|
| 332 |
+
snapshot, and the prefix and severity survive to the emitted Signal."""
|
| 333 |
+
frame = _weekly_prod_frame(_rsi_series(*_CROSS_UP))
|
| 334 |
+
fired = meanrev._weekly_rsi_extreme("AAA", frame)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
+
assert [x.name for x in fired] == ["WEEKLY_RSI_OVERBOUGHT"]
|
| 337 |
+
assert fired[0].severity == "high"
|
| 338 |
+
assert fired[0].snapshot["week_ending"] == weekly_bars(frame, "AAA").index[-1].date().isoformat()
|
| 339 |
|
| 340 |
|
| 341 |
+
def test_rsi_extreme_emits_the_prefix_severity_and_snapshot_extra():
|
| 342 |
+
"""The caller-supplied prefix, severity and extra all reach the Signal, and
|
| 343 |
+
the rule's own snapshot fields survive the merge."""
|
| 344 |
+
fired = meanrev._rsi_extreme(
|
| 345 |
"AAA",
|
| 346 |
+
_prod_frame(_rsi_series(*_CROSS_UP)),
|
|
|
|
|
|
|
| 347 |
name_prefix="WEEKLY_",
|
| 348 |
severity="high",
|
| 349 |
snapshot_extra={"week_ending": "2026-09-04"},
|
| 350 |
)
|
| 351 |
+
assert [x.name for x in fired] == ["WEEKLY_RSI_OVERBOUGHT"]
|
| 352 |
assert fired[0].severity == "high"
|
| 353 |
assert fired[0].snapshot["week_ending"] == "2026-09-04"
|
| 354 |
+
assert "rsi" in fired[0].snapshot
|
|
|
|
| 355 |
|
| 356 |
|
| 357 |
+
def test_rsi_extreme_defaults_emit_no_prefix_and_medium():
|
| 358 |
+
fired = meanrev._rsi_extreme("AAA", _prod_frame(_rsi_series(*_CROSS_UP)))
|
| 359 |
+
assert [x.name for x in fired] == ["RSI_OVERBOUGHT"]
|
| 360 |
assert fired[0].severity == "medium"
|
| 361 |
assert "week_ending" not in fired[0].snapshot
|
| 362 |
|
| 363 |
|
| 364 |
+
def test_rsi_extreme_fires_on_the_bar_it_crosses_each_threshold():
|
| 365 |
+
up = _prod_frame(_rsi_series(*_CROSS_UP))
|
| 366 |
+
down = _prod_frame(_rsi_series(*_CROSS_DOWN))
|
| 367 |
+
|
| 368 |
+
fired_up = meanrev._rsi_extreme("AAA", up)
|
| 369 |
+
assert [x.name for x in fired_up] == ["RSI_OVERBOUGHT"]
|
| 370 |
+
assert fired_up[0].snapshot["rsi"] >= meanrev.OVERBOUGHT
|
| 371 |
+
assert fired_up[0].snapshot["rsi_prev"] < meanrev.OVERBOUGHT
|
| 372 |
+
|
| 373 |
+
fired_down = meanrev._rsi_extreme("AAA", down)
|
| 374 |
+
assert [x.name for x in fired_down] == ["RSI_OVERSOLD"]
|
| 375 |
+
assert fired_down[0].snapshot["rsi"] <= meanrev.OVERSOLD
|
| 376 |
+
assert fired_down[0].snapshot["rsi_prev"] > meanrev.OVERSOLD
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def test_rsi_already_in_the_zone_on_the_previous_bar_does_not_refire():
|
| 380 |
+
"""`lookback=1`: a name parked past the threshold fires once, on the
|
| 381 |
+
crossing, not on every bar it stays there."""
|
| 382 |
+
for step, n in (_PARKED_UP, _PARKED_DOWN):
|
| 383 |
+
frame = _prod_frame(_rsi_series(step, n))
|
| 384 |
+
assert meanrev._rsi_extreme("AAA", frame) == [], (step, n)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def test_the_trigger_bar_is_the_crossing_bar():
|
| 388 |
+
"""Read off `frame.index` instead of the `time` column and this is None on
|
| 389 |
+
every production frame, which silently unkeys the dedup."""
|
| 390 |
+
frame = _prod_frame(_rsi_series(*_CROSS_UP))
|
| 391 |
+
fired = meanrev._rsi_extreme("AAA", frame)
|
| 392 |
+
assert fired[0].snapshot["trigger_bar"] == frame["time"].iloc[-1].date().isoformat()
|
| 393 |
+
|
| 394 |
+
|
| 395 |
def test_weekly_volume_dryup_carries_the_data_week():
|
| 396 |
import numpy as np
|
| 397 |
|
|
|
|
| 574 |
("breakout", lambda m: m._weekly_new_extreme("005930.KS", _krx_frame())),
|
| 575 |
("breakout", lambda m: m._weekly_volume_dryup_signal("005930.KS", _krx_frame())),
|
| 576 |
("breakout", lambda m: m.detect_weekly_volume_dryup(_krx_frame(), ticker="005930.KS")),
|
| 577 |
+
("meanrev", lambda m: m._weekly_rsi_extreme("005930.KS", _krx_frame())),
|
| 578 |
("trend", lambda m: m._ma_cross_grid("005930.KS", _krx_frame())),
|
| 579 |
],
|
| 580 |
)
|
| 581 |
def test_every_weekly_entry_point_forwards_the_ticker(monkeypatch, module_name, call):
|
| 582 |
"""`weekly_bars` needs the ticker to pick the right session calendar."""
|
| 583 |
+
from TerraFin.analytics.analysis.patterns import breakout, meanrev, trend
|
| 584 |
|
| 585 |
+
module = {"breakout": breakout, "meanrev": meanrev, "trend": trend}[module_name]
|
| 586 |
seen = []
|
| 587 |
real = weekly_bars
|
| 588 |
|
|
|
|
| 597 |
assert all(t == "005930.KS" for t in seen), seen
|
| 598 |
|
| 599 |
|
| 600 |
+
def test_the_oversold_branch_also_merges_snapshot_extra():
|
| 601 |
+
"""The two branches emit independently, so the overbought fixture does not
|
| 602 |
+
exercise this one."""
|
| 603 |
+
fired = meanrev._rsi_extreme(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
"AAA",
|
| 605 |
+
_prod_frame(_rsi_series(*_CROSS_DOWN)),
|
|
|
|
|
|
|
| 606 |
name_prefix="WEEKLY_",
|
| 607 |
severity="high",
|
| 608 |
snapshot_extra={"week_ending": "2026-09-04"},
|
| 609 |
)
|
| 610 |
+
assert [x.name for x in fired] == ["WEEKLY_RSI_OVERSOLD"]
|
| 611 |
assert fired[0].severity == "high"
|
| 612 |
assert fired[0].snapshot["week_ending"] == "2026-09-04"
|
| 613 |
+
assert "rsi" in fired[0].snapshot
|
| 614 |
|
| 615 |
|
| 616 |
def test_weekly_new_high_does_not_refire_while_the_close_ties_the_max():
|
|
|
|
| 654 |
names = {s.name for s in evaluate("AAA", advance)}
|
| 655 |
assert "WEEKLY_NEW_HIGH" in names
|
| 656 |
|
| 657 |
+
# A steady ramp never crosses into an RSI extreme, so the meanrev school is
|
| 658 |
+
# asserted separately by the wiring test below.
|
| 659 |
|
| 660 |
|
| 661 |
def test_evaluate_reaches_the_weekly_rsi_leg(monkeypatch):
|
| 662 |
+
from TerraFin.analytics.analysis.patterns import evaluate, meanrev
|
| 663 |
|
| 664 |
called = []
|
| 665 |
+
monkeypatch.setattr(meanrev, "_weekly_rsi_extreme", lambda t, o: called.append(t) or [])
|
| 666 |
evaluate("AAA", _frame(_ramp(100, 200, 400)))
|
| 667 |
assert called == ["AAA"]
|
| 668 |
|