Skip to content

Chart Pattern Detection

The fundcloud.features.patterns subpackage detects classical chart patterns (Head & Shoulders, Double Top/Bottom, Triangles, etc.) and exposes them as sklearn-compatible feature transformers. Detection itself runs in pure Rust under fundcloud._core for speed; the Python layer wraps it as IndicatorSpec subclasses so patterns compose with the rest of the feature pipeline (TA-Lib indicators, FeaturePipeline, Simulator.run_signals, PurgedKFold).

For a runnable end-to-end walkthrough, see the example scripts examples/31_head_and_shoulders_detection.py (synthetic data) and examples/32_pattern_scan_real_data.py (real Yahoo Finance bars).

Status

The full v1 surface — 9 tier-1 reversal/continuation detectors — is now shipped. Each one is a Rust module implementing PatternDetector plus a thin Python subclass of PatternIndicator registered via @register_indicator.

Detector Rust Python class Status
Head and Shoulders HeadAndShoulders shipped
Inverse Head and Shoulders InverseHeadAndShoulders shipped
Double Top DoubleTop shipped
Double Bottom DoubleBottom shipped
Triple Top TripleTop shipped
Triple Bottom TripleBottom shipped
Ascending Triangle AscendingTriangle shipped
Descending Triangle DescendingTriangle shipped
Symmetrical Triangle SymmetricalTriangle shipped

Architecture at a glance

┌─────────────────────────────────────────────────────────────────┐
│ Python: fundcloud.features.patterns                             │
│   PatternIndicator(IndicatorSpec)        ← sklearn fit/transform │
│   ├── HeadAndShoulders / InverseHeadAndShoulders                 │
│   ├── DoubleTop / DoubleBottom                                   │
│   ├── TripleTop / TripleBottom                                   │
│   └── AscendingTriangle / DescendingTriangle / SymmetricalTriangle│
│                                                                  │
│   PatternCondition (entry/exit descriptor + presets)             │
│   events table (canonical 14-column schema)                      │
│   Enums (Pattern, Direction, SignalMode, EntryRule, …)           │
└──────────────────┬──────────────────────────────────────────────┘
                   │  numpy zero-copy + PyO3
┌─────────────────────────────────────────────────────────────────┐
│ Rust: crates/fundcloud-core/src/patterns/                       │
│   types.rs        Pivot / TrendLine / Pattern / Detection       │
│   pivots.rs       multi_level_pivots()                          │
│   trendline.rs    fit_trendline() + boundary helpers            │
│   scoring.rs      GeometricScorer  (0–100 quality)              │
│   detect.rs       PatternDetector trait + scan() entry point    │
│   detectors/                                                     │
│     head_shoulders.rs    HeadShoulders + Inverse                │
│     double.rs            DoubleTop + DoubleBottom                │
│     triple.rs            TripleTop + TripleBottom                │
│     triangles.rs         Asc / Desc / Symmetrical Triangle       │
└─────────────────────────────────────────────────────────────────┘

Data flow — bars in, detections out

1. User: bars (pd.DataFrame, MultiIndex columns (field, asset),
                              DatetimeIndex)
   │ HeadAndShoulders().fit_transform(bars)
2. PatternIndicator._compute(per_asset)
   │ pull aligned numpy arrays:
   │   ts_ns = index.view("int64")   (UTC nanoseconds)
   │   open, high, low, close, volume = numpy float64
   │ py.allow_threads → one PyO3 call per (pattern × asset)
3. _core.scan_pattern("head_and_shoulders", ts, o, h, l, c, v,
                       pivot_orders, min_quality)
   │ Rust side (no GIL):
   │   a. Build OhlcvView (zero-copy borrows of numpy buffers)
   │   b. multi_level_pivots(highs, lows, ts, orders=(3,5,8))
   │   c. PatternDetector::detect(&pivots, ohlcv) for the named pattern
   │   d. GeometricScorer::score(&pattern, ohlcv) for each detection
   │   e. Filter by min_quality
   │ return Vec<Detection> as Python list[dict]
4. build_events_frame()  → canonical 14-column events DataFrame
   events_to_signal()    → per-bar 0/1 signal series (BREAKOUT mode)
5. User: pd.DataFrame[index, asset]   (one column per asset)

Pivot detection — multi_level_pivots

The first stage of every pattern scan. It identifies the swing highs and swing lows (collectively, "pivots") that the detector then walks in sequence.

Definition: swing high / swing low at order=N

A bar at index i qualifies as a swing high at order N when its price is >= every neighbour within N bars on each side:

x[i] >= x[i-1], x[i-2], ..., x[i-N]
x[i] >= x[i+1], x[i+2], ..., x[i+N]

Out-of-bounds neighbours are clipped to the boundary value (matching scipy's argrelextrema(comparator, order=N, mode='clip')). Swing lows use <= against lows[i].

Multi-scale union + dedup

multi_level_pivots(highs, lows, ts_ns, orders) runs the detection at every order in orders (smallest to largest), then deduplicates.

Step What
1. Detect For each order, find all swing highs (on highs[]) and swing lows (on lows[])
2. Union Concatenate every order's pivot list
3. Sort By (index, kind)
4. Dedup For same-kind pivots within ±2 bars: keep the strictly more extreme price; on equal prices, the first-iterated (smallest order) wins
5. Alternate Walk left-to-right; collapse consecutive same-kind pivots, keeping the more extreme. Output is strictly alternating High/Low

Inputs / outputs

Type
highs, lows &[f64] of equal length, same as the OHLCV panel
ts_ns &[i64] UTC nanoseconds, monotonic ascending
orders &[usize] lookback half-window sizes; default [3, 5, 8]
returns Vec<Pivot>, alternating High/Low, sorted by index

Pivot record

pub struct Pivot {
    pub index: usize,    // bar offset into the OHLCV panel
    pub ts_ns: i64,      // UTC nanoseconds
    pub price: f64,      // highs[index] for High, lows[index] for Low
    pub kind: PivotKind, // High | Low
    pub order: u8,       // smallest order that detected this pivot (metadata)
}

pivot_orders — what it actually controls

Per the math above, the smallest order in orders determines the pivot count. Adding larger orders to the tuple does not add or remove pivots — they are a strict subset. Concretely:

pivot_orders=(3,)            → N pivots
pivot_orders=(3, 5)          → N pivots (identical bars)
pivot_orders=(3, 5, 8)       → N pivots (identical bars)
pivot_orders=(3, 5, 8, 13)   → N pivots (identical bars)

pivot_orders=(5,)            → M < N pivots (different smallest order)
pivot_orders=(8,)            → P < M pivots

The order field on each surviving pivot is the smallest order that detected it — set during dedup (smaller orders iterate first; equal prices don't replace). It is recorded as metadata; v1 detectors do not read it. Future detectors or quality scorers may use it to prefer "macro" pivots, but as of v1 it is purely informational.

Practical guidance: for v1, you may treat pivot_orders as a single-element tuple holding the lookback you actually want ((3,), (5,), etc.). The default (3, 5, 8) is kept for parity with pattern-service.

Tuning by bar timeframe

Bars Suggested pivot_orders
Daily equities (default) (3, 5, 8)
Daily crypto / very volatile (5, 8, 13)
Intraday 5-minute (2, 5)
Weekly bars (2, 4)
Macro setups only (8, 13)

Tune by the bars' character (timeframe, volatility), not by the pattern. The same pivot_orders is appropriate for every detector running on the same bars.

Trend-line fitting — fit_trendline

Closed-form ordinary least squares through 2+ pivots. Used by every detector to construct necklines, channels, and triangle sides.

Algorithm

For pivots with (index_i, price_i):

x_mean = mean(index_i)
y_mean = mean(price_i)
S_xx   = sum((x_i - x_mean)^2)
S_yy   = sum((y_i - y_mean)^2)
S_xy   = sum((x_i - x_mean) * (y_i - y_mean))

slope     = S_xy / S_xx
intercept = y_mean - slope * x_mean
r_squared = 1 - (S_yy - slope * S_xy) / S_yy   (clamped to [0, 1])

Rank-deficient branch: when all pivots share the same index (S_xx = 0), the closed form would divide by zero. The fallback (matching numpy lstsq's minimum-norm solution) is slope=0, intercept=y_mean. r_squared is 1.0 if S_yy=0 (constant y), else 0.0.

Output

pub struct TrendLine {
    pub start_index: usize,    // index of the leftmost anchor pivot
    pub end_index: usize,      // index of the rightmost anchor pivot
    pub slope: f64,            // dPrice / dBar
    pub intercept: f64,        // y at bar 0
    pub r_squared: f64,        // [0, 1]
    pub touch_count: u8,       // number of pivots used in the fit
    pub role: Role,            // Upper / Lower — which side of the line
                               // the scorer evaluates for boundary respect
}

The companion helpers validate_boundaries (channel-width sanity check) and count_touches (how many bars come within tolerance of the line) are used by triangle / channel / rectangle detectors and are documented inline in crates/fundcloud-core/src/patterns/trendline.rs.

Geometric quality score — GeometricScorer

Every detection is graded on a 0..=100 composite score. The scorer is stateless and pattern-aware (the symmetry sub-scorer dispatches by pattern name).

Composition

raw = 30% * symmetry + 25% * volume + 25% * trendline_r2 + 20% * completeness

symmetry_gate = clamp(symmetry / 10, 0.1, 1.0)
duration_gate = clamp((bar_count − 4) / 6, 0, 1)   if bar_count < 10, else 1.0

quality = round(raw * symmetry_gate * duration_gate), clamped to [0, 100]

The two multiplicative gates crush the composite when a structural prerequisite fails (near-zero symmetry, or formations shorter than the detector minimum). See docs/scoring/quality.md for the full rationale and motivating cases.

Sub-scorers

symmetry (30%)

Pattern-specific. Returns 0..=100.

Pattern name Formula
head_and_shoulders / inverse_head_and_shoulders (shoulder_score + neckline_score) / 2, where each is 100 * (1 - pct_diff(pair) / 0.10), clamped at 0
double_top / double_bottom 100 * (1 - pct_diff(p1, p3) / 0.015), clamped at 0
ascending_triangle / descending_triangle 100 * (1 - cv(pivot_spacings)), clamped at 0; cv = std/mean of the spacing-between-pivots series
anything else 50.0 (neutral)

pct_diff(a, b) = abs(a - b) / ((|a| + |b|) / 2). Symmetry below roughly 0.7 starts to look "off"; tight formations score above 90.

volume (25%)

Declining volume during the formation is a confirmation signal.

ratio = mean(volumes[mid:]) / mean(volumes[:mid])
where mid = len(volumes) // 2

ratio <= 0.5  → 100  (ideal)
ratio >= 1.5  → 0
otherwise     → 100 * (1.5 - ratio)

If volume data is unavailable or the formation is < 4 bars, returns 50.0 (neutral).

trendline_r2 (25%)

Trend-line quality sub-score, scaled to 0–100. The metric dispatches by touch count:

  • 3+ anchor lines (triple_top / triple_bottom and well-pivoted triangle sides) use the mean anchor-only TrendLine::r_squared. This varies in [0, 1] and reflects how cleanly the pivots line up.
  • 2-anchor lines (double_top / double_bottom, H&S necklines, 2-touch triangle sides) use the boundary-respect ratio — the fraction of intermediate bars whose high/low respects the line within a 0.5% tolerance. This is implemented by features.trendline_r2 in crates/fundcloud-core/src/patterns/features/trendline.rs and is a genuine discriminator for these patterns, not a constant.

If no trend lines are attached (e.g., a pivot-only detection), returns 50.0. See docs/scoring/quality.md for the dispatch rules and rationale.

completeness (20%)

Combines duration and trend-line touch count.

duration_score:
  bar_count < 5         → 0
  5 <= bar_count < 10   → linear ramp 0 → 50
  10 <= bar_count <= 60 → 100   (the sweet spot)
  60 < bar_count <= 120 → linear ramp 100 → 50
  bar_count > 120       → 50

touch_score (sum of TrendLine.touch_count across the detection):
  total <= 2 → 30
  total <= 4 → 60
  total > 4  → min(100, 60 + (total - 4) * 10)

completeness = (duration_score + touch_score) / 2

Output

pub struct PatternScore {
    pub quality: f64,                       // 0..=100
    pub features: HashMap<String, f64>,     // {symmetry, volume, trendline_r2, completeness} as 0..=1 floats
}

The features map is preserved verbatim into the events table's meta["features"] so users can inspect the breakdown.

PatternDetector trait

Every detector implements:

pub trait PatternDetector: Send + Sync {
    fn name(&self) -> &'static str;   // matches Pattern enum value
    fn detect(&self, pivots: &[Pivot], ohlcv: OhlcvView<'_>) -> Vec<Pattern>;
}

pivots are pre-computed by multi_level_pivots; ohlcv is borrowed zero-copy from the user's numpy arrays. The detector returns unscored Patterns; the geometric scorer is applied centrally by run_detector.

Detector reference

Head and Shoulders (HeadShouldersDetector)

Direction: Bearish. Stable name: "head_and_shoulders".

A reversal pattern: a peak (head) flanked by two lower peaks (shoulders) of similar height, sitting above a neckline drawn through the two intervening lows. The breakout below the neckline projects a measured-move target equal to the head-to-neckline distance below the neckline.

Input (per detector call)

Argument Description
pivots: &[Pivot] Alternating swing highs and lows from multi_level_pivots
ohlcv: OhlcvView<'_> Borrowed view of the bars (used for prior-trend gating)

Validation rules

The detector slides a 5-pivot window over pivots. A window passes when every condition below holds:

# Rule Default
1 Sequence is H-L-H-L-H (kind alternation) always
2 Head price strictly above both shoulder prices always
3 pct_diff(left_shoulder, right_shoulder) <= shoulder_tolerance 0.10 (10%)
4 (head - avg_shoulder) / avg_shoulder >= min_head_prominence 0.03 (3%)
5 right_shoulder.index - left_shoulder.index >= MIN_FORMATION_BARS 8 bars
6 prior_trend_slope(closes, head_left.index, window=10) > 0 (uptrend before reversal) always

prior_trend_slope is a closed-form OLS slope over the window bars immediately preceding the formation start. It returns 0.0 when there are fewer than 3 prior bars or mean(prior_closes) == 0. Returning exactly 0.0 from this helper means "no signal — reject", not "flat is OK".

Output (per match)

Pattern {
    name: "head_and_shoulders",
    direction: Direction::Bearish,
    pivots: vec![h1, l1, h2, l2, h3],            // the 5 anchors
    trend_lines: vec![neckline, resistance],      // both empty if fit fails
    formation: (h1.index, h3.index),
    entry_price: Some(neckline.price_at(h3.index)),
    breakout_price: Some(neckline.price_at(h3.index)),
    variant: None,
}
  • Neckline = OLS fit through (l1, l2). Falls back to (l1.price + l2.price) / 2 if the fit is rejected (only happens when the two lows share an index — extremely rare with alternating pivots).
  • Resistance line = OLS fit through (h1, h3). Used only as metadata — does not feed into entry_price.
  • Entry / breakout price = the neckline value projected to the right-shoulder bar (h3.index). This is what charting platforms render and is what the events table uses as the breakout level.

Reference

crates/fundcloud-core/src/patterns/detectors/head_shoulders.rs, struct HeadShouldersDetector.

Inverse Head and Shoulders (InverseHeadShouldersDetector)

Direction: Bullish. Stable name: "inverse_head_and_shoulders".

The mirror of the regular variant — a trough (head) flanked by two higher troughs (shoulders), with a neckline drawn through the two intervening highs.

Validation rules

Identical structure to the regular detector, with everything mirrored (<= in place of >= and vice versa):

# Rule Default
1 Sequence is L-H-L-H-L always
2 Head price strictly below both shoulder prices always
3 pct_diff(left_shoulder, right_shoulder) <= shoulder_tolerance 0.10
4 (avg_shoulder - head) / avg_shoulder >= min_head_prominence 0.03
5 right_shoulder.index - left_shoulder.index >= MIN_FORMATION_BARS 8 bars
6 prior_trend_slope(closes, head_left.index, window=10) < 0 (downtrend before reversal) always

Output

Pattern {
    name: "inverse_head_and_shoulders",
    direction: Direction::Bullish,
    pivots: vec![l1, h1, l2, h2, l3],
    trend_lines: vec![neckline, support],         // neckline through h1,h2; support through l1,l3
    formation: (l1.index, l3.index),
    entry_price: Some(neckline.price_at(l3.index)),
    breakout_price: Some(neckline.price_at(l3.index)),
    variant: None,
}

Reference

crates/fundcloud-core/src/patterns/detectors/head_shoulders.rs, struct InverseHeadShouldersDetector.

Double Top / Double Bottom

Stable names: "double_top" (Bearish, H-L-H), "double_bottom" (Bullish, L-H-L).

Two peaks (or troughs) at approximately the same level separated by an intervening trough (peak). Each detection carries a Bulkowski variant in Pattern.variant:

  • STRICT when the second extreme does not breach the first (resistance / support held on both tests — the textbook case); WEAK otherwise.
  • Each pivot is then tagged ADAM (narrow 1–3 bar spike) or EVE (rounded reversal, ≥ 5 nearby bars) based on how many bars in a ±5 window sit within 1.5% of the pivot price.
  • Final variant strings: "STRICT_ADAM_ADAM""WEAK_EVE_EVE".
# Rule Default
1 Sequence is H-L-H (Double Top) or L-H-L (Double Bottom) always
2 pct_diff(p1, p3) <= peak_tolerance 0.015 (1.5%)
3 Trough depth / peak height ≥ min_prominence (avg-relative) 0.03 (3%)
4 p3.index - p1.index >= MIN_FORMATION_BARS 5 bars

Output: 3-pivot formation, neckline = p2.price (the trough for tops, the peak for bottoms), entry / breakout = neckline, optional resistance / support trend line through (p1, p3).

Reference: crates/fundcloud-core/src/patterns/detectors/double.rs.

Triple Top / Triple Bottom

Stable names: "triple_top" (Bearish, H-L-H-L-H), "triple_bottom" (Bullish, L-H-L-H-L).

Three peaks (or troughs) at approximately the same level. Distinguished from Head-and-Shoulders by all three extremes being roughly equal — H&S has a prominent middle peak (head). The breakout level is the worst intervening pivot (Bulkowski's confirmation rule):

  • Triple Top: neckline = min(p2, p4) — the lowest valley.
  • Triple Bottom: neckline = max(p2, p4) — the highest peak.

Using the average would trigger entries at a price the formation has already traded through, which isn't a confirmed breakout.

# Rule Default
1 Sequence alternates 5 pivots always
2 Each peak/trough within peak_tolerance of the trio's mean 0.02 (2%)
3 Pattern depth / height ≥ min_prominence of the mean 0.02 (2%)
4 p5.index - p1.index >= min_bar_count 10 bars

Reference: crates/fundcloud-core/src/patterns/detectors/triple.rs.

Ascending / Descending / Symmetrical Triangle

Stable names: "ascending_triangle" (Bullish, flat resistance + rising support), "descending_triangle" (Bearish, falling resistance + flat support), "symmetrical_triangle" (direction inferred from prior trend, falling resistance + rising support).

Triangles are the only family that runs validate_boundaries: every bar inside the formation must stay within the channel formed by the two trend lines. Asc / Desc use a fraction-of-channel-width tolerance (2%); Symmetric uses an absolute-price tolerance (5% of the starting gap) because the channel collapses to zero near the apex.

Asymmetric flat-line tolerance is applied for the asc / desc detectors: the full flat_threshold is allowed in the consistent direction, but only 70% of it is allowed in the wrong direction (a strongly-rising support contradicts the descending-triangle thesis, and vice versa).

# Rule Default
1 Flat leg normalised slope within asymmetric flat_threshold band (asc / desc) 0.0005
2 Sloping leg normalised slope strictly in the right direction (sym: |slope| > min_slope_threshold) 0.0005
3 Lines must converge (end gap < start gap, both positive) always
4 Every bar in formation stays within the channel under the chosen tolerance always
5 Formation length ≥ min_bar_count 8 (asc / desc), 10 (sym)

Direction labelling for symmetric triangles uses prior_trend_slope(closes, formation_start, prior_window=10): Bullish when slope > 0, Bearish when slope < 0, Bullish as a fallback when slope == 0 (insufficient history or flat data — preserves the reference Python's behaviour).

Overlapping detections are deduplicated, keeping the one with more pivots; an "overlap" is > 50% of the shorter formation's length.

Reference: crates/fundcloud-core/src/patterns/detectors/triangles.rs.

PyO3 bindings

The Rust core is exposed under fundcloud._core (a single flat module following the existing convention; no nested submodule).

_core.scan_pattern

_core.scan_pattern(
    name: str,                # e.g. "head_and_shoulders"
    ts_ns: np.ndarray[int64], # UTC nanoseconds, monotonic ascending
    open:   np.ndarray[float64],
    high:   np.ndarray[float64],
    low:    np.ndarray[float64],
    close:  np.ndarray[float64],
    volume: np.ndarray[float64],
    pivot_orders: list[int],
    min_quality: float,
) -> list[dict]

Returns a list of detection dicts (see schema below). All numpy arrays must have identical length. The GIL is released around the actual scan via py.allow_threads, so a thread pool over multiple (pattern × asset) pairs scales linearly.

_core.multi_level_pivots

_core.multi_level_pivots(
    highs:  np.ndarray[float64],
    lows:   np.ndarray[float64],
    ts_ns:  np.ndarray[int64],
    orders: list[int],
) -> list[dict]

Exposed for testing and advanced use; scan_pattern calls this internally.

_core.list_pattern_names

_core.list_pattern_names() -> list[str]

Returns the registered detector names — all 9 v1 detectors: ["head_and_shoulders", "inverse_head_and_shoulders", "double_top", "double_bottom", "triple_top", "triple_bottom", "ascending_triangle", "descending_triangle", "symmetrical_triangle"].

Detection dict schema (PyO3 output)

Key Type Description
name str Stable lowercase pattern identifier
direction str "bullish" / "bearish" / "neutral"
pivots list[dict] {index, ts_ns, price, kind, order} per pivot
trend_lines list[dict] {start_index, end_index, slope, intercept, r_squared, touch_count, role}role is "upper" or "lower", set by the detector and used by the scorer to pick which side of the line to evaluate.
formation_start int Bar offset of the formation start
formation_end int Bar offset of the formation end
entry_price float or None Where the strategy is "entered" — usually the breakout level
breakout_price float or None Same as entry for v1
variant str or None Pattern-specific subclass label (e.g., "STRICT_ADAM_ADAM")
quality float 0..=100 from GeometricScorer
features dict[str, float] Sub-scores: symmetry, volume, trendline_r2, completeness (each 0..=1)

Python feature layer

PatternIndicator(IndicatorSpec)

The base class every concrete pattern indicator subclasses. It plugs into the same IndicatorSpec machinery as TA-Lib indicators, so it composes naturally with FeaturePipeline, FeatureStore, and PurgedKFold.

Class-level attributes

Attribute Type Description
inputs tuple[str, ...] ("open", "high", "low", "close", "volume") — required Bars fields
outputs tuple[str, ...] ("signal",) — single per-bar float column
pattern_name str Stable lowercase Rust detector key (matches Pattern enum value)
condition PatternCondition Default entry/exit preset; per-instance overridable
default_params dict {"min_quality": 50.0, "pivot_orders": (3, 5, 8), "signal_mode": SignalMode.BREAKOUT, "decay_bars": 5}

Public methods

indicator.fit_transform(bars) -> pd.DataFrame
    # Sklearn standard. Returns a wide signal panel:
    #   index   = bars.index
    #   columns = one per asset
    #   dtype   = float64 (1.0 on breakout, 0.0 otherwise; varies by signal_mode)

indicator.events(bars) -> pd.DataFrame
    # Rich event log with the canonical 14-column schema; one row per
    # detected pattern across all assets.

indicator.effective_condition  # property
    # Returns the active PatternCondition (per-instance override or class preset).

Required input frame shape

bars must be a pd.DataFrame with:

  • MultiIndex columns (field, asset) where field ∈ {"open", "high", "low", "close", "volume"}
  • pd.DatetimeIndex — naive timestamps are treated as UTC; tz-aware timestamps are converted to UTC before being passed to Rust
  • Sorted ascending by index
  • All five OHLCV fields present for every asset (KeyError raised otherwise)

The shape (T, 5 × n_assets) for T bars and n_assets assets.

Output: per-bar signal panel

signals = indicator.fit_transform(bars)
Type
index pd.DatetimeIndex matching bars.index
columns one per asset (single-output indicator → no __asset suffix)
dtype float64

Cell value semantics depend on signal_mode:

SignalMode Cell value
BREAKOUT (default) 1.0 on each breakout_ts bar; 0.0 elsewhere
FORMATION 1.0 from formation_start to formation_end inclusive; 0.0 outside
DECAY 1.0 on the breakout bar, decaying linearly to 0.0 over decay_bars

This shape is exactly what Simulator.run_signals(entries, exits) consumes, so backtesting is a direct plug-in.

Output: events table

events = indicator.events(bars)

The canonical schema is identical for every detector (so user code generalises cleanly across patterns):

Column Type Description
pattern Pattern (enum) The pattern identifier
asset str Column name from bars
direction Direction (enum) BULLISH / BEARISH / NEUTRAL
formation_start pd.Timestamp UTC, first pivot of the formation
formation_end pd.Timestamp UTC, last pivot of the formation
breakout_ts pd.Timestamp or pd.NaT When the breakout was confirmed (v1: equals formation_end)
entry_price float Where the strategy enters (v1: neckline @ right edge)
breakout_price float or NaN Same as entry_price in v1
target_price float or NaN Filled by apply_condition (v1: NaN until Phase 7)
stop_price float or NaN Filled by apply_condition (v1: NaN until Phase 7)
quality float 0–100 from GeometricScorer
variant str or None Pattern-specific label (e.g., for double tops)
pivots list[dict] [{ts, price, kind}] per anchor pivot — useful for chart overlays
meta dict Pattern-specific extras: features (the sub-scores) and trend_lines

The schema is exposed as EVENTS_COLUMNS from fundcloud.features.patterns for assertions.

Configuration knobs

Pass at construction or override with set_params:

HeadAndShoulders(
    min_quality=50.0,        # quality cutoff (0–100); detections below are dropped
    pivot_orders=(3, 5, 8),  # see "pivot_orders" section above
    signal_mode=SignalMode.BREAKOUT,  # how events project to per-bar signals
    decay_bars=5,            # window for SignalMode.DECAY
    condition=...,           # PatternCondition override (see below)
)

PatternCondition

Frozen dataclass describing the entry / exit rules a strategy applies to detected events. Mirrors the convention used by fundcloud.strategies.scheduler.Cadence — one source of truth, with override(...) returning a new instance.

@dataclass(frozen=True, slots=True)
class PatternCondition:
    entry_rule:    EntryRule    = EntryRule.ON_BREAKOUT
    exit_rule:     ExitRule     = ExitRule.TARGET_OR_STOP
    target_method: TargetMethod = TargetMethod.MEASURED_MOVE
    stop_method:   StopMethod   = StopMethod.BELOW_PIVOT
    time_stop_bars: int | None  = None
    atr_window:     int         = 14
    atr_multiple:   float       = 2.0

    def override(self, **kwargs) -> PatternCondition: ...

Each detector class ships a sensible preset on the class (HeadAndShoulders.condition); per-instance overrides go via the condition= constructor argument. override accepts both Enum values and their .value strings, so:

HeadAndShoulders(
    condition=PatternCondition().override(
        entry_rule="on_pullback",   # str or EntryRule.ON_PULLBACK both work
        time_stop_bars=20,
        target_method=TargetMethod.FIB_1_618,
    )
)

Enums

All in fundcloud.features.patterns:

class Pattern(str, Enum):
    HEAD_AND_SHOULDERS         = "head_and_shoulders"
    INVERSE_HEAD_AND_SHOULDERS = "inverse_head_and_shoulders"
    DOUBLE_TOP                 = "double_top"
    DOUBLE_BOTTOM              = "double_bottom"
    TRIPLE_TOP                 = "triple_top"
    TRIPLE_BOTTOM              = "triple_bottom"
    ASCENDING_TRIANGLE         = "ascending_triangle"
    DESCENDING_TRIANGLE        = "descending_triangle"
    SYMMETRICAL_TRIANGLE       = "symmetrical_triangle"

class Direction(str, Enum):
    BULLISH, BEARISH, NEUTRAL

class SignalMode(str, Enum):
    BREAKOUT, FORMATION, DECAY

class EntryRule(str, Enum):
    ON_BREAKOUT, ON_FORMATION_COMPLETE, ON_PULLBACK

class ExitRule(str, Enum):
    TARGET_OR_STOP, TIME_STOP, TRAILING_STOP

class TargetMethod(str, Enum):
    MEASURED_MOVE, FIB_1_618, FIXED_ATR

class StopMethod(str, Enum):
    BELOW_PIVOT, ATR_MULTIPLE, FIXED_PCT

All public APIs accept EnumType | str. Use the .coerce(value, EnumType) helper to coerce at the boundary; it raises ValueError with a list of valid values on miss.

Composition examples

With FeaturePipeline

from fundcloud.features import FeaturePipeline
from fundcloud.features.indicators import RSI
from fundcloud.features.patterns import HeadAndShoulders

pipe = FeaturePipeline([
    ("rsi", RSI(timeperiod=14)),
    ("hns", HeadAndShoulders(min_quality=70)),
])
panel = pipe.fit_transform(bars)

Backtest with Simulator.run_signals

import fundcloud  # registers .fc accessor
from fundcloud.features.patterns import InverseHeadAndShoulders

bars = ...  # MultiIndex Bars frame
entries = InverseHeadAndShoulders().fit_transform(bars).astype(bool)
exits   = entries.shift(20).fillna(False).astype(bool)  # 20-bar holding period
result  = bars.fc.run_signals(entries, exits, size=0.1)
print(result.summary())

Cross-validate pattern parameters with PurgedKFold

from sklearn.model_selection import GridSearchCV
from fundcloud.validate import PurgedKFold

search = GridSearchCV(
    HeadAndShoulders(),
    param_grid={"min_quality": [50, 60, 70, 80]},
    cv=PurgedKFold(n_splits=5, purge=20),
)

Performance characteristics

  • Per (pattern × asset) scan: O(T) for pivot detection + O(P × k) for the detector pass, where T is the bar count, P is the pivot count, and k is the detector's window size (5 for H&S).
  • Memory: zero-copy borrows of the user's numpy buffers; no intermediate float64 arrays allocated in Rust beyond the pivot list.
  • Concurrency: scan_pattern releases the GIL; concurrent ThreadPoolExecutor over multiple assets scales linearly. (The Python loop in PatternIndicator.transform runs assets serially in v1; a 2D batched scan is a future optimisation that does not change the public API.)

Empirically, scanning 11k bars × 9 tickers × 2 detectors completes in well under a second on commodity hardware.

Feature-quality metrics

The fundcloud.metrics.feature_quality submodule grades a pattern's predictive power on a given OHLCV universe. Not flat-reexported from fundcloud.metrics (avoids collision with metrics.win_rate); import as:

from fundcloud.metrics import feature_quality as fq

evaluate(events, bars, *, horizons, condition=None, trade_direction='natural', baseline=True)

Headline panel — one row per horizon. Columns:

Column Meaning
n_events Events with sufficient lookahead at this horizon
hit_rate Fraction of events where directional close-to-entry move > 0 at t+h
baseline_hit Asset-weighted unconditional P(close[t+h] − close[t] in direction > 0) — the random-entry yardstick
expectancy Mean realised R-multiple. R = signed move / stop distance
edge_ratio avg(MFE_atr) / avg(MAE_atr) — payoff asymmetry
mfe_atr Average maximum favourable excursion in ATR units (intraday max forward high minus entry, or mirror for bearish)
mae_atr Average maximum adverse excursion in ATR units
mae_p95_atr 95th-percentile MAE — the stop-sizing reference
ic Spearman ρ between event quality and signed forward return
icir Mean / std of yearly ICs — stability of the IC across periods
throwback Fraction of events that re-touch entry within 10 bars

condition (optional): if a PatternCondition is passed, the metric calls apply_condition first to fill target_price / stop_price per the condition's target / stop methods. R-multiples then use the real stop distance instead of the 1×ATR fallback.

trade_direction: "natural" (default) uses each event's emitted direction; "inverse" flips every event (test the fade-the-pattern hypothesis); "long" / "short" force a fixed side. The baseline is transformed in lockstep so the comparison stays honest.

Stratified diagnostics

Function View
quality_buckets(events, bars, *, horizon, n_buckets=5) Quintile events by quality; one row per bucket. Validates the geometric scorer — monotonic Q1→Q5 means the scorer earns its weight.
per_asset(events, bars, *, horizon) One row per asset — discovery tool for asset-specific deployment lists.
time_stability(events, bars, *, horizon, n_folds=5) Equal-event-count chronological folds — exposes regime sensitivity.

Scalar primitives

For ad-hoc use or composition into custom dashboards, the per-event scalars expose the same logic without going through the bundle:

hit_rate, expectancy, edge_ratio, avg_mfe_atr, avg_mae_atr, mae_p95_atr, throwback_rate, information_coefficient, icir.

All take a list of _EventPath objects (the internal aligned-path representation) so they can be combined cheaply across stratifications.

apply_condition and PatternStrategy

fundcloud.features.patterns.apply_condition(events, condition, bars) returns a copy of the events table with target_price and stop_price filled per the supplied PatternCondition. Pattern height is derived from the events table's pivots:

  • Bullish: entry − min(low_pivot_prices)
  • Bearish: max(high_pivot_prices) − entry

fundcloud.strategies.PatternStrategy(indicator, *, condition=None, size=0.1, inverse=False) is the long-only backtest wrapper. init runs the indicator once and applies the condition; decide walks the per-bar context, opens trades on event timestamps, and closes them on intraday target hit, intraday stop hit, or time_stop_bars. Bearish events are skipped unless inverse=True, which flips every event's direction so the strategy long-trades the inverse hypothesis.

bars.fc.run_pattern(pattern, *, condition, size, inverse, **params) is the one-liner accessor that wraps the indicator + strategy + simulator into a SimResult.

Limitations and future work

  • breakout_ts fires at formation_end, not at a confirmed close-through-neckline breakout. This inflates throwback_rate systematically (the "breakout" bar is right next to the neckline by construction) and makes some events fire one or two bars before the textbook entry. A future phase will add price-action breakout confirmation as an opt-in mode.
  • No regime-aware scoring — pattern-service's ReliabilityScorer blends GeometricScorer with empirical hit-rate per (direction × timeframe × regime × asset_bucket). Out of scope for v1.
  • No ML scorer — pattern-service ships an XGBoost MLScorer predicting realised R-multiple at a 20-bar horizon. Out of scope for v1.
  • pivot.order is recorded but unused — see the discussion under "Pivot detection". Future detectors or quality scorers may consume it.
  • No streaming / tick-by-tick path — every scan is a batch over the whole bar range. The PyO3 layer has the seams to add a streaming wrapper later without breaking the API.
  • PatternStrategy is long-only — bearish events are skipped unless inverse=True (which flips them to long). Native short-side trading is a follow-up (depends on simulator support for naked shorts).

File reference

Concern File
Rust types crates/fundcloud-core/src/patterns/types.rs
Pivot detection crates/fundcloud-core/src/patterns/pivots.rs
Trend-line fit crates/fundcloud-core/src/patterns/trendline.rs
Geometric scorer crates/fundcloud-core/src/patterns/scoring.rs
Detector trait + scan entry point crates/fundcloud-core/src/patterns/detect.rs
H&S detector pair crates/fundcloud-core/src/patterns/detectors/head_shoulders.rs
Double Top / Bottom crates/fundcloud-core/src/patterns/detectors/double.rs
Triple Top / Bottom crates/fundcloud-core/src/patterns/detectors/triple.rs
Triangle trio crates/fundcloud-core/src/patterns/detectors/triangles.rs
PyO3 bindings crates/fundcloud-py/src/lib.rs (scan_pattern, multi_level_pivots, list_pattern_names)
Python PatternIndicator python/fundcloud/features/patterns/_base.py
Python condition descriptor python/fundcloud/features/patterns/_condition.py
Events frame + projection python/fundcloud/features/patterns/_events.py
Public enums python/fundcloud/features/patterns/_enums.py
Indicator subclasses python/fundcloud/features/patterns/{head_and_shoulders, inverse_head_and_shoulders, double_top, double_bottom, triple_top, triple_bottom, ascending_triangle, descending_triangle, symmetrical_triangle}.py
Synthetic walkthrough examples/31_head_and_shoulders_detection.py
Real-data scan examples/32_pattern_scan_real_data.py