python wrapper

This commit is contained in:
Miha Kralj
2026-02-28 14:14:35 -08:00
parent 82e0248eb0
commit 83e9511261
521 changed files with 62395 additions and 15669 deletions
@@ -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
}
+53 -28
View File
@@ -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;
}
+10 -1
View File
@@ -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
+6 -3
View File
@@ -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]