Skip to content

Commit 45e15b6

Browse files
michaelchuclaude
andauthored
Add price signals and cross-symbol signal support (#233)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e60a00 commit 45e15b6

File tree

9 files changed

+997
-33
lines changed

9 files changed

+997
-33
lines changed

optopsy/signals/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
_get_close,
5151
_get_high,
5252
_get_low,
53+
_get_open,
5354
_get_volume,
5455
_groupby_symbol,
5556
_ohlcv_crossover_signal,
@@ -152,6 +153,24 @@
152153
zlma_cross_below,
153154
)
154155

156+
# --- Price signals ---
157+
from .price import (
158+
consecutive_down,
159+
consecutive_up,
160+
daily_return_above,
161+
daily_return_below,
162+
drawdown_from_high,
163+
gap_down,
164+
gap_up,
165+
high_of_n_days,
166+
low_of_n_days,
167+
price_above,
168+
price_below,
169+
price_cross_above,
170+
price_cross_below,
171+
rally_from_low,
172+
)
173+
155174
# --- Trend signals ---
156175
from .trend import (
157176
# ADX
@@ -224,6 +243,21 @@
224243
"Signal",
225244
"signal",
226245
"signal_dates",
246+
# Price
247+
"price_above",
248+
"price_below",
249+
"price_cross_above",
250+
"price_cross_below",
251+
"gap_up",
252+
"gap_down",
253+
"high_of_n_days",
254+
"low_of_n_days",
255+
"daily_return_above",
256+
"daily_return_below",
257+
"drawdown_from_high",
258+
"rally_from_low",
259+
"consecutive_up",
260+
"consecutive_down",
227261
# Momentum
228262
"rsi_below",
229263
"rsi_above",

optopsy/signals/_helpers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ def _get_hl(group: pd.DataFrame) -> "tuple[pd.Series | None, pd.Series | None] |
290290
return _get_high(group), _get_low(group)
291291

292292

293+
def _get_open(group: pd.DataFrame) -> "pd.Series | None":
294+
"""Get open prices if available, else None."""
295+
if "open" in group.columns:
296+
return group["open"]
297+
return None
298+
299+
293300
def _get_volume(group: pd.DataFrame) -> "pd.Series | None":
294301
"""Get volume if available, else None."""
295302
if "volume" in group.columns:

optopsy/signals/price.py

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
"""Price signals: levels, crossovers, gaps, breakouts, returns, drawdowns."""
2+
3+
import pandas as pd
4+
5+
from ._helpers import (
6+
SignalFunc,
7+
_crossover_signal,
8+
_get_close,
9+
_get_high,
10+
_get_low,
11+
_get_open,
12+
_groupby_symbol,
13+
_per_symbol_signal,
14+
)
15+
16+
# ---------------------------------------------------------------------------
17+
# State-based: price above/below a fixed level
18+
# ---------------------------------------------------------------------------
19+
20+
21+
def price_above(level: float) -> SignalFunc:
22+
"""True every bar where the close price is above *level*."""
23+
level = float(level)
24+
return _per_symbol_signal(
25+
lambda p: pd.Series(level, index=p.index),
26+
lambda prices, lvl: prices > lvl,
27+
)
28+
29+
30+
def price_below(level: float) -> SignalFunc:
31+
"""True every bar where the close price is below *level*."""
32+
level = float(level)
33+
return _per_symbol_signal(
34+
lambda p: pd.Series(level, index=p.index),
35+
lambda prices, lvl: prices < lvl,
36+
)
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# Event-based: price crosses a fixed level
41+
# ---------------------------------------------------------------------------
42+
43+
44+
def price_cross_above(level: float) -> SignalFunc:
45+
"""True on the bar where close crosses above *level*."""
46+
level = float(level)
47+
return _crossover_signal(
48+
lambda prices: (prices, pd.Series(level, index=prices.index)),
49+
above=True,
50+
)
51+
52+
53+
def price_cross_below(level: float) -> SignalFunc:
54+
"""True on the bar where close crosses below *level*."""
55+
level = float(level)
56+
return _crossover_signal(
57+
lambda prices: (prices, pd.Series(level, index=prices.index)),
58+
above=False,
59+
)
60+
61+
62+
# ---------------------------------------------------------------------------
63+
# Gap signals (need open + close)
64+
# ---------------------------------------------------------------------------
65+
66+
67+
def gap_up(pct: float = 0.5) -> SignalFunc:
68+
"""True when today's open gaps above yesterday's close by at least *pct* %."""
69+
pct = float(pct)
70+
71+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
72+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
73+
close = _get_close(group)
74+
open_ = _get_open(group)
75+
if close is None or open_ is None:
76+
return pd.Series(False, index=group.index)
77+
threshold = close.shift(1) * (1 + pct / 100)
78+
return (open_ > threshold).fillna(False)
79+
80+
return _groupby_symbol(data, _compute_group)
81+
82+
return _signal
83+
84+
85+
def gap_down(pct: float = 0.5) -> SignalFunc:
86+
"""True when today's open gaps below yesterday's close by at least *pct* %."""
87+
pct = float(pct)
88+
89+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
90+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
91+
close = _get_close(group)
92+
open_ = _get_open(group)
93+
if close is None or open_ is None:
94+
return pd.Series(False, index=group.index)
95+
threshold = close.shift(1) * (1 - pct / 100)
96+
return (open_ < threshold).fillna(False)
97+
98+
return _groupby_symbol(data, _compute_group)
99+
100+
return _signal
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# N-period high/low breakout signals
105+
# ---------------------------------------------------------------------------
106+
107+
108+
def high_of_n_days(period: int = 252) -> SignalFunc:
109+
"""True when close reaches or exceeds the N-bar rolling high (breakout).
110+
111+
The rolling window is shifted by 1 to avoid look-ahead bias — the
112+
comparison is against the highest high of the *previous* N bars.
113+
"""
114+
period = int(period)
115+
116+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
117+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
118+
close = _get_close(group)
119+
high = _get_high(group)
120+
if close is None or high is None:
121+
return pd.Series(False, index=group.index)
122+
rolling_high = high.rolling(period, min_periods=1).max().shift(1)
123+
return (close >= rolling_high).fillna(False)
124+
125+
return _groupby_symbol(data, _compute_group)
126+
127+
return _signal
128+
129+
130+
def low_of_n_days(period: int = 252) -> SignalFunc:
131+
"""True when close reaches or falls below the N-bar rolling low (breakdown).
132+
133+
The rolling window is shifted by 1 to avoid look-ahead bias — the
134+
comparison is against the lowest low of the *previous* N bars.
135+
"""
136+
period = int(period)
137+
138+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
139+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
140+
close = _get_close(group)
141+
low = _get_low(group)
142+
if close is None or low is None:
143+
return pd.Series(False, index=group.index)
144+
rolling_low = low.rolling(period, min_periods=1).min().shift(1)
145+
return (close <= rolling_low).fillna(False)
146+
147+
return _groupby_symbol(data, _compute_group)
148+
149+
return _signal
150+
151+
152+
# ---------------------------------------------------------------------------
153+
# Daily return signals
154+
# ---------------------------------------------------------------------------
155+
156+
157+
def daily_return_above(pct: float = 1.0) -> SignalFunc:
158+
"""True when the daily close-to-close return exceeds *pct* %.
159+
160+
Example: ``daily_return_above(2.0)`` fires on days the stock gains > 2%.
161+
"""
162+
threshold = float(pct) / 100 # compare in decimal form
163+
164+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
165+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
166+
close = _get_close(group)
167+
if close is None:
168+
return pd.Series(False, index=group.index)
169+
ret = close.pct_change()
170+
return (ret > threshold).fillna(False)
171+
172+
return _groupby_symbol(data, _compute_group)
173+
174+
return _signal
175+
176+
177+
def daily_return_below(pct: float = -1.0) -> SignalFunc:
178+
"""True when the daily close-to-close return is below *pct* %.
179+
180+
Use negative values for drops: ``daily_return_below(-3.0)`` fires on
181+
days the stock falls more than 3%.
182+
"""
183+
threshold = float(pct) / 100 # compare in decimal form
184+
185+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
186+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
187+
close = _get_close(group)
188+
if close is None:
189+
return pd.Series(False, index=group.index)
190+
ret = close.pct_change()
191+
return (ret < threshold).fillna(False)
192+
193+
return _groupby_symbol(data, _compute_group)
194+
195+
return _signal
196+
197+
198+
# ---------------------------------------------------------------------------
199+
# Drawdown / rally signals
200+
# ---------------------------------------------------------------------------
201+
202+
203+
def drawdown_from_high(period: int = 20, pct: float = 5.0) -> SignalFunc:
204+
"""True when close is down at least *pct* % from its *period*-bar rolling high.
205+
206+
Measures how far the current close has fallen from the highest close
207+
over the last *period* bars (inclusive of the current bar).
208+
"""
209+
period = int(period)
210+
threshold = float(pct) / 100 # convert to decimal
211+
212+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
213+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
214+
close = _get_close(group)
215+
if close is None:
216+
return pd.Series(False, index=group.index)
217+
rolling_high = close.rolling(period, min_periods=1).max()
218+
dd_ratio = (close - rolling_high) / rolling_high # negative values
219+
return (dd_ratio <= -threshold).fillna(False)
220+
221+
return _groupby_symbol(data, _compute_group)
222+
223+
return _signal
224+
225+
226+
def rally_from_low(period: int = 20, pct: float = 5.0) -> SignalFunc:
227+
"""True when close is up at least *pct* % from its *period*-bar rolling low.
228+
229+
Measures how far the current close has risen from the lowest close
230+
over the last *period* bars (inclusive of the current bar).
231+
"""
232+
period = int(period)
233+
threshold = float(pct) / 100 # convert to decimal
234+
235+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
236+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
237+
close = _get_close(group)
238+
if close is None:
239+
return pd.Series(False, index=group.index)
240+
rolling_low = close.rolling(period, min_periods=1).min()
241+
rally_ratio = (close - rolling_low) / rolling_low
242+
return (rally_ratio >= threshold).fillna(False)
243+
244+
return _groupby_symbol(data, _compute_group)
245+
246+
return _signal
247+
248+
249+
# ---------------------------------------------------------------------------
250+
# Consecutive up/down day signals
251+
# ---------------------------------------------------------------------------
252+
253+
254+
def consecutive_up(days: int = 3) -> SignalFunc:
255+
"""True on the bar completing *days* consecutive closes above prior close.
256+
257+
Example: ``consecutive_up(3)`` fires after 3 straight up-closes.
258+
"""
259+
days = int(days)
260+
if days < 1:
261+
raise ValueError(f"days must be >= 1, got {days}")
262+
263+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
264+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
265+
close = _get_close(group)
266+
if close is None:
267+
return pd.Series(False, index=group.index)
268+
up = (close > close.shift(1)).astype(int)
269+
streak = up.rolling(days, min_periods=days).sum()
270+
return (streak == days).fillna(False)
271+
272+
return _groupby_symbol(data, _compute_group)
273+
274+
return _signal
275+
276+
277+
def consecutive_down(days: int = 3) -> SignalFunc:
278+
"""True on the bar completing *days* consecutive closes below prior close.
279+
280+
Example: ``consecutive_down(3)`` fires after 3 straight down-closes.
281+
"""
282+
days = int(days)
283+
if days < 1:
284+
raise ValueError(f"days must be >= 1, got {days}")
285+
286+
def _signal(data: pd.DataFrame) -> "pd.Series[bool]":
287+
def _compute_group(group: pd.DataFrame) -> "pd.Series[bool]":
288+
close = _get_close(group)
289+
if close is None:
290+
return pd.Series(False, index=group.index)
291+
down = (close < close.shift(1)).astype(int)
292+
streak = down.rolling(days, min_periods=days).sum()
293+
return (streak == days).fillna(False)
294+
295+
return _groupby_symbol(data, _compute_group)
296+
297+
return _signal

0 commit comments

Comments
 (0)