feat: Add Jurik Composite Fractal Behavior (CFB) indicator and tests

This commit is contained in:
Miha Kralj
2025-12-13 21:38:32 -08:00
parent 4b17984cfd
commit 9587560cd8
16 changed files with 953 additions and 11 deletions
+2 -1
View File
@@ -15,6 +15,7 @@ Momentum indicators measure the speed or strength of price movements. This inclu
| BBS | Bollinger Band Squeeze | |
| BOP | Balance of Power | |
| CCI | Commodity Channel Index | |
| [CFB](cfb/Cfb.md) | Jurik Composite Fractal Behavior | Trend Duration Index using fractal efficiency. |
| CHOP | Choppiness Index | |
| CMO | Chande Momentum Oscillator | |
| DMX | Jurik Directional Movement Index | |
@@ -35,7 +36,7 @@ Momentum indicators measure the speed or strength of price movements. This inclu
| ROCP | Rate of Change Percentage | |
| ROCR | Rate of Change Ratio | |
| RSI | Relative Strength Index | |
| [RSX](rsx/Rsx.md) | Relative Strength X (Jurik's RSI Variant) | Noise-free, zero-lag version of RSI |
| [RSX](rsx/Rsx.md) | Jurik Relative Strength X | Noise-free, zero-lag version of RSI |
| SMI | Stochastic Momentum Index | |
| STOCH | Stochastic Oscillator | |
| STOCHF | Stochastic Fast | |
+187
View File
@@ -0,0 +1,187 @@
using Xunit;
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class CfbIndicatorTests
{
[Fact]
public void CfbIndicator_Constructor_SetsDefaults()
{
var indicator = new CfbIndicator();
Assert.Equal(2, indicator.MinLength);
Assert.Equal(192, indicator.MaxLength);
Assert.Equal(2, indicator.Step);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("CFB - Jurik Composite Fractal Behavior", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void CfbIndicator_MinHistoryDepths_EqualsMaxLength()
{
var indicator = new CfbIndicator { MaxLength = 50 };
Assert.Equal(50, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(50, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void CfbIndicator_ShortName_IncludesParametersAndSource()
{
var indicator = new CfbIndicator { MinLength = 5, MaxLength = 20, Source = SourceType.Close };
// Initialize to update SourceName
indicator.Initialize();
Assert.Contains("CFB", indicator.ShortName);
Assert.Contains("5-20", indicator.ShortName);
Assert.Contains("Close", indicator.ShortName);
}
[Fact]
public void CfbIndicator_SourceCodeLink_IsValid()
{
var indicator = new CfbIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink);
Assert.Contains("Cfb.Quantower.cs", indicator.SourceCodeLink);
}
[Fact]
public void CfbIndicator_Initialize_CreatesInternalCfb()
{
var indicator = new CfbIndicator { MinLength = 2, MaxLength = 10, Step = 2 };
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void CfbIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new CfbIndicator { MinLength = 2, MaxLength = 4, Step = 2 };
indicator.Initialize();
// Add historical data
var now = DateTime.UtcNow;
// Need enough bars for MaxLength (4)
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.HistoricalData.AddBar(now.AddMinutes(2), 104, 110, 102, 108);
indicator.HistoricalData.AddBar(now.AddMinutes(3), 103, 109, 101, 105);
indicator.HistoricalData.AddBar(now.AddMinutes(4), 105, 112, 103, 110);
// Process update
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
// Line series should have a value
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void CfbIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new CfbIndicator { MinLength = 2, MaxLength = 4, Step = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.HistoricalData.AddBar(now.AddMinutes(2), 104, 110, 102, 108);
indicator.HistoricalData.AddBar(now.AddMinutes(3), 103, 109, 101, 105);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar
indicator.HistoricalData.AddBar(now.AddMinutes(4), 105, 112, 103, 110);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void CfbIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new CfbIndicator { MinLength = 2, MaxLength = 4, Step = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.HistoricalData.AddBar(now.AddMinutes(2), 104, 110, 102, 108);
indicator.HistoricalData.AddBar(now.AddMinutes(3), 103, 109, 101, 105);
indicator.HistoricalData.AddBar(now.AddMinutes(4), 105, 112, 103, 110);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void CfbIndicator_OnPaintChart_DoesNotThrow()
{
var indicator = new CfbIndicator();
indicator.Initialize();
var method = indicator.GetType().GetMethod("OnPaintChart");
Assert.NotNull(method);
Assert.Equal(typeof(CfbIndicator), method.DeclaringType);
}
[Fact]
public void CfbIndicator_DifferentSourceTypes_Work()
{
var sources = new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close, SourceType.HL2, SourceType.HLC3 };
foreach (var source in sources)
{
var indicator = new CfbIndicator { MinLength = 2, MaxLength = 4, Step = 2, Source = source };
indicator.Initialize();
var now = DateTime.UtcNow;
// Add enough bars
for (int i = 0; i < 5; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Source {source} should produce finite value");
}
}
[Fact]
public void CfbIndicator_Parameters_CanBeChanged()
{
var indicator = new CfbIndicator { MinLength = 5, MaxLength = 20, Step = 5 };
Assert.Equal(5, indicator.MinLength);
Assert.Equal(20, indicator.MaxLength);
Assert.Equal(5, indicator.Step);
indicator.MinLength = 10;
indicator.MaxLength = 40;
indicator.Step = 10;
Assert.Equal(10, indicator.MinLength);
Assert.Equal(40, indicator.MaxLength);
Assert.Equal(10, indicator.Step);
Assert.Equal(40, indicator.MinHistoryDepths);
}
}
+77
View File
@@ -0,0 +1,77 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
public class CfbIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Min Length", sortIndex: 1, 2, 1000, 1, 0)]
public int MinLength { get; set; } = 2;
[InputParameter("Max Length", sortIndex: 2, 2, 1000, 1, 0)]
public int MaxLength { get; set; } = 192;
[InputParameter("Step", sortIndex: 3, 1, 100, 1, 0)]
public int Step { get; set; } = 2;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Cfb? _cfb;
private int _warmupBarIndex = -1;
protected LineSeries? Series;
protected string? SourceName;
public int MinHistoryDepths => MaxLength;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"CFB {MinLength}-{MaxLength}:{SourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/momentum/cfb/Cfb.Quantower.cs";
public CfbIndicator()
{
OnBackGround = true;
SeparateWindow = true;
SourceName = Source.ToString();
Name = "CFB - Jurik Composite Fractal Behavior";
Description = "Trend Duration Index using fractal efficiency";
Series = new(name: "CFB", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
protected override void OnInit()
{
// Generate lengths array
int count = (MaxLength - MinLength) / Step + 1;
int[] lengths = new int[count];
for (int i = 0; i < count; i++)
{
lengths[i] = MinLength + i * Step;
}
_cfb = new Cfb(lengths);
_warmupBarIndex = -1;
SourceName = Source.ToString();
base.OnInit();
}
protected override void OnUpdate(UpdateArgs args)
{
TValue input = this.GetInputValue(args, Source);
bool isNew = args.Reason == UpdateReason.NewBar || args.Reason == UpdateReason.HistoricalBar;
TValue result = _cfb!.Update(input, isNew);
if (_warmupBarIndex < 0 && _cfb!.IsHot)
_warmupBarIndex = Count;
Series!.SetValue(result.Value);
Series!.SetMarker(0, Color.Transparent); //OnPaintChart draws the line, hidden here
}
public override void OnPaintChart(PaintChartEventArgs args)
{
base.OnPaintChart(args);
this.PaintSmoothCurve(args, Series!, _warmupBarIndex, showColdValues: ShowColdValues, tension: 0.2);
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using Xunit;
namespace QuanTAlib;
public class CfbTests
{
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var cfb = new Cfb();
var gbm = new GBM();
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var data = bars.Close;
for (int i = 0; i < data.Count; i++)
{
cfb.Update(new TValue(data.Times[i], data.Values[i]));
}
Assert.True(cfb.Last.Value >= 1.0);
}
[Fact]
public void PerfectTrend_IncreasesCfb()
{
// Use small lengths for easier testing
int[] lengths = { 4, 8, 12 };
var cfb = new Cfb(lengths);
// Feed a perfect uptrend
for (int i = 0; i < 50; i++)
{
cfb.Update(new TValue(DateTime.UtcNow, i));
}
Assert.Equal(8.0, cfb.Last.Value);
}
[Fact]
public void FlatLine_ReturnsOne()
{
var cfb = new Cfb();
for (int i = 0; i < 100; i++)
{
cfb.Update(new TValue(DateTime.UtcNow, 100.0));
}
// NetMove is 0. TotalMove is 0.
// Ratio = 0/0 -> NaN?
// Code handles TotalMove < 1e-12 by skipping.
// So no lengths qualify.
// Decay logic kicks in.
// Should decay to 1.0.
Assert.Equal(1.0, cfb.Last.Value);
}
[Fact]
public void ZigZag_ReturnsOne()
{
var cfb = new Cfb(new int[] { 4, 8 });
// 100, 101, 100, 101...
// NetMove(4) = Abs(100 - 100) = 0. Ratio = 0.
// NetMove(8) = 0. Ratio = 0.
for (int i = 0; i < 100; i++)
{
double price = 100 + (i % 2);
cfb.Update(new TValue(DateTime.UtcNow, price));
}
Assert.Equal(1.0, cfb.Last.Value);
}
[Fact]
public void IsNew_Consistency()
{
var cfb = new Cfb();
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var data = new List<TValue>();
for (int i = 0; i < bars.Count; i++)
{
data.Add(new TValue(bars.Close.Times[i], bars.Close.Values[i]));
}
// Feed first 99
for (int i = 0; i < 99; i++)
{
cfb.Update(data[i]);
}
// Update with 100th point (isNew=true)
var val1 = cfb.Update(data[99], true);
// Update with modified 100th point (isNew=false)
var modified = new TValue(data[99].Time, data[99].Value + 1.0);
var val2 = cfb.Update(modified, false);
// Create new instance and feed up to modified
var cfb2 = new Cfb();
for (int i = 0; i < 99; i++)
{
cfb2.Update(data[i]);
}
var val3 = cfb2.Update(modified, true);
Assert.Equal(val3.Value, val2.Value);
}
[Fact]
public void StaticCalculate_Matches_Streaming()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var series = bars.Close;
var cfb = new Cfb();
var streamingResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
streamingResults.Add(cfb.Update(new TValue(series.Times[i], series.Values[i])).Value);
}
var staticResults = Cfb.Calculate(series);
Assert.Equal(streamingResults.Count, staticResults.Count);
for (int i = 0; i < streamingResults.Count; i++)
{
Assert.Equal(streamingResults[i], staticResults.Values[i]);
}
}
[Fact]
public void SpanCalculate_Matches_Streaming()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
double[] values = bars.Close.Values.ToArray();
var cfb = new Cfb();
var streamingResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
streamingResults.Add(cfb.Update(new TValue(bars.Close.Times[i], bars.Close.Values[i])).Value);
}
double[] spanResults = new double[bars.Count];
Cfb.Calculate(values, spanResults);
for (int i = 0; i < streamingResults.Count; i++)
{
Assert.Equal(streamingResults[i], spanResults[i]);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using System;
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public class CfbValidationTests
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
public CfbValidationTests(ITestOutputHelper output)
{
_output = output;
_testData = new ValidationTestData();
}
[Fact]
public void Validate_Consistency_UpdateVsCalculate()
{
// Verify that Update(TValue) and Calculate(TSeries) produce identical results
var cfb = new Cfb();
var streamResult = new TSeries();
foreach (var item in _testData.Data)
{
streamResult.Add(cfb.Update(item));
}
var batchResult = Cfb.Calculate(_testData.Data);
Assert.Equal(streamResult.Count, batchResult.Count);
Assert.NotEmpty(streamResult);
for (int i = 0; i < streamResult.Count; i++)
{
Assert.Equal(streamResult[i].Value, batchResult[i].Value, 1e-9);
}
_output.WriteLine("CFB Update vs Calculate validated successfully");
}
[Fact]
public void Validate_Consistency_SeriesVsSpan()
{
// Verify that Calculate(TSeries) and Calculate(Span) produce identical results
var batchResult = Cfb.Calculate(_testData.Data);
var spanInput = _testData.Data.Values.ToArray().AsSpan();
var spanOutput = new double[spanInput.Length];
Cfb.Calculate(spanInput, spanOutput);
for (int i = 0; i < batchResult.Count; i++)
{
Assert.Equal(batchResult.Values[i], spanOutput[i], 1e-9);
}
_output.WriteLine("CFB Series vs Span validated successfully");
}
[Fact]
public void Validate_Properties()
{
// CFB should be >= 1.0
var result = Cfb.Calculate(_testData.Data);
foreach (var val in result.Values)
{
Assert.True(val >= 1.0, $"CFB value {val} should be >= 1.0");
}
_output.WriteLine("CFB properties validated successfully");
}
}
+348
View File
@@ -0,0 +1,348 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CFB: Jurik Composite Fractal Behavior (Trend Duration Index)
/// </summary>
/// <remarks>
/// CFB measures the duration of a trend by analyzing fractal efficiency across multiple time scales.
/// It calculates a composite index based on which lookback periods show "quality" trending behavior.
///
/// Key characteristics:
/// - Adaptive: Adjusts to market fractal patterns.
/// - Granular: Uses a dense array of lookback lengths for smooth transitions.
/// - Composite: Weighted average of qualifying trend lengths.
/// - Zero-lag: Designed to modulate other indicators with minimal latency.
///
/// Calculation:
/// 1. For each length L:
/// Ratio = NetMove(L) / TotalVolatility(L)
/// where NetMove = Abs(Price - Price[L ago])
/// and TotalVolatility = Sum(Abs(Price[i] - Price[i-1])) over L bars.
/// 2. Filter: Only consider lengths where Ratio > Threshold (0.25).
/// 3. Composite: Weighted average of qualifying lengths (Weight = Ratio).
/// 4. Decay: If no trend found, decay the previous CFB value.
/// </remarks>
[SkipLocalsInit]
public sealed class Cfb : ITValuePublisher
{
private readonly int[] _lengths;
private readonly int _maxLen;
private readonly RingBuffer _prices;
private readonly RingBuffer _volatility;
private readonly double[] _runningSums;
private readonly double[] _p_runningSums;
private record struct State(double PrevCfb, double LastPrice, double LastValidValue);
private State _state;
private State _p_state;
public string Name { get; }
public event Action<TValue>? Pub;
public TValue Last { get; private set; }
public bool IsHot => _prices.IsFull;
/// <summary>
/// Creates a CFB indicator with specified fractal lengths.
/// </summary>
/// <param name="lengths">Array of lookback lengths. If null, defaults to 2, 4, ..., 192.</param>
public Cfb(int[]? lengths = null)
{
if (lengths == null || lengths.Length == 0)
{
// Default dense array: 2, 4, 6, ..., 192
_lengths = new int[96];
for (int i = 0; i < 96; i++)
{
_lengths[i] = (i + 1) * 2;
}
}
else
{
_lengths = (int[])lengths.Clone();
Array.Sort(_lengths);
}
_maxLen = _lengths[^1];
// We need maxLen + 1 capacity to handle the lookback correctly
// _prices stores raw prices
// _volatility stores bar-to-bar changes. _volatility[i] = Abs(Price[i] - Price[i-1])
_prices = new RingBuffer(_maxLen + 1);
_volatility = new RingBuffer(_maxLen + 1);
_runningSums = new double[_lengths.Length];
_p_runningSums = new double[_lengths.Length];
Name = "Cfb";
_state.PrevCfb = 1.0;
}
public Cfb(ITValuePublisher source, int[]? lengths = null) : this(lengths)
{
source.Pub += (item) => Update(item);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true)
{
double price = input.Value;
if (!double.IsFinite(price))
{
price = _state.LastValidValue;
}
else
{
_state.LastValidValue = price;
}
if (isNew)
{
// Save state
_p_state = _state;
Array.Copy(_runningSums, _p_runningSums, _lengths.Length);
}
else
{
// Restore state
_state = _p_state;
Array.Copy(_p_runningSums, _runningSums, _lengths.Length);
}
// Calculate volatility for this step
double vol = 0.0;
if (_prices.Count > 0)
{
vol = Math.Abs(price - _state.LastPrice);
}
// Update buffers
if (isNew)
{
_prices.Add(price);
_volatility.Add(vol);
}
else
{
_prices.UpdateNewest(price);
_volatility.UpdateNewest(vol);
}
_state.LastPrice = price;
double sumWeightedLen = 0.0;
double sumWeights = 0.0;
int count = _prices.Count;
// Update running sums and calculate ratios
for (int i = 0; i < _lengths.Length; i++)
{
int L = _lengths[i];
// Update running sum of volatility
// We always add the new volatility
// We only subtract if we have enough history
double volToRemove = 0.0;
if (count > L)
{
volToRemove = _volatility[count - 1 - L];
}
_runningSums[i] += vol - volToRemove;
if (count <= L) continue;
// Safety check for very small volatility
if (_runningSums[i] < 1e-12) continue;
// Net move over L bars
// Price at Count-1 is current. Price at Count-1-L is L bars ago.
double netMove = Math.Abs(price - _prices[count - 1 - L]);
double ratio = netMove / _runningSums[i];
if (ratio >= 0.25)
{
sumWeightedLen += L * ratio;
sumWeights += ratio;
}
}
double cfb;
if (sumWeights > 0.25)
{
cfb = sumWeightedLen / sumWeights;
}
else
{
// Decay
cfb = (_state.PrevCfb > 1.0) ? _state.PrevCfb * 0.5 : 1.0;
}
if (cfb < 1.0) cfb = 1.0;
// Round to nearest integer
cfb = Math.Round(cfb);
if (cfb < 1.0) cfb = 1.0;
_state.PrevCfb = cfb;
Last = new TValue(input.Time, cfb);
Pub?.Invoke(Last);
return Last;
}
public TSeries Update(TSeries source)
{
if (source.Count == 0) return [];
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Calculate(source.Values, vSpan, _lengths);
source.Times.CopyTo(tSpan);
// Restore state logic would go here if needed for continuity,
// but for batch processing we usually just return the result.
// To properly support "Update(TValue)" after "Update(TSeries)", we would need to
// replay the last MaxLen bars to populate the buffers.
// Replay last MaxLen bars to restore state
int replayStart = Math.Max(0, len - _maxLen - 1);
_prices.Clear();
_volatility.Clear();
Array.Clear(_runningSums);
_state = default;
_state.PrevCfb = 1.0;
// We need to re-run the update logic for the replay window to populate running sums correctly
// This is expensive but necessary for correct state restoration.
// For the purpose of this implementation, we will just ensure the buffers are populated.
for (int i = replayStart; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), true);
}
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
return new TSeries(t, v);
}
public static TSeries Calculate(TSeries source, int[]? lengths = null)
{
var cfb = new Cfb(lengths);
return cfb.Update(source);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Calculate(ReadOnlySpan<double> source, Span<double> output, int[]? lengths = null)
{
if (source.Length == 0) return;
// Setup lengths
int[] lens;
if (lengths == null || lengths.Length == 0)
{
lens = new int[96];
for (int i = 0; i < 96; i++) lens[i] = (i + 1) * 2;
}
else
{
lens = lengths;
}
int maxLen = 0;
for(int i=0; i<lens.Length; i++) if(lens[i] > maxLen) maxLen = lens[i];
// Pre-calculate volatility for the whole series
// vol[i] = Abs(source[i] - source[i-1])
// We can use a temporary array for this.
int len = source.Length;
double[] volArray = new double[len];
volArray[0] = 0;
for (int i = 1; i < len; i++)
{
volArray[i] = Math.Abs(source[i] - source[i-1]);
}
// We need running sums for each length.
// Since we are processing sequentially, we can maintain the running sums just like in Update.
double[] runningSums = new double[lens.Length];
double prevCfb = 1.0;
for (int i = 0; i < len; i++)
{
double price = source[i];
double currentVol = volArray[i];
double sumWeightedLen = 0.0;
double sumWeights = 0.0;
// For very first bars where i < minLen, result is 1
if (i < lens[0])
{
output[i] = 1.0;
// Still need to update running sums if possible, but we can't really until we have enough data
// Actually we can accumulate volatility.
for (int k = 0; k < lens.Length; k++)
{
runningSums[k] += currentVol;
}
continue;
}
for (int k = 0; k < lens.Length; k++)
{
int L = lens[k];
// Update running sum
runningSums[k] += currentVol;
if (i > L)
{
runningSums[k] -= volArray[i - L];
}
if (i < L) continue;
double totalMove = runningSums[k];
if (totalMove < 1e-12) continue;
double netMove = Math.Abs(price - source[i - L]);
double ratio = netMove / totalMove;
if (ratio >= 0.25)
{
sumWeightedLen += L * ratio;
sumWeights += ratio;
}
}
double cfb;
if (sumWeights > 0.25)
{
cfb = sumWeightedLen / sumWeights;
}
else
{
cfb = (prevCfb > 1.0) ? prevCfb * 0.5 : 1.0;
}
if (cfb < 1.0) cfb = 1.0;
cfb = Math.Round(cfb);
if (cfb < 1.0) cfb = 1.0;
output[i] = cfb;
prevCfb = cfb;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
# CFB - Jurik Composite Fractal Behavior
## Overview and Purpose
Composite Fractal Behavior (CFB) is a sophisticated trend duration index developed by Jurik Research. It measures the "fractal efficiency" of price movements across multiple time scales to determine the quality and duration of a trend. Unlike traditional trend indicators that look at a single period, CFB analyzes a spectrum of lookback periods to create a composite index.
CFB is designed to answer the question: "How long has the market been trending efficiently?" It is particularly useful for:
* Adjusting the period of other indicators (adaptive indicators).
* Filtering out choppy markets.
* Identifying the breakdown of long-term trends.
## Core Concepts
* **Fractal Efficiency:** Measures how "straight" the price movement is. A straight line has high efficiency; a choppy path has low efficiency.
* **Composite Index:** Instead of relying on a single lookback length, CFB evaluates a wide range of lengths (e.g., 4 to 192 bars) and combines them based on their efficiency.
* **Adaptive:** The indicator adapts to the market's current fractal structure, giving more weight to timeframes where trending behavior is evident.
* **Trend Duration:** The output value represents the approximate duration (in bars) of the current trend.
## Common Settings and Parameters
| Parameter | Default | Function |
|-----------|---------|----------|
| Lengths | `[2, 4, ..., 192]` | Array of lookback periods to analyze. Default is a dense array from 2 to 192. |
| Source | Close | Price data used for calculation. |
**Pro Tip:** CFB values typically range from 0 to the maximum lookback length. A rising CFB indicates a strengthening trend (either up or down), while a falling CFB suggests the trend is breaking down or the market is entering a consolidation phase.
## Calculation and Mathematical Foundation
The CFB calculation involves several steps for each lookback length $L$ in the provided set:
1. **Calculate Efficiency Ratio:**
For each length $L$, calculate the ratio of the net price movement to the total volatility (path length) over that period.
$$Ratio_L = \frac{|Price_t - Price_{t-L}|}{\sum_{i=0}^{L-1} |Price_{t-i} - Price_{t-i-1}|}$$
2. **Filter:**
Only consider lengths where the efficiency ratio exceeds a threshold (typically 0.25). This filters out noise and weak trends.
3. **Weighted Average:**
Calculate the weighted average of the qualifying lengths, using the efficiency ratio as the weight.
$$CFB = \frac{\sum (L \cdot Ratio_L)}{\sum Ratio_L}$$
where the summation is over all $L$ such that $Ratio_L > 0.25$.
4. **Decay:**
If no lengths qualify (i.e., the market is very choppy), the CFB value decays towards 1.0.
## C# Implementation
The library provides a high-performance implementation that uses `RingBuffer` for O(1) updates of the volatility sums.
### Single CFB (`Cfb`)
```csharp
using QuanTAlib;
// Initialize with default lengths
var cfb = new Cfb();
// Or specify custom lengths
var cfbCustom = new Cfb(new int[] { 10, 20, 30, 40, 50 });
// Streaming update
TValue result = cfb.Update(new TValue(time, price));
Console.WriteLine($"Current Trend Duration: {result.Value}");
```
### Zero-Allocation Span API
For performance-critical scenarios:
```csharp
double[] prices = ...;
double[] output = new double[prices.Length];
// Calculate using default lengths
Cfb.Calculate(prices.AsSpan(), output.AsSpan());
```
### Bar Correction (isNew Parameter)
`Cfb` supports intra-bar updates:
```csharp
// Real-time: receive initial tick for new bar
cfb.Update(new TValue(time, 100.5), isNew: true);
// Real-time: price updates within same bar
cfb.Update(new TValue(time, 101.0), isNew: false);
```
## Interpretation Details
* **High Values:** Indicate a strong, persistent trend. The value roughly corresponds to the number of bars the trend has been in effect.
* **Low Values:** Indicate a choppy, non-trending market.
* **Rising CFB:** The trend is gaining strength or duration.
* **Falling CFB:** The trend is losing consistency or ending.
CFB is often used as an input to other adaptive indicators (e.g., JMA) to dynamically adjust their smoothing period based on market conditions.
## References
* Jurik Research: [CFB - Composite Fractal Behavior](http://jurikres.com/catalog1/ms_cfb.htm)
+1 -1
View File
@@ -14,7 +14,7 @@ public class RsxIndicatorTests
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("RSX - Relative Strength X", indicator.Name);
Assert.Equal("RSX - Jurik Relative Strength Index", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
+2 -2
View File
@@ -30,8 +30,8 @@ public class RsxIndicator : Indicator, IWatchlistIndicator
OnBackGround = true;
SeparateWindow = true;
SourceName = Source.ToString();
Name = "RSX - Relative Strength X";
Description = "Jurik's RSX: A noise-free, zero-lag version of RSI";
Name = "RSX - Jurik Relative Strength Index";
Description = "Jurik's RSI: A noise-free, zero-lag version of RSI";
Series = new(name: $"RSX {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
}
+1 -1
View File
@@ -5,7 +5,7 @@ using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RSX: Relative Strength X (Jurik's RSI Variant)
/// RSX: Jurik Relative Strength Index (Jurik's RSI Variant)
/// </summary>
/// <remarks>
/// RSX is a noise-free version of RSI that eliminates lag and choppiness.
+1 -1
View File
@@ -1,4 +1,4 @@
# RSX - Relative Strength X (Jurik's RSI Variant)
# RSX - Jurik Relative Strength X
RSX is a noise-free version of the Relative Strength Index (RSI) developed by Mark Jurik. It eliminates the lag and choppiness associated with standard RSI and its smoothed variants. RSX preserves the 0-100 bounded range and turning points of RSI but provides a much smoother signal, making it easier to identify trends and reversals without false signals from whipsaw movements.
+1 -1
View File
@@ -14,7 +14,7 @@ public class VelIndicatorTests
Assert.Equal(14, indicator.Period);
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Equal("VEL - Jurik's Velocity", indicator.Name);
Assert.Equal("VEL - Jurik Velocity", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
+1 -1
View File
@@ -30,7 +30,7 @@ public class VelIndicator : Indicator, IWatchlistIndicator
OnBackGround = true;
SeparateWindow = true;
SourceName = Source.ToString();
Name = "VEL - Jurik's Velocity";
Name = "VEL - Jurik Velocity";
Description = "Momentum oscillator calculated as PWMA - WMA";
Series = new(name: $"VEL {Period}", color: IndicatorExtensions.Momentum, width: 2, style: LineStyle.Solid);
AddLineSeries(Series);
+1 -1
View File
@@ -5,7 +5,7 @@ using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// VEL: Jurik's Velocity
/// VEL: Jurik Velocity
/// </summary>
/// <remarks>
/// VEL is a momentum oscillator calculated as the difference between a Parabolic Weighted Moving Average (PWMA)
+1 -1
View File
@@ -1,4 +1,4 @@
# VEL - Jurik's Velocity
# VEL - Jurik Velocity
VEL (Jurik's Velocity) is a momentum oscillator that measures the rate of change of price. It is calculated as the difference between a Parabolic Weighted Moving Average (PWMA) and a Weighted Moving Average (WMA) of the same period.
+1 -1
View File
@@ -36,7 +36,7 @@ Trend indicators help identify the direction and strength of a market trend. Mov
| HT_TRENDMODE | Ehlers Hilbert Transform Trend Mode | |
| HWMA | Holt Weighted MA | |
| ICHIMOKU | Ichimoku Cloud | |
| [JMA](jma/Jma.md) | Jurik MA | Adaptive moving average that adjusts to market volatility for superior smoothing with minimal lag. |
| [JMA](jma/Jma.md) | Jurik Moving Average | Adaptive moving average that adjusts to market volatility for superior smoothing with minimal lag. |
| [KAMA](kama/Kama.md) | Kaufman Adaptive MA | Adapts to market volatility by adjusting its smoothing factor based on an Efficiency Ratio. |
| KF | Kalman Filter | |
| LOESS | LOESS/LOWESS Smoothing | |