Writing custom strategies

Author a custom Forven strategy in Python — the BaseStrategy contract, the Signal shape, the vectorized path, allowed imports and enrichment columns, trade modes, registration, and the safety guard.

A custom strategy is a small Python class that decides whether to enter or exit a position. Forven registers your class, runs it through the same execution kernel and robustness battery as every bundled strategy, and refuses to treat it differently. This page is for developers writing that class by hand: the BaseStrategy contract, the vectorized path that keeps it fast, what you may import, how it gets registered, and the safety guard that screens it.

There are easier routes if you don't want to write the file yourself. The Strategy Creator builds strategies from rules that read as sentences (Rules), gives you a code editor (Python), and can draft or change either from plain English (AI assist). An AI client connected over MCP can write and register a file through the AI Drop Zone. All of them produce the same kind of class described here.

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 — subclasses BaseStrategy from forven.strategies.base. The required surface is small:

  • Four properties: name, asset, strategy_type (return your TYPE_NAME), and default_params (a dict of your tunables and their defaults).
  • generate_signal(df) -> Signal — the per-bar decision. df is the OHLCV window up to the current bar; return a Signal.

The engine does the rest: loading data, merging enrichment columns, the 70/30 in-sample/out-of-sample split, costs, sizing, stops, and metrics all happen outside your class.

The Signal shape

Signal is a dataclass (not an enum), imported from forven.strategies.base:

FieldTypeMeaning
entry_signalboolOpen a position on this bar.
exit_signalboolClose the open position on this bar.
pricefloatThe reference price, usually the close.
direction"long" or "short"Which side an entry takes.
confidencefloat0 to 1.
indicatorsdictValues worth recording alongside the decision.
regime_tagstr or NoneOptional regime label.

"No trade" is simply Signal().

The vectorized path

Implement generate_signals(df) as well. Without it the engine calls generate_signal once per bar, recomputing every rolling indicator over the whole window each time. That is quadratic work, and a long intraday backtest will hit the timeout. The vectorized method computes indicators once and returns aligned boolean series — (entries, exits) — or a DirectionalSignals object when you trade both sides.

Two rules keep the two paths honest:

  • They must agree. On the last bar, generate_signals(df) must give the same answer as generate_signal(df).
  • Stay stateless. Don't track self.position or an entry price: the engine owns position state and only acts on an exit signal when a trade is open. Reading engine state that doesn't exist crashes on the first bar, and registration's smoke test rejects it.

Optional overrides

  • parameter_space() — what the optimizer may search, per parameter: a (min, max, step) range or a list of choices. Declare a small one: 2–3 parameters with 2–4 values each. The deflated Sharpe is charged every combination the optimizer tries, so a wide search raises the bar the strategy must clear. Without one, a strategy registered through the AI Drop Zone or by an agent (these run in the sandboxed worker) is searched over ×0.8, ×1.0 and ×1.2 of up to three numeric defaults — at most 27 combinations (Automatic search grid in Settings → Lab → Robustness Gauntlet (Step 2)); a custom strategy loaded in-process falls back to sweeping each numeric parameter about ±40% around its default. See parameter ranges and choices.
  • data_requirements() — a list of {asset, exchange, timeframe, min_bars} entries. The backtest pre-flight checks availability and fetches missing candles.
  • compatible_regimes — the market regimes the strategy is built for (see below).
  • supported_trade_modes — the sides your logic implements (see trade modes).
  • describe() — a plain-English description using the actual parameter values.

Parameters

Read tunables from self.params (or the self.p accessor). Two underscore-prefixed keys in default_params are binding declarations, not hints:

  • _timeframe (for example "4h") is the only timeframe the pipeline screens, sweeps and validates the strategy on. If your code declares none but the strategy is linked to an idea that names exactly one timeframe, that timeframe is written into the stored params and becomes the declaration. Otherwise the strategy starts at 1h and the quick screen tries several timeframes and keeps the best.
  • _asset (for example "ETH") pins the market. The validation matrix stays on it, and a backtest on another market never re-homes the strategy there. Without it, the pipeline may cross-test other markets and move the strategy to the best one before paper.

Declare both whenever the logic was designed for one market and bar size — a funding strategy built around 4h bars judged on 1h bars is a different strategy.

If your TYPE_NAME reuses a bundled family (such as rsi_momentum or stochastic), use that family's canonical parameter names: aliases are mapped for you (entry_oversold → k_oversold), and chart overlays work. A novel family name is equally welcome — its parameters are stored as they are.

Keep risk out of default_params. Sizing, stops, and take-profits are execution settings, not parameters; see execution settings live outside params.

Parameter ranges and choices

Each parameter_space() entry is one of:

FormExampleSearched values
(min, max, step)(20, 120, 10)20, 30, … 120
A list of choices[14, 21, 28, 50]exactly those
{"values": [...]}{"values": [168, 336, 720]}exactly those
{"range": [min, max, step]}{"range": [1.5, 3.0, 0.25]}1.5, 1.75, … 3.0

A stored tuple becomes a list, so a three-item list is ambiguous. Forven reads it as a range unless that reading would search fewer than two values, in which case it is a list of choices: [168, 336, 720] (the step is larger than the span), [-0.5, -1.0, -1.5] (min above max), and ["fast", "mid", "slow"] are all read as choices. Use the {"values": ...} or {"range": ...} form when you want no doubt.

What you can import

The import gate is an allowlist. A strategy may import:

  • Numeric and data libraries: pandas, numpy, pandas_ta, scipy, sklearn.
  • Pure-computation standard library: math, statistics, decimal, fractions, random, json, re, string, datetime, typing, dataclasses, abc, enum, collections, itertools, functools, warnings.
  • The strategy-facing Forven API: forven.strategies.base, forven.strategies.indicators (the shared indicator library behind the Strategy Creator), and forven.strategies.builtin / forven.strategies.composite (to compose existing strategies).

Everything else is rejected — including the filesystem, network, process, database, exchange, and credential modules. The ta package is banned outright; compute indicators with pandas and numpy or the indicator library.

Enrichment columns

Besides open, high, low, close, and volume, the frame carries crypto-native columns when that history has been collected — joined backward, with no look-ahead:

ColumnMeaning
funding_ratePerpetual funding; positive means longs pay shorts.
open_interestAggregated open interest.
taker_buy_sell_ratioAggressive taker buy volume ÷ sell volume; above 1 is net buying pressure.
ls_ratioLong/short account ratio; above 1 is crowded long.
long_liq_usd, short_liq_usd, liq_imbalanceLiquidations in USD, and their imbalance from −1 to 1.

The frame also carries basis and the implied-volatility columns iv_btc and iv_eth. Every enrichment column is always present: where the coin or window has no data for it, the column is empty (NaN), so your strategy simply doesn't fire on those bars instead of crashing. Check for real values (df["funding_rate"].notna()) rather than for the column, and treat 0.0 as "no data" for the two ratios — gaps in a collected ratio fill with zero, and a real ratio is never exactly zero. See data sources.

Declaring compatible regimes

Forven classifies every bar into one of four regimes — TREND_UP, TREND_DOWN, RANGE_BOUND, HIGH_VOL. A strategy that declares compatible_regimes (for example {"TREND_UP", "TREND_DOWN"}) has its entries blocked outside that set during a backtest, and is forced out if the regime turns incompatible mid-trade. That keeps a trend follower from being credited — or blamed — for trades it would never have taken in a range.

Regime classification needs warmup: windows shorter than about 210 bars default to RANGE_BOUND. Give the strategy enough history (min_bars in data_requirements()) to be judged on real regimes.

Trade modes

Every strategy defaults to long_only. To trade short or both sides, declare both of these:

supported_trade_modes = {"long_only", "short_only", "both"}   # the sides your logic implements

@property
def default_params(self) -> dict:
    return {"trade_mode": "both", ...}                          # the mode it runs by default

Hedged (both) trading needs explicit directional signals — return DirectionalSignals from generate_signals; a two-series (entries, exits) payload can't express it. Trade mode also drives the long/short by_side breakdown in results.

The shorthands long, short, and long_short are accepted and read as long_only, short_only, and both. An unrecognized trade_mode logs a warning and falls back to long_only.

Execution settings live outside params

This is a deliberate safety boundary. Stop-loss, take-profit, trailing stops, time stops, and sizing are execution settings. The backtest engine ignores them inside a strategy's params and honours them only when they are passed as execution settings — from the Manual Backtest form, the Strategy Creator, or the gauntlet, which can optimize them. Without that rule, a stop_loss_pct sitting in params could make a strategy look protected in a backtest while it wasn't. If you set a stop in params and see no effect, this is why. See backtesting.

Registering your strategy

A strategy module lives in forven/strategies/custom/ and exports, at module level:

  • TYPE_NAME — any descriptive snake_case name.
  • STRATEGY_CLASS — the class itself (not an instance).
  • optionally STRATEGIES — a list of (id, cls, params) tuples to pre-register variants.
# forven/strategies/custom/btc_zscore_fade_s00701.py
import numpy as np
import pandas as pd
from forven.strategies.base import BaseStrategy, Signal

TYPE_NAME = "zscore_fade"


class ZScoreFade(BaseStrategy):
    """Fade stretched moves back toward the mean."""

    @property
    def name(self) -> str:
        return f"Z-Score Fade ({self.asset})"

    @property
    def asset(self) -> str:
        return self.params.get("_asset", "BTC")

    @property
    def strategy_type(self) -> str:
        return TYPE_NAME

    @property
    def default_params(self) -> dict:
        return {"lookback": 50, "z_entry": 2.0, "z_exit": 0.5, "_asset": "BTC", "_timeframe": "1h"}

    def parameter_space(self) -> dict:
        return {"lookback": (20, 120, 10), "z_entry": (1.5, 3.0, 0.25)}

    def _zscore(self, close: pd.Series) -> pd.Series:
        n = int(self.params["lookback"])
        mean = close.rolling(n).mean()
        std = close.rolling(n).std().replace(0, np.nan)
        return (close - mean) / std

    def generate_signals(self, df: pd.DataFrame):
        z = self._zscore(df["close"])
        entries = z < -self.params["z_entry"]
        exits = z > -self.params["z_exit"]
        return entries.fillna(False).astype(bool), exits.fillna(False).astype(bool)

    def generate_signal(self, df: pd.DataFrame) -> Signal:
        if len(df) < int(self.params["lookback"]) + 1:
            return Signal()
        entries, exits = self.generate_signals(df)
        return Signal(
            entry_signal=bool(entries.iloc[-1]),
            exit_signal=bool(exits.iloc[-1]),
            price=float(df["close"].iloc[-1]),
        )


STRATEGY_CLASS = ZScoreFade

The example is illustrative — a structure to copy, not a strategy that works.

Then register it, in any of three ways:

  • the Python mode of the Strategy Creator;
  • the AI Drop Zone — POST /api/strategies/intake/register-file with the file's absolute path, or forven_register_strategy_file over MCP;
  • POST /api/strategies/intake/scan, which picks up new files in the custom folder.

Registration validates the class in an isolated worker: the contract, the parameters, a look-ahead probe that looks for signals reading future bars, and an execution smoke test on clean synthetic data. A strategy that passes lands at quick_screen; one that fails is archived as untestable, with an untestable: reason attached, so you can fix the file and register it again. That is not a verdict on the idea — only on whether it could be fairly tested. Editing the logic of a registered file doesn't need a re-registration — the next backtest uses it.

The quick screen then judges it. By default (the fair sample rule) it looks at the whole backtest window, in-sample and out-of-sample together, and widens the window — up to four years, data permitting — until it holds 20 trades. It wants a Sharpe above 0, a profit factor of at least 1.0, a non-negative return and a drawdown under 30%, and the out-of-sample slice must not lose badly (Sharpe above −0.5). A Sharpe of 5 or more, or a profit factor of 8 or more, is rejected as a likely look-ahead leak. A strategy that trades less than about five times a year can't be judged and is archived as untestable. These are the default floors; see configuration.

A strategy type with no registered class behind it is an orphan, and would silently produce zero signals. The backtest refuses to run an orphan and says so, rather than returning an empty result.

The safety guard

Strategy code is never trusted blindly. Before a custom module is imported, a static scan of its syntax tree rejects it if it:

  • imports anything outside the allowlist, including re-exports that smuggle a forbidden module back in;
  • calls eval, exec, compile, open, globals, vars, locals, input, or breakpoint;
  • reaches for the dunder internals used in sandbox escapes, or uses getattr tricks to the same end;
  • exceeds 100 KB or 1,500 lines.

A module that fails is quarantined for the life of the process with the reason logged, and nothing in it runs. Write your logic in plain pandas and numpy over the frame the engine hands you — you never need the filesystem, the network, or a subprocess, and asking for one stops the module from loading.

The guard is the first line, not the only one:

  • Imported strategies — code brought in from outside your install — always generate their signals in a separate, locked-down worker process: no database, no network, no secrets, capped memory and CPU. The class never runs in the main process at all.
  • Your own custom strategies run inside the backtest process by default, behind the guard. Set FORVEN_ISOLATED_STRATEGY_EXEC=1 to send them through the locked-down worker too — worth doing if you run code an AI wrote without reading it first.
  • Backtests run in spawned subprocesses with a timeout, so a hung strategy can't freeze the app.

Steps

  1. Write a module in forven/strategies/custom/ (or start from the Strategy Creator's Python mode) that exports TYPE_NAME and STRATEGY_CLASS.
  2. Implement generate_signal and a vectorized generate_signals that agrees with it.
  3. Declare _asset and _timeframe in default_params, add a small parameter_space() and compatible_regimes, and declare trade modes if you trade short.
  4. Import only from the allowlist.
  5. Register it. A quick_screen stage means it passed validation; an archived, untestable strategy comes with the reason.
  6. Backtest it on the Manual Backtest page with enough history, and read the out-of-sample block first (see metrics). Backtest windows end at the held-back data cutoff: the most recent months are reserved for the strategy's one held-back test, which by default runs when it applies for live.
  7. Let the pipeline take it from there: the gauntlet, then the promotion gates into paper and, later, live.

What you'll see

A registered strategy appears in The Forge with its stage. A backtest returns the in_sample and out_of_sample blocks, the per-trade ledger, and a chart with entry/exit markers and regime shadings. If the strategy is an orphan, fails the guard, or reads the future, you get an error that says so — never a misleading empty backtest.

Caveats

  • Timeouts scale with data. An isolated backtest gets 60 seconds plus 16 seconds per 1,000 bars, up to 10 minutes. A per-bar-only strategy on a long intraday window can still run out of time — that's what the vectorized path is for.
  • params are for tunables. Risk settings belong in execution settings.
  • Use canonical names for bundled families. Aliases are mapped; unknown keys are passed through to your code as they are.
  • Custom discovery is strict. A broken or unsafe module is quarantined, not half-loaded.