Add price signals and cross-symbol signal support#233
Conversation
Add 14 new price-related signals in optopsy/signals/price.py: - Price level: price_above, price_below, price_cross_above, price_cross_below - Gaps: gap_up, gap_down - Breakouts: high_of_n_days, low_of_n_days - Returns: daily_return_above, daily_return_below - Drawdown: drawdown_from_high, rally_from_low - Streaks: consecutive_up, consecutive_down Add cross-symbol signal support via signal_symbol parameter on build_signal and build_custom_signal, enabling use cases like "use VIX data to trigger entries on SPX options." Update agent system prompt, tool schemas, and signal descriptions to document all new signals and cross-symbol workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new suite of first-class “price” signals to the optopsy.signals API and extends the UI toolchain to support cross-symbol signal computation (compute signals on one ticker’s OHLCV and apply them to another ticker’s options dataset).
Changes:
- Introduces 14 new price-based signals (levels, crossovers, gaps, breakouts, returns, drawdown/rally, streaks) and exports them from
optopsy.signals. - Adds
signal_symbolsupport tobuild_signal/build_custom_signal, including yfinance fetching for an explicit symbol and remapping signal dates to the options dataset symbols. - Updates tool registry/schemas/models and agent prompt docs; adds a new test module covering the new price signals.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_signals_price.py |
Adds signal-level pytest coverage for the new price signal functions. |
optopsy/signals/price.py |
Implements the new price signals (state + event style signals). |
optopsy/signals/_helpers.py |
Adds _get_open() helper used by gap signals. |
optopsy/signals/__init__.py |
Exports new price signals (and _get_open) from the package API. |
optopsy/ui/tools/_signals_builder.py |
Adds signal_symbol support to build_signal / build_custom_signal and remaps cross-symbol signal dates. |
optopsy/ui/tools/_helpers.py |
Adds _fetch_stock_data_for_symbol() to fetch OHLCV for a single ticker for cross-symbol workflows. |
optopsy/ui/tools/_schemas.py |
Registers the new signal names + params and marks relevant ones as OHLC-requiring. |
optopsy/ui/tools/_models.py |
Documents new signals and adds signal_symbol to the tool argument models. |
optopsy/ui/agent.py |
Updates the agent system prompt documentation for the new signals and cross-symbol workflow. |
- Strip whitespace from signal_symbol to prevent cache key issues - Gate cross-symbol remapping on needs_stock to avoid duplicating rows when signal_symbol is set on IV or date-only signals Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract _fetch_single_symbol_stock from the duplicated fetch logic in _fetch_stock_data_for_signals and _fetch_stock_data_for_symbol - Extract _remap_cross_symbol_dates from the duplicated remap blocks in _handle_build_signal and _handle_build_custom_signal - Extract _STOCK_COLS constant for the shared column list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add _OPEN_SIGNALS set and validate open column for gap_up/gap_down - Add days >= 1 validation to consecutive_up/consecutive_down - Make gap and high_of_n_days tests assert exact firing indices Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| _STOCK_COLS = [ | ||
| "underlying_symbol", | ||
| "quote_date", | ||
| "open", | ||
| "high", | ||
| "low", | ||
| "close", | ||
| "volume", | ||
| ] | ||
|
|
||
|
|
||
| def _fetch_single_symbol_stock( | ||
| symbol: str, | ||
| padded_start: "date", | ||
| date_max: "date", | ||
| ) -> pd.DataFrame | None: | ||
| """Fetch and slice OHLCV data for one symbol. | ||
|
|
||
| Reads from (or populates) the yfinance parquet cache, then slices to | ||
| ``[padded_start, date_max]``. Returns a DataFrame with ``_STOCK_COLS`` | ||
| or ``None`` on failure. | ||
| """ | ||
| try: | ||
| cached = _yf_cache.read(_YF_CACHE_CATEGORY, symbol) | ||
| cached = _yf_fetch_and_cache(symbol, cached, date_max) | ||
|
|
||
| if cached is None or cached.empty: | ||
| return None | ||
|
|
||
| result = cached[ | ||
| (pd.to_datetime(cached["date"]).dt.date >= padded_start) | ||
| & (pd.to_datetime(cached["date"]).dt.date <= date_max) | ||
| ].rename(columns={"date": "quote_date"}) | ||
| if result.empty: | ||
| return None | ||
| return result[_STOCK_COLS] | ||
| except (OSError, ValueError, KeyError, pd.errors.ParserError) as exc: | ||
| _log.warning("yfinance fetch failed for %s: %s", symbol, exc) | ||
| return None |
There was a problem hiding this comment.
_fetch_single_symbol_stock() always returns result[_STOCK_COLS], but _normalise_yf_df() can legitimately omit optional columns (e.g. volume / open / high / low) when yfinance doesn’t provide them. In that case this selection raises KeyError, causing the fetch to fail even for signals that only need close (and preventing the more specific missing-column validations from triggering). Consider returning the intersection of available columns while enforcing a minimal required set (at least underlying_symbol, quote_date, close), and letting callers validate additional columns as needed.
There was a problem hiding this comment.
Valid concern. _fetch_single_symbol_stock now selects only available columns ([c for c in _STOCK_COLS if c in result.columns]) instead of hard-coding all 7, and returns None only if close is missing. This lets signals that only need close work even when yfinance omits optional columns, while letting the caller-level validations (_OHLC_SIGNALS, _OPEN_SIGNALS, _VOLUME_SIGNALS) report specific missing-column errors. Fixed in 79c8c24.
| # Signals that require OHLC data (high, low, close) — need stock data fetch. | ||
| _OHLC_SIGNALS = frozenset( | ||
| { | ||
| "stoch_below", | ||
| "stoch_above", | ||
| "willr_below", | ||
| "willr_above", | ||
| "cci_below", | ||
| "cci_above", | ||
| "uo_above", | ||
| "uo_below", | ||
| "squeeze_on", | ||
| "squeeze_off", | ||
| "ao_above", | ||
| "ao_below", | ||
| "kc_above_upper", | ||
| "kc_below_lower", | ||
| "donchian_above_upper", | ||
| "donchian_below_lower", | ||
| "natr_above", | ||
| "natr_below", | ||
| "massi_above", | ||
| "massi_below", | ||
| "adx_above", | ||
| "adx_below", | ||
| "aroon_cross_above", | ||
| "aroon_cross_below", | ||
| "supertrend_buy", | ||
| "supertrend_sell", | ||
| "psar_buy", | ||
| "psar_sell", | ||
| "chop_above", | ||
| "chop_below", | ||
| "gap_up", | ||
| "gap_down", | ||
| "high_of_n_days", | ||
| "low_of_n_days", | ||
| } |
There was a problem hiding this comment.
_OHLC_SIGNALS is documented/validated as requiring high, low, and close, but it now includes gap_up/gap_down and high_of_n_days/low_of_n_days. The new price signals don’t actually need all of high+low (and gap signals don’t use high/low at all), so the build_signal OHLC validation can reject otherwise-computable signals and produce a misleading error. Consider splitting these into more precise requirement sets (e.g. close-only vs open+close vs full OHLC) and validating only the columns each signal truly needs.
There was a problem hiding this comment.
Good point. Removed gap_up/gap_down from _OHLC_SIGNALS — they don't need high/low and already have their own _OPEN_SIGNALS validation checking for the open column. high_of_n_days/low_of_n_days remain in _OHLC_SIGNALS since they genuinely use the high/low columns. Fixed in 79c8c24.
| result = high_of_n_days(period=5)(data) | ||
| # Bar 7: close=103, prev 5-bar high max = max(103,99,98,100,104) | ||
| # Wait — rolling high is on the high column, shifted by 1 | ||
| # Bar 7 rolling(5).max().shift(1) = max of bars 2-6 highs = max(101,103,99,98,100) = 103 | ||
| # close=103 >= 103 → True | ||
| assert result.iloc[7] == True | ||
| # Bar 9: close=105, prev 5-bar high = max of bars 4-8 = max(99,98,100,104,103) = 104 | ||
| # 105 >= 104 → True | ||
| assert result.iloc[9] == True |
There was a problem hiding this comment.
test_high_of_n_days_breakout only asserts that a couple of bars are True, so it would still pass if high_of_n_days() incorrectly fires on additional bars (false positives). To make the test more regression-proof, consider asserting the full expected boolean vector or the exact list of firing indices for this fixture.
There was a problem hiding this comment.
Agreed. Updated to assert exact firing indices: assert fire_indices == [1, 3, 7, 9]. Added per-bar computation comments for clarity. Fixed in 79c8c24.
| result = low_of_n_days(period=5)(data) | ||
| # Bar 7: close=96, prev 5-bar low = min of bars 2-6 lows = min(99,101,97,96,98) = 96 | ||
| # 96 <= 96 → True | ||
| assert result.iloc[7] == True | ||
|
|
There was a problem hiding this comment.
test_low_of_n_days_breakdown currently asserts only one True bar. This can miss regressions where low_of_n_days() produces extra True bars. Consider asserting the exact firing indices (or full boolean vector) for the entire series so the test fails on false positives as well.
There was a problem hiding this comment.
Agreed. Updated to assert exact firing indices: assert fire_indices == [4, 5, 7, 8, 9]. Added per-bar computation comments. Fixed in 79c8c24.
- _fetch_single_symbol_stock now selects only available columns from _STOCK_COLS instead of hard-coding all 7, preventing KeyError when yfinance omits optional columns (open/high/low/volume) - Remove gap_up/gap_down from _OHLC_SIGNALS (they need open+close, not high/low); they already have their own _OPEN_SIGNALS validation - Make high_of_n_days and low_of_n_days tests assert exact fire indices Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
optopsy/signals/price.py: price level (above/below/cross), gaps, N-period breakouts, daily returns, drawdown/rally from high/low, and consecutive up/down streakssignal_symbolparameter onbuild_signalandbuild_custom_signal, enabling use cases like "use VIX data to trigger entries on SPX options"Test plan
tests/test_signals_price.pycovering all 14 signals + edge casesruff checkandruff formatcleantytype checker passes🤖 Generated with Claude Code