sk851 Claude Opus 5 (1M context) commited on
Commit
b0fc1d0
·
1 Parent(s): 16a6a6f

Replace RSI divergence with a 70/30 crossing

Browse files

The 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 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
- | `reversal` | Bull / bear engulfing at extreme, RSI price divergence |
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`, `RSI_BULL_DIVERGENCE`, `RSI_BEAR_DIVERGENCE`, 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_BULL_DIVERGENCE`, `WEEKLY_RSI_BEAR_DIVERGENCE`
488
- - a weekly divergence re-fires for about three consecutive weekly bars, so `"high"` is not a one-shot bucket
489
 
490
- A cross fires only on the bar where the close flips sides, so expect roughly 1–2 signals per symbol — not a flood.
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, MINERVINI_TEMPLATE, "
304
- "RSI_BULL_DIVERGENCE, RSI_BEAR_DIVERGENCE, and "
 
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
- "MINERVINI_TEMPLATE}. A cross fires only on the bar where "
311
- "the close flips sides, so expect roughly 1-2 signals per "
312
- "symbol, not a flood.\n"
 
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
- - ``reversal`` — RSI/price divergence
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, reversal, 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, reversal)
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
- "WEEKLY_RSI_BULL_DIVERGENCE": "weekly",
37
- "WEEKLY_RSI_BEAR_DIVERGENCE": "weekly",
38
- # Reversal
39
- "RSI_BULL_DIVERGENCE": "daily",
40
- "RSI_BEAR_DIVERGENCE": "daily",
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
- divergence that re-confirms on three consecutive weekly bars off one pivot
49
- stays a 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
 
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="RSI_BEAR_DIVERGENCE", ticker=ticker, severity="medium", message="stub medium 2"),
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 divergence re-confirms on three consecutive weekly bars off one pivot.
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="WEEKLY_RSI_BULL_DIVERGENCE",
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 divergence pair build their names by f-string,
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}RSI_BULL_DIVERGENCE", f"{prefix}RSI_BEAR_DIVERGENCE"}
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
- "WEEKLY_RSI_BULL_DIVERGENCE",
106
- "WEEKLY_RSI_BEAR_DIVERGENCE",
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 reversal
141
 
142
- def seg(a, b, n):
143
- # Exclusive of the start: a duplicated joint is never strictly extreme,
144
- # so `swing_pivots` would confirm no pivot at the turn.
145
- return list(np.linspace(a, b, n + 1))[1:]
 
 
 
 
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-01", periods=len(closes), freq="B"),
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 reversal.evaluate("AAA", frame)]
166
- assert keyed == [("RSI_BULL_DIVERGENCE", "2020-06-02")], (
167
- "if the series stopped producing this pivot, adjust the fixture, not the assertion"
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 divergence.
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, reversal
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 divergence ────────────────────────────────────────────────────
219
 
220
 
221
- def test_weekly_rsi_delegates_with_weekly_bars_and_looser_bounds(monkeypatch):
222
- """The weekly pass must reuse the daily rule, on weekly bars, at 60/40.
223
 
224
- Weekly RSI(14) rarely reaches 30/70 at 30 the bull side never fired on
225
- any real series tested — so the weekly call loosens the bounds while daily
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(reversal, "_rsi_divergence", spy)
237
  daily = _frame(_ramp(100, 300, 600))
238
- reversal._weekly_rsi_divergence("AAA", daily)
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 reversal._weekly_rsi_divergence("AAA", _frame(flat)) == []
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(monkeypatch):
301
- """`_weekly_rsi_divergence` must inject `week_ending` into the snapshot.
302
-
303
- Asserted through the delegation contract because the divergence fire
304
- conditions are not reachable from a synthetic series.
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
- return [100 + 0.6 * i + 25 * (0.995**i) * math.sin(2 * math.pi * i / 20) for i in range(n)]
 
 
327
 
328
 
329
- def test_rsi_divergence_emits_the_prefix_severity_and_snapshot_extra():
330
- """A real fire, not a spy: the prefix, severity and merged snapshot all
331
- have to survive to the emitted Signal."""
332
- fired = reversal._rsi_divergence(
333
  "AAA",
334
- _frame(_divergence_fixture()),
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] == ["WEEKLY_RSI_BEAR_DIVERGENCE"]
342
  assert fired[0].severity == "high"
343
  assert fired[0].snapshot["week_ending"] == "2026-09-04"
344
- # The rule's own fields survive the merge.
345
- assert "rsi_high" in fired[0].snapshot
346
 
347
 
348
- def test_rsi_divergence_defaults_emit_no_prefix_and_medium():
349
- fired = reversal._rsi_divergence("AAA", _frame(_divergence_fixture()), rsi_high=60.0, rsi_low=40.0)
350
- assert [x.name for x in fired] == ["RSI_BEAR_DIVERGENCE"]
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
- ("reversal", lambda m: m._weekly_rsi_divergence("005930.KS", _krx_frame())),
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, reversal, trend
544
 
545
- module = {"breakout": breakout, "reversal": reversal, "trend": trend}[module_name]
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 _bull_divergence_fixture(n: int = 48):
561
- """Mirror of `_divergence_fixture` tuned to fire the BULL branch.
562
-
563
- The two branches build their snapshots independently, so the bear fixture
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
- _frame(_bull_divergence_fixture()),
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] == ["WEEKLY_RSI_BULL_DIVERGENCE"]
582
  assert fired[0].severity == "high"
583
  assert fired[0].snapshot["week_ending"] == "2026-09-04"
584
- assert "rsi_low" in fired[0].snapshot
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
- # The reversal school is asserted separately, via the delegation tests
629
- # below a natural weekly-RSI fire is not reachable from a ramp.
630
 
631
 
632
  def test_evaluate_reaches_the_weekly_rsi_leg(monkeypatch):
633
- from TerraFin.analytics.analysis.patterns import evaluate, reversal
634
 
635
  called = []
636
- monkeypatch.setattr(reversal, "_weekly_rsi_divergence", lambda t, o: called.append(t) or [])
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