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.
- Shell:
zsh(macOS default). All scripts usezsh, notbash. - Python: A virtualenv at
.venv. Activate withsource .venv/bin/activatebefore 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 containinguser_data/(its location is machine-specific; this guide never assumes one) -- not fromscripts/or any subdir. The scripts use relative paths (strat_dir="user_data/strategies", config atuser_data/strategies/config/…) and prepend.toPYTHONPATHso the in-treefreqtradepackage resolves — both only work when the CWD is the repo root. Running from the wrong directory fails with a misleadingModuleNotFoundError: 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
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_MLXwithout its own json falls back toNNNCStrategy'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 withInvalid 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>/*.safetensorsfreqtrade 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.
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 baselineUseful 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_hashto 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'sPOWER_THRESHOLD_PP = 1.0is 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_seedis inert for multi-task). Note the gate is often tight enough that two seeds produce identical W1 trades — that is one measurement, not two.
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_leveland friends at their incumbent values; they have each been swept and none is resolvable. See the memory notesproject-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 thatcompare.pycalled 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_trainfires only whenpair_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.jsonexcludes it. run_arm.pyhas no--timerange—--windowis required, with choices fromregimedata.WINDOWS. Do NOT add keys to that dict;phase_c_trackability.pyiterates it. Inject the window into the in-memory dict in a per-fold subprocess instead.
- 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 livefreqtrade tradedry run. Usepkill -9 -f 'freqtrade backtesting', or kill by PID after checkingps.TaskStopdoes not kill process trees. - Only the nested
user_data/strategiesrepo is ours; the outer freqtrade checkout is upstream. Commit directly tomasterthere. - Never
git add -A/git add .— files are edited concurrently outside the agent session. Stage only the files your change touched, and checkgit statusfirst.
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 intoFramework/(base classes) and the top-levelsaved_data/(scalers). Older docs / older branches still referenceNeuralNets/; those references are stale.
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(intoBaseStrategy) — assessment / probability / correlation printing. Pure diagnostics; no effect on training or trading.FeatureNormalizer(intoBaseNNStrategy) — 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(intoBaseNNStrategy) —prepare_training_data,get_training_class_weights,train_model, markov helpers, GAN augmentation (enhance_training_data/preprocess_training_data+ their helpers), andmaybe_train(the training triggerpopulate_indicatorsdelegates 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.
- 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 callsuper().bot_start(**kwargs).iteration_init()— runs at the start of eachpopulate_indicators()cycle. Now slim: just per-iteration scaler reset.custom_exit()— most actual sells happen here, not inpopulate_exit_trendcustom_stoploss()confirm_trade_entry()/confirm_trade_exit()- Guard conditions (disable trading in bad market conditions)
- Hyperopt parameters: guards, prediction threshold
populate_indicators()callsDataframePopulatorto add all technical indicators- Assessment / distribution / correlation printing — via the
StrategyDiagnosticsmixin (pure diagnostics; safe to ignore when tracing trading behaviour)
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 fromTrainingEngine.maybe_train). - Normalization / feature lists →
FeatureNormalizer. - Training + GAN augmentation + class weights →
TrainingEngine. GAN augmentation is a single dispatcher:enhance_training_datainspectsgan_typeand the label shape (ndarray vs dict), validates the saved GAN's metadata against the strategy's current config, and routes toGANs.balance.balance_single_task/balance_multi_task. Concrete strategies declaregan_type(and optionallygan_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 withgan_augment = Falseand run their ownpreprocess_training_data. - Train / save / load lifecycle —
populate_indicators()delegates toTrainingEngine.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 drawsmx.randomon first call, polluting the seeded stream. Validate GAN-path changes with a separate-process augmentation diff, not an in-process golden. Setgan_augment_seed = Nonefor non-deterministic augmentation.
- Create a new
.pyfile in the appropriate family directory (e.g.,NNNC/,NNMT/,NNPredict/,Anomaly/,Sklearn/) - Inherit from the appropriate family base class (e.g.,
NNNCStrategy,NNMTStrategy,NNPredictStrategy,SklearnStrategy) - Override
get_classifier_type()andget_classifier()to return your model (or regressor, forNNPredictStrategysubclasses) - Optionally override
add_strategy_indicators(),get_custom_training_data(), etc. - Run backtest over a long period to train and save the model
- Create a new
.pyfile inSimpleStrategies/ - Inherit from
SimpleStrategy - Override
populate_entry_trend()and (optionally)populate_exit_trend() - No training needed
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.
# 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 1dzsh 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-20231231zsh user_data/strategies/scripts/test_group.sh NNNC "NNNC_CGP_MLX*"
zsh user_data/strategies/scripts/test_group.sh TSPredict "TS_Wavelet*"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-20231231zsh user_data/strategies/scripts/check_bias.sh NNNC NNNC_CGPzsh user_data/strategies/scripts/plot_strat.sh NNNC NNNC_CGP BTC/USDT
# Output: user_data/plot/freqtrade-plot-BTC_USDT-5m.htmlfreqtrade 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/.
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-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_CGPzsh user_data/strategies/scripts/run_strat.sh NNNC NNNC_CGPUnderstanding 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.
- Calls
super().bot_start()(BaseStrategy: banner, environment, helpers). - Configures TF/MLX device visibility for
util_no_exchangeruns. - 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_TYPEthe GAN was trained with). - Falls back to
buy_params/sell_paramsoverrides if no GAN metadata.
- Checks that scalers exist in
saved_data/. - Calls
DataframePopulatorto add all technical indicators. - Adds training labels via
get_training_labels(TrainingSignals). - Delegates to
TrainingEngine.maybe_train(): sets up the classifier, and (if no saved model) collects dataframes across the whitelist, trains viatrain_model— which normalizes (FeatureNormalizer), GAN-augments, computes class weights, and fits — then saves. If a model exists, training is skipped.
- Normalizes the dataframe
- Converts to sequences of shape
[batch, seq_len, num_features] - Returns probability predictions from the model
- Applies threshold to get discrete class (sell/hold/buy)
- Applies guard conditions
- Combines model predictions with additional technical filters
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 = 1024NN 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.
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 |
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) —--setcannot reach it; subclass instead. - It is inert by construction —
self.stoplossandtrailing_*underuse_atr_adaptive_stoploss, or a guard whose threshold is looser than the label that produced the bars (entry_guard_thresholdwas 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.
Delete the saved model directory:
rm -rf user_data/strategies/saved_data/<StrategyName>/*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_CGPIf you're running Python/freqtrade manually, also make sure PYTHONPATH includes the strategies directory:
export PYTHONPATH="$PWD/user_data/strategies:$PYTHONPATH" # from the repo rootAlmost certainly lookahead bias. Check that:
- No global
mean()/min()/max()applied to the full column - All rolling operations use
min_periodsand only look backwards - TA-lib functions are used instead of manual rolling where possible
- Run
check_bias.shto confirm
Run CreateScalers (see "Create scalers" above). NN strategies will fail at startup without them.
- Create a new file in
user_data/strategies/hyperopts/inheriting fromIHyperOptLoss - Implement
hyperopt_loss_function(results, trade_count, min_date, max_date, config, processed, backtest_stats, *args, **kwargs) -> float - Return a float where lower is better
- Copy the file to
user_data/hyperopts/to make it available to freqtrade - Reference it with
--hyperopt-loss <ClassName>or-l <ClassName>
There are two distinct extension cases:
Most additions are this case (e.g. a CTAB-GAN+ trained on a different feature set, or a WGAN with a different augmentation ratio).
- Create a new builder script in
GANs/(e.g.CreateMyGAN.py). - Inherit from
CreateGAN(single-task) orCreateMTGAN(multi-task) and setgan_type = GANType.Xplus 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. - Run the new strategy via backtesting on a long timerange to train and save.
- The saved model goes to
saved_data/<StrategyName>/GANs/<gan_type>/(orGANs_PCA/<gan_type>/for PCA-reduced strategies) — the layout is centralised inGANs/paths.py::gan_save_path, so subclasses don't pick a directory name. - Strategies consume the GAN by setting
gan_type = GANType.Xon the class.BaseNNStrategy.enhance_training_datathen loads the model viaGANInterface, validates its saved metadata against the strategy's current thresholds (raisingGANMetadataMismatchErroron drift), and dispatches class balancing throughGANs.balance.balance_single_task/balance_multi_task. Override_gan_expected_metadataif your strategy needs to validate extra keys on top of the default thresholds + training_type.
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 — seedocs/superpowers/specs/2026-05-11-tabddpm-design.mdanddocs/superpowers/plans/2026-05-11-tabddpm-implementation.mdfor a concrete worked example. It's MLX-only and continuous-only.
- Add the new enum entry to
GANType(GANs/GANType.py). - Create the trainer/model class(es) in
GANs/df_<name>_*.py(TF and/or MLX, following the existingdf_wgan_*anddf_ctab_*patterns). - Create a backend class in
GANs/backends/<name>.pysubclassingGANBackendand decorate with@register_backend. Implementfit / generate / save / loadandis_available(). - Add
from . import <name> # noqa: F401toGANs/backends/__init__.pyso it registers at import time. - Add a
_DEFAULTSentry inGANInterfaceif your type has trainer-specific defaults that callers shouldn't be forced to know about. - Add the new type to
_BACKEND_MIGRATEDinGANInterface.py. - 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.
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:
ruffis the source of truth (formatter + linter). 88-character lines. Runruff formatandruff check --fixbefore declaring a change done. Don't hand-format what the formatter owns. - Naming:
snake_casefor functions and variables,PascalCasefor classes,UPPER_CASEfor module-level constants. Strategy class names are load-bearing — they're referenced by configs,saved_data/directories, and thetest_*.shscripts — 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 matchingsaved_data/<Name>/dir and any<Name>.json. - Type hints on all new/edited function signatures (params and return).
Prefer
from __future__ import annotationsat module top so forward references andX | Nonework 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
.pyfiles, and configs with secrets stay out of git. - Imports: absolute within the strategies tree (the directory is on
PYTHONPATH). No wildcard imports except the deliberatefrom . import <name> # noqa: F401registry pattern inGANs/backends/. - Lookahead safety is a hard rule, not a style preference. No global
mean/min/maxover a full column; rolling ops usemin_periodsand only look backwards (see Troubleshooting). A "cleaner" refactor that introduces lookahead bias is a regression — verify withcheck_bias.sh.
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.
- 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 inBaseNNStrategy. 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_backendpattern inGANs/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.
- Inheritance is for genuine is-a with shared behavior. The current
Base → NNNCStrategy → NNNC_CGP → NNNC_CGP_MLX → NNNC_CGP_MLX_LSTMdepth 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 theCreateGAN/CreateMTGANshims 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/orutils/, not duplicated in each family dir.
- 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 callsuper()— overriding withoutsuper()silently drops base setup. Flag any override that doesn't.
- 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 anotherif-branch in the base. - The
BaseNNStrategydecomposition intoStrategyDiagnostics/FeatureNormalizer/TrainingEnginemixins 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-growingBaseNNStrategy. - Cross-family shared logic lives in exactly one place. Duplicate helper methods
across family bases are consolidation targets — lift them to
BaseNNStrategyor autils/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.