mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-26 06:18:05 +00:00
python wrapper
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
using Skender.Stock.Indicators;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Tulip;
|
||||
using Xunit;
|
||||
@@ -8,8 +9,8 @@ using Xunit.Abstractions;
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Fisher Transform against Tulip NETCore and manual computation.
|
||||
/// Tulip's fisher indicator uses the same normalization + arctanh approach.
|
||||
/// Validates Fisher Transform against Skender, Tulip, Ooples, and manual computation.
|
||||
/// Primary reference: Skender (Ehlers 2002 IIR algorithm with HL2 input).
|
||||
/// </summary>
|
||||
public sealed class FisherValidationTests(ITestOutputHelper output) : IDisposable
|
||||
{
|
||||
@@ -65,9 +66,10 @@ public sealed class FisherValidationTests(ITestOutputHelper output) : IDisposabl
|
||||
double[] batchOutput = new double[values.Length];
|
||||
Fisher.Batch(values.AsSpan(), batchOutput.AsSpan(), period);
|
||||
|
||||
// Manual computation
|
||||
// Manual computation — Ehlers 2002 TASC algorithm
|
||||
double[] manualOutput = new double[values.Length];
|
||||
double emaValue = 0.0;
|
||||
double fisherValue = 0.0;
|
||||
var buffer = new double[period];
|
||||
int bufCount = 0;
|
||||
int bufIdx = 0;
|
||||
@@ -104,14 +106,30 @@ public sealed class FisherValidationTests(ITestOutputHelper output) : IDisposabl
|
||||
}
|
||||
|
||||
double range = highest - lowest;
|
||||
double normalized = range > 0.0
|
||||
? 2.0 * ((val - lowest) / range) - 1.0
|
||||
: 0.0;
|
||||
if (range != 0.0)
|
||||
{
|
||||
emaValue = (0.66 * (((val - lowest) / range) - 0.5))
|
||||
+ (0.67 * emaValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
emaValue = 0.0; // Skender: xv[i] = 0 when range=0
|
||||
}
|
||||
|
||||
emaValue = 0.33 * normalized + 0.67 * emaValue;
|
||||
// Ehlers/Skender: snap to ±0.999 when |Value1| > 0.99
|
||||
// Clamped value stored back — Skender stores array2[i] clamped
|
||||
if (emaValue > 0.99)
|
||||
{
|
||||
emaValue = 0.999;
|
||||
}
|
||||
else if (emaValue < -0.99)
|
||||
{
|
||||
emaValue = -0.999;
|
||||
}
|
||||
|
||||
double clamped = Math.Clamp(emaValue, -0.999, 0.999);
|
||||
manualOutput[i] = 0.5 * Math.Log((1.0 + clamped) / (1.0 - clamped));
|
||||
// Ehlers 2002: Fish = arctanh(Value1) + 0.5 * Fish[1] (IIR feedback)
|
||||
fisherValue = 0.5 * Math.Log((1.0 + emaValue) / (1.0 - emaValue)) + 0.5 * fisherValue;
|
||||
manualOutput[i] = fisherValue;
|
||||
}
|
||||
|
||||
int validCount = 0;
|
||||
@@ -310,4 +328,109 @@ public sealed class FisherValidationTests(ITestOutputHelper output) : IDisposabl
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Skender Cross-Validation
|
||||
|
||||
/// <summary>
|
||||
/// Numeric validation against Skender <c>GetFisherTransform</c>.
|
||||
/// Both use Ehlers 2002 IIR algorithm: <c>Fish = arctanh(Value1) + 0.5 * Fish[1]</c>.
|
||||
/// Skender uses HL2 input with expanding window during warmup.
|
||||
/// QuanTAlib uses same HL2 input via RingBuffer (expanding window when not full).
|
||||
/// Both should converge; tolerance allows warmup-phase divergence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Skender_FisherTransform_Numeric()
|
||||
{
|
||||
const int period = 10;
|
||||
var sResult = _testData.SkenderQuotes.GetFisherTransform(period).ToList();
|
||||
|
||||
// Feed HL2 to QuanTAlib (same input as Skender)
|
||||
var quotes = _testData.SkenderQuotes.ToList();
|
||||
var fisher = new Fisher(period);
|
||||
var qtFisher = new double[quotes.Count];
|
||||
var qtSignal = new double[quotes.Count];
|
||||
for (int i = 0; i < quotes.Count; i++)
|
||||
{
|
||||
// Match Skender's HL2 computation: decimal arithmetic then convert
|
||||
double hl2 = (double)((quotes[i].High + quotes[i].Low) / 2m);
|
||||
fisher.Update(new TValue(quotes[i].Date, hl2));
|
||||
qtFisher[i] = fisher.FisherValue;
|
||||
qtSignal[i] = fisher.Signal;
|
||||
}
|
||||
|
||||
// Numeric comparison — skip warmup (first 2*period bars)
|
||||
int startIdx = period * 2;
|
||||
int validCount = 0;
|
||||
for (int i = startIdx; i < sResult.Count; i++)
|
||||
{
|
||||
if (sResult[i].Fisher is null) { continue; }
|
||||
double sFisher = sResult[i].Fisher!.Value;
|
||||
|
||||
Assert.True(Math.Abs(sFisher - qtFisher[i]) < 1e-9,
|
||||
$"Fisher mismatch at i={i}: Skender={sFisher:F9}, QuanTAlib={qtFisher[i]:F9}");
|
||||
validCount++;
|
||||
}
|
||||
|
||||
Assert.True(validCount > 100, $"Expected >100 valid comparisons, got {validCount}");
|
||||
_output.WriteLine($"Fisher Skender numeric: validated {validCount} points at 1e-9 tolerance.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates signal line (Trigger = Fish[1]) matches Skender's Trigger output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Skender_Signal_Numeric()
|
||||
{
|
||||
const int period = 10;
|
||||
var sResult = _testData.SkenderQuotes.GetFisherTransform(period).ToList();
|
||||
|
||||
// Feed HL2 to QuanTAlib
|
||||
var quotes = _testData.SkenderQuotes.ToList();
|
||||
var fisher = new Fisher(period);
|
||||
var qtSignal = new double[quotes.Count];
|
||||
for (int i = 0; i < quotes.Count; i++)
|
||||
{
|
||||
double hl2 = (double)((quotes[i].High + quotes[i].Low) / 2m);
|
||||
fisher.Update(new TValue(quotes[i].Date, hl2));
|
||||
qtSignal[i] = fisher.Signal;
|
||||
}
|
||||
|
||||
// Signal comparison — skip warmup
|
||||
int startIdx = period * 2;
|
||||
int validCount = 0;
|
||||
for (int i = startIdx; i < sResult.Count; i++)
|
||||
{
|
||||
if (sResult[i].Trigger is null) { continue; }
|
||||
double sTrigger = sResult[i].Trigger!.Value;
|
||||
|
||||
Assert.True(Math.Abs(sTrigger - qtSignal[i]) < 1e-9,
|
||||
$"Signal mismatch at i={i}: Skender={sTrigger:F9}, QuanTAlib={qtSignal[i]:F9}");
|
||||
validCount++;
|
||||
}
|
||||
|
||||
Assert.True(validCount > 100, $"Expected >100 valid signal comparisons, got {validCount}");
|
||||
_output.WriteLine($"Fisher Signal Skender numeric: validated {validCount} points at 1e-9 tolerance.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structural validation: both Skender and QuanTAlib produce finite output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Skender_FisherTransform_Structural()
|
||||
{
|
||||
var sResult = _testData.SkenderQuotes.GetFisherTransform(TestPeriod).ToList();
|
||||
|
||||
var fisher = new Fisher(TestPeriod);
|
||||
foreach (var item in _testData.Data) { fisher.Update(item); }
|
||||
|
||||
int finiteCount = sResult.Count(r => r.Fisher is not null && double.IsFinite(r.Fisher.Value));
|
||||
Assert.True(finiteCount > 100, $"Skender should produce >100 finite Fisher values, got {finiteCount}");
|
||||
Assert.True(fisher.IsHot, "QuanTAlib Fisher must be hot");
|
||||
Assert.True(double.IsFinite(fisher.Last.Value), "QuanTAlib Fisher last must be finite");
|
||||
|
||||
_output.WriteLine($"Fisher Skender structural: {finiteCount} finite Skender values, " +
|
||||
$"QuanTAlib last={fisher.Last.Value:F6}, Skender last={sResult[^1].Fisher:F6}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ namespace QuanTAlib;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Converts price into a Gaussian normal distribution via the inverse
|
||||
/// hyperbolic tangent, producing sharp turning points for reversal detection:
|
||||
/// <c>Fisher = 0.5 × ln((1 + v) / (1 − v))</c>
|
||||
/// hyperbolic tangent with IIR feedback, producing sharp turning points:
|
||||
/// <c>Fisher = atanh(v) + 0.5 × Fish[1]</c>
|
||||
/// where <c>v</c> is the EMA-smoothed normalized price clamped to (−0.999, 0.999).
|
||||
///
|
||||
/// Normalization maps price to [−1, 1] using highest/lowest over <c>period</c> bars.
|
||||
/// Signal line is an EMA of <c>Fisher</c> with the same smoothing factor (α = 0.33).
|
||||
/// Signal line (Trigger) is the previous bar's Fisher value: <c>Fish[1]</c>.
|
||||
///
|
||||
/// References:
|
||||
/// John Ehlers, "Using The Fisher Transform", 2002
|
||||
@@ -24,7 +24,6 @@ public sealed class Fisher : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
@@ -56,7 +55,6 @@ public sealed class Fisher : AbstractBase
|
||||
|
||||
_period = period;
|
||||
_alpha = alpha;
|
||||
_decay = 1.0 - alpha;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Fisher({period})";
|
||||
WarmupPeriod = period;
|
||||
@@ -138,25 +136,38 @@ public sealed class Fisher : AbstractBase
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to [-1, 1]
|
||||
// Ehlers/Skender normalization
|
||||
double range = highest - lowest;
|
||||
double normalized = range > 0.0
|
||||
? 2.0 * ((value - lowest) / range) - 1.0
|
||||
: 0.0;
|
||||
if (range != 0.0)
|
||||
{
|
||||
_state.Value = (0.66 * (((value - lowest) / range) - 0.5))
|
||||
+ (0.67 * _state.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.Value = 0.0; // Skender: xv[i] = 0 when range=0
|
||||
}
|
||||
|
||||
// EMA smooth the normalized value
|
||||
_state.Value = Math.FusedMultiplyAdd(_state.Value, _decay, _alpha * normalized);
|
||||
// Ehlers/Skender: snap to ±0.999 when |Value1| > 0.99
|
||||
// Clamped value MUST be stored back — Skender stores array2[i] clamped,
|
||||
// so next iteration's IIR feedback (0.67 * xv[i-1]) uses the clamped value.
|
||||
if (_state.Value > 0.99)
|
||||
{
|
||||
_state.Value = 0.999;
|
||||
}
|
||||
else if (_state.Value < -0.99)
|
||||
{
|
||||
_state.Value = -0.999;
|
||||
}
|
||||
|
||||
// Clamp to (-0.999, 0.999) — domain protection for arctanh
|
||||
double clamped = Math.Clamp(_state.Value, -0.999, 0.999);
|
||||
// Ehlers 2002: Fish = arctanh(Value1) + 0.5 * Fish[1] (IIR feedback)
|
||||
double fisher = (0.5 * Math.Log((1.0 + _state.Value) / (1.0 - _state.Value)))
|
||||
+ (0.5 * _state.FisherValue);
|
||||
|
||||
// Fisher Transform: arctanh(x) = 0.5 * ln((1+x)/(1-x))
|
||||
double fisher = 0.5 * Math.Log((1.0 + clamped) / (1.0 - clamped));
|
||||
// Signal line: previous bar's Fisher value (Fish[1])
|
||||
_state.Signal = _state.FisherValue;
|
||||
_state.FisherValue = fisher;
|
||||
|
||||
// Signal line: EMA of Fisher
|
||||
_state.Signal = Math.FusedMultiplyAdd(_state.Signal, _decay, _alpha * fisher);
|
||||
|
||||
Last = new TValue(input.Time, fisher);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
@@ -252,7 +263,6 @@ public sealed class Fisher : AbstractBase
|
||||
return;
|
||||
}
|
||||
|
||||
double decay = 1.0 - alpha;
|
||||
var buffer = new RingBuffer(period);
|
||||
double emaValue = 0.0;
|
||||
double fisherValue = 0.0;
|
||||
@@ -290,18 +300,33 @@ public sealed class Fisher : AbstractBase
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize
|
||||
// Ehlers/Skender normalization
|
||||
double range = highest - lowest;
|
||||
double normalized = range > 0.0
|
||||
? 2.0 * ((val - lowest) / range) - 1.0
|
||||
: 0.0;
|
||||
if (range != 0.0)
|
||||
{
|
||||
emaValue = (0.66 * (((val - lowest) / range) - 0.5))
|
||||
+ (0.67 * emaValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
emaValue = 0.0; // Skender: xv[i] = 0 when range=0
|
||||
}
|
||||
|
||||
// EMA smooth
|
||||
emaValue = Math.FusedMultiplyAdd(emaValue, decay, alpha * normalized);
|
||||
// Ehlers/Skender: snap to ±0.999 when |Value1| > 0.99
|
||||
// Clamped value stored back — Skender stores array2[i] clamped,
|
||||
// so next iteration's IIR feedback (0.67 * xv[i-1]) uses the clamped value.
|
||||
if (emaValue > 0.99)
|
||||
{
|
||||
emaValue = 0.999;
|
||||
}
|
||||
else if (emaValue < -0.99)
|
||||
{
|
||||
emaValue = -0.999;
|
||||
}
|
||||
|
||||
// Clamp and transform
|
||||
double clamped = Math.Clamp(emaValue, -0.999, 0.999);
|
||||
fisherValue = 0.5 * Math.Log((1.0 + clamped) / (1.0 - clamped));
|
||||
// Ehlers 2002: Fish = arctanh(Value1) + 0.5 * Fish[1] (IIR feedback)
|
||||
fisherValue = (0.5 * Math.Log((1.0 + emaValue) / (1.0 - emaValue)))
|
||||
+ (0.5 * fisherValue);
|
||||
|
||||
output[i] = fisherValue;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,14 @@ The arctanh function diverges at ±1. [`Math.Clamp`](lib/oscillators/fisher/Fish
|
||||
|
||||
## Validation
|
||||
|
||||
No standard TA-Lib implementation matches this exact formulation (Ehlers' EMA-smoothed variant with configurable alpha). Validation is performed against manual arctanh computation and cross-mode consistency.
|
||||
| Library | Status | Tolerance | Notes |
|
||||
|---------|--------|-----------|-------|
|
||||
| Skender | ✅ Numeric | `1e-9` | `GetFisherTransform(period)` Fisher + Trigger validated after 2× period warmup, HL2 input |
|
||||
| Tulip | ✅ Structural | -- | Two-input (high[], low[]) variant; both produce finite output on same data |
|
||||
| Ooples | ✅ Structural | -- | `CalculateEhlersFisherTransform`; OHLCV input differs from single-price; finite output verified |
|
||||
| TA-Lib | -- | -- | No TA-Lib Fisher Transform implementation |
|
||||
|
||||
### Internal Consistency
|
||||
|
||||
| Check | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
@@ -136,6 +143,8 @@ No standard TA-Lib implementation matches this exact formulation (Ehlers' EMA-sm
|
||||
| Streaming vs Batch vs Span | ✅ | All three modes agree within 1e-9 |
|
||||
| Event-based vs Streaming | ✅ | Identical within 1e-12 |
|
||||
|
||||
Skender uses the same Ehlers 2002 IIR algorithm (`Fish = arctanh(Value1) + 0.5 × Fish[1]`) with HL2 input. QuanTAlib matches Skender numerically at `1e-9` tolerance after warmup convergence. The signal line (`Trigger = Fish[1]`) also matches at `1e-9`. Tulip and Ooples use different input conventions (high/low arrays vs OHLCV), so only structural validation (finite output, correct sign direction) is asserted.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
@@ -27,11 +27,14 @@ fisher(series float source, simple int period) =>
|
||||
float alpha = 0.33
|
||||
value := alpha * normalized + (1.0 - alpha) * value
|
||||
|
||||
value := math.max(-0.999, math.min(0.999, value))
|
||||
// Ehlers/Skender: snap to ±0.999 when |Value1| > 0.99
|
||||
value := value > 0.99 ? 0.999 : value < -0.99 ? -0.999 : value
|
||||
|
||||
fisher := 0.5 * math.log((1.0 + value) / (1.0 - value))
|
||||
// Ehlers 2002: Fish = arctanh(Value1) + 0.5 * Fish[1] (IIR feedback)
|
||||
fisher := 0.5 * math.log((1.0 + value) / (1.0 - value)) + 0.5 * fisher
|
||||
|
||||
signal := alpha * fisher + (1.0 - alpha) * signal
|
||||
// Signal = Fish[1] (previous bar's Fisher)
|
||||
signal := fisher[1]
|
||||
|
||||
[fisher, signal]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user