Writing custom strategies

Author a custom Forven strategy by implementing the BaseStrategy interface — generate_signal, metadata, optional overrides, regime compatibility, and the safety guard.

A custom strategy is a small Python class that decides, bar by bar, whether to enter or exit a position. Forven discovers your class, runs it through the same backtest engine and robustness battery as every bundled strategy, and refuses to treat it differently. This page is for developers writing that class: the BaseStrategy contract, the optional overrides that make a strategy faster and safer, how to declare regime compatibility, and the AST guard that screens uploaded modules.

If you only need to assemble entry and exit rules without code, use the visual rule builder in the Backtest Studio instead — see backtesting. This page is the code path.

Forven is a research tool. A strategy that backtests well describes past behaviour on historical data; the result is illustrative, not predictive, and nothing here is financial advice. Custom code is held to the same gauntlet as anything bundled — that discipline is the point.

The BaseStrategy contract

Every strategy — bundled or custom — inherits from BaseStrategy (in forven/strategies/base.py) and implements an abstract contract. The required surface is small:

  • generate_signal(...) — the per-bar decision. Given the current market state, return whether to be flat, long, or short. This is the one method you must implement.
  • metadata properties — declarative descriptions of the strategy: its type name, the parameters it accepts, and the regimes it is built for (covered below).

A minimal strategy implements generate_signal plus metadata and nothing else. The engine fills in the rest: data loading, the 70/30 in-sample / out-of-sample split, cost simulation, and metric computation all happen outside your class.

Optional overrides

Four optional methods let you make the strategy faster, self-describing, and easier to optimize. Implement them when they apply; skip them when they do not.

  • generate_signals(...) (the vectorized path) — generate every entry and exit at once over the whole frame instead of bar by bar. This is the high-performance path (_vectorized_signals()), turning an O(n²) per-bar loop into an O(n) sweep. Around 20 builtin types ship a vectorized path; it requires pre-computed indicators. If you do not provide one, the engine falls back to the per-bar generate_signal.
  • parameter_space() — declare the ranges the optimizer may sweep, as {param_name: (min, max, step)} or {param_name: [discrete_values]}. If you omit this, grid search falls back to a mechanical space of roughly ±40% around your defaults. Declaring it yourself gives the optimizer a sensible, intentional search.
  • data_requirements() — return a list of {asset, exchange, timeframe, min_bars} entries. The backtest pre-flight checks availability and auto-fetches missing candles from CCXT before running.
  • compatible_regimes — declare which market regimes the strategy is meant to trade in (see the next section).

Reading parameters with ParamAccessor

Strategies read their tunable values through a parameter accessor rather than reaching into a raw dict. This gives every parameter a single, canonical name. Forven canonicalizes aliases before signals are generated — for example entry_oversold is normalized to k_oversold — so your generate_signal always sees the canonical schema. Conform to the canonical parameter names; values supplied under a known alias are mapped for you, but unknown keys are not invented.

Declaring compatible regimes

Forven classifies every bar into one of four regimesTREND_UP, TREND_DOWN, RANGE_BOUND, HIGH_VOL — using ADX, EMA alignment, ATR ratio, and RSI. A strategy declares the regimes it belongs in via the compatible_regimes property, for example [TREND_UP, RANGE_BOUND].

When a backtest runs with the regime gate enabled, the engine pre-computes the regime for every bar, blocks entries in regimes your strategy did not declare, and forces an exit if the regime changes to an incompatible one mid-trade. This keeps a trend-following strategy from being credited (or blamed) for trades it would never have taken in a range.

Regime classification needs warmup. The gate pre-computes per-bar regimes at a 210+ bar warmup; windows shorter than 210 bars default to RANGE_BOUND. Declare data_requirements() with enough min_bars so your strategy is judged on real regimes, not the short-window default.

Trade mode and signal shape

A strategy runs in one of three trade modes, which affects how its results are sliced (the by_side breakdown) and how regime gating applies:

  • long_only (the default)
  • short_only
  • both (hedged)

If you target both, the engine needs an explicit directional payload — DirectionalSignals or a four-series payload from your strategy. The legacy two-series shape (entries, exits) is not enough to express a hedged book and will be rejected. Decide your trade mode up front and emit signals that match it.

Execution controls live outside params

This is a deliberate safety boundary, so read it carefully. Risk knobs — stop_loss_pct, take_profit_pct, trailing_stop_pct, time_stop_bars, risk_per_trade, max_concurrent_positions, daily-loss caps, and the rest — are not read from your strategy's params dict during a backtest. They are applied only when passed explicitly through the execution_controls argument.

The reasoning (an internal safety rule the source calls "B-4"): a stop_loss_pct sitting in params would be silently ignored by the backtest engine, so a strategy could appear protected while trading unprotected. To prevent that quiet bypass, the engine ignores those fields in params and honours them only from execution_controls. The same params value still flows through to paper and live, where it carries percentage semantics — so do not rely on params for backtest-time risk. If you want a stop enforced in your backtest, pass it via execution_controls.

How Forven discovers your strategy

You do not register a strategy by hand. The registry (forven/strategies/registry.py) auto-discovers strategy classes at startup by walking forven/strategies/builtin/ and forven/strategies/custom/.

Each module declares, at module scope:

  • TYPE_NAME — the string type name (for example rsi_momentum).
  • STRATEGY_CLASS — your class, extending BaseStrategy.
  • optionally STRATEGIES — a list of (id, cls, params) tuples to register several variants from one module.

On discovery, register_type(TYPE_NAME, STRATEGY_CLASS) validates that the abstract contract is satisfied and caches the result in the global _TYPE_MAP. Discovery is idempotent (gated by a _discovered flag), so it is safe to trigger more than once; it is called lazily on the first backtest or scan.

# forven/strategies/custom/my_breakout.py
from forven.strategies.base import BaseStrategy

TYPE_NAME = "my_breakout"

class MyBreakout(BaseStrategy):
    compatible_regimes = ["TREND_UP", "TREND_DOWN"]

    def generate_signal(self, ctx):
        # return a flat / long / short decision for the current bar
        ...

    def parameter_space(self):
        return {"lookback": (10, 60, 5), "atr_mult": (1.0, 3.0, 0.5)}

    def data_requirements(self):
        return [{"asset": "BTC", "exchange": "hyperliquid",
                 "timeframe": "1h", "min_bars": 500}]

STRATEGY_CLASS = MyBreakout

A strategy type with no registered runtime class and no known parameter family is an orphan. It would silently produce zero signals. The backtest refuses to run on an orphan and logs the error — register the class (or archive the strategy) rather than letting it return an empty result.

The safety guard for uploaded modules

Custom modules are not trusted blindly. Before a custom module is imported, assert_custom_module_safe() runs a static AST scan and fails fast if the code:

  • imports a forbidden module — os, subprocess, socket, and similar, or
  • reaches for dynamic execution — exec, eval, or
  • accesses dunder internals (__-prefixed) to escape the sandbox.

A module that trips the guard is quarantined — once per process, to avoid flooding the logs on repeated discovery. Builtin modules are loaded with a tolerant fallback (a single bad builtin logs a warning and discovery continues), but custom modules must pass the guard before they run at all. Write your signal logic in pure Python over the data the engine hands you; you do not need filesystem, network, or process access, and asking for it will stop your module from loading.

Steps

To author, register, and validate a custom strategy:

  1. Create a module under forven/strategies/custom/ (for example my_breakout.py) that defines TYPE_NAME, a BaseStrategy subclass, and STRATEGY_CLASS.
  2. Implement generate_signal. Add parameter_space(), data_requirements(), and a vectorized generate_signals() if they apply.
  3. Set compatible_regimes so the regime gate judges the strategy in the right markets.
  4. Keep the module clean of forbidden imports and dynamic execution so it passes the AST guard.
  5. Open the Backtest Studio at /backtest/new, choose the custom code source, and run a backtest on a symbol and timeframe with enough history. The registry discovers your class on the first run.
  6. Read the out-of-sample block first (see metrics), then optimize parameters and validate robustness in the strategy lab.
  7. When a survivor clears the promotion gates, it advances through the pipeline toward quick_screen, the gauntlet, and paper.

What you'll see

When you submit a custom strategy in the Backtest Studio (/backtest/new), the engine loads candles, runs your signals (vectorized where you provided a path, otherwise per bar), and returns three blocks — in_sample, out_of_sample, and robustness — alongside the per-trade ledger and a chart with entry/exit markers and regime shadings. If your strategy is an orphan or trips the AST guard, you will instead see an error explaining why it would not run, rather than a misleading empty backtest.

Caveats

  • Backtests run in spawned subprocesses by default (FORVEN_BACKTEST_PROCESS_ISOLATION), so a hung or crashing strategy cannot freeze the research daemon. Long signal logic on large windows can still hit the isolation timeout — base 60s plus 8s per 1,000 bars, capped at 300s for a backtest.
  • params is for the strategy's own tunables. Risk controls belong in execution_controls; values left in params are ignored by the backtest engine (the B-4 boundary above).
  • Conform to canonical parameter names. Aliases are mapped before signals are generated, but unknown keys are not.
  • Builtin discovery is resilient; custom discovery is strict. A broken custom module is quarantined for the process rather than silently half-loaded.