DEV Community

Fxm Brand
Fxm Brand

Posted on

I Coded the Institutional Order-Flow Logic Behind Our Gold Strategy — Here's the Confluence Engine That Filters Signal From Noise

A trading strategy built on market structure (Smart Money Concepts) is really just a rule-based pattern classifier with a very specific, very deliberate rule: no single detected pattern is a signal on its own. This post is the actual confluence engine behind the Goldmine Strategy — the code that detects liquidity sweeps, structure shifts, order blocks, fair value gaps, and structure breaks, and refuses to call anything tradeable until enough of them agree. No execution, no bot, no broker calls — just the detection and scoring logic that decides whether a pattern is worth acting on at all.


Why this is a classification problem, not a prediction problem

Every "does gold strategy X actually work" argument on the internet conflates two different questions: can you detect a pattern, and is a detected pattern worth trading. The first is a solved, deterministic problem — swing points, candle geometry, and a handful of comparison operators get you most of the way there. The second is where almost every homegrown strategy actually falls apart, because it's tempting to treat "I found a CHoCH" as equivalent to "this is a good trade," and those are not the same claim.

The engine below is built around keeping those two questions separate: a detection layer that's purely mechanical and reproducible, and a scoring layer that decides whether a detected pattern has enough confluence behind it to matter.


The five detectors

Each of these is intentionally narrow — one job, one deterministic output, easy to unit test in isolation.

Liquidity sweep detector:

def detect_liquidity_sweep(candles, swing_level, lookback=20):
    """
    Flags when price pierces a prior swing level and closes back
    on the other side within the same or next candle — a sweep,
    not a genuine breakout.
    """
    recent = candles[-lookback:]
    for i, candle in enumerate(recent[:-1]):
        pierced = (candle.high > swing_level.price if swing_level.type == 'high'
                   else candle.low < swing_level.price)
        if pierced:
            next_candle = recent[i + 1]
            reclaimed = (next_candle.close < swing_level.price if swing_level.type == 'high'
                         else next_candle.close > swing_level.price)
            if reclaimed:
                return LiquiditySweep(level=swing_level, sweep_candle=candle)
    return None
Enter fullscreen mode Exit fullscreen mode

Change of Character (CHoCH) detector:

def detect_choch(swings, prevailing_trend):
    last_swing = swings[-1]
    reference_point = get_last_opposing_structure_point(swings, prevailing_trend)

    if prevailing_trend == 'bearish' and last_swing.price > reference_point.price:
        return ChoCH('bullish', reference_point.price, last_swing.time)
    if prevailing_trend == 'bullish' and last_swing.price < reference_point.price:
        return ChoCH('bearish', reference_point.price, last_swing.time)
    return None
Enter fullscreen mode Exit fullscreen mode

Order block detector:

def detect_order_block(candles, choch, impulse_threshold=1.5):
    """
    The last opposing candle before the impulsive move that
    produced the CHoCH. impulse_threshold is measured in
    average true range multiples to qualify as "impulsive."
    """
    idx = index_at_time(candles, choch.time)
    atr = average_true_range(candles[max(0, idx - 14):idx])

    for i in range(idx, 0, -1):
        candle = candles[i]
        move_size = abs(candle.close - candle.open)
        is_opposing = (candle.close < candle.open if choch.direction == 'bullish'
                       else candle.close > candle.open)
        next_move = abs(candles[i + 1].close - candles[i + 1].open)

        if is_opposing and next_move > atr * impulse_threshold:
            return OrderBlock(candle=candle, direction=choch.direction)
    return None
Enter fullscreen mode Exit fullscreen mode

Fair value gap detector:

def detect_fvg(candles, idx):
    """
    Three-candle imbalance: candle[idx-1].high < candle[idx+1].low
    (bullish gap) or the inverse (bearish gap).
    """
    prev_c, next_c = candles[idx - 1], candles[idx + 1]
    if prev_c.high < next_c.low:
        return FairValueGap('bullish', prev_c.high, next_c.low)
    if prev_c.low > next_c.high:
        return FairValueGap('bearish', next_c.high, prev_c.low)
    return None
Enter fullscreen mode Exit fullscreen mode

Break of Structure (BOS) detector follows the same shape as CHoCH but confirms continuation of the new direction rather than the initial shift — omitted here for length, but structurally identical to the CHoCH detector with the reference point updated to the most recent structure point in the new trend direction.


The confluence engine — where the actual decision gets made

None of the five detectors above return a trade signal. They return evidence. The confluence engine's job is to require enough evidence, weighted appropriately, before anything downstream treats a pattern as tradeable:

class ConfluenceEngine:
    WEIGHTS = {
        'liquidity_sweep': 20,
        'choch': 20,
        'order_block_fresh': 15,   # unmitigated, not previously tested
        'fvg_present': 15,
        'bos_confirmed': 20,
        'htf_aligned': 10,
    }
    MIN_SCORE = 70

    def evaluate(self, evidence: dict) -> ConfluenceResult:
        score = sum(
            weight for factor, weight in self.WEIGHTS.items()
            if evidence.get(factor)
        )
        return ConfluenceResult(
            score=score,
            qualifies=score >= self.MIN_SCORE,
            evidence=evidence,
        )
Enter fullscreen mode Exit fullscreen mode

MIN_SCORE is the single most consequential constant in this entire engine, and it's tuned empirically rather than derived analytically — set it too low and you're trading every CHoCH with a sweep behind it regardless of context; set it too high and the engine goes silent for days waiting for a "perfect" setup that costs real opportunities in choppy-but-tradeable conditions. This is also exactly the threshold whose sensitivity you should stress-test with the walk-forward validation approach from strategy backtesting — it's a hyperparameter like any other, and treating it as a fixed constant chosen once is how strategies quietly overfit to whatever period they were tuned on.


Why rule-based confluence instead of an ML classifier

This comes up every time this architecture is discussed, so it's worth addressing directly: a gradient-boosted classifier trained on the same five features could plausibly outperform a fixed-weight sum on historical data. The reasons this engine stays rule-based:

Determinism and auditability. When a trade doesn't fire, you can point to exactly which factor was missing and why. An ML classifier's decision boundary is opaque in a way that makes debugging "why didn't this obvious-looking setup trigger" much harder — and in a system executing real trades, that auditability has practical value beyond just interpretability.

Overfitting risk on a relatively small, non-stationary feature space. Five engineered features and a market that changes regime is a recipe for a classifier that fits noise in the training window and generalizes poorly — the exact failure mode walk-forward validation exists to catch, and rule-based weights are considerably easier to validate for stability across regimes than a learned decision boundary.

This isn't a permanent architectural stance — a learned scoring layer sitting on top of the same five deterministic detectors is a reasonable direction to explore, provided it's validated with the same walk-forward discipline. The detectors themselves (the actual pattern definitions) would stay identical either way; only the weighting/scoring layer would change.


Testing structural detection like you'd test anything else

Because every detector is a pure function over candle data, they're straightforward to unit test with constructed fixtures rather than needing live market data:

def test_choch_detects_bullish_reversal():
    swings = build_swing_fixture([
        ('high', 2010), ('low', 1995), ('high', 2005),
        ('low', 1998), ('high', 2015),  # breaks above prior high of 2010
    ])
    result = detect_choch(swings, prevailing_trend='bearish')
    assert result is not None
    assert result.direction == 'bullish'

def test_order_block_requires_impulsive_followthrough():
    candles = build_candle_fixture(weak_followthrough=True)
    choch = ChoCH('bullish', 2010, candles[-1].time)
    result = detect_order_block(candles, choch, impulse_threshold=1.5)
    assert result is None  # follow-through didn't clear the ATR threshold
Enter fullscreen mode Exit fullscreen mode

This is also where a meaningful share of real bugs get caught before they ever reach a backtest: off-by-one errors in swing indexing, ATR window boundaries that include or exclude the wrong candle, and edge cases where a gap gets misclassified because two candles share an exact high/low.


Where this connects to the rest of the system

This engine is the detection-and-scoring core underneath both the Goldmine Strategy (as a discretionary framework traders apply manually) and the Goldmine indicator/bot (which plots and executes on identical logic). Nothing here talks to a broker or fires a webhook — that's deliberately a separate concern, covered in the execution-pipeline side of this project. The point of keeping this layer isolated is that the "is this pattern worth trading" question should be answerable and testable completely independently of "how do we act on it once it qualifies."

Full disclosure: this is the actual logic behind a product we build and sell. Posting it because I think the detection/scoring separation is a useful pattern for anyone building rule-based classification over noisy, adversarial time-series data — trading-specific or not.


FAQ

Why weight the factors instead of requiring all five unconditionally?
Requiring every factor unconditionally produces very few qualifying setups and misses valid trades where one weaker factor (say, no clean FVG) is offset by strong ones elsewhere (a clear sweep plus HTF alignment). Weighted scoring lets strong evidence compensate for a missing weaker factor, which better reflects how these patterns actually co-occur in practice.

How was MIN_SCORE = 70 chosen?
Empirically, through walk-forward testing across multiple threshold values rather than a single backtest — the value that generalized best out-of-sample, not the value that maximized in-sample results, which would risk overfitting the threshold itself.

Isn't "fresh" order block (unmitigated) hard to track over time?
It requires maintaining state on which zones have already been tested by price and marking them mitigated once touched — a straightforward bookkeeping problem, but one that's easy to get subtly wrong if mitigation criteria (a wick touch vs. a full close through the zone) aren't defined precisely up front.

Could this run on instruments other than gold?
The detectors themselves are instrument-agnostic — they operate on generic OHLC structure. The weights and MIN_SCORE threshold, however, are tuned specifically to gold's volatility and session behavior and would need separate validation before trusting them elsewhere.

Would you actually recommend the ML approach as a future direction?
Worth exploring with proper walk-forward discipline, but not worth adopting just because it's more sophisticated — the rule-based version's auditability has real value in a system where you need to explain exactly why a trade did or didn't fire, and that's a genuine trade-off against any potential accuracy gain.

You can get access to the bot - Grab The Goldmine Trading Bot


Discussion

If you've built a rule-based classifier and considered swapping in a learned model, what made you stay rule-based (or what made you switch)? Curious whether auditability wins out as often outside trading as it seems to here.

Top comments (0)