Filtering Strategies with Mangrove SIEVE

A full backtest measures one strategy carefully. SIEVE measures 99 strategies cheaply. Use it as a pre-filter when you have many candidate parameter combinations and only want to backtest the ones likely to produce real trades and a positive outcome. SIEVE is a learned classifier trained on millions of historical Mangrove sweep runs. It returns two probability distributions:
  • BinaryP(no_trades) vs P(trades). Filter out strategies that won’t fire at all.
  • 4-classlosing / no_trades / wash / winning. Rank what’s left by expected outcome.
Both heads are a screen, not a verdict — they cheaply order what’s worth backtesting; only a real backtest decides whether a strategy is good. This guide walks through using both heads to compress a large fixed candidate list down to the ~50 worth a real backtest, then handing those to a backtest.
This is the shortlist-screening use of SIEVE (you supply the candidates). It is distinct from a parameter sweep, where the Experiments engine generates its own candidates and applies the binary head as an inline pre-filter. Don’t feed a screened list into a sweep.

Prerequisites

  • An authentication token. See the Authentication guide.
  • A list of candidate strategies you want to filter. They can be hand-built or generated by varying parameters of a known template.
export TOKEN="your-jwt-token-here"
export BASE="https://api.mangrovedeveloper.ai"

Step 1 — Score one strategy to confirm the endpoint works

curl -X POST $BASE/api/v1/oracle/sieve/score \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "strategies": [
      {
        "asset": "BTC",
        "entry": [
          {"name": "macd_bullish_cross", "signal_type": "TRIGGER", "timeframe": "1h",
           "params": {"window_fast": 12, "window_slow": 26, "window_sign": 9}},
          {"name": "is_above_sma", "signal_type": "FILTER", "timeframe": "1h",
           "params": {"window": 50}}
        ],
        "exit": [
          {"name": "macd_bearish_cross", "signal_type": "TRIGGER", "timeframe": "1h",
           "params": {"window_fast": 12, "window_slow": 26, "window_sign": 9}}
        ],
        "execution_config": {"reward_factor": 2.0, "max_risk_per_trade": 0.01}
      }
    ]
  }'
Expected response (sample):
{
  "predictions": [{
    "binary":     {"p_no_trades": 0.082, "p_trades": 0.918},
    "four_class": {"losing": 0.110, "no_trades": 0.080, "wash": 0.314, "winning": 0.496}
  }],
  "count": 1,
  "model_version": "mangrove-sieve:0b9a2da0d827",
  "code_version":  "oracle:v0.12.0 ai:v3.9.5 kb:1.0.3 roots:v0.3.0"
}

Step 2 — Score a batch and filter on the binary head

Build a batch of up to 99 candidates and score them in one round-trip. The binary head is your “will this fire at all” filter — drop anything with p_no_trades > 0.5 and stop wasting backtest budget on dead strategies.
Python
candidates = build_candidates(...)  # your strategy generator, up to 99 items
assert len(candidates) <= 99

resp = requests.post(
    f"{BASE}/api/v1/oracle/sieve/score",
    headers=HEADERS,
    json={"strategies": candidates},
)
predictions = resp.json()["predictions"]

# Keep strategies the model thinks will actually trade
will_trade = [
    (s, p) for s, p in zip(candidates, predictions)
    if p["binary"]["p_no_trades"] < 0.5
]
print(f"Kept {len(will_trade)} / {len(candidates)} after binary filter")
If your batch is larger than 99, chunk it:
Python
def chunked(seq, n=99):
    for i in range(0, len(seq), n):
        yield seq[i:i + n]

scored = []
for batch in chunked(candidates):
    resp = requests.post(
        f"{BASE}/api/v1/oracle/sieve/score",
        headers=HEADERS,
        json={"strategies": batch},
    )
    scored.extend(zip(batch, resp.json()["predictions"]))

Step 3 — Rank survivors by P(winning) and backtest the top N

The 4-class head tells you which surviving strategies are most likely to be profitable. Sort descending on P(winning) and feed the top slice into the backtest endpoint.
Python
ranked = sorted(
    will_trade,
    key=lambda sp: sp[1]["four_class"]["winning"],
    reverse=True,
)

top_50 = [s for s, _ in ranked[:50]]
for strategy in top_50:
    backtest_response = requests.post(
        f"{BASE}/api/v1/backtesting/backtest",
        headers=HEADERS,
        json={
            **strategy_to_backtest_body(strategy),  # your shape adapter
        },
    )
    # ... store metrics, compare against benchmarks, etc.

Step 4 — Save the provenance with every score

Every SIEVE response carries model_version and code_version. Log them next to any decision you make based on the score, so when the model is retrained you can tell which predictions came from which snapshot.
Python
import datetime

for (strategy, pred), score in zip(will_trade, scores):
    log_entry = {
        "strategy_name": strategy.get("name", "unnamed"),
        "asset": strategy["asset"],
        "p_winning": pred["four_class"]["winning"],
        "p_no_trades": pred["binary"]["p_no_trades"],
        "model_version": resp.json()["model_version"],
        "code_version": resp.json()["code_version"],
        "scored_at": datetime.datetime.utcnow().isoformat(),
    }
    log.append(log_entry)
If you cache SIEVE scores in your own system, key the cache on (input_hash, model_version) so a model retrain naturally invalidates the cache.

Tips

  • Same model, same answer. SIEVE is deterministic on (input, model_version). If you hit the endpoint twice with the same payload, you’ll get byte-identical probabilities back.
  • The binary head is the bigger lift. In practice it ships ~80% of the filtering value — it cheaply removes dead-on-arrival strategies whose parameter ranges make entries impossible.
  • Don’t trust the model alone. SIEVE is a fast pre-filter, not a backtest replacement. Always backtest the top survivors before making a real decision.
  • Watch the soft-failure modes. predictions[].binary.p_no_trades close to 1.0 usually means your TRIGGER thresholds are unreachable — adjust the parameter window rather than removing the strategy entirely.

See also