From 9587560cd8ad9d3147268b76820d7a2666a97d27 Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Sat, 13 Dec 2025 21:38:32 -0800 Subject: [PATCH] feat: Add Jurik Composite Fractal Behavior (CFB) indicator and tests --- lib/momentum/_index.md | 3 +- lib/momentum/cfb/Cfb.Quantower.Tests.cs | 187 ++++++++++++ lib/momentum/cfb/Cfb.Quantower.cs | 77 +++++ lib/momentum/cfb/Cfb.Tests.cs | 158 ++++++++++ lib/momentum/cfb/Cfb.Validation.Tests.cs | 68 +++++ lib/momentum/cfb/Cfb.cs | 348 +++++++++++++++++++++++ lib/momentum/cfb/Cfb.md | 103 +++++++ lib/momentum/rsx/Rsx.Quantower.Tests.cs | 2 +- lib/momentum/rsx/Rsx.Quantower.cs | 4 +- lib/momentum/rsx/Rsx.cs | 2 +- lib/momentum/rsx/Rsx.md | 2 +- lib/momentum/vel/Vel.Quantower.Tests.cs | 2 +- lib/momentum/vel/Vel.Quantower.cs | 2 +- lib/momentum/vel/Vel.cs | 2 +- lib/momentum/vel/Vel.md | 2 +- lib/trends/_index.md | 2 +- 16 files changed, 953 insertions(+), 11 deletions(-) create mode 100644 lib/momentum/cfb/Cfb.Quantower.Tests.cs create mode 100644 lib/momentum/cfb/Cfb.Quantower.cs create mode 100644 lib/momentum/cfb/Cfb.Tests.cs create mode 100644 lib/momentum/cfb/Cfb.Validation.Tests.cs create mode 100644 lib/momentum/cfb/Cfb.cs create mode 100644 lib/momentum/cfb/Cfb.md diff --git a/lib/momentum/_index.md b/lib/momentum/_index.md index a58f4490..eea3ebe7 100644 --- a/lib/momentum/_index.md +++ b/lib/momentum/_index.md @@ -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 | | diff --git a/lib/momentum/cfb/Cfb.Quantower.Tests.cs b/lib/momentum/cfb/Cfb.Quantower.Tests.cs new file mode 100644 index 00000000..2a405258 --- /dev/null +++ b/lib/momentum/cfb/Cfb.Quantower.Tests.cs @@ -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); + } +} diff --git a/lib/momentum/cfb/Cfb.Quantower.cs b/lib/momentum/cfb/Cfb.Quantower.cs new file mode 100644 index 00000000..145e6309 --- /dev/null +++ b/lib/momentum/cfb/Cfb.Quantower.cs @@ -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); + } +} diff --git a/lib/momentum/cfb/Cfb.Tests.cs b/lib/momentum/cfb/Cfb.Tests.cs new file mode 100644 index 00000000..ad912ea6 --- /dev/null +++ b/lib/momentum/cfb/Cfb.Tests.cs @@ -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(); + 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(); + 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(); + 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]); + } + } +} diff --git a/lib/momentum/cfb/Cfb.Validation.Tests.cs b/lib/momentum/cfb/Cfb.Validation.Tests.cs new file mode 100644 index 00000000..aedbb10f --- /dev/null +++ b/lib/momentum/cfb/Cfb.Validation.Tests.cs @@ -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"); + } +} diff --git a/lib/momentum/cfb/Cfb.cs b/lib/momentum/cfb/Cfb.cs new file mode 100644 index 00000000..780f6234 --- /dev/null +++ b/lib/momentum/cfb/Cfb.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace QuanTAlib; + +/// +/// CFB: Jurik Composite Fractal Behavior (Trend Duration Index) +/// +/// +/// 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. +/// +[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? Pub; + public TValue Last { get; private set; } + public bool IsHot => _prices.IsFull; + + /// + /// Creates a CFB indicator with specified fractal lengths. + /// + /// Array of lookback lengths. If null, defaults to 2, 4, ..., 192. + 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(len); + var v = new List(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 source, Span 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 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; + } + } +} diff --git a/lib/momentum/cfb/Cfb.md b/lib/momentum/cfb/Cfb.md new file mode 100644 index 00000000..efbf1e5d --- /dev/null +++ b/lib/momentum/cfb/Cfb.md @@ -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) diff --git a/lib/momentum/rsx/Rsx.Quantower.Tests.cs b/lib/momentum/rsx/Rsx.Quantower.Tests.cs index 0f3a2ac9..41970172 100644 --- a/lib/momentum/rsx/Rsx.Quantower.Tests.cs +++ b/lib/momentum/rsx/Rsx.Quantower.Tests.cs @@ -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); } diff --git a/lib/momentum/rsx/Rsx.Quantower.cs b/lib/momentum/rsx/Rsx.Quantower.cs index 4b3f3c85..5588113e 100644 --- a/lib/momentum/rsx/Rsx.Quantower.cs +++ b/lib/momentum/rsx/Rsx.Quantower.cs @@ -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); } diff --git a/lib/momentum/rsx/Rsx.cs b/lib/momentum/rsx/Rsx.cs index fefe98db..e3e53b93 100644 --- a/lib/momentum/rsx/Rsx.cs +++ b/lib/momentum/rsx/Rsx.cs @@ -5,7 +5,7 @@ using System.Runtime.InteropServices; namespace QuanTAlib; /// -/// RSX: Relative Strength X (Jurik's RSI Variant) +/// RSX: Jurik Relative Strength Index (Jurik's RSI Variant) /// /// /// RSX is a noise-free version of RSI that eliminates lag and choppiness. diff --git a/lib/momentum/rsx/Rsx.md b/lib/momentum/rsx/Rsx.md index 4d2fbf97..74e8ce7f 100644 --- a/lib/momentum/rsx/Rsx.md +++ b/lib/momentum/rsx/Rsx.md @@ -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. diff --git a/lib/momentum/vel/Vel.Quantower.Tests.cs b/lib/momentum/vel/Vel.Quantower.Tests.cs index b425b7fe..d891feb7 100644 --- a/lib/momentum/vel/Vel.Quantower.Tests.cs +++ b/lib/momentum/vel/Vel.Quantower.Tests.cs @@ -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); } diff --git a/lib/momentum/vel/Vel.Quantower.cs b/lib/momentum/vel/Vel.Quantower.cs index a2d9e032..1725f6a2 100644 --- a/lib/momentum/vel/Vel.Quantower.cs +++ b/lib/momentum/vel/Vel.Quantower.cs @@ -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); diff --git a/lib/momentum/vel/Vel.cs b/lib/momentum/vel/Vel.cs index 999f45d4..4bf110e1 100644 --- a/lib/momentum/vel/Vel.cs +++ b/lib/momentum/vel/Vel.cs @@ -5,7 +5,7 @@ using System.Runtime.InteropServices; namespace QuanTAlib; /// -/// VEL: Jurik's Velocity +/// VEL: Jurik Velocity /// /// /// VEL is a momentum oscillator calculated as the difference between a Parabolic Weighted Moving Average (PWMA) diff --git a/lib/momentum/vel/Vel.md b/lib/momentum/vel/Vel.md index 6c3a4c7f..ebeba9e0 100644 --- a/lib/momentum/vel/Vel.md +++ b/lib/momentum/vel/Vel.md @@ -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. diff --git a/lib/trends/_index.md b/lib/trends/_index.md index f3da113d..09ff43cd 100644 --- a/lib/trends/_index.md +++ b/lib/trends/_index.md @@ -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 | |