Skip to content

Latest commit

 

History

History
775 lines (647 loc) · 39.5 KB

File metadata and controls

775 lines (647 loc) · 39.5 KB

Agent Guide: Building and Running freqtrade Strategies

This file is intended to orient an AI agent (or a new developer) on how to build, test, and run trading strategies in this codebase. It covers the directory layout, class hierarchy, common tasks, and the commands to run them.


Environment

  • Shell: zsh (macOS default). All scripts use zsh, not bash.
  • Python: A virtualenv at .venv. Activate with source .venv/bin/activate before running any Python or freqtrade commands. (Not conda.)
  • Working directory: All scripts in scripts/ and freqtrade commands must be run from the repo root -- the directory containing user_data/ (its location is machine-specific; this guide never assumes one) -- not from scripts/ or any subdir. The scripts use relative paths (strat_dir="user_data/strategies", config at user_data/strategies/config/…) and prepend . to PYTHONPATH so the in-tree freqtrade package resolves — both only work when the CWD is the repo root. Running from the wrong directory fails with a misleading ModuleNotFoundError: No module named 'freqtrade' + config file not found (see Troubleshooting).
  • PYTHONPATH: The scripts set this automatically. If running manually, export:
    export PYTHONPATH="$PWD/user_data/strategies:$PYTHONPATH"   # from the repo root
    

Silent failure modes — read this first

Every item here fails with no error and a plausible-looking number. They have each cost real time in this repo.

A re-parameterised subclass trains a fresh model inside its own backtest. BaseNNStrategy.get_model_path keys off self.__class__.__name__, so class MyArm(NNNC_MLX) looks for saved_data/MyArm/, finds nothing, and trains one — fitting the evaluation window. The run succeeds and reports different numbers. If your arm changes only exit or entry parameters and should reuse the parent's weights, pin it:

def get_model_path(self) -> str:
    return self.get_storage_location() + "NNNC_MLX/" + "NNNC_MLX.keras"

Verify by checking saved_data/<YourArm>/ is still empty after the backtest.

<Strategy>.json beats the class, and a missing json is not neutral. Params load as deep_merge_dicts(file_params, class_params) — the file wins, the class only fills gaps. So:

  • An arm subclassing NNNC_MLX without its own json falls back to NNNCStrategy's class params, which have every entry guard open. Completely different strategy, no warning.
  • The json's "strategy_name" must match the class name, or freqtrade aborts with Invalid parameter file provided. Copying a json between arms without updating it fails this way.
  • Give every arm its own json copied from the parent's, unless the parent has none (e.g. NNMT_MLX), in which case the arm should also have none — that is the matching config.

test_strat.sh exits 0 even when training crashed. The only reliable check is whether the weights exist:

ls user_data/strategies/saved_data/<CLS>/*.safetensors

freqtrade Parameter objects are class-level singletons on BaseStrategy. Loading two strategies in one process contaminates the second — reading NNNC then NNMT reported NNMT's take-profit as NNNC's. Read configuration one strategy per process. Values are only real after ft_load_hyper_params() (called inside ft_bot_start()); before that you see raw defaults.

self.stoploss and trailing_* do nothing for NN strategies. BaseNNStrategy sets use_atr_adaptive_stoploss = True, so --stoploss only changes the cosmetic initial_stop_loss_abs. The live levers are the class attributes atr_stoploss_multiplier / _floor / _cap (unreachable via --set, hence a subclass) and stoploss_grace_hours / stoploss_grace_level, which set the worst-case trade size.

Exit tags carry a TRAILING SPACE. BaseStrategy builds them with exit_tag += "model_exit " so several tags can concatenate, so the archive's exit_reason is "model_exit ", not "model_exit". A Counter(...).get("model_exit") therefore returns None and the natural reading is "the model exit never fires". It fires 11 times in 99 trades on NNNC. Always .strip() an exit_reason before comparing it.

In backtest archives, trailing_stop_loss means the ATR stop after it moved. trailing_stop is False framework-wide, so attributing those exits to native trailing mis-assigns a large share of trades. stop_loss is the initial/grace stop.


Evaluating a change

Do not judge a change from a bare test_strat.sh backtest on an ad-hoc timerange.

Pinned windows (regime/regimedata.py), training range 20220101-20250831:

window range status
W1 20250901-20260831 the sole acceptance window
W2 20240601-20250531 in-sample — report, never accept on
W3 20230101-20231231 in-sample, and low-information

The harness — regime/run_arm.py records one arm and refuses metrics from failed or stale runs. Run from the repo root:

PYTHONPATH=user_data/strategies .venv/bin/python user_data/strategies/regime/run_arm.py \
  --strategy NNNC_MLX --window W1 --tag baseline

Useful flags: --set / --set-sell (buy/sell params), --roi, --threshold, --guards, --timerange. Records land in regime/arms/<tag>_<window>.json.

regime/tail/compare.py <arm_tag> <control_tag> gives the verdict.

Rules that make a result admissible:

  • Confirm returncode == 0. A failed or stale record is not evidence.
  • Identical trades_hash to the control ⇒ INCONCLUSIVE, not refuted — the change never reached the trades.
  • Zero trades ⇒ inconclusive, never a zero return.
  • Judge on W1 realised return — but see the noise floor below. compare.py's POWER_THRESHOLD_PP = 1.0 is a rule of thumb that was never derived, and it is roughly 7.6x too lenient. Treat its verdict as a smoke test, never as evidence.
  • Never accept on a classifier metric. val_mcc and P&L move independently here — it has been measured repeatedly (higher MCC with identical trades; +0.155 MCC for no P&L change).
  • Report every window with its net/gross ratio, not just its trade count (W1 29.8%, W2 18.7%, W3 7.5% on NNMT — W3's four worst trades exceed its entire profit, so it cannot carry a sub-threshold conclusion).
  • Where a model is involved, use two seeds via MLX_SEED (train_seed is inert for multi-task). Note the gate is often tight enough that two seeds produce identical W1 trades — that is one measurement, not two.

The noise floor — measured, and it governs everything above

Bootstrapping the acceptance window's own per-trade P&L (NNNC W1, 99 trades, +9.831%):

95% CI on the W1 return statistic : [2.383%, 17.649%]
half-width (the noise floor)      : 7.633pp     (6.1pp when arms are paired)
sampling SD                       : 3.893pp
compare.py's threshold            : 1.000pp     <- ~7.6x too lenient

A whole parameter sweep fits inside that band (the rvol sweep spans 4.93pp), so the "best" rung of a sweep is a ranking of noise. Confirmed independently: full sweeps of two parameters across all three windows disagreed on the optimum every time, with contradictory rank correlations between windows (rvol W1W2 -0.46, grace W1W2 +0.71), none significant. The optima are not trackable.

Consequences for anything you are about to do:

  • Do not tune parameters on ~100-trade windows. Not on W1, not on W2, not on their average. Leave entry_rvol_threshold, the ATR band, stoploss_grace_level and friends at their incumbent values; they have each been swept and none is resolvable. See the memory notes project-atr-band-sweep-no-lever, project-grace-level-sweep, project-rvol-sweep-and-w1-regime-warning.
  • Before believing any delta, bootstrap it. Pull the arm's and control's trades from the backtest archive (user_data/backtest_results/*.zip, strategy.<Name>.trades[].profit_abs), pair on (pair, open_date), and bootstrap the sum over the symmetric difference. Worked example: a +1.86pp delta that compare.py called IMPROVED has a 95% CI of [-4.29, +7.93]pp, p=0.54.
  • Only pursue effects large enough to see. Structural changes (a mechanism fix, a label redefinition, an execution-model correction) can clear 6pp. Parameter tweaks cannot, and are economically marginal even when real.
  • A W1/W2 disagreement is not evidence of regime dependence until you have shown the effect is resolvable. Two noise draws disagreeing looks identical and is the simpler explanation.

THE HARNESS NOW EXISTS — feature 007, delivered 2026-09-09. regime/wf/ runs pooled walk-forward evaluation: 15 anchored folds, every test fold genuinely out-of-sample, trades pooled before any statistic. Measured result — 378 pooled trades and a floor of 3.350 pp/yr against the single-window 7.633, a 2.28x improvement. Usage guide: regime/wf/README.md. Power write-up: regime/WALKFORWARD_POWER.md.

PYTHONPATH=user_data/strategies .venv/bin/python user_data/strategies/regime/wf/foldplan.py \
  --family NNNC --base-strategy NNNC_MLX --out regime/wf/folds/plan_nnnc.json
... regime/wf/run_folds.py --plan <plan> --seed 1        # ~113 min, sequential
... regime/wf/pool.py      --plan <plan> --out <pooled>
... regime/wf/power.py     --pooled <pooled>
... regime/wf/compare_pooled.py --arm <pooled_arm> --control <pooled_control>

The decision rule this gives you:

  • Effects of 3pp/yr or larger can now be resolved. Use the pooled harness for them.
  • Effects of 1-2pp remain unresolvable and always will be on 5.68 yr of data at ~100 trades/yr. Nothing that returned "not resolved" in the 2026-09-08 sweeps would resolve now. The harness is NOT licence to resume parameter tuning.
  • A single-window comparison is still valid for a LARGE effect, but quote the 7.633pp floor with it.
  • Annualise before comparing floors. A pooled CI spans ~3.7 years; the baseline spans one. The first version of the power report compared them directly and declared a working instrument a 0.62x regression.

Two traps the harness had to solve, which apply to any historical backtest here:

  • A pair that lists mid-history silently prevents training. TrainingEngine.maybe_train fires only when pair_count == len(whitelist), so a window predating any whitelist listing trains NOTHING and then fails at predict. SUI/USDT lists 2023-10-25; config/config_wf.json excludes it.
  • run_arm.py has no --timerange — --window is required, with choices from regimedata.WINDOWS. Do NOT add keys to that dict; phase_c_trackability.py iterates it. Inject the window into the in-memory dict in a per-fold subprocess instead.

Operational rules

  • Run everything sequentially. Two concurrent jobs put load 60 on 14 cores and finished slower than serialising, and concurrent runs can cross-contaminate the arm archive.
  • Never pkill -9 -f freqtrade — that pattern matches a live freqtrade trade dry run. Use pkill -9 -f 'freqtrade backtesting', or kill by PID after checking ps. TaskStop does not kill process trees.
  • Only the nested user_data/strategies repo is ours; the outer freqtrade checkout is upstream. Commit directly to master there.
  • Never git add -A / git add . — files are edited concurrently outside the agent session. Stage only the files your change touched, and check git status first.

Directory Map

user_data/strategies/
├── Framework/           ← Universal base classes + mixins
│   ├── BaseStrategy.py  ← Root base class for ALL strategies (ROI, stoploss,
│   │                      bot_start lifecycle, custom_exit, guards)
│   ├── BaseNNStrategy.py← NN base (inherits the mixins below + BaseStrategy):
│   │                      lifecycle hooks, label generation, prediction,
│   │                      entry/exit wiring
│   ├── StrategyDiagnostics.py ← mixin: assessment/probability/correlation
│   │                      printing (pure diagnostics; mixed into BaseStrategy)
│   ├── FeatureNormalizer.py   ← mixin: feature lists (include_list /
│   │                      pre_normalized_columns), scaler + PCA state/methods
│   ├── TrainingEngine.py      ← mixin: training pipeline — prepare/class-weights/
│   │                      train_model, markov, GAN augmentation, maybe_train
│   ├── TrainingSignals.py  ← Future-aware label generation
│   └── CreateScalers.py ← Run once to generate normalization scalers
├── utils/               ← Shared utility code
│   ├── DataframeUtils.py
│   ├── DataframePopulator.py     ← Adds all technical indicators to a dataframe
│   ├── ClassifierKeras*.py        ← Keras classifier implementations
│   ├── ClassifierMLX*.py          ← Apple MLX classifier implementations
│   ├── ClassifierMLXMultiTask.py  ← MLX multi-task base (focal loss + grad clipping)
│   ├── ClassifierSklearn.py       ← sklearn classifier implementations
│   ├── Wavelets.py
│   ├── Forecasters.py
│   └── ...
├── NNNC/                ← N-ary (trinary) Classification strategies + NNNClassifier
├── NNMT/                ← Multi-Task strategies + NNMTClassifier (TF)
│                          + NNMTClassifierMLX (Apple Silicon)
├── NNPredict/           ← Regression strategies (continuous future_gain
│                          target → rolling-quantile signal). Keras / MLX /
│                          Ridge regressor backends.
├── Anomaly/             ← Anomaly Detection strategies (autoencoder + GANomaly)
├── Sklearn/             ← sklearn classifier strategies (RandomForest, XGBoost, …)
│                          inherit from BaseNNStrategy via SklearnStrategy
├── GANs/                ← GAN implementations + GANInterface + GANBackend ABC
│   ├── GANInterface.py  ← Thin facade: fit/generate/save/load
│   ├── GANBackend.py    ← Abstract base + registry (resolve_backend, fit/load_with_fallback)
│   ├── backends/        ← Concrete backends, one file per type/backend pair
│   ├── df_*_gp.py       ← TensorFlow trainer implementations
│   ├── df_*_mlx.py      ← MLX trainer implementations
│   ├── Create*GAN*.py   ← Strategy classes you run under freqtrade backtesting
│   │                      to train + save a GAN
│   └── tests/           ← Contract + robustness tests
├── MLX/                 ← Apple MLX neural net components (Mamba, etc.)
├── TSPredict/           ← Time-series/wavelet-based strategies
├── SimpleStrategies/    ← Single-indicator strategies (no ML)
├── Debug/               ← Debug/visualisation utilities
├── hyperopts/           ← Custom hyperopt loss functions
├── config/              ← Exchange-specific config files
├── saved_data/          ← Trained model files (keyed by strategy name)
│                          + GANs/<gan_type>/ subdirs for every GAN type
│                          (GANs_PCA/<gan_type>/ for PCA-reduced strategies)
├── scripts/             ← Shell scripts for all workflow tasks
├── archived/            ← Old/abandoned strategies (reference only)
└── reference/           ← External strategies for learning

Historical note: an older NeuralNets/ directory used to contain a separate git repo holding the NN base class (NNStrategy) and the scaler storage. That directory is now deprecated — its contents have been folded into Framework/ (base classes) and the top-level saved_data/ (scalers). Older docs / older branches still reference NeuralNets/; those references are stale.


Class Hierarchy

BaseStrategy(StrategyDiagnostics, IStrategy)            (Framework/BaseStrategy.py)
├── BaseNNStrategy(TrainingEngine, FeatureNormalizer, BaseStrategy)  ← composes the ML mixins
│   ├── NNNCStrategy (NNNC/NNNCStrategy.py)
│   │   └── NNNC_CGP, NNNC_CGP_LSTM2, NNNC_CGP_MLX_*, ... (concrete strategies)
│   ├── NNMTStrategy (NNMT/NNMTStrategy.py)
│   │   └── NNMT_WGAN, NNMT_WGAN_MLX, NNMT_CGP, ... (concrete strategies)
│   ├── NNPredictStrategy (NNPredict/NNPredictStrategy.py)  ← regression family
│   │   └── NNPredict_LSTM, NNPredict_MLX_LSTM, NNPredict_Ridge
│   ├── NNAnomalyStrategy (Anomaly/NNAnomalyStrategy.py)
│   └── SklearnStrategy (Sklearn/SklearnStrategy.py)
│       └── Skl_RandomForest, Skl_XGBoost, Skl_RandomForest_WGAN, ...
├── SimpleStrategy (SimpleStrategies/SimpleStrategy.py)
│   └── AO, BBBreakout, EMACross, ... (each in own file)
└── TSPredict (TSPredict/TSPredict.py)
    └── TS_Wavelet_DWT, TS_Coeff_FFT, ... (concrete strategies)

There is no separate NNStrategy class — BaseNNStrategy is the ML pipeline base and the per-family bases (NNNC/NNMT/Anomaly/Sklearn) inherit directly from it. Older docs may still reference NNStrategy; that's stale.

The ML pipeline is decomposed into mixins (composed in MRO order, mixin first) rather than a single monolith — each is byte-identical-relocated, not rewritten:

  • StrategyDiagnostics (into BaseStrategy) — assessment / probability / correlation printing. Pure diagnostics; no effect on training or trading.
  • FeatureNormalizer (into BaseNNStrategy) — feature-list selection (include_list / pre_normalized_columns / one_hot_columns) + scaler/PCA state and the methods that consume them (rolling_dataframe_normalise, normalise_for_gan, clean_for_tensor, apply_pca, get_normalized_size). Generic engine — list contents are overridable class attrs the families set.
  • TrainingEngine (into BaseNNStrategy) — prepare_training_data, get_training_class_weights, train_model, markov helpers, GAN augmentation (enhance_training_data / preprocess_training_data + their helpers), and maybe_train (the training trigger populate_indicators delegates to).

The mixins assume composition (they call collaborators via self, e.g. TrainingEngine calls FeatureNormalizer.scale_dataframe) and introduce no literal references to their host class — consistent with the one-way dependency rule below.

BaseStrategy responsibilities

  • ROI table, stoploss, trailing stop config
  • bot_start() — freqtrade's one-time-init hook. Handles environment setup, hyperopt-parameter printing, and shared utility instantiation (DataframeUtils, DataframePopulator). Subclasses overriding this MUST call super().bot_start(**kwargs).
  • iteration_init() — runs at the start of each populate_indicators() cycle. Now slim: just per-iteration scaler reset.
  • custom_exit() — most actual sells happen here, not in populate_exit_trend
  • custom_stoploss()
  • confirm_trade_entry() / confirm_trade_exit()
  • Guard conditions (disable trading in bad market conditions)
  • Hyperopt parameters: guards, prediction threshold
  • populate_indicators() calls DataframePopulator to add all technical indicators
  • Assessment / distribution / correlation printing — via the StrategyDiagnostics mixin (pure diagnostics; safe to ignore when tracing trading behaviour)

BaseNNStrategy responsibilities (in addition to BaseStrategy's)

BaseNNStrategy itself keeps the freqtrade lifecycle (bot_start/iteration_init/ populate_indicators), label generation (get_training_labels + TrainingSignals

  • MASTER thresholds), prediction, and entry/exit wiring. The rest is provided by its composed mixins:
  • Classifier construction via get_classifier_type() + get_classifier() (family-specific; called from TrainingEngine.maybe_train).
  • Normalization / feature lists → FeatureNormalizer.
  • Training + GAN augmentation + class weights → TrainingEngine. GAN augmentation is a single dispatcher: enhance_training_data inspects gan_type and the label shape (ndarray vs dict), validates the saved GAN's metadata against the strategy's current config, and routes to GANs.balance.balance_single_task / balance_multi_task. Concrete strategies declare gan_type (and optionally gan_target_ratio, gan_run_diagnostics, gan_passthrough_columns, gan_augment_seed) — they don't see GAN-type-specific code. Multi-task 3-D pipelines (e.g. NNMT_WGAN) turn off the 2-D dispatcher with gan_augment = False and run their own preprocess_training_data.
  • Train / save / load lifecycle — populate_indicators() delegates to TrainingEngine.maybe_train() (classifier setup + multi-pair aggregation + training trigger).

Reproducibility: GAN augmentation seeds its sampler from gan_augment_seed (default 42). Seeded runs are byte-reproducible across separate processes (each cold-starts identically), but NOT across repeated augmentations within one process — the AE-filter's lazy model load draws mx.random on first call, polluting the seeded stream. Validate GAN-path changes with a separate-process augmentation diff, not an in-process golden. Set gan_augment_seed = None for non-deterministic augmentation.

Adding a new strategy (NN family)

  1. Create a new .py file in the appropriate family directory (e.g., NNNC/, NNMT/, NNPredict/, Anomaly/, Sklearn/)
  2. Inherit from the appropriate family base class (e.g., NNNCStrategy, NNMTStrategy, NNPredictStrategy, SklearnStrategy)
  3. Override get_classifier_type() and get_classifier() to return your model (or regressor, for NNPredictStrategy subclasses)
  4. Optionally override add_strategy_indicators(), get_custom_training_data(), etc.
  5. Run backtest over a long period to train and save the model

Adding a new SimpleStrategy

  1. Create a new .py file in SimpleStrategies/
  2. Inherit from SimpleStrategy
  3. Override populate_entry_trend() and (optionally) populate_exit_trend()
  4. No training needed

Config Files

All config files live in user_data/strategies/config/.

File Purpose
config_binanceus.json Main backtest/hyperopt config (static pairlist)
config_binanceus_short.json Futures/short trading config
config_binanceus_download.json Download config (may use VolumePairlist)
config_binanceus_train.json Long-range config for training NN models
config_binanceus_leveraged.json Leveraged trading config

Config files used with scripts are referenced by exchange name. The scripts resolve the path automatically.


Common Commands

1. Download data

# Download last 180 days for all exchanges
zsh user_data/strategies/scripts/download.sh

# Specific exchange
zsh user_data/strategies/scripts/download.sh binanceus

# Futures/short data
zsh user_data/strategies/scripts/download.sh --short binanceus

# Manual command
freqtrade download-data --timerange=20230101- \
  -c user_data/strategies/config/config_binanceus.json \
  -t 5m 15m 1h 1d

2. Backtest a single strategy

zsh user_data/strategies/scripts/test_strat.sh NNNC NNNC_CGP

# Manual equivalent
freqtrade backtesting \
  -c user_data/strategies/config/config_binanceus.json \
  --strategy-path user_data/strategies/NNNC \
  --strategy NNNC_CGP \
  --timerange=20230101-20231231

3. Backtest a group of strategies (with wildcards)

zsh user_data/strategies/scripts/test_group.sh NNNC "NNNC_CGP_MLX*"
zsh user_data/strategies/scripts/test_group.sh TSPredict "TS_Wavelet*"

4. Hyperopt a strategy

zsh user_data/strategies/scripts/hyp_strat.sh NNNC NNNC_CGP

# With custom loss and spaces
zsh user_data/strategies/scripts/hyp_strat.sh \
  -l ExpectancyHyperOptLoss \
  -s "buy sell roi" \
  binanceus NNNC_CGP

# Manual equivalent
freqtrade hyperopt \
  -c user_data/strategies/config/config_binanceus.json \
  --strategy-path user_data/strategies/NNNC \
  --strategy NNNC_CGP \
  --spaces buy sell roi \
  --hyperopt-loss ExpectancyHyperOptLoss \
  --timerange=20230101-20231231

5. Check for lookahead bias

zsh user_data/strategies/scripts/check_bias.sh NNNC NNNC_CGP

6. Plot a strategy

zsh user_data/strategies/scripts/plot_strat.sh NNNC NNNC_CGP BTC/USDT

# Output: user_data/plot/freqtrade-plot-BTC_USDT-5m.html

7. Create scalers (one-time setup for NN strategies)

freqtrade backtesting \
  -c user_data/strategies/config/config_binanceus.json \
  --strategy-path user_data/strategies/Framework \
  --strategy CreateScalers \
  --timerange=20220101-

Scalers are saved to user_data/strategies/saved_data/.

8. Retrain a neural net model

Delete the existing model files and re-run backtest with a long timerange:

rm -rf user_data/strategies/saved_data/NNNC_CGP/*
zsh user_data/strategies/scripts/test_strat.sh NNNC NNNC_CGP \
  --timerange 20220101-

9. Dry run

zsh user_data/strategies/scripts/dryrun_strat.sh NNNC NNNC_CGP

# With port (for multiple simultaneous strategies)
zsh user_data/strategies/scripts/dryrun_strat.sh -p 8081 NNNC NNNC_CGP

10. Live run

zsh user_data/strategies/scripts/run_strat.sh NNNC NNNC_CGP

BaseNNStrategy Pipeline

Understanding the data flow is critical for debugging or extending NN strategies. The pipeline lives in Framework/BaseNNStrategy.py and its mixins (FeatureNormalizer = normalization, TrainingEngine = training/GAN), and is shared across NNNC, NNMT, Anomaly, and Sklearn family bases.

bot_start(**kwargs) (one-time, called once per backtest/dry-run/live)

  1. Calls super().bot_start() (BaseStrategy: banner, environment, helpers).
  2. Configures TF/MLX device visibility for util_no_exchange runs.
  3. Loads MASTER thresholds from saved GAN metadata if present (so the strategy always uses the same MIN_BUY_GAIN_THRESHOLD / MIN_SELL_LOSS_THRESHOLD / TRAINING_TYPE the GAN was trained with).
  4. Falls back to buy_params / sell_params overrides if no GAN metadata.

populate_indicators(dataframe, metadata) (per pair, per iteration)

  1. Checks that scalers exist in saved_data/.
  2. Calls DataframePopulator to add all technical indicators.
  3. Adds training labels via get_training_labels (TrainingSignals).
  4. Delegates to TrainingEngine.maybe_train(): sets up the classifier, and (if no saved model) collects dataframes across the whitelist, trains via train_model — which normalizes (FeatureNormalizer), GAN-augments, computes class weights, and fits — then saves. If a model exists, training is skipped.

get_predictions(dataframe)

  1. Normalizes the dataframe
  2. Converts to sequences of shape [batch, seq_len, num_features]
  3. Returns probability predictions from the model
  4. Applies threshold to get discrete class (sell/hold/buy)

populate_entry_trend / populate_exit_trend

  • Applies guard conditions
  • Combines model predictions with additional technical filters

Key configuration flags (class-level)

aggregate_pairs = True    # train on all pairs combined
use_gan = False           # augment with GAN-generated data
seq_len = 8               # input sequence length
num_epochs = 100          # training epochs
batch_size = 1024

Feature Engineering Rules (NN strategies)

NN strategies need pair-agnostic features — indicators that have consistent ranges across all pairs. The following are prohibited:

  • Raw price (open, close, high, low)
  • Raw volume
  • Any indicator directly proportional to price

Use instead:

  • Oscillators (RSI, MFI, CMF — already bounded 0-100 or -1 to 1)
  • Z-score normalized indicators
  • Percentage changes (returns)
  • Ratio-based indicators

The DataframePopulator class already handles all of this — it adds a standard set of pre-approved indicators. Custom indicators must follow the same rules.


Hyperopt Loss Functions

Copy from user_data/strategies/hyperopts/ to user_data/hyperopts/ before using.

Function Best for
ExpectancyHyperOptLoss General use — robust across datasets (recommended)
OnlyExpectancyHyperOptLoss When you want pure expectancy, nothing else
WeightedProfitHyperOptLoss Maximising total profit
QuickProfitHyperOptLoss Maximising profit with short-duration trades
WinHyperOptLoss Maximising win rate
MarketHyperOptLoss Market-condition-adjusted win rate
MedianProfitHyperOptLoss Robust profit (less sensitive to outliers)
PEDHyperOptLoss Balanced: Profit + Expectancy + Duration

Troubleshooting

Changed parameters aren't taking effect

Check the .json file beside the .py file (e.g., NNNC_CGP.json). Hyperopt results in this file override Python defaults (deep_merge_dicts(file_params, class_params) — file wins). Three further ways a parameter silently does nothing:

  • It is a class attribute, not a Parameter (e.g. atr_stoploss_multiplier) — --set cannot reach it; subclass instead.
  • It is inert by construction — self.stoploss and trailing_* under use_atr_adaptive_stoploss, or a guard whose threshold is looser than the label that produced the bars (entry_guard_threshold was inert for exactly this reason).
  • The label and the guard filter the same quantity, so the stricter one binds and the other never fires. See "Silent failure modes" at the top.

Model not retraining

Delete the saved model directory:

rm -rf user_data/strategies/saved_data/<StrategyName>/*

ImportError or ModuleNotFoundError

First check your working directory — ModuleNotFoundError: No module named 'freqtrade' (usually alongside config file not found: user_data/strategies/config/config.json) when running a scripts/*.sh almost always means you launched it from the wrong CWD. The scripts use relative paths and a .-prefixed PYTHONPATH, so they only work from the project root:

cd /path/to/freqtrade        # the directory containing user_data/
zsh user_data/strategies/scripts/test_strat.sh NNNC NNNC_CGP

If you're running Python/freqtrade manually, also make sure PYTHONPATH includes the strategies directory:

export PYTHONPATH="$PWD/user_data/strategies:$PYTHONPATH"   # from the repo root

Suspiciously high backtest results (100%+)

Almost certainly lookahead bias. Check that:

  • No global mean()/min()/max() applied to the full column
  • All rolling operations use min_periods and only look backwards
  • TA-lib functions are used instead of manual rolling where possible
  • Run check_bias.sh to confirm

Scalers missing

Run CreateScalers (see "Create scalers" above). NN strategies will fail at startup without them.


Adding a New Hyperopt Loss Function

  1. Create a new file in user_data/strategies/hyperopts/ inheriting from IHyperOptLoss
  2. Implement hyperopt_loss_function(results, trade_count, min_date, max_date, config, processed, backtest_stats, *args, **kwargs) -> float
  3. Return a float where lower is better
  4. Copy the file to user_data/hyperopts/ to make it available to freqtrade
  5. Reference it with --hyperopt-loss <ClassName> or -l <ClassName>

Adding to / extending the GAN system

There are two distinct extension cases:

Case A — new variant of an existing GAN type

Most additions are this case (e.g. a CTAB-GAN+ trained on a different feature set, or a WGAN with a different augmentation ratio).

  1. Create a new builder script in GANs/ (e.g. CreateMyGAN.py).
  2. Inherit from CreateGAN (single-task) or CreateMTGAN (multi-task) and set gan_type = GANType.X plus any per-class config overrides. The pre-existing classes (CreateWGAN, CreateCtabGanPlus, etc.) are thin shims over these two unified bases — copy one of them as a template.
  3. Run the new strategy via backtesting on a long timerange to train and save.
  4. The saved model goes to saved_data/<StrategyName>/GANs/<gan_type>/ (or GANs_PCA/<gan_type>/ for PCA-reduced strategies) — the layout is centralised in GANs/paths.py::gan_save_path, so subclasses don't pick a directory name.
  5. Strategies consume the GAN by setting gan_type = GANType.X on the class. BaseNNStrategy.enhance_training_data then loads the model via GANInterface, validates its saved metadata against the strategy's current thresholds (raising GANMetadataMismatchError on drift), and dispatches class balancing through GANs.balance.balance_single_task / balance_multi_task. Override _gan_expected_metadata if your strategy needs to validate extra keys on top of the default thresholds + training_type.

Case B — genuinely new GAN type (new GANType enum value)

Rare. Add when the existing types can't capture the new behaviour (e.g. a different label modality or a fundamentally different conditioning).

Reference: TabDDPM (GANType.TAB_DDPM) was added as a Case B follow-up — see docs/superpowers/specs/2026-05-11-tabddpm-design.md and docs/superpowers/plans/2026-05-11-tabddpm-implementation.md for a concrete worked example. It's MLX-only and continuous-only.

  1. Add the new enum entry to GANType (GANs/GANType.py).
  2. Create the trainer/model class(es) in GANs/df_<name>_*.py (TF and/or MLX, following the existing df_wgan_* and df_ctab_* patterns).
  3. Create a backend class in GANs/backends/<name>.py subclassing GANBackend and decorate with @register_backend. Implement fit / generate / save / load and is_available().
  4. Add from . import <name> # noqa: F401 to GANs/backends/__init__.py so it registers at import time.
  5. Add a _DEFAULTS entry in GANInterface if your type has trainer-specific defaults that callers shouldn't be forced to know about.
  6. Add the new type to _BACKEND_MIGRATED in GANInterface.py.
  7. Cover it with the contract tests in GANs/tests/: test_gan_metadata_roundtrip.py (what gets persisted), test_gan_output_contracts.py (shape/dtype/finiteness), and (gated) test_gan_robustness.py.

Python Conventions

These apply to all code under user_data/strategies/. When in doubt, match the surrounding file; consistency within a module beats global purity.

  • Formatting / linting: ruff is the source of truth (formatter + linter). 88-character lines. Run ruff format and ruff check --fix before declaring a change done. Don't hand-format what the formatter owns.
  • Naming: snake_case for functions and variables, PascalCase for classes, UPPER_CASE for module-level constants. Strategy class names are load-bearing — they're referenced by configs, saved_data/ directories, and the test_*.sh scripts — so never rename a strategy class as a side effect of a refactor. If a rename is genuinely needed, list it explicitly and update the matching saved_data/<Name>/ dir and any <Name>.json.
  • Type hints on all new/edited function signatures (params and return). Prefer from __future__ import annotations at module top so forward references and X | None work without runtime cost.
  • Docstrings on public classes and methods — one line on what and why, not a restatement of the signature. Document the contract (shapes, units, side effects), especially for anything touching the NN pipeline.
  • No secrets in code. Exchange keys, API tokens, etc. live in config/env, never in .py files, and configs with secrets stay out of git.
  • Imports: absolute within the strategies tree (the directory is on PYTHONPATH). No wildcard imports except the deliberate from . import <name> # noqa: F401 registry pattern in GANs/backends/.
  • Lookahead safety is a hard rule, not a style preference. No global mean/min/max over a full column; rolling ops use min_periods and only look backwards (see Troubleshooting). A "cleaner" refactor that introduces lookahead bias is a regression — verify with check_bias.sh.

Object-Oriented Design Rules

The hierarchy here grew over a long time and has drifted. These rules are the target state; the refactor work should move code toward them.

Dependency direction is one-way: bases never know their subclasses

  • A base class must never reference a subclass — not in code, not in type hints, not even in comments or docstrings. No imports from a subclass module, no isinstance(self, NNNC_CGP_MLX), no "used by NNMT_WGAN" notes in BaseNNStrategy. If a base needs subclass-specific behavior, that's a signal to invert the dependency: expose a hook/abstract method the subclass overrides, or a registry the subclass registers into (the @register_backend pattern in GANs/ is the model to copy).
  • Imports flow upward only: NNNC_CGP → NNNCStrategy → BaseNNStrategy → BaseStrategy. A lower layer importing from a higher/sibling family layer is a layering violation to flag.

Prefer shallow hierarchies + composition over deep inheritance

  • Inheritance is for genuine is-a with shared behavior. The current Base → NNNCStrategy → NNNC_CGP → NNNC_CGP_MLX → NNNC_CGP_MLX_LSTM depth is a smell — each level should earn its existence with real differentiated behavior, not just hold one overridden constant.
  • When subclasses differ only in a value (model name, a threshold, a backend choice), that's configuration, not a class. Collapse them into one parameterized class or a small data-driven table rather than N near-identical files. (DataframePopulator, GANInterface, and the CreateGAN/CreateMTGAN shims already embody this — extend that style.)
  • Reach for mixins/composition when behavior is shared across siblings in different branches (e.g. an MLX-device concern shared by NNNC and NNMT). A mixin shared by two families belongs in Framework/ or utils/, not duplicated in each family dir.

Keep the override contract honest

  • A subclass override that is byte-identical to the parent should be deleted — it's dead weight and hides where behavior actually lives.
  • An override that changes behavior must respect the parent contract: same shapes / return types / side effects. If it can't, the method is mis-placed in the hierarchy.
  • Lifecycle hooks that the framework chains (bot_start, iteration_init) must call super() — overriding without super() silently drops base setup. Flag any override that doesn't.

Single responsibility & explicit interfaces

  • One class, one job. New behavior should go into a collaborator (utils/, a backend, a populator) the strategy uses, or one of the Framework mixins, not another if-branch in the base.
  • The BaseNNStrategy decomposition into StrategyDiagnostics / FeatureNormalizer / TrainingEngine mixins is the realized example of this: each was a behavior-preserving (byte-identical) relocation, verified by the training-pipeline characterization fixtures + scaler-diff/plot/backtest. Add to the matching mixin rather than re-growing BaseNNStrategy.
  • Cross-family shared logic lives in exactly one place. Duplicate helper methods across family bases are consolidation targets — lift them to BaseNNStrategy or a utils/ helper.
  • Abstract bases (GANBackend, the family bases' required overrides) should declare their required methods explicitly (@abstractmethod) so a half-built subclass fails loudly at construction, not deep in a backtest.