Bull pennant detection criteria — as of 2026-05-11¶
Read-only documentation pull from live code. No code changes made.
Source files inspected:
- uriel/detect/pennant.py — 4-stage pipeline driver, pennant geometry scan, event emission
- uriel/detect/flagpole.py — pole validation (validate_flagpole)
- uriel/detect/trend_filter.py — EMA_55 slope gate (passes_trend_filter)
- uriel/config.py — pydantic config models + defaults
- uriel.toml — live threshold values
- Project_Uriel_Charter_v1.5.pdf §§ 6.3, 7.1–7.5, 7.9 (Charter v1.5 is a housekeeping-only rev of v1.4 — no spec deltas)
1. Pennant geometry (Stage 1)¶
- Duration: 5 to 15 bars, inclusive (
min_duration_bars=5,max_duration_bars=15). Measured asend_idx − begin_idxwherebegin_idxis the bar immediately after the pole top andend_idxis the anchor (pennant's last bar). - Highs slope rule: linear regression slope of
highover the window must be strictly < 0 (pennant.py:287). - Lows slope rule: linear regression slope of
lowover the window must be strictly > 0 (pennant.py:287). - Retrace depth (preliminary, pre-pole):
(pole_top_close − min(close_in_pennant)) / pole_top_close ≤ max_retrace_pct × 1.5— a generous pre-filter applied before the pole is located (pennant.py:302–304). - Retrace depth (final, close-based, pole-height-normalized):
(pole_top_close − min(close_in_pennant)) / (pole_top_close − pole_start_close) ≤ 0.382(pennant.py:313–317). Falls back to 999 (i.e. reject) ifpole_top_close − pole_start_close ≤ 0. - Volume behavior (Stage 4 gate):
mean(pennant_volume) ≤ mean(pole_volume)(pennant.py:325–330). Ifpole_avg_vol == 0the check is skipped. - Dedup / skip-ahead: on a successful detection,
skip_until_idx = end_idx + min_durso the next iteration jumps past the just-emitted pennant (pennant.py:391). - Slope computation: vectorized via
np.lib.stride_tricks.sliding_window_view+ centered-x dot product, mathematically equivalent to the prior_linreg_slope(pennant.py:414–456). Held to this exact arithmetic order to preserve the post-9a.3 baseline.
2. Flagpole validation (Stage 2)¶
Implemented in uriel/detect/flagpole.py. Searches backward from the pennant's first bar (= pole top) for a qualifying pole; iterates lookback = min_duration_bars … max_duration_bars and keeps the longest qualifying pole.
- Magnitude — dual gate (both must pass):
- Gate A (percent):
(pole_top_close − pole_start_close) / pole_start_close ≥ 0.12(flagpole.py:73–76). - Gate B (ATR-normalized):
(pole_top_close − pole_start_close) / ATR(20)[pole_start_idx] ≥ 4.0(flagpole.py:78–84). - Duration: 1 to 10 bars from pole start to pole top (
flagpole.py:64). - Volume:
mean(pole_volume) / mean(volume_over_20_bars_preceding_pole_start) ≥ 1.5(flagpole.py:87–100). If baseline window is empty (pole starts at index 0) the ratio is NaN and the check is skipped (passes). - ATR(20): Wilder's smoothing (
ewm(alpha=1/20, min_periods=20, adjust=False)), computed once per ticker (pennant.py:209–219).
3. Trend validation (Stage 3)¶
Implemented in uriel/detect/trend_filter.py.
- Rule:
EMA_55[pole_start_idx] ≥ EMA_55[pole_start_idx − 10]. Flat or rising passes; declining fails (trend_filter.py:45). - Measurement:
close.ewm(span=55, adjust=False)(pennant.py:222); lookback 10 trading days (config.py:54,uriel.toml). - Exclusions: declining EMA_55, or NaN at either endpoint, or
pole_start_idx − 10 < 0(trend_filter.py:34–42).
4. Detection order — as implemented¶
Per pennant.py:_detect_for_ticker, for each (end_idx, win_len) pair:
- Pennant geometry pre-filter: slope signs, prelim retrace vs pole_top (
pennant.py:283–304). - Flagpole validation:
validate_flagpole(...)— dual magnitude gate + duration + pole volume (pennant.py:307–308). - Refined retrace depth gate: retrace ≤ 38.2 % of pole height (
pennant.py:312–317). - Trend validation:
passes_trend_filter(...)— EMA_55 slope ≥ 0 over 10-day lookback (pennant.py:321). - Pennant volume gate:
mean(pennant_vol) ≤ mean(pole_vol)(pennant.py:325–330). - Record event: geometry + pole metrics + volume ratio + volume trend + range-contraction ratio written to
pattern_events.pattern_metadataJSON (pennant.py:355–388).
5. Other filters / gates applied at detection time¶
- Active-ticker filter:
SELECT symbol FROM tickers WHERE active = TRUEis the universe (pennant.py:49). Liquidity floor ($1 M ADV per Charter §4) is enforced via the universe build, not at detection time. - History minimum: ticker is skipped if
n_bars < 300(pennant.py:188–190). Logged at INFO, not written toingest_failures(Phase 9e.2 / Finding 5). - Scan start offset: the sliding window only emits from
end_idx ≥ 220(pennant.py:250), leaving room for indicator warm-up + max-pole lookback. - Cooldown — recent-scan only:
detect_pennants_recent(...)skips any ticker that already has apattern_eventsrow within the last 30 days (pennant.py:115–122,pennant.py:136). The full historical scandetect_pennants(...)does not apply cooldown. - Dedup at detection time: in-loop
skip_until_idxprevents overlapping anchors within a single ticker run (pennant.py:391); recent-scan additionally dedups againstpattern_events.event_idalready in the DB (pennant.py:149). - No squeeze gate at detection (consistent with Charter §7.5: squeeze is a feature, not a gate).
6. Constants and thresholds — reference table¶
| Parameter | Value | Source file:line |
|---|---|---|
| pennant.min_duration_bars | 5 | uriel.toml:32, config.py:68 |
| pennant.max_duration_bars | 15 | uriel.toml:33, config.py:69 |
| pennant.max_retrace_pct | 0.382 | uriel.toml:31, config.py:67 |
| flagpole.min_magnitude_pct | 12.0 (%) | uriel.toml:23, config.py:58 |
| flagpole.min_atr_multiple | 4.0 | uriel.toml:24, config.py:59 |
| flagpole.min_duration_bars | 1 | uriel.toml:26, config.py:61 |
| flagpole.max_duration_bars | 10 | uriel.toml:25, config.py:60 |
| flagpole.volume_ratio_min | 1.5 | uriel.toml:27, config.py:62 |
| flagpole.volume_baseline_days | 20 | uriel.toml:28, config.py:63 |
| trend_filter.ema_period | 55 | uriel.toml:19, config.py:53 |
| trend_filter.slope_lookback_days | 10 | uriel.toml:20, config.py:54 |
| overlap.cooldown_days | 30 | uriel.toml:47, config.py:86 |
| Hardcoded: scan start offset | 220 bars | pennant.py:250 |
| Hardcoded: min history | 300 bars | pennant.py:188 |
| Hardcoded: prelim retrace multiplier | 1.5 × max_retrace | pennant.py:303 |
| Hardcoded: ATR period | 20 (Wilder) | pennant.py:217 |
7. Discrepancies vs Charter v1.4 / v1.5 §7¶
-
Gate A measurement basis. Charter §7.2 says "Gate A (percent): Close-to-high move ≥ 12 % from pole start to pole top."
flagpole.py's docstring repeats the "close-to-high" wording, but the implementation is close-to-close —(close_arr[pole_top_idx] − close_arr[start_idx]) / close_arr[start_idx]. Same for Gate B (close-to-close, not close-to-high) (flagpole.py:73, 82). Either the charter wording or the implementation is off; live behavior is close-to-close. -
Cooldown / overlap handling in recent-scan path. Charter §7.9 specifies that overlapping pennants are recorded with
overlapping = trueand excluded only from the primary precursor search.detect_pennants_recentinstead skips entire tickers that are in the 30-day cooldown window (pennant.py:117–140) — those overlap candidates are never written topattern_events. The full historicaldetect_pennantsapplies no cooldown at all and emits everything, so the historical study path is consistent with the charter; only the recent-scan path diverges. -
overlappingfield not populated at detection time. Charter §6.2 listsoverlappingas a boolean onpattern_events; the detector's metadata JSON (pennant.py:355–378) does not include it, and the INSERT (pennant.py:31–40) writes onlyevent_id, symbol, event_date, pattern_type, pattern_metadata. If the column exists, it is populated by a downstream pass, not the detector. -
Detection-order substage count. Charter §7.3 enumerates three gating substages (Pennant → Flagpole → Trend), then "Feature recording — no gating." The implementation interleaves a fourth hard gate (Q4 pennant volume ≤ pole volume, charter §7.5 Q4) after the trend filter and before recording (
pennant.py:325–330). This is consistent with §7.5 calling Q4 a hard rule, but it is not listed as a separate substage in §7.3. Organizational only — no behavioral divergence from §7.5. -
Warm-up minima not specified in charter.
n_bars ≥ 300andstart_pos = 220are hardcoded inpennant.py(lines 188, 250). Reasonable for 200-SMA + 20-ATR + max-pole warm-up, but unspecified in §7. -
Liquidity floor location. Charter §4 sets $1 M 30-day ADV as a universe filter. The detector itself has no liquidity gate; it relies on
tickers.active = TRUE(pennant.py:49) as a proxy. Not a divergence if universe construction enforces ADV at theactiveflag, but worth confirming.
End of report.