Method
How the strategy, the dataset and the model work, with the real 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
| Candle | High | Close | CCI 24 | Cross | Event |
|---|---|---|---|---|---|
| 11:00 | 100.40 | 99.90 | −60 | bearish | ignored |
| 12:00 | 100.10 | 99.20 | −120 | — | stop armed |
| 13:00 | 99.60 | 99.40 | −90 | — | nothing |
| 14:00 | 99.80 | 99.70 | −40 | bullish | stop at 99.70 |
If a high had reached 100.60 before 14:00, the take profit would have closed the trade first.
/// <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.
/// <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:
lineLevellineSlopesignalLevelsignalSlopehistLevelhistSlopecciLevelcciSlope
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
- 2020training
- 2021training
- 2022training
- 2023training
- 2024training
- 2025tested
/// <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.
/// <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.
/// <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
| Candle | High | Close | CCI 24 | Cross | Event |
|---|---|---|---|---|---|
| 11:00 | 100.40 | 99.90 | −60 | bearish | ignored |
| 12:00 | 100.10 | 99.20 | −120 | — | stop armed |
| 13:00 | 99.60 | 99.40 | −90 | — | nothing |
| 14:00 | 99.80 | 99.70 | −40 | bullish | stop at 99.70 |
If a high had reached 100.60 before 14:00, the take profit would have closed the trade first.
/// <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.
/// <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:
lineLevellineSlopesignalLevelsignalSlopehistLevelhistSlopecciLevelcciSlope
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
- 2020training
- 2021training
- 2022training
- 2023training
- 2024training
- 2025tested
/// <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.
/// <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.
/// <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.