DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Weak-Signal Blacklisting: Two-Month Windows and a 70% Win-Rate Gate

The Problem with "Just Skip the Weak Ones"

Every signal generator eventually produces marginal setups. The breakout that barely clears the range, the mean-reversion entry where the z-score is 1.8 instead of 2.0, the momentum flip that fires on a thin bar. Traders eyeball these and skip them, but eyeballing doesn't scale and doesn't leave an audit trail. You need a rule: if a signal type can't clear a win-rate bar over a recent window, it goes on probation, and if it keeps failing, it gets blacklisted until it earns its way back.

This post describes a small, self-contained implementation of that policy: a two-month rolling window per signal type, a 70% win-rate gate, and a three-strike blacklist. The interesting parts are the bookkeeping details — how you key the statistics, how you persist them safely, and what exactly you're blacklisting.

Defining a Signal Type Key

The whole system collapses if your aggregation key is wrong. A "signal type" is not the instrument, not the timestamp, and not the direction. It's the rule identity: the thing whose historical win rate you're actually trying to estimate.

A good key is a stable, human-readable string composed of the components that define the rule:

from dataclasses import dataclass

@dataclass(frozen=True)
class SignalType:
    family: str        # e.g. "range_breakout"
    timeframe: str     # e.g. "1h"
    variant: str       # e.g. "atr_buffer_0.5"

    def key(self) -> str:
        return f"{self.family}|{self.timeframe}|{self.variant}"
Enter fullscreen mode Exit fullscreen mode

Three rules for the key:

  1. Include every parameter that can change the edge. If atr_buffer_0.5 and atr_buffer_1.0 behave differently, they're different signal types. Aggregating them hides a weak variant behind a strong one.
  2. Exclude the instrument. The same breakout rule applied to ten instruments is still one signal type — you're testing the rule's edge, not its luck on one symbol. (More on this distinction below.)
  3. Never derive the key from runtime state. No timestamps, no trade IDs, no randomly generated suffixes. If the key changes between runs, your history silently resets.

If you version rules, put the version in the key: range_breakout|1h|atr_buffer_0.5|v3. A rule change is a new signal type with no history — which is exactly the conservative behavior you want.

Rolling-Window Bookkeeping

You don't need a database for this. A JSON file per signal type, or one file with a dict keyed by signal type, is enough — as long as writes are atomic and the window is pruned on every update.

Each record stores the outcome of one closed paper trade and the timestamp of its close:

import json
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path

WINDOW_DAYS = 60          # ~two months
WIN_RATE_GATE = 0.70      # required to stay tradeable
MIN_SAMPLES = 10          # don't judge on 3 trades
MAX_CONSECUTIVE_FAILURES = 3

STORE_PATH = Path("signal_stats.json")

def _atomic_write(path: Path, data: dict) -> None:
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(data, f, indent=2, sort_keys=True)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)   # atomic on POSIX and Windows
    except BaseException:
        os.unlink(tmp)
        raise

def load_stats() -> dict:
    if not STORE_PATH.exists():
        return {}
    with STORE_PATH.open() as f:
        return json.load(f)

def save_stats(stats: dict) -> None:
    _atomic_write(STORE_PATH, stats)
Enter fullscreen mode Exit fullscreen mode

The os.replace call is the atomicity guarantee: a crash mid-write leaves either the old file or the new one, never a truncated JSON blob. On Windows, os.replace overwrites the destination, which os.rename does not — that's why it's used here.

Now the update path. Every time a paper trade closes, append it, prune anything older than the window, and recompute the gate:

def record_outcome(signal_key: str, won: bool, closed_at: datetime | None = None) -> dict:
    closed_at = closed_at or datetime.now(timezone.utc)
    stats = load_stats()
    entry = stats.setdefault(signal_key, {
        "trades": [],
        "consecutive_failures": 0,
        "blacklisted": False,
    })

    entry["trades"].append({"closed_at": closed_at.isoformat(), "won": won})

    cutoff = closed_at - timedelta(days=WINDOW_DAYS)
    entry["trades"] = [
        t for t in entry["trades"]
        if datetime.fromisoformat(t["closed_at"]) >= cutoff
    ]

    trades = entry["trades"]
    n = len(trades)
    wins = sum(t["won"] for t in trades)
    win_rate = wins / n if n else 0.0

    if n < MIN_SAMPLES:
        entry["consecutive_failures"] = 0
        entry["blacklisted"] = False
    elif win_rate >= WIN_RATE_GATE:
        entry["consecutive_failures"] = 0
        entry["blacklisted"] = False
    else:
        entry["consecutive_failures"] += 1
        if entry["consecutive_failures"] >= MAX_CONSECUTIVE_FAILURES:
            entry["blacklisted"] = True

    save_stats(stats)
    return entry
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices:

  • The failure counter increments per evaluation, not per trade. The gate is checked when a trade closes. Three consecutive evaluations below 70% (with enough samples) trigger the blacklist. This prevents a single bad stretch from flipping the switch instantly, while still reacting within a few closed trades.
  • MIN_SAMPLES prevents premature judgment. A signal type with four paper trades and three wins isn't a 75% winner; it's noise. Until the sample is large enough, the counter resets and nothing gets blacklisted.
  • Pruning happens on write, not on read. Reads stay cheap and consistent; the file never grows unbounded.

The gate check itself is trivial:

def is_tradeable(signal_key: str) -> bool:
    entry = load_stats().get(signal_key)
    if entry is None:
        return True  # no history: allow, but it's on probation by default
    return not entry["blacklisted"]
Enter fullscreen mode Exit fullscreen mode

Note the default: an unknown signal type is tradeable. That's fine for paper trading, where you want new signal types to accumulate history. If you're routing live capital, invert the default and require explicit qualification.

Blacklisting a Signal vs. Blacklisting an Instrument

These are different mechanisms and conflating them causes real damage.

Blacklisting a signal type says: "This rule, in its current form, doesn't clear the bar." The rule stops generating tradeable entries everywhere. The underlying instrument is untouched.

Blacklisting an instrument says: "This symbol is untradeable right now" — because of liquidity, spread, a corporate action, or a data quality problem. The signal rule is untouched; it just can't fire on that symbol.

Keep them in separate stores with separate lifecycles:

def is_tradeable(signal_key: str, instrument: str) -> bool:
    stats = load_stats()
    if stats.get(signal_key, {}).get("blacklisted", False):
        return False
    instrument_flags = load_instrument_flags()
    if instrument_flags.get(instrument, {}).get("blocked", False):
        return False
    return True
Enter fullscreen mode Exit fullscreen mode

Why the separation matters: if you blacklist the signal because it performed poorly on one illiquid instrument, you'll wrongly shut down a rule that works fine elsewhere. Conversely, if you blacklist the instrument because one rule struggles on it, you'll block every other rule that trades it profitably. The statistics only mean something if the key matches the scope of the claim. A win rate is a property of a rule; liquidity is a property of a symbol. Don't mix them.

Start Probation on Paper, Not Live Money

The three-strike counter is a blunt instrument. It fires after a handful of bad evaluations and can fire on a signal type that was genuinely profitable last quarter but hit a regime shift. You do not want that discovery to happen with real positions.

So the lifecycle is:

  1. New signal type → paper trading. It accumulates outcomes in the rolling window. No capital at risk while the sample is small.
  2. Paper history clears the gate → eligible for live routing. The gate decision uses the same window and the same 70% threshold.
  3. Live performance degrades → back to paper. When the live win rate over the window drops below the gate for three consecutive evaluations, the signal type is blacklisted and demoted. It re-qualifies only by rebuilding a paper record that clears the gate again.

The re-qualification step deserves a note. When a blacklisted signal type re-enters paper trading, you have a choice: keep its old trades in the window, or reset the history. Resetting is usually correct — the old trades are what got it blacklisted, and leaving them in the window means the win rate can't recover until they age out (up to two months of dead time). A clean reset gives the rule a fair fresh evaluation:

def requalify(signal_key: str) -> None:
    stats = load_stats()
    entry = stats.get(signal_key)
    if entry is None:
        return
    entry["trades"] = []
    entry["consecutive_failures"] = 0
    entry["blacklisted"] = False
    save_stats(stats)
Enter fullscreen mode Exit fullscreen mode

Call this only when you deliberately move a signal type back to paper. Don't automate it — a human should confirm that the rule was actually revised or that the market conditions that caused the failure have plausibly changed.

Operational Notes

  • Timezones. Store all timestamps in UTC and parse with datetime.fromisoformat. Mixing local and UTC timestamps in a rolling window produces subtly wrong prunes that are painful to debug.
  • Concurrency. If multiple processes write the same JSON file, os.replace prevents corruption but not lost updates. For anything beyond a single trading loop, wrap the read-modify-write in a file lock (msvcrt.locking on Windows, fcntl.flock on POSIX) or move to SQLite.
  • Audit trail. Keep a separate append-only log of every outcome and every blacklist transition. The JSON store is working state; the log is what lets you reconstruct why a signal type was shut off six weeks ago.
  • Window length. Sixty days is a starting point, not a law. Shorter windows react faster but blacklist on noise; longer windows are stable but slow to catch decay. The two-month default balances the two, and the gate threshold should be tuned against your own paper results, not copied blindly.

The design is intentionally boring: one key, one window, one threshold, three strikes. Boring bookkeeping is what makes the gate trustworthy — and a gate you don't trust is just a number in a config file.

More notes like this ship every week on this site.


Daily Picks

The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-11-02 08:46:42

Long

Pair Signal Price Take Profit Stop Loss R/R
AIN 高质量标的做多 $0.2872 $0.3061 $0.2777 1:2.0

1 picks selected. Scanner runs every 15 minutes.

Top comments (0)