1. Strategy

  • Entry at the close of a MACD 12 / 26 / 9 crossover: bullish → LONG, bearish → SHORT. 1h candles.
  • Take profit at +0.6 % from the entry (−0.6 % for a SHORT).
  • The stop is armed when the CCI 24 reaches −100 (LONG) or +100 (SHORT); the trade then exits at the close of the next crossover. Without arming, it waits for its take profit.
  • One position at a time · 10 USDT per entry · 1,000 USDT account · Bybit taker fees.

Example: LONG entered at 100.00 at 10:00, take profit 100.60

CandleHighCloseCCI 24CrossEvent
11:00100.4099.90−60bearishignored
12:00100.1099.20−120—stop armed
13:0099.6099.40−90—nothing
14:0099.8099.70−40bullishstop at 99.70

If a high had reached 100.60 before 14:00, the take profit would have closed the trade first.

server/core/src/BacktestMl.Core/Indicators/Macd.cs · lines 11–63
/// <summary>MACD 12 / 26 / 9 on the close (C-3): line = EMA12 − EMA26, signal = EMA9(line), hist = line − signal.</summary>
public static MacdSeries Compute(IReadOnlyList<double> close, int fast = 12, int slow = 26, int signal = 9)
{
    var n = close.Count;
    var emaFast = Ema(close, fast);
    var emaSlow = Ema(close, slow);
    var line = new double?[n];
    var lineFrom = slow - 1; // the line exists once both averages do
    for (var i = lineFrom; i < n; i++)
        line[i] = emaFast[i] - emaSlow[i];

    var signalPart = Ema([.. line.Skip(lineFrom).Select(v => v!.Value)], signal);
    var sig = new double?[n];
    var hist = new double?[n];
    for (var i = lineFrom; i < n; i++)
    {
        sig[i] = signalPart[i - lineFrom];
        hist[i] = line[i] - sig[i];
    }
    return new MacdSeries(line, sig, hist);
}

/// <summary>EMA started like Skender.Stock.Indicators: its first value is the simple average of the first n.</summary>
private static double?[] Ema(IReadOnlyList<double> values, int n)
{
    var ema = new double?[values.Count];
    if (values.Count < n)
        return ema;
    var last = 0d;
    for (var i = 0; i < n; i++)
        last += values[i];
    last /= n;
    ema[n - 1] = last;
    var k = 2d / (n + 1);
    for (var i = n; i < values.Count; i++)
    {
        last += k * (values[i] - last);
        ema[i] = last;
    }
    return ema;
}

/// <summary>Crossover at t, read at the close on the raw histogram (C-7, C-8): bullish → LONG, bearish → SHORT.</summary>
public static Side? Crossover(double?[] hist, int t)
{
    if (t < 1 || hist[t - 1] is not { } before || hist[t] is not { } now)
        return null;
    if (before <= 0 && now > 0)
        return Side.Long;
    if (before >= 0 && now < 0)
        return Side.Short;
    return null;
}

Scroll sideways to read the code.

server/core/src/BacktestMl.Core/Strategy/TradeOutcome.cs · lines 30–59
/// <summary>What candle k (after the entry) does to the position, in this order (C-14, C-15).</summary>
public static TradeExit? Step(Position p, Market m, int k)
{
    var c = m.Candles;
    // 1. Take profit, touched during the candle: it comes before the stop, which is read at the close.
    if (p.Side == Side.Long ? c.High[k] >= p.TakeProfit : c.Low[k] <= p.TakeProfit)
        return new TradeExit(p.TakeProfit, ExitReason.TakeProfit);
    // 2. Stop: armed on an earlier candle, and this candle carries a crossover (either way) → exit at the close.
    if (p.Armed && m.Crossovers[k] is not null)
        return new TradeExit(c.Close[k], ExitReason.Stop);
    // 3. Arming: the raw CCI 24 reaches −100 (LONG) or +100 (SHORT). A null CCI does not arm.
    if (m.Cci[k] is { } cci && (p.Side == Side.Long ? cci <= -100 : cci >= 100))
        p.Armed = true;
    return null;
}

/// <summary>
/// Label of the crossover at t (C-16): 1 if the take profit comes before the stop, 0 if the stop comes first,
/// <c>null</c> when the trade has no outcome before candle <paramref name="end"/>. Fees are ignored.
/// </summary>
public static bool? Label(Market m, int t, int end)
{
    var p = Position.Open(m, t, score: 0);
    for (var k = t + 1; k < end; k++)
    {
        if (Step(p, m, k) is { } exit)
            return exit.Reason == ExitReason.TakeProfit;
    }
    return null;
}

Scroll sideways to read the code.

2. Dataset

One row per crossover, 8 features, nothing else:

  • lineLevel
  • lineSlope
  • signalLevel
  • signalSlope
  • histLevel
  • histSlope
  • cciLevel
  • cciSlope

Level = value of the series at the crossover · Slope = level − previous level.

Cleaning, before the slopes

  • MACD line, signal and hist: a value below 0.05 % of the close becomes ±0.05 % of the close, then ÷ close.
  • CCI 24: a value between −10 and +10 becomes ±10 (0 stays 0), then ÷ 1,000.
  • A row with a null value is excluded. Label = 1 if the take profit comes before the stop, else 0.
date            hist/C   slope    CCI/1000  label
2024-03-12 14h  +0.0021  +0.0004  -0.035    1
2024-03-13 02h  -0.0012  -0.0003  +0.120    0
2024-03-14 09h  +0.0006  +0.0002  -0.160    1

Backtest of 2025: the model never sees 2025

  1. 2020training
  2. 2021training
  3. 2022training
  4. 2023training
  5. 2024training
  6. 2025tested
server/core/src/BacktestMl.Core/Dataset/Cleaning.cs · lines 7–25
/// <summary>
/// MACD line, signal or hist: a value closer to 0 than 0.05 % of the close becomes ± 0.05 % of the close (with
/// its sign; 0 stays 0), then it is divided by the close of the same candle.
/// </summary>
public static double Macd(double value, double close)
{
    var floor = 0.0005 * close;
    if (value != 0 && Math.Abs(value) < floor)
        value = Math.CopySign(floor, value);
    return value / close;
}

/// <summary>CCI 24: a value between −10 and 10 becomes ± 10 (0 stays 0), then it is divided by 1 000.</summary>
public static double Cci(double value)
{
    if (value != 0 && Math.Abs(value) < 10)
        value = Math.CopySign(10, value);
    return value / 1000;
}

Scroll sideways to read the code.

3. Model

  • Random forest from ML.NET (FastForest), default options, one thread and a fixed seed: the same backtest always gives the same result.
  • Trained at each backtest on the crossovers before 1 January of the tested year.
  • Confidence score = probability of reaching the take profit × 100. A trade opens only if the score reaches the chosen value.
server/core/src/BacktestMl.Core/Model/Forest.cs · lines 35–61
/// <summary>
/// FastForest of ML.NET (binary classification), default options except one thread; with the fixed seed, the
/// same rows always give the same model (C-19). One model for LONG and SHORT.
/// </summary>
public static Forest Train(IReadOnlyList<DatasetRow> rows)
{
    if (rows.Count < MinRows || rows.All(r => r.Label) || rows.All(r => !r.Label))
        throw new NotEnoughDataException(rows.Count);
    var ml = new MLContext(seed: 42);
    var data = ml.Data.LoadFromEnumerable(rows.Select(r => new Example { Features = r.Features, Label = r.Label }));
    var options = new FastForestBinaryTrainer.Options { NumberOfThreads = 1 };
    var model = ml.BinaryClassification.Trainers.FastForest(options).Fit(data);
    return new Forest(ml, model, options.NumberOfTrees, rows.Count);
}

/// <summary>
/// Confidence score of each crossover (C-20): probability of label 1 × 100, rounded to an integer. Each tree
/// answers 2p − 1, p being the share of label 1 in its leaf, and FastForest sums the trees: the probability of
/// the forest is the average p of its trees.
/// </summary>
public int[] Score(IReadOnlyList<float[]> features)
{
    var data = _ml.Data.LoadFromEnumerable(features.Select(f => new Example { Features = f }));
    return [.. _ml.Data.CreateEnumerable<Prediction>(_model.Transform(data), reuseRowObject: false)
        .Select(p => (p.Score / _trees + 1) / 2)
        .Select(probability => (int)Math.Round(Math.Clamp(probability, 0, 1) * 100, MidpointRounding.AwayFromZero))];
}

Scroll sideways to read the code.

4. Backtest and rules

  • Hour by hour over the chosen year: first the open position (take profit, then stop), then a new entry if the crossover’s score is high enough.
  • Ulcer index = √(mean of the squared drawdowns, in %) of the hourly marked equity, for each month and for the year.
  • Prop firm rules, in % of the 1,000 USDT start: worst trade ≤ 3 %, worst day ≤ 3 %, worst month ≤ 5 %, no trade over 3 days, target ≥ 10 % in a month.
  • Read in this order: no violation, then fewest losing months, then PnL.
server/core/src/BacktestMl.Core/Metrics/Ulcer.cs · lines 6–23
/// <summary>
/// Ulcer index of an hourly equity curve (C-26): √(mean of Rᵢ²), where Rᵢ is the drop of the equity in %
/// from its highest value so far: Rᵢ = 100 × (Eᵢ − max Eⱼ≤ᵢ) ÷ max Eⱼ≤ᵢ.
/// </summary>
public static double Index(IReadOnlyList<double> equity)
{
    if (equity.Count == 0)
        return 0;
    var peak = equity[0];
    var sum = 0d;
    foreach (var e in equity)
    {
        peak = Math.Max(peak, e);
        var r = 100 * (e - peak) / peak;
        sum += r * r;
    }
    return Math.Sqrt(sum / equity.Count);
}

Scroll sideways to read the code.

1. Strategy

  • Entry at the close of a MACD 12 / 26 / 9 crossover: bullish → LONG, bearish → SHORT. 1h candles.
  • Take profit at +0.6 % from the entry (−0.6 % for a SHORT).
  • The stop is armed when the CCI 24 reaches −100 (LONG) or +100 (SHORT); the trade then exits at the close of the next crossover. Without arming, it waits for its take profit.
  • One position at a time · 10 USDT per entry · 1,000 USDT account · Bybit taker fees.

Example: LONG entered at 100.00 at 10:00, take profit 100.60

CandleHighCloseCCI 24CrossEvent
11:00100.4099.90−60bearishignored
12:00100.1099.20−120—stop armed
13:0099.6099.40−90—nothing
14:0099.8099.70−40bullishstop at 99.70

If a high had reached 100.60 before 14:00, the take profit would have closed the trade first.

server/core/src/BacktestMl.Core/Indicators/Macd.cs · lines 11–63
/// <summary>MACD 12 / 26 / 9 on the close (C-3): line = EMA12 − EMA26, signal = EMA9(line), hist = line − signal.</summary>
public static MacdSeries Compute(IReadOnlyList<double> close, int fast = 12, int slow = 26, int signal = 9)
{
    var n = close.Count;
    var emaFast = Ema(close, fast);
    var emaSlow = Ema(close, slow);
    var line = new double?[n];
    var lineFrom = slow - 1; // the line exists once both averages do
    for (var i = lineFrom; i < n; i++)
        line[i] = emaFast[i] - emaSlow[i];

    var signalPart = Ema([.. line.Skip(lineFrom).Select(v => v!.Value)], signal);
    var sig = new double?[n];
    var hist = new double?[n];
    for (var i = lineFrom; i < n; i++)
    {
        sig[i] = signalPart[i - lineFrom];
        hist[i] = line[i] - sig[i];
    }
    return new MacdSeries(line, sig, hist);
}

/// <summary>EMA started like Skender.Stock.Indicators: its first value is the simple average of the first n.</summary>
private static double?[] Ema(IReadOnlyList<double> values, int n)
{
    var ema = new double?[values.Count];
    if (values.Count < n)
        return ema;
    var last = 0d;
    for (var i = 0; i < n; i++)
        last += values[i];
    last /= n;
    ema[n - 1] = last;
    var k = 2d / (n + 1);
    for (var i = n; i < values.Count; i++)
    {
        last += k * (values[i] - last);
        ema[i] = last;
    }
    return ema;
}

/// <summary>Crossover at t, read at the close on the raw histogram (C-7, C-8): bullish → LONG, bearish → SHORT.</summary>
public static Side? Crossover(double?[] hist, int t)
{
    if (t < 1 || hist[t - 1] is not { } before || hist[t] is not { } now)
        return null;
    if (before <= 0 && now > 0)
        return Side.Long;
    if (before >= 0 && now < 0)
        return Side.Short;
    return null;
}

Scroll sideways to read the code.

server/core/src/BacktestMl.Core/Strategy/TradeOutcome.cs · lines 30–59
/// <summary>What candle k (after the entry) does to the position, in this order (C-14, C-15).</summary>
public static TradeExit? Step(Position p, Market m, int k)
{
    var c = m.Candles;
    // 1. Take profit, touched during the candle: it comes before the stop, which is read at the close.
    if (p.Side == Side.Long ? c.High[k] >= p.TakeProfit : c.Low[k] <= p.TakeProfit)
        return new TradeExit(p.TakeProfit, ExitReason.TakeProfit);
    // 2. Stop: armed on an earlier candle, and this candle carries a crossover (either way) → exit at the close.
    if (p.Armed && m.Crossovers[k] is not null)
        return new TradeExit(c.Close[k], ExitReason.Stop);
    // 3. Arming: the raw CCI 24 reaches −100 (LONG) or +100 (SHORT). A null CCI does not arm.
    if (m.Cci[k] is { } cci && (p.Side == Side.Long ? cci <= -100 : cci >= 100))
        p.Armed = true;
    return null;
}

/// <summary>
/// Label of the crossover at t (C-16): 1 if the take profit comes before the stop, 0 if the stop comes first,
/// <c>null</c> when the trade has no outcome before candle <paramref name="end"/>. Fees are ignored.
/// </summary>
public static bool? Label(Market m, int t, int end)
{
    var p = Position.Open(m, t, score: 0);
    for (var k = t + 1; k < end; k++)
    {
        if (Step(p, m, k) is { } exit)
            return exit.Reason == ExitReason.TakeProfit;
    }
    return null;
}

Scroll sideways to read the code.

2. Dataset

One row per crossover, 8 features, nothing else:

  • lineLevel
  • lineSlope
  • signalLevel
  • signalSlope
  • histLevel
  • histSlope
  • cciLevel
  • cciSlope

Level = value of the series at the crossover · Slope = level − previous level.

Cleaning, before the slopes

  • MACD line, signal and hist: a value below 0.05 % of the close becomes ±0.05 % of the close, then ÷ close.
  • CCI 24: a value between −10 and +10 becomes ±10 (0 stays 0), then ÷ 1,000.
  • A row with a null value is excluded. Label = 1 if the take profit comes before the stop, else 0.
date            hist/C   slope    CCI/1000  label
2024-03-12 14h  +0.0021  +0.0004  -0.035    1
2024-03-13 02h  -0.0012  -0.0003  +0.120    0
2024-03-14 09h  +0.0006  +0.0002  -0.160    1

Backtest of 2025: the model never sees 2025

  1. 2020training
  2. 2021training
  3. 2022training
  4. 2023training
  5. 2024training
  6. 2025tested
server/core/src/BacktestMl.Core/Dataset/Cleaning.cs · lines 7–25
/// <summary>
/// MACD line, signal or hist: a value closer to 0 than 0.05 % of the close becomes ± 0.05 % of the close (with
/// its sign; 0 stays 0), then it is divided by the close of the same candle.
/// </summary>
public static double Macd(double value, double close)
{
    var floor = 0.0005 * close;
    if (value != 0 && Math.Abs(value) < floor)
        value = Math.CopySign(floor, value);
    return value / close;
}

/// <summary>CCI 24: a value between −10 and 10 becomes ± 10 (0 stays 0), then it is divided by 1 000.</summary>
public static double Cci(double value)
{
    if (value != 0 && Math.Abs(value) < 10)
        value = Math.CopySign(10, value);
    return value / 1000;
}

Scroll sideways to read the code.

3. Model

  • Random forest from ML.NET (FastForest), default options, one thread and a fixed seed: the same backtest always gives the same result.
  • Trained at each backtest on the crossovers before 1 January of the tested year.
  • Confidence score = probability of reaching the take profit × 100. A trade opens only if the score reaches the chosen value.
server/core/src/BacktestMl.Core/Model/Forest.cs · lines 35–61
/// <summary>
/// FastForest of ML.NET (binary classification), default options except one thread; with the fixed seed, the
/// same rows always give the same model (C-19). One model for LONG and SHORT.
/// </summary>
public static Forest Train(IReadOnlyList<DatasetRow> rows)
{
    if (rows.Count < MinRows || rows.All(r => r.Label) || rows.All(r => !r.Label))
        throw new NotEnoughDataException(rows.Count);
    var ml = new MLContext(seed: 42);
    var data = ml.Data.LoadFromEnumerable(rows.Select(r => new Example { Features = r.Features, Label = r.Label }));
    var options = new FastForestBinaryTrainer.Options { NumberOfThreads = 1 };
    var model = ml.BinaryClassification.Trainers.FastForest(options).Fit(data);
    return new Forest(ml, model, options.NumberOfTrees, rows.Count);
}

/// <summary>
/// Confidence score of each crossover (C-20): probability of label 1 × 100, rounded to an integer. Each tree
/// answers 2p − 1, p being the share of label 1 in its leaf, and FastForest sums the trees: the probability of
/// the forest is the average p of its trees.
/// </summary>
public int[] Score(IReadOnlyList<float[]> features)
{
    var data = _ml.Data.LoadFromEnumerable(features.Select(f => new Example { Features = f }));
    return [.. _ml.Data.CreateEnumerable<Prediction>(_model.Transform(data), reuseRowObject: false)
        .Select(p => (p.Score / _trees + 1) / 2)
        .Select(probability => (int)Math.Round(Math.Clamp(probability, 0, 1) * 100, MidpointRounding.AwayFromZero))];
}

Scroll sideways to read the code.

4. Backtest and rules

  • Hour by hour over the chosen year: first the open position (take profit, then stop), then a new entry if the crossover’s score is high enough.
  • Ulcer index = √(mean of the squared drawdowns, in %) of the hourly marked equity, for each month and for the year.
  • Prop firm rules, in % of the 1,000 USDT start: worst trade ≤ 3 %, worst day ≤ 3 %, worst month ≤ 5 %, no trade over 3 days, target ≥ 10 % in a month.
  • Read in this order: no violation, then fewest losing months, then PnL.
server/core/src/BacktestMl.Core/Metrics/Ulcer.cs · lines 6–23
/// <summary>
/// Ulcer index of an hourly equity curve (C-26): √(mean of Rᵢ²), where Rᵢ is the drop of the equity in %
/// from its highest value so far: Rᵢ = 100 × (Eᵢ − max Eⱼ≤ᵢ) ÷ max Eⱼ≤ᵢ.
/// </summary>
public static double Index(IReadOnlyList<double> equity)
{
    if (equity.Count == 0)
        return 0;
    var peak = equity[0];
    var sum = 0d;
    foreach (var e in equity)
    {
        peak = Math.Max(peak, e);
        var r = 100 * (e - peak) / peak;
        sum += r * r;
    }
    return Math.Sqrt(sum / equity.Count);
}

Scroll sideways to read the code.