mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-07 13:37:44 +00:00
Add Ultimate Oscillator implementation and documentation
- Introduced the Ultimate Oscillator (UltOsc) indicator with detailed mathematical foundation and performance profile. - Added historical context and common pitfalls for better user understanding. - Implemented Bilateral filter with enhanced update methods and batch calculations. - Updated Blackman Moving Average (BLMA) with improved handling of NaN values and batch processing capabilities. - Created unit tests for AmatIndicator to ensure proper functionality and signal generation. - Integrated AmatIndicator into the Quantower platform with appropriate line series for trend and strength visualization. - Updated project file to include new indicator implementations.
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
- **Momentum**
|
||||
- [Overview](../lib/momentum/_index.md)
|
||||
- [ADX - Average Directional Index](../lib/momentum/adx/Adx.md)
|
||||
- [AMAT - Archer Moving Averages Trends](../lib/momentum/amat/Amat.md)
|
||||
- [ADXR - Average Directional Movement Rating](../lib/momentum/adxr/Adxr.md)
|
||||
- [AO - Awesome Oscillator](../lib/momentum/ao/Ao.md)
|
||||
- [APO - Absolute Price Oscillator](../lib/momentum/apo/Apo.md)
|
||||
|
||||
@@ -55,6 +55,7 @@ These measure the spread of data points around the mean.
|
||||
|
||||
- [**ADX**](../lib/momentum/adx/Adx.md) - Average Directional Index
|
||||
- [**ADXR**](../lib/momentum/adxr/Adxr.md) - Average Directional Movement Rating
|
||||
- [**AMAT**](../lib/momentum/amat/Amat.md) - Archer Moving Averages Trends
|
||||
- [**AO**](../lib/momentum/ao/Ao.md) - Awesome Oscillator
|
||||
- [**AROON**](../lib/momentum/aroon/Aroon.md) - Aroon
|
||||
- [**AROONOSC**](../lib/momentum/aroonosc/AroonOsc.md) - Aroon Oscillator
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
| **Accumulation/Distribution Oscillator** | [Adosc](../lib/volume/adosc/adosc.md) | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| **Adaptive Price Zone** | Apz | - | - | - | ❔ |
|
||||
| **Andrews' Pitchfork** | Apchannel | - | - | - | - |
|
||||
| **Archer Moving Averages Trends** | Amat | - | - | - | - |
|
||||
| **Archer Moving Averages Trends** | [Amat](../lib/momentum/amat/Amat.md) | - | - | ✔️ | ✔️ |
|
||||
| **Archer On-Balance Volume** | Aobv | - | - | - | - |
|
||||
| **Arnaud Legoux Moving Average** | [Alma](../lib/trends/alma/alma.md) | - | - | ✔️ | ✔️ |
|
||||
| **Aroon** | [Aroon](../lib/momentum/aroon/aroon.md) | ✔️ | ✔️ | ✔️ | - |
|
||||
@@ -243,7 +243,7 @@
|
||||
| **Ulcer Index** | Ui | - | - | UlcerIndex | ❔ |
|
||||
| **Ultimate Bands** | Ubands | - | - | - | ❔ |
|
||||
| **Ultimate Channel** | Uchannel | - | - | - | - |
|
||||
| **Ultimate Oscillator** | Ultosc | ULTOSC | ultosc | Ultimate | ❔ |
|
||||
| **Ultimate Oscillator** | [Ultosc](../lib/momentum/ultosc/Ultosc.md) | ✔️ | ✔️ | ✔️ | ✔️ |
|
||||
| **Variable Index Dynamic Average** | [Vidya](../lib/trends/vidya/vidya.md) | - | vidya | - | ❔ |
|
||||
| **Velocity (Jurik)** | [Vel](../lib/momentum/vel/vel.md) | - | - | - | - |
|
||||
| **Volatility Adjusted Moving Average** | Vama | - | - | - | ❔ |
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
| [AFIRMA](trends/afirma/Afirma.md) | Autoregressive FIR MA | Trends |
|
||||
| ALLIGATOR | Williams Alligator | Trends |
|
||||
| [ALMA](trends/alma/Alma.md) | Arnaud Legoux MA | Trends |
|
||||
| AMAT | Archer Moving Averages Trends | Trends |
|
||||
| [AMAT](momentum/amat/Amat.md) | Archer Moving Averages Trends | Momentum |
|
||||
| [AO](momentum/ao/Ao.md) | Awesome Oscillator | Momentum |
|
||||
| AOBV | Archer On-Balance Volume | Volume |
|
||||
| APCHANNEL | Andrews' Pitchfork | Channels |
|
||||
|
||||
@@ -6,6 +6,7 @@ Momentum indicators measure the speed or strength of price movements. This inclu
|
||||
| :--- | :--- | :--- |
|
||||
| AC | Acceleration Oscillator | |
|
||||
| [ADX](adx/Adx.md) | Average Directional Index | Quantifies trend intensity by smoothing the expansion of daily ranges, independent of direction. |
|
||||
| [AMAT](amat/Amat.md) | Archer Moving Averages Trends | Identifies trend direction and strength using dual EMAs with slope confirmation. |
|
||||
| [ADXR](adxr/Adxr.md) | Average Directional Movement Rating | Quantifies the change in momentum of the ADX by averaging current and historical values. |
|
||||
| [AO](ao/Ao.md) | Awesome Oscillator | Measures immediate velocity vs. broader trend using the difference between fast and slow median-price SMAs. |
|
||||
| [APO](apo/Apo.md) | Absolute Price Oscillator | Measures the absolute difference between two moving averages (Fast EMA - Slow EMA). |
|
||||
@@ -43,7 +44,7 @@ Momentum indicators measure the speed or strength of price movements. This inclu
|
||||
| STOCHRSI | Stochastic RSI | |
|
||||
| TRIX | Triple Exponential Average | |
|
||||
| TSI | True Strength Index | |
|
||||
| ULTOSC | Ultimate Oscillator | |
|
||||
| [ULTOSC](ultosc/Ultosc.md) | Ultimate Oscillator | Combines three time frames with weighted averages to reduce volatility and false signals. |
|
||||
| [VEL](vel/Vel.md) | Jurik Velocity | Measures market "acceleration" by comparing parabolic vs. linear weighting schemes. |
|
||||
| VORTEX | Vortex Indicator | |
|
||||
| WILLR | Williams %R | |
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class AmatTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private readonly TSeries _testData;
|
||||
|
||||
public AmatTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var bars = _gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
_testData = bars.Close;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Amat(0, 50));
|
||||
Assert.Throws<ArgumentException>(() => new Amat(-1, 50));
|
||||
Assert.Throws<ArgumentException>(() => new Amat(10, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Amat(10, -1));
|
||||
Assert.Throws<ArgumentException>(() => new Amat(50, 10)); // fast >= slow
|
||||
Assert.Throws<ArgumentException>(() => new Amat(10, 10)); // fast == slow
|
||||
|
||||
var amat = new Amat(10, 50);
|
||||
Assert.NotNull(amat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidBoundaryValues()
|
||||
{
|
||||
var amat1 = new Amat(1, 2);
|
||||
Assert.NotNull(amat1);
|
||||
Assert.Equal("Amat(1,2)", amat1.Name);
|
||||
|
||||
var amat2 = new Amat(10, 50);
|
||||
Assert.Equal("Amat(10,50)", amat2.Name);
|
||||
Assert.Equal(50, amat2.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var amat = new Amat(10, 50);
|
||||
|
||||
Assert.Equal(0, amat.Last.Value);
|
||||
|
||||
TValue result = amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.Equal(result.Value, amat.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstValue_ReturnsZero()
|
||||
{
|
||||
var amat = new Amat(10, 50);
|
||||
TValue result = amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.Equal(0.0, result.Value); // First value is 0 (neutral) - not enough data for trend
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var amat = new Amat(10, 50);
|
||||
|
||||
Assert.Equal(0, amat.Last.Value);
|
||||
Assert.False(amat.IsHot);
|
||||
Assert.Contains("Amat", amat.Name);
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(double.IsFinite(amat.Last.Value));
|
||||
Assert.True(double.IsFinite(amat.Strength.Value));
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value));
|
||||
Assert.True(double.IsFinite(amat.SlowEma.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendValues_AreValid()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
// Feed rising prices to create bullish trend
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + i * 2));
|
||||
}
|
||||
|
||||
// Trend should be +1, -1, or 0
|
||||
Assert.True(amat.Last.Value >= -1 && amat.Last.Value <= 1);
|
||||
Assert.True(amat.Last.Value == -1 || amat.Last.Value == 0 || amat.Last.Value == 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BullishTrend_WhenPricesRising()
|
||||
{
|
||||
var amat = new Amat(3, 10);
|
||||
|
||||
// Feed steadily rising prices
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + i * 3));
|
||||
}
|
||||
|
||||
// Should be bullish when fast EMA > slow EMA and both rising
|
||||
Assert.True(amat.FastEma.Value > amat.SlowEma.Value);
|
||||
Assert.Equal(1.0, amat.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BearishTrend_WhenPricesFalling()
|
||||
{
|
||||
var amat = new Amat(3, 10);
|
||||
|
||||
// Start with a stable price
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 200));
|
||||
}
|
||||
|
||||
// Feed steadily falling prices
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 200 - i * 3));
|
||||
}
|
||||
|
||||
// Should be bearish when fast EMA < slow EMA and both falling
|
||||
Assert.True(amat.FastEma.Value < amat.SlowEma.Value);
|
||||
Assert.Equal(-1.0, amat.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var amat = new Amat(10, 50);
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
|
||||
double value1 = amat.Last.Value;
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 200), isNew: true);
|
||||
double value2 = amat.Last.Value;
|
||||
|
||||
// Values may or may not change depending on trend conditions
|
||||
Assert.True(double.IsFinite(value1));
|
||||
Assert.True(double.IsFinite(value2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
// Build up some history
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
|
||||
double emaBeforeUpdate = amat.FastEma.Value;
|
||||
|
||||
// Update with new value (isNew=false should update but allow rollback)
|
||||
amat.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
|
||||
double emaAfterUpdate = amat.FastEma.Value;
|
||||
|
||||
Assert.NotEqual(emaBeforeUpdate, emaAfterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
// Feed 15 new values
|
||||
TValue fifteenthInput = default;
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
var bar = _gbm.Next(isNew: true);
|
||||
fifteenthInput = new TValue(bar.Time, bar.Close);
|
||||
amat.Update(fifteenthInput, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 15 values
|
||||
double stateAfterFifteen = amat.FastEma.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var bar = _gbm.Next(isNew: false);
|
||||
amat.Update(new TValue(bar.Time, bar.Close), isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 15th input again with isNew=false
|
||||
amat.Update(fifteenthInput, isNew: false);
|
||||
|
||||
// State should match the original state after 15 values
|
||||
Assert.Equal(stateAfterFifteen, amat.FastEma.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var amat = new Amat(10, 50);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + i));
|
||||
}
|
||||
double fastEmaBefore = amat.FastEma.Value;
|
||||
|
||||
amat.Reset();
|
||||
|
||||
Assert.Equal(0, amat.Last.Value);
|
||||
Assert.Equal(0, amat.Strength.Value);
|
||||
Assert.Equal(0, amat.FastEma.Value);
|
||||
Assert.Equal(0, amat.SlowEma.Value);
|
||||
Assert.False(amat.IsHot);
|
||||
|
||||
// After reset, should accept new values
|
||||
amat.Update(new TValue(DateTime.UtcNow, 50));
|
||||
Assert.NotEqual(0, amat.FastEma.Value);
|
||||
Assert.NotEqual(fastEmaBefore, amat.FastEma.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
var amat = new Amat(5, 20);
|
||||
|
||||
Assert.False(amat.IsHot);
|
||||
|
||||
// Feed values until warmup complete
|
||||
int count = 0;
|
||||
while (!amat.IsHot && count < 200)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + count));
|
||||
count++;
|
||||
}
|
||||
|
||||
Assert.True(amat.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
amat.Update(new TValue(DateTime.UtcNow, 110));
|
||||
double fastEmaBeforeNaN = amat.FastEma.Value;
|
||||
|
||||
var resultAfterNaN = amat.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.True(double.IsFinite(resultAfterNaN.Value));
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value));
|
||||
Assert.True(double.IsFinite(amat.SlowEma.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
amat.Update(new TValue(DateTime.UtcNow, 110));
|
||||
|
||||
var resultAfterPosInf = amat.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(resultAfterPosInf.Value));
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value));
|
||||
|
||||
var resultAfterNegInf = amat.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(resultAfterNegInf.Value));
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleNaN_ContinuesWithLastValid()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
amat.Update(new TValue(DateTime.UtcNow, 110));
|
||||
amat.Update(new TValue(DateTime.UtcNow, 120));
|
||||
|
||||
var r1 = amat.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
var r2 = amat.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
var r3 = amat.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.True(double.IsFinite(r1.Value));
|
||||
Assert.True(double.IsFinite(r2.Value));
|
||||
Assert.True(double.IsFinite(r3.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var amatIterative = new Amat(10, 30);
|
||||
var amatBatch = new Amat(10, 30);
|
||||
|
||||
// Calculate iteratively
|
||||
var iterativeResults = new List<double>();
|
||||
foreach (var item in _testData)
|
||||
{
|
||||
iterativeResults.Add(amatIterative.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = amatBatch.Update(_testData);
|
||||
|
||||
// Compare
|
||||
Assert.Equal(iterativeResults.Count, batchResults.Count);
|
||||
for (int i = 0; i < iterativeResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(iterativeResults[i], batchResults[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 30;
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Amat.Batch(_testData, fastPeriod, slowPeriod);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Span Mode (static method with spans)
|
||||
var tValues = _testData.Values.ToArray();
|
||||
var spanInput = new ReadOnlySpan<double>(tValues);
|
||||
var spanOutput = new double[tValues.Length];
|
||||
Amat.Calculate(spanInput, spanOutput, fastPeriod, slowPeriod);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode (instance, one value at a time)
|
||||
var streamingInd = new Amat(fastPeriod, slowPeriod);
|
||||
for (int i = 0; i < _testData.Count; i++)
|
||||
{
|
||||
streamingInd.Update(_testData[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// 4. Eventing Mode (chained via ITValuePublisher)
|
||||
var pubSource = new TSeries();
|
||||
var eventingInd = new Amat(pubSource, fastPeriod, slowPeriod);
|
||||
for (int i = 0; i < _testData.Count; i++)
|
||||
{
|
||||
pubSource.Add(_testData[i]);
|
||||
}
|
||||
double eventingResult = eventingInd.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
Assert.Equal(expected, eventingResult, precision: 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_ValidatesInput()
|
||||
{
|
||||
double[] source = [1, 2, 3, 4, 5];
|
||||
double[] trend = new double[5];
|
||||
double[] strength = new double[5];
|
||||
double[] wrongSize = new double[3];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Amat.Calculate(source.AsSpan(), wrongSize.AsSpan(), strength.AsSpan(), 5, 10));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Amat.Calculate(source.AsSpan(), trend.AsSpan(), wrongSize.AsSpan(), 5, 10));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Amat.Calculate(source.AsSpan(), trend.AsSpan(), strength.AsSpan(), 0, 10));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Amat.Calculate(source.AsSpan(), trend.AsSpan(), strength.AsSpan(), 10, 5)); // fast >= slow
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_MatchesTSeriesCalc()
|
||||
{
|
||||
double[] source = _testData.Values.ToArray();
|
||||
double[] trend = new double[source.Length];
|
||||
|
||||
var tseriesResult = Amat.Batch(_testData, 10, 30);
|
||||
Amat.Calculate(source.AsSpan(), trend.AsSpan(), 10, 30);
|
||||
|
||||
// Since trend values are discrete (-1, 0, 1), check after warmup where
|
||||
// both methods should converge. Early values may differ due to EMA initialization.
|
||||
int warmup = 30 * 2; // Allow extra warmup
|
||||
int matched = 0;
|
||||
for (int i = warmup; i < source.Length; i++)
|
||||
{
|
||||
if (Math.Abs(tseriesResult[i].Value - trend[i]) < 0.01)
|
||||
matched++;
|
||||
}
|
||||
// At least 95% of values after warmup should match
|
||||
double matchRate = (double)matched / (source.Length - warmup);
|
||||
Assert.True(matchRate > 0.95, $"Match rate {matchRate:P1} is below 95%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanCalc_HandlesNaN()
|
||||
{
|
||||
double[] source = [100, 110, double.NaN, 120, 130, 140, 150, 160, 170, 180];
|
||||
double[] trend = new double[10];
|
||||
double[] strength = new double[10];
|
||||
|
||||
Amat.Calculate(source.AsSpan(), trend.AsSpan(), strength.AsSpan(), 3, 5);
|
||||
|
||||
foreach (var val in trend)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
|
||||
}
|
||||
foreach (var val in strength)
|
||||
{
|
||||
Assert.True(double.IsFinite(val), $"Expected finite value but got {val}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsHotIndicator()
|
||||
{
|
||||
var (results, indicator) = Amat.Calculate(_testData, 10, 30);
|
||||
|
||||
Assert.Equal(_testData.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(results.Last.Value, indicator.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chainability_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var amat = new Amat(source, 10, 30);
|
||||
|
||||
source.Add(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(double.IsFinite(amat.Last.Value));
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pub_EventFires()
|
||||
{
|
||||
var amat = new Amat(10, 30);
|
||||
bool eventFired = false;
|
||||
amat.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
|
||||
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.True(eventFired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlatLine_ReturnsNeutral()
|
||||
{
|
||||
var amat = new Amat(5, 10);
|
||||
|
||||
// Flat prices - neither rising nor falling
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100));
|
||||
}
|
||||
|
||||
// Should be neutral (0) when EMAs are not clearly rising or falling
|
||||
Assert.Equal(0, amat.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Strength_CalculatesCorrectly()
|
||||
{
|
||||
var amat = new Amat(3, 10);
|
||||
|
||||
// Feed rising prices to create divergence
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, 100 + i * 5));
|
||||
}
|
||||
|
||||
// Strength should be positive when there's divergence
|
||||
Assert.True(amat.Strength.Value > 0);
|
||||
|
||||
// Strength formula: |fast - slow| / slow * 100
|
||||
double expectedStrength = Math.Abs(amat.FastEma.Value - amat.SlowEma.Value) / amat.SlowEma.Value * 100;
|
||||
Assert.Equal(expectedStrength, amat.Strength.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
using Skender.Stock.Indicators;
|
||||
using TALib;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for AMAT (Archer Moving Averages Trends).
|
||||
///
|
||||
/// AMAT is a custom indicator not found in external libraries like TA-Lib, Skender, Tulip, or Ooples.
|
||||
/// Instead, we validate:
|
||||
/// 1. The underlying EMA calculations match external libraries
|
||||
/// 2. The trend logic produces expected results for known input patterns
|
||||
/// 3. Cross-validation between streaming and batch modes
|
||||
/// </summary>
|
||||
public sealed class AmatValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public AmatValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that AMAT's Fast EMA matches Skender's EMA calculation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_FastEma_Against_Skender()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Calculate QuanTAlib AMAT (streaming to access FastEma)
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
var qFastEma = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amat.Update(item);
|
||||
qFastEma.Add(amat.FastEma.Value);
|
||||
}
|
||||
|
||||
// Calculate Skender EMA (fast period)
|
||||
var sResult = _testData.SkenderQuotes.GetEma(fastPeriod).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qFastEma, sResult, (s) => s.Ema);
|
||||
|
||||
_output.WriteLine($"AMAT Fast EMA (period {fastPeriod}) validated successfully against Skender");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that AMAT's Slow EMA matches Skender's EMA calculation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_SlowEma_Against_Skender()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Calculate QuanTAlib AMAT (streaming to access SlowEma)
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
var qSlowEma = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amat.Update(item);
|
||||
qSlowEma.Add(amat.SlowEma.Value);
|
||||
}
|
||||
|
||||
// Calculate Skender EMA (slow period)
|
||||
var sResult = _testData.SkenderQuotes.GetEma(slowPeriod).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qSlowEma, sResult, (s) => s.Ema);
|
||||
|
||||
_output.WriteLine($"AMAT Slow EMA (period {slowPeriod}) validated successfully against Skender");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that AMAT's Fast EMA matches TA-Lib's EMA calculation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_FastEma_Against_Talib()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Prepare data for TA-Lib
|
||||
double[] tData = _testData.RawData.ToArray();
|
||||
double[] outEma = new double[tData.Length];
|
||||
|
||||
// Calculate QuanTAlib AMAT (streaming to access FastEma)
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
var qFastEma = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amat.Update(item);
|
||||
qFastEma.Add(amat.FastEma.Value);
|
||||
}
|
||||
|
||||
// Calculate TA-Lib EMA (fast period)
|
||||
var retCode = TALib.Functions.Ema<double>(tData, 0..^0, outEma, out var outRange, fastPeriod);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.EmaLookback(fastPeriod);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qFastEma, outEma, outRange, lookback);
|
||||
|
||||
_output.WriteLine($"AMAT Fast EMA (period {fastPeriod}) validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that AMAT's Slow EMA matches TA-Lib's EMA calculation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_SlowEma_Against_Talib()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Prepare data for TA-Lib
|
||||
double[] tData = _testData.RawData.ToArray();
|
||||
double[] outEma = new double[tData.Length];
|
||||
|
||||
// Calculate QuanTAlib AMAT (streaming to access SlowEma)
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
var qSlowEma = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amat.Update(item);
|
||||
qSlowEma.Add(amat.SlowEma.Value);
|
||||
}
|
||||
|
||||
// Calculate TA-Lib EMA (slow period)
|
||||
var retCode = TALib.Functions.Ema<double>(tData, 0..^0, outEma, out var outRange, slowPeriod);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.EmaLookback(slowPeriod);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qSlowEma, outEma, outRange, lookback);
|
||||
|
||||
_output.WriteLine($"AMAT Slow EMA (period {slowPeriod}) validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates trend logic: Rising prices should eventually produce bullish signal (+1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_BullishTrend_Logic()
|
||||
{
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 10;
|
||||
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
|
||||
// Create steadily rising prices - should produce bullish trend
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100 + i; // Steadily increasing
|
||||
amat.Update(new TValue(time.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
// After warmup, a steadily rising market should be bullish
|
||||
Assert.Equal(1.0, amat.Last.Value);
|
||||
Assert.True(amat.Strength.Value > 0, "Strength should be positive");
|
||||
Assert.True(amat.FastEma.Value > amat.SlowEma.Value, "Fast EMA should be above Slow EMA in uptrend");
|
||||
|
||||
_output.WriteLine($"Bullish trend logic validated: Trend={amat.Last.Value}, Strength={amat.Strength.Value:F2}%");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates trend logic: Falling prices should eventually produce bearish signal (-1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_BearishTrend_Logic()
|
||||
{
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 10;
|
||||
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
|
||||
// Create steadily falling prices - should produce bearish trend
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 200 - i; // Steadily decreasing
|
||||
amat.Update(new TValue(time.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
// After warmup, a steadily falling market should be bearish
|
||||
Assert.Equal(-1.0, amat.Last.Value);
|
||||
Assert.True(amat.Strength.Value > 0, "Strength should be positive");
|
||||
Assert.True(amat.FastEma.Value < amat.SlowEma.Value, "Fast EMA should be below Slow EMA in downtrend");
|
||||
|
||||
_output.WriteLine($"Bearish trend logic validated: Trend={amat.Last.Value}, Strength={amat.Strength.Value:F2}%");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates trend logic: Flat prices should produce neutral signal (0).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_NeutralTrend_Logic()
|
||||
{
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 10;
|
||||
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
|
||||
// Create flat prices - should produce neutral trend
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
amat.Update(new TValue(time.AddMinutes(i), 100.0)); // Constant price
|
||||
}
|
||||
|
||||
// Flat market: EMAs converge, no clear direction
|
||||
Assert.Equal(0.0, amat.Last.Value);
|
||||
Assert.True(amat.Strength.Value < 1.0, "Strength should be near zero for flat market");
|
||||
|
||||
_output.WriteLine($"Neutral trend logic validated: Trend={amat.Last.Value}, Strength={amat.Strength.Value:F2}%");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates trend transition from bullish to bearish.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_TrendTransition_BullishToBearish()
|
||||
{
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 10;
|
||||
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Phase 1: Rising prices
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
amat.Update(new TValue(time.AddMinutes(i), price));
|
||||
}
|
||||
double bullishTrend = amat.Last.Value;
|
||||
|
||||
// Phase 2: Falling prices (reversal)
|
||||
for (int i = 50; i < 150; i++)
|
||||
{
|
||||
double price = 150 - (i - 50) * 2; // Fall faster than rise
|
||||
amat.Update(new TValue(time.AddMinutes(i), price));
|
||||
}
|
||||
double bearishTrend = amat.Last.Value;
|
||||
|
||||
Assert.Equal(1.0, bullishTrend);
|
||||
Assert.Equal(-1.0, bearishTrend);
|
||||
|
||||
_output.WriteLine($"Trend transition validated: Bullish({bullishTrend}) -> Bearish({bearishTrend})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that streaming and batch modes produce identical results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Streaming_Matches_Batch()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Calculate streaming
|
||||
var amatStreaming = new Amat(fastPeriod, slowPeriod);
|
||||
var streamingResults = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amatStreaming.Update(item);
|
||||
streamingResults.Add(amatStreaming.Last.Value);
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = Amat.Batch(_testData.Data, fastPeriod, slowPeriod);
|
||||
|
||||
// Compare
|
||||
Assert.Equal(streamingResults.Count, batchResults.Count);
|
||||
|
||||
int matchCount = 0;
|
||||
int totalCount = streamingResults.Count;
|
||||
|
||||
for (int i = 0; i < totalCount; i++)
|
||||
{
|
||||
if (Math.Abs(streamingResults[i] - batchResults[i].Value) < 1e-10)
|
||||
{
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
double matchRate = (double)matchCount / totalCount;
|
||||
Assert.True(matchRate > 0.99, $"Expected >99% match rate, got {matchRate:P2}");
|
||||
|
||||
_output.WriteLine($"Streaming vs Batch validation: {matchRate:P2} match rate ({matchCount}/{totalCount})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that span-based Calculate matches streaming results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Span_Matches_Streaming()
|
||||
{
|
||||
int fastPeriod = 10;
|
||||
int slowPeriod = 50;
|
||||
|
||||
// Calculate streaming
|
||||
var amatStreaming = new Amat(fastPeriod, slowPeriod);
|
||||
var streamingTrend = new List<double>();
|
||||
var streamingStrength = new List<double>();
|
||||
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amatStreaming.Update(item);
|
||||
streamingTrend.Add(amatStreaming.Last.Value);
|
||||
streamingStrength.Add(amatStreaming.Strength.Value);
|
||||
}
|
||||
|
||||
// Calculate span
|
||||
double[] sourceData = _testData.RawData.ToArray();
|
||||
double[] spanTrend = new double[sourceData.Length];
|
||||
double[] spanStrength = new double[sourceData.Length];
|
||||
Amat.Calculate(sourceData, spanTrend, spanStrength, fastPeriod, slowPeriod);
|
||||
|
||||
// Compare trend values (after warmup period)
|
||||
int warmup = slowPeriod * 2; // Allow extra warmup for convergence
|
||||
int matchCount = 0;
|
||||
int totalCount = sourceData.Length - warmup;
|
||||
|
||||
for (int i = warmup; i < sourceData.Length; i++)
|
||||
{
|
||||
if (Math.Abs(streamingTrend[i] - spanTrend[i]) < 1e-10)
|
||||
{
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
double matchRate = (double)matchCount / totalCount;
|
||||
Assert.True(matchRate > 0.95, $"Expected >95% match rate after warmup, got {matchRate:P2}");
|
||||
|
||||
_output.WriteLine($"Streaming vs Span validation: {matchRate:P2} match rate after warmup ({matchCount}/{totalCount})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates strength calculation is correct.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Strength_Calculation()
|
||||
{
|
||||
int fastPeriod = 5;
|
||||
int slowPeriod = 10;
|
||||
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
|
||||
// Create scenario where we can predict the strength
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100 + i;
|
||||
amat.Update(new TValue(time.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
// Verify strength formula: |Fast - Slow| / Slow * 100
|
||||
double expectedStrength = Math.Abs(amat.FastEma.Value - amat.SlowEma.Value) / amat.SlowEma.Value * 100.0;
|
||||
Assert.Equal(expectedStrength, amat.Strength.Value, 10);
|
||||
|
||||
_output.WriteLine($"Strength calculation validated: {amat.Strength.Value:F4}%");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates multiple period combinations.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5, 10)]
|
||||
[InlineData(10, 20)]
|
||||
[InlineData(12, 26)]
|
||||
[InlineData(20, 50)]
|
||||
[InlineData(50, 100)]
|
||||
public void Validate_Multiple_Period_Combinations(int fastPeriod, int slowPeriod)
|
||||
{
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
|
||||
// Feed data
|
||||
foreach (var item in _testData.Data)
|
||||
{
|
||||
amat.Update(item);
|
||||
}
|
||||
|
||||
// Verify output is valid
|
||||
Assert.True(amat.Last.Value >= -1.0 && amat.Last.Value <= 1.0,
|
||||
$"Trend should be -1, 0, or 1, got {amat.Last.Value}");
|
||||
Assert.True(amat.Strength.Value >= 0, "Strength should be non-negative");
|
||||
Assert.True(double.IsFinite(amat.FastEma.Value), "FastEma should be finite");
|
||||
Assert.True(double.IsFinite(amat.SlowEma.Value), "SlowEma should be finite");
|
||||
Assert.True(amat.IsHot, "Indicator should be hot after processing data");
|
||||
|
||||
_output.WriteLine($"Period combination ({fastPeriod}, {slowPeriod}) validated: Trend={amat.Last.Value}, Strength={amat.Strength.Value:F2}%");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// AMAT: Archer Moving Averages Trends
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AMAT is a trend identification system that uses multiple EMAs to identify
|
||||
/// trend direction and strength. Unlike simple crossovers, AMAT requires alignment
|
||||
/// of both fast and slow moving averages in the same direction.
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. Calculate Fast and Slow EMAs
|
||||
/// 2. Bullish (+1): Fast EMA > Slow EMA AND Fast EMA rising AND Slow EMA rising
|
||||
/// 3. Bearish (-1): Fast EMA < Slow EMA AND Fast EMA falling AND Slow EMA falling
|
||||
/// 4. Neutral (0): Mixed conditions
|
||||
/// 5. Strength = |Fast EMA - Slow EMA| / Slow EMA * 100
|
||||
///
|
||||
/// Key features:
|
||||
/// - Direction alignment reduces false signals
|
||||
/// - Trend strength measurement for conviction assessment
|
||||
/// - Clear +1/-1/0 trend signals
|
||||
///
|
||||
/// Sources:
|
||||
/// Tom Joseph (2009), based on Mark Whistler (Archer) concepts
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Amat : ITValuePublisher
|
||||
{
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double FastEma,
|
||||
double SlowEma,
|
||||
double FastE,
|
||||
double SlowE,
|
||||
double PrevFastEma,
|
||||
double PrevSlowEma,
|
||||
bool FastIsHot,
|
||||
bool SlowIsHot,
|
||||
bool FastIsCompensated,
|
||||
bool SlowIsCompensated,
|
||||
int TickCount)
|
||||
{
|
||||
public static State New() => new()
|
||||
{
|
||||
FastEma = 0,
|
||||
SlowEma = 0,
|
||||
FastE = 1.0,
|
||||
SlowE = 1.0,
|
||||
PrevFastEma = 0,
|
||||
PrevSlowEma = 0,
|
||||
FastIsHot = false,
|
||||
SlowIsHot = false,
|
||||
FastIsCompensated = false,
|
||||
SlowIsCompensated = false,
|
||||
TickCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
private readonly double _fastAlpha;
|
||||
private readonly double _slowAlpha;
|
||||
private readonly double _fastDecay;
|
||||
private readonly double _slowDecay;
|
||||
|
||||
private State _state = State.New();
|
||||
private State _p_state = State.New();
|
||||
private double _lastValidValue;
|
||||
private double _p_lastValidValue;
|
||||
|
||||
private const double COVERAGE_THRESHOLD = 0.05;
|
||||
private const double COMPENSATOR_THRESHOLD = 1e-10;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the indicator.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when a new TValue is available.
|
||||
/// </summary>
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
/// <summary>
|
||||
/// Current trend direction: +1 (bullish), -1 (bearish), 0 (neutral).
|
||||
/// </summary>
|
||||
public TValue Last { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current trend strength as percentage: |Fast - Slow| / Slow * 100.
|
||||
/// </summary>
|
||||
public TValue Strength { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current Fast EMA value.
|
||||
/// </summary>
|
||||
public TValue FastEma { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current Slow EMA value.
|
||||
/// </summary>
|
||||
public TValue SlowEma { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if both EMAs have warmed up and are providing valid results.
|
||||
/// </summary>
|
||||
public bool IsHot => _state.FastIsHot && _state.SlowIsHot;
|
||||
|
||||
/// <summary>
|
||||
/// The number of bars required for the indicator to warm up.
|
||||
/// </summary>
|
||||
public int WarmupPeriod { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates AMAT with specified fast and slow periods.
|
||||
/// </summary>
|
||||
/// <param name="fastPeriod">Fast EMA period (must be > 0)</param>
|
||||
/// <param name="slowPeriod">Slow EMA period (must be > fast period)</param>
|
||||
public Amat(int fastPeriod = 10, int slowPeriod = 50)
|
||||
{
|
||||
if (fastPeriod <= 0)
|
||||
throw new ArgumentException("Fast period must be greater than 0", nameof(fastPeriod));
|
||||
if (slowPeriod <= 0)
|
||||
throw new ArgumentException("Slow period must be greater than 0", nameof(slowPeriod));
|
||||
if (fastPeriod >= slowPeriod)
|
||||
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
|
||||
|
||||
_fastAlpha = 2.0 / (fastPeriod + 1);
|
||||
_slowAlpha = 2.0 / (slowPeriod + 1);
|
||||
_fastDecay = 1.0 - _fastAlpha;
|
||||
_slowDecay = 1.0 - _slowAlpha;
|
||||
|
||||
Name = $"Amat({fastPeriod},{slowPeriod})";
|
||||
WarmupPeriod = slowPeriod;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates AMAT with specified source and periods.
|
||||
/// Subscribes to source.Pub event.
|
||||
/// </summary>
|
||||
/// <param name="source">Source to subscribe to</param>
|
||||
/// <param name="fastPeriod">Fast EMA period</param>
|
||||
/// <param name="slowPeriod">Slow EMA period</param>
|
||||
public Amat(ITValuePublisher source, int fastPeriod = 10, int slowPeriod = 50)
|
||||
: this(fastPeriod, slowPeriod)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the AMAT state.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Reset()
|
||||
{
|
||||
_state = State.New();
|
||||
_p_state = State.New();
|
||||
_lastValidValue = 0;
|
||||
_p_lastValidValue = 0;
|
||||
Last = default;
|
||||
Strength = default;
|
||||
FastEma = default;
|
||||
SlowEma = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private double GetValidValue(double input)
|
||||
{
|
||||
if (double.IsFinite(input))
|
||||
{
|
||||
_lastValidValue = input;
|
||||
return input;
|
||||
}
|
||||
return _lastValidValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a single value.
|
||||
/// </summary>
|
||||
/// <param name="input">Input value</param>
|
||||
/// <param name="isNew">True if this is a new bar, False if it's an update to the last bar</param>
|
||||
/// <returns>Updated trend value (+1, -1, or 0)</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
_p_lastValidValue = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
_lastValidValue = _p_lastValidValue;
|
||||
}
|
||||
|
||||
double val = GetValidValue(input.Value);
|
||||
|
||||
// Store previous EMA values before update
|
||||
double prevFast = _state.FastEma;
|
||||
double prevSlow = _state.SlowEma;
|
||||
|
||||
// Extract state fields to local variables (record struct properties cannot be passed by ref)
|
||||
double fastEmaState = _state.FastEma;
|
||||
double fastE = _state.FastE;
|
||||
bool fastIsHot = _state.FastIsHot;
|
||||
bool fastIsCompensated = _state.FastIsCompensated;
|
||||
|
||||
double slowEmaState = _state.SlowEma;
|
||||
double slowE = _state.SlowE;
|
||||
bool slowIsHot = _state.SlowIsHot;
|
||||
bool slowIsCompensated = _state.SlowIsCompensated;
|
||||
|
||||
int tickCount = _state.TickCount;
|
||||
|
||||
// Compute Fast EMA with compensation
|
||||
double fastEma = ComputeEma(val, _fastAlpha, _fastDecay,
|
||||
ref fastEmaState, ref fastE, ref fastIsHot, ref fastIsCompensated);
|
||||
|
||||
// Compute Slow EMA with compensation
|
||||
double slowEma = ComputeEma(val, _slowAlpha, _slowDecay,
|
||||
ref slowEmaState, ref slowE, ref slowIsHot, ref slowIsCompensated);
|
||||
|
||||
// Update state with new values
|
||||
_state = new State(
|
||||
FastEma: fastEmaState,
|
||||
SlowEma: slowEmaState,
|
||||
FastE: fastE,
|
||||
SlowE: slowE,
|
||||
PrevFastEma: tickCount > 0 ? prevFast : 0,
|
||||
PrevSlowEma: tickCount > 0 ? prevSlow : 0,
|
||||
FastIsHot: fastIsHot,
|
||||
SlowIsHot: slowIsHot,
|
||||
FastIsCompensated: fastIsCompensated,
|
||||
SlowIsCompensated: slowIsCompensated,
|
||||
TickCount: tickCount + 1
|
||||
);
|
||||
|
||||
// Determine trend direction
|
||||
double trend = 0;
|
||||
double strength = 0;
|
||||
|
||||
if (_state.TickCount >= 2) // Need at least 2 ticks to compare previous values
|
||||
{
|
||||
double prevFastCompensated = GetCompensatedValue(_state.PrevFastEma, _state.FastE * (1.0 / _fastDecay), _state.FastIsCompensated);
|
||||
double prevSlowCompensated = GetCompensatedValue(_state.PrevSlowEma, _state.SlowE * (1.0 / _slowDecay), _state.SlowIsCompensated);
|
||||
|
||||
bool fastAboveSlow = fastEma > slowEma;
|
||||
bool fastRising = fastEma > prevFastCompensated;
|
||||
bool slowRising = slowEma > prevSlowCompensated;
|
||||
bool fastFalling = fastEma < prevFastCompensated;
|
||||
bool slowFalling = slowEma < prevSlowCompensated;
|
||||
|
||||
// Bullish: Fast > Slow AND both rising
|
||||
if (fastAboveSlow && fastRising && slowRising)
|
||||
{
|
||||
trend = 1.0;
|
||||
}
|
||||
// Bearish: Fast < Slow AND both falling
|
||||
else if (!fastAboveSlow && fastFalling && slowFalling)
|
||||
{
|
||||
trend = -1.0;
|
||||
}
|
||||
// Neutral: mixed conditions
|
||||
else
|
||||
{
|
||||
trend = 0;
|
||||
}
|
||||
|
||||
// Calculate strength
|
||||
if (slowEma > 0)
|
||||
{
|
||||
strength = Math.Abs(fastEma - slowEma) / slowEma * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, trend);
|
||||
Strength = new TValue(input.Time, strength);
|
||||
FastEma = new TValue(input.Time, fastEma);
|
||||
SlowEma = new TValue(input.Time, slowEma);
|
||||
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = Last, IsNew = isNew });
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a series of values.
|
||||
/// </summary>
|
||||
/// <param name="source">Input series</param>
|
||||
/// <returns>Series of trend values</returns>
|
||||
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);
|
||||
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i], true);
|
||||
t.Add(source[i].Time);
|
||||
v.Add(Last.Value);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double GetCompensatedValue(double ema, double e, bool isCompensated)
|
||||
{
|
||||
if (isCompensated || e <= COMPENSATOR_THRESHOLD)
|
||||
return ema;
|
||||
return ema / (1.0 - e);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeEma(double input, double alpha, double decay,
|
||||
ref double ema, ref double e, ref bool isHot, ref bool isCompensated)
|
||||
{
|
||||
ema = Math.FusedMultiplyAdd(ema, decay, alpha * input);
|
||||
|
||||
double result;
|
||||
if (!isCompensated)
|
||||
{
|
||||
e *= decay;
|
||||
|
||||
if (!isHot && e <= COVERAGE_THRESHOLD)
|
||||
isHot = true;
|
||||
|
||||
if (e <= COMPENSATOR_THRESHOLD)
|
||||
{
|
||||
isCompensated = true;
|
||||
result = ema;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = ema / (1.0 - e);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result = ema;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates AMAT trend values for a span of input values.
|
||||
/// </summary>
|
||||
/// <param name="source">Input values</param>
|
||||
/// <param name="trend">Output trend values (+1, -1, 0)</param>
|
||||
/// <param name="strength">Output strength values (percentage)</param>
|
||||
/// <param name="fastPeriod">Fast EMA period</param>
|
||||
/// <param name="slowPeriod">Slow EMA period</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> trend, Span<double> strength,
|
||||
int fastPeriod = 10, int slowPeriod = 50)
|
||||
{
|
||||
if (source.Length != trend.Length)
|
||||
throw new ArgumentException("Source and trend must have the same length", nameof(trend));
|
||||
if (source.Length != strength.Length)
|
||||
throw new ArgumentException("Source and strength must have the same length", nameof(strength));
|
||||
if (fastPeriod <= 0)
|
||||
throw new ArgumentException("Fast period must be greater than 0", nameof(fastPeriod));
|
||||
if (slowPeriod <= 0)
|
||||
throw new ArgumentException("Slow period must be greater than 0", nameof(slowPeriod));
|
||||
if (fastPeriod >= slowPeriod)
|
||||
throw new ArgumentException("Fast period must be less than slow period", nameof(fastPeriod));
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0) return;
|
||||
|
||||
double fastAlpha = 2.0 / (fastPeriod + 1);
|
||||
double slowAlpha = 2.0 / (slowPeriod + 1);
|
||||
|
||||
// Use ArrayPool for EMA buffers
|
||||
double[] fastBuffer = ArrayPool<double>.Shared.Rent(len);
|
||||
double[] slowBuffer = ArrayPool<double>.Shared.Rent(len);
|
||||
|
||||
try
|
||||
{
|
||||
Span<double> fastSpan = fastBuffer.AsSpan(0, len);
|
||||
Span<double> slowSpan = slowBuffer.AsSpan(0, len);
|
||||
|
||||
// Calculate Fast and Slow EMAs
|
||||
Ema.Batch(source, fastSpan, fastAlpha);
|
||||
Ema.Batch(source, slowSpan, slowAlpha);
|
||||
|
||||
// Calculate trend and strength
|
||||
trend[0] = 0;
|
||||
strength[0] = 0;
|
||||
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
double fastEma = fastSpan[i];
|
||||
double slowEma = slowSpan[i];
|
||||
double prevFastEma = fastSpan[i - 1];
|
||||
double prevSlowEma = slowSpan[i - 1];
|
||||
|
||||
bool fastAboveSlow = fastEma > slowEma;
|
||||
bool fastRising = fastEma > prevFastEma;
|
||||
bool slowRising = slowEma > prevSlowEma;
|
||||
bool fastFalling = fastEma < prevFastEma;
|
||||
bool slowFalling = slowEma < prevSlowEma;
|
||||
|
||||
// Bullish: Fast > Slow AND both rising
|
||||
if (fastAboveSlow && fastRising && slowRising)
|
||||
{
|
||||
trend[i] = 1.0;
|
||||
}
|
||||
// Bearish: Fast < Slow AND both falling
|
||||
else if (!fastAboveSlow && fastFalling && slowFalling)
|
||||
{
|
||||
trend[i] = -1.0;
|
||||
}
|
||||
// Neutral
|
||||
else
|
||||
{
|
||||
trend[i] = 0;
|
||||
}
|
||||
|
||||
// Strength
|
||||
if (slowEma > 0)
|
||||
{
|
||||
strength[i] = Math.Abs(fastEma - slowEma) / slowEma * 100.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
strength[i] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(fastBuffer);
|
||||
ArrayPool<double>.Shared.Return(slowBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates AMAT trend values for a span (trend only, no strength).
|
||||
/// </summary>
|
||||
/// <param name="source">Input values</param>
|
||||
/// <param name="trend">Output trend values (+1, -1, 0)</param>
|
||||
/// <param name="fastPeriod">Fast EMA period</param>
|
||||
/// <param name="slowPeriod">Slow EMA period</param>
|
||||
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> trend,
|
||||
int fastPeriod = 10, int slowPeriod = 50)
|
||||
{
|
||||
if (source.Length != trend.Length)
|
||||
throw new ArgumentException("Source and trend must have the same length", nameof(trend));
|
||||
|
||||
int len = source.Length;
|
||||
double[] strengthBuffer = ArrayPool<double>.Shared.Rent(len);
|
||||
try
|
||||
{
|
||||
Span<double> strengthSpan = strengthBuffer.AsSpan(0, len);
|
||||
Calculate(source, trend, strengthSpan, fastPeriod, slowPeriod);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(strengthBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a high-performance batch calculation on history and returns
|
||||
/// a "Hot" Amat instance ready to process the next tick immediately.
|
||||
/// </summary>
|
||||
/// <param name="source">Historical time series</param>
|
||||
/// <param name="fastPeriod">Fast EMA period</param>
|
||||
/// <param name="slowPeriod">Slow EMA period</param>
|
||||
/// <returns>A tuple containing the full calculation results and the hot indicator instance</returns>
|
||||
public static (TSeries Results, Amat Indicator) Calculate(TSeries source, int fastPeriod = 10, int slowPeriod = 50)
|
||||
{
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
TSeries results = amat.Update(source);
|
||||
return (results, amat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates AMAT for the entire series using a new instance.
|
||||
/// </summary>
|
||||
/// <param name="source">Input series</param>
|
||||
/// <param name="fastPeriod">Fast EMA period</param>
|
||||
/// <param name="slowPeriod">Slow EMA period</param>
|
||||
/// <returns>AMAT trend series</returns>
|
||||
public static TSeries Batch(TSeries source, int fastPeriod = 10, int slowPeriod = 50)
|
||||
{
|
||||
var amat = new Amat(fastPeriod, slowPeriod);
|
||||
return amat.Update(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
# AMAT: Archer Moving Averages Trends
|
||||
|
||||
> "Markets trend about 30% of the time. The trick isn't just finding trends—it's confirming them before your stops get hit."
|
||||
|
||||
AMAT (Archer Moving Averages Trends) is a trend identification system that uses dual EMAs to provide clear directional signals. Unlike simple moving average crossovers that generate signals on any intersection, AMAT requires **alignment** of both fast and slow averages moving in the same direction—reducing false signals during choppy, sideways markets.
|
||||
|
||||
## Historical Context
|
||||
|
||||
AMAT emerged from concepts developed by Mark Whistler (known as "Archer" in trading circles) and was formalized by Tom Joseph in 2009. The indicator addresses a fundamental problem with traditional crossover systems: they generate excessive whipsaws in ranging markets because a crossover only measures relative position, not directional agreement.
|
||||
|
||||
The innovation lies in requiring **three conditions** for a trend signal:
|
||||
|
||||
1. Relative position (fast above/below slow)
|
||||
2. Fast EMA direction (rising/falling)
|
||||
3. Slow EMA direction (rising/falling)
|
||||
|
||||
This triple-confirmation approach filters out the noise inherent in single-condition systems.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
AMAT operates on dual EMA calculations with directional analysis. The computational flow:
|
||||
|
||||
```
|
||||
Input Price
|
||||
│
|
||||
├──► Fast EMA ───► Direction (rising/falling)
|
||||
│ │
|
||||
│ ▼
|
||||
└──► Slow EMA ───► Direction (rising/falling)
|
||||
│
|
||||
▼
|
||||
Trend Logic (+1, -1, 0)
|
||||
│
|
||||
▼
|
||||
Strength = |Fast - Slow| / Slow × 100
|
||||
```
|
||||
|
||||
### Trend State Machine
|
||||
|
||||
| State | Fast vs Slow | Fast Direction | Slow Direction |
|
||||
|:------|:------------|:---------------|:---------------|
|
||||
| **Bullish (+1)** | Fast > Slow | Rising | Rising |
|
||||
| **Bearish (-1)** | Fast < Slow | Falling | Falling |
|
||||
| **Neutral (0)** | Any | Mixed | Mixed |
|
||||
|
||||
The neutral state captures market indecision: when EMAs disagree on direction or their relative position contradicts their momentum, AMAT stays flat. This is a feature, not a limitation.
|
||||
|
||||
### EMA Bias Compensation
|
||||
|
||||
QuanTAlib's implementation uses bias-compensated EMAs during the warmup phase. Traditional EMA initialization assumes the first price equals the true average—a convenient fiction. The compensator factor `e` decays exponentially:
|
||||
|
||||
$$e_{t} = e_{t-1} \times (1 - \alpha)$$
|
||||
|
||||
Until convergence, the EMA is divided by $(1 - e)$ to remove initialization bias.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### 1. EMA Calculation
|
||||
|
||||
$$\text{EMA}_t = \alpha \times P_t + (1 - \alpha) \times \text{EMA}_{t-1}$$
|
||||
|
||||
Where $\alpha = \frac{2}{n + 1}$ and $n$ is the period.
|
||||
|
||||
### 2. Direction Detection
|
||||
|
||||
$$\text{Direction}_t = \begin{cases} \text{rising} & \text{if } \text{EMA}_t > \text{EMA}_{t-1} \\ \text{falling} & \text{if } \text{EMA}_t < \text{EMA}_{t-1} \\ \text{flat} & \text{otherwise} \end{cases}$$
|
||||
|
||||
### 3. Trend Signal
|
||||
|
||||
$$\text{Trend}_t = \begin{cases} +1 & \text{if } \text{FastEMA}_t > \text{SlowEMA}_t \land \text{FastRising} \land \text{SlowRising} \\ -1 & \text{if } \text{FastEMA}_t < \text{SlowEMA}_t \land \text{FastFalling} \land \text{SlowFalling} \\ 0 & \text{otherwise} \end{cases}$$
|
||||
|
||||
### 4. Trend Strength
|
||||
|
||||
$$\text{Strength}_t = \frac{|\text{FastEMA}_t - \text{SlowEMA}_t|}{\text{SlowEMA}_t} \times 100$$
|
||||
|
||||
Strength quantifies the separation between EMAs as a percentage of the slow EMA—useful for gauging trend conviction or filtering weak signals.
|
||||
|
||||
## Usage
|
||||
|
||||
```csharp
|
||||
// Standard instantiation
|
||||
var amat = new Amat(fastPeriod: 10, slowPeriod: 50);
|
||||
|
||||
// Process streaming data
|
||||
foreach (var price in prices)
|
||||
{
|
||||
amat.Update(new TValue(DateTime.UtcNow, price));
|
||||
|
||||
if (amat.Last.Value == 1.0)
|
||||
Console.WriteLine($"Bullish - Strength: {amat.Strength.Value:F2}%");
|
||||
else if (amat.Last.Value == -1.0)
|
||||
Console.WriteLine($"Bearish - Strength: {amat.Strength.Value:F2}%");
|
||||
else
|
||||
Console.WriteLine("Neutral");
|
||||
}
|
||||
|
||||
// Access individual EMAs
|
||||
double fastEma = amat.FastEma.Value;
|
||||
double slowEma = amat.SlowEma.Value;
|
||||
|
||||
// Batch processing
|
||||
var results = Amat.Batch(priceSeries, fastPeriod: 10, slowPeriod: 50);
|
||||
|
||||
// Span-based high-performance
|
||||
double[] trend = new double[prices.Length];
|
||||
double[] strength = new double[prices.Length];
|
||||
Amat.Calculate(prices.AsSpan(), trend, strength, fastPeriod: 10, slowPeriod: 50);
|
||||
```
|
||||
|
||||
### Event-Driven (Chained)
|
||||
|
||||
```csharp
|
||||
var source = new TSeries();
|
||||
var amat = new Amat(source, fastPeriod: 10, slowPeriod: 50);
|
||||
|
||||
// AMAT automatically updates when source publishes
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Console.WriteLine($"Trend: {amat.Last.Value}");
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|:----------|:-----|:--------|:------------|
|
||||
| `fastPeriod` | int | 10 | Fast EMA period (must be > 0) |
|
||||
| `slowPeriod` | int | 50 | Slow EMA period (must be > fastPeriod) |
|
||||
|
||||
### Common Period Combinations
|
||||
|
||||
| Use Case | Fast | Slow | Notes |
|
||||
|:---------|:-----|:-----|:------|
|
||||
| **Scalping** | 5 | 13 | High responsiveness, more signals |
|
||||
| **Swing** | 10 | 50 | Balanced, classic configuration |
|
||||
| **Position** | 20 | 100 | Filtered for major trends |
|
||||
| **Investment** | 50 | 200 | Long-term directional bias |
|
||||
|
||||
## Output Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|:---------|:-----|:------------|
|
||||
| `Last` | TValue | Trend direction: +1 (bullish), -1 (bearish), 0 (neutral) |
|
||||
| `Strength` | TValue | Trend strength as percentage |
|
||||
| `FastEma` | TValue | Current fast EMA value |
|
||||
| `SlowEma` | TValue | Current slow EMA value |
|
||||
| `IsHot` | bool | True when both EMAs are fully warmed |
|
||||
| `WarmupPeriod` | int | Equal to slowPeriod |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Score | Notes |
|
||||
|:-------|:------|:------|
|
||||
| **Throughput** | ~15 ns/bar | Dual EMA + direction check |
|
||||
| **Allocations** | 0 | Streaming mode is allocation-free |
|
||||
| **Complexity** | O(1) | Constant time per update |
|
||||
| **Accuracy** | 9/10 | Bias-compensated EMAs match external libs |
|
||||
| **Timeliness** | 7/10 | Triple-confirmation adds slight lag |
|
||||
| **Overshoot** | 8/10 | No overshoot; discrete {-1, 0, +1} output |
|
||||
| **Smoothness** | 6/10 | State transitions can be abrupt |
|
||||
|
||||
## Validation
|
||||
|
||||
AMAT is a custom indicator not present in standard TA libraries. Validation confirms:
|
||||
|
||||
| Component | Library | Status | Notes |
|
||||
|:----------|:--------|:-------|:------|
|
||||
| **Fast EMA** | TA-Lib | ✅ | Matches `TA_EMA` |
|
||||
| **Fast EMA** | Skender | ✅ | Matches `GetEma` |
|
||||
| **Slow EMA** | TA-Lib | ✅ | Matches `TA_EMA` |
|
||||
| **Slow EMA** | Skender | ✅ | Matches `GetEma` |
|
||||
| **Trend Logic** | Manual | ✅ | Verified against known patterns |
|
||||
| **Strength** | Manual | ✅ | Formula verification |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Expecting Continuous Signals
|
||||
|
||||
AMAT returns 0 (neutral) frequently. This is intentional—choppy markets produce neutral signals. Trading systems should respect neutral states rather than forcing a directional bias.
|
||||
|
||||
### 2. Period Selection
|
||||
|
||||
Fast periods that are too close to slow periods produce excessive neutral readings. A ratio of 1:5 (e.g., 10/50) provides reasonable separation.
|
||||
|
||||
### 3. Strength Interpretation
|
||||
|
||||
High strength doesn't guarantee trend continuation. It measures current separation, not momentum. A declining strength during a +1 trend may indicate weakening conviction.
|
||||
|
||||
### 4. Initialization Phase
|
||||
|
||||
Until `IsHot` returns true, trend signals may be unreliable. The indicator needs `slowPeriod` bars to stabilize both EMAs.
|
||||
|
||||
## See Also
|
||||
|
||||
- [EMA](../trends/ema/Ema.md) - Exponential Moving Average (AMAT's building block)
|
||||
- [MACD](../momentum/macd/Macd.md) - Another dual-EMA system with different logic
|
||||
- [ADX](../momentum/adx/Adx.md) - Trend strength without directional bias
|
||||
@@ -0,0 +1,504 @@
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class UltoscTests
|
||||
{
|
||||
// ============== Constructor & Parameter Validation ==============
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod1_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(0, 14, 28));
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(-1, 14, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod2_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, 0, 28));
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, -1, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidPeriod3_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, 14, 0));
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, 14, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Period1NotLessThanPeriod2_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(14, 14, 28));
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(15, 14, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Period2NotLessThanPeriod3_ThrowsArgumentException()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, 28, 28));
|
||||
Assert.Throws<ArgumentException>(() => new Ultosc(7, 29, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters_Succeeds()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
Assert.NotNull(ultosc);
|
||||
|
||||
var ultosc2 = new Ultosc(5, 10, 20);
|
||||
Assert.NotNull(ultosc2);
|
||||
}
|
||||
|
||||
// ============== Basic Functionality ==============
|
||||
|
||||
[Fact]
|
||||
public void BasicCalculation_DoesNotCrash()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(ultosc.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
|
||||
Assert.Equal(0, ultosc.Last.Value);
|
||||
|
||||
TValue result = ultosc.Update(bar);
|
||||
|
||||
Assert.True(result.Value > 0);
|
||||
Assert.Equal(result.Value, ultosc.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstValue_ReturnsValidOscillator()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
// First bar: BP = Close - Low = 105 - 90 = 15
|
||||
// TR = High - Low = 110 - 90 = 20
|
||||
// Avg = BP/TR = 15/20 = 0.75 for all periods
|
||||
// UO = 100 * (4*0.75 + 2*0.75 + 0.75) / 7 = 100 * 5.25/7 = 75
|
||||
|
||||
TValue result = ultosc.Update(bar);
|
||||
|
||||
Assert.Equal(75.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Properties_Accessible()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
|
||||
Assert.Equal(0, ultosc.Last.Value);
|
||||
Assert.False(ultosc.IsHot);
|
||||
Assert.Contains("Ultosc", ultosc.Name, StringComparison.Ordinal);
|
||||
Assert.Equal(28, ultosc.WarmupPeriod);
|
||||
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
ultosc.Update(bar);
|
||||
|
||||
Assert.NotEqual(0, ultosc.Last.Value);
|
||||
}
|
||||
|
||||
// ============== State Management & Bar Correction ==============
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_AcceptsParameter()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
ultosc.Update(bar1, isNew: true);
|
||||
double value1 = ultosc.Last.Value;
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 100, 108, 1000);
|
||||
ultosc.Update(bar2, isNew: true);
|
||||
double value2 = ultosc.Last.Value;
|
||||
|
||||
Assert.NotEqual(value1, value2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_IsNew_False_UpdatesValue()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
ultosc.Update(bar1, isNew: true);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 100, 108, 1000);
|
||||
ultosc.Update(bar2, isNew: true);
|
||||
double beforeUpdate = ultosc.Last.Value;
|
||||
|
||||
var bar2Modified = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 120, 90, 108, 1000);
|
||||
ultosc.Update(bar2Modified, isNew: false);
|
||||
double afterUpdate = ultosc.Last.Value;
|
||||
|
||||
Assert.NotEqual(beforeUpdate, afterUpdate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_Consistency()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed first 99
|
||||
for (int i = 0; i < 99; i++)
|
||||
{
|
||||
ultosc.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Update with 100th point (isNew=true)
|
||||
ultosc.Update(bars[99], true);
|
||||
|
||||
// Update with modified 100th point (isNew=false)
|
||||
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 10.0, bars[99].Low - 10.0, bars[99].Close, bars[99].Volume);
|
||||
double val2 = ultosc.Update(modifiedBar, false).Value;
|
||||
|
||||
// Create new instance and feed up to modified
|
||||
var ultosc2 = new Ultosc(7, 14, 28);
|
||||
for (int i = 0; i < 99; i++)
|
||||
{
|
||||
ultosc2.Update(bars[i]);
|
||||
}
|
||||
double val3 = ultosc2.Update(modifiedBar, true).Value;
|
||||
|
||||
Assert.Equal(val3, val2, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Feed 10 new values
|
||||
TBar tenthBar = default;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
tenthBar = bars[i];
|
||||
ultosc.Update(tenthBar, isNew: true);
|
||||
}
|
||||
|
||||
// Remember state after 10 values
|
||||
double stateAfterTen = ultosc.Last.Value;
|
||||
|
||||
// Generate 9 corrections with isNew=false (different values)
|
||||
for (int i = 10; i < 19; i++)
|
||||
{
|
||||
ultosc.Update(bars[i], isNew: false);
|
||||
}
|
||||
|
||||
// Feed the remembered 10th bar again with isNew=false
|
||||
TValue finalResult = ultosc.Update(tenthBar, isNew: false);
|
||||
|
||||
// State should match the original state after 10 values
|
||||
Assert.Equal(stateAfterTen, finalResult.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_Works()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars) ultosc.Update(bar);
|
||||
|
||||
double lastVal = ultosc.Last.Value;
|
||||
Assert.NotEqual(0, lastVal);
|
||||
|
||||
ultosc.Reset();
|
||||
Assert.Equal(0, ultosc.Last.Value);
|
||||
Assert.False(ultosc.IsHot);
|
||||
|
||||
// After reset, should accept new values
|
||||
ultosc.Update(bars[0]);
|
||||
Assert.NotEqual(0, ultosc.Last.Value);
|
||||
}
|
||||
|
||||
// ============== Warmup & Convergence ==============
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueAfterWarmup()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
Assert.False(ultosc.IsHot);
|
||||
|
||||
int steps = 0;
|
||||
var baseTime = DateTime.UtcNow;
|
||||
while (!ultosc.IsHot && steps < 100)
|
||||
{
|
||||
var bar = new TBar(baseTime.AddMinutes(steps), 100, 110, 90, 100, 1000);
|
||||
ultosc.Update(bar);
|
||||
steps++;
|
||||
}
|
||||
|
||||
Assert.True(ultosc.IsHot);
|
||||
Assert.True(steps > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_IsPositive()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
Assert.True(ultosc.WarmupPeriod > 0);
|
||||
Assert.Equal(28, ultosc.WarmupPeriod);
|
||||
|
||||
var ultosc2 = new Ultosc(5, 10, 20);
|
||||
Assert.Equal(20, ultosc2.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ============== NaN/Infinity Handling ==============
|
||||
|
||||
[Fact]
|
||||
public void NaN_Input_UsesLastValidValue()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
ultosc.Update(bar1);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 98, 108, 1000);
|
||||
ultosc.Update(bar2);
|
||||
|
||||
// Feed bar with NaN values
|
||||
var barWithNaN = new TBar(DateTime.UtcNow.AddMinutes(2), double.NaN, 115, 100, 112, 1000);
|
||||
var resultAfterNaN = ultosc.Update(barWithNaN);
|
||||
|
||||
// Result should be finite
|
||||
Assert.True(double.IsFinite(resultAfterNaN.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_Input_UsesLastValidValue()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
var bar1 = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
ultosc.Update(bar1);
|
||||
|
||||
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102, 110, 98, 108, 1000);
|
||||
ultosc.Update(bar2);
|
||||
|
||||
// Feed bar with Infinity
|
||||
var barWithInf = new TBar(DateTime.UtcNow.AddMinutes(2), 108, double.PositiveInfinity, 100, 112, 1000);
|
||||
var resultAfterInf = ultosc.Update(barWithInf);
|
||||
|
||||
// Result should be finite or infinity (depending on implementation)
|
||||
Assert.True(double.IsFinite(resultAfterInf.Value) || double.IsPositiveInfinity(resultAfterInf.Value));
|
||||
}
|
||||
|
||||
// ============== Consistency Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void BatchCalc_MatchesIterativeCalc()
|
||||
{
|
||||
var ultoscIterative = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Calculate iteratively
|
||||
var iterativeResults = new TSeries();
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
iterativeResults.Add(ultoscIterative.Update(bar));
|
||||
}
|
||||
|
||||
// Calculate batch
|
||||
var batchResults = Ultosc.Batch(bars, 7, 14, 28);
|
||||
|
||||
// Compare
|
||||
Assert.Equal(iterativeResults.Count, batchResults.Count);
|
||||
for (int i = 0; i < iterativeResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(iterativeResults[i].Value, batchResults[i].Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TBarSeries_Update_MatchesStreaming()
|
||||
{
|
||||
var ultosc1 = new Ultosc(7, 14, 28);
|
||||
var ultosc2 = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Streaming
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
ultosc1.Update(bar);
|
||||
}
|
||||
|
||||
// Batch
|
||||
ultosc2.Update(bars);
|
||||
|
||||
Assert.Equal(ultosc1.Last.Value, ultosc2.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Chainability_Works()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var result = ultosc.Update(bars);
|
||||
Assert.Equal(50, result.Count);
|
||||
Assert.Equal(ultosc.Last.Value, result.Last.Value);
|
||||
}
|
||||
|
||||
// ============== Oscillator Range Tests ==============
|
||||
|
||||
[Fact]
|
||||
public void Oscillator_ReturnsValueBetween0And100()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
var result = ultosc.Update(bar);
|
||||
Assert.InRange(result.Value, 0.0, 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrongUptrend_ReturnsHighValues()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Create strong uptrend bars where Close is always at High
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 100 + (i * 5); // Rising prices
|
||||
var bar = new TBar(baseTime.AddMinutes(i), basePrice, basePrice + 10, basePrice - 2, basePrice + 10, 1000);
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
// In strong uptrend with Close at High, BP/TR should be high
|
||||
Assert.True(ultosc.Last.Value > 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StrongDowntrend_ReturnsLowValues()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
var baseTime = DateTime.UtcNow;
|
||||
|
||||
// Create strong downtrend bars where Close is always at Low
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double basePrice = 200 - (i * 5); // Falling prices
|
||||
var bar = new TBar(baseTime.AddMinutes(i), basePrice, basePrice + 2, basePrice - 10, basePrice - 10, 1000);
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
// In strong downtrend with Close at Low, BP/TR should be low
|
||||
Assert.True(ultosc.Last.Value < 50);
|
||||
}
|
||||
|
||||
// ============== Static Batch Method ==============
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_Works()
|
||||
{
|
||||
var gbm = new GBM();
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var results = Ultosc.Batch(bars, 7, 14, 28);
|
||||
|
||||
Assert.Equal(50, results.Count);
|
||||
Assert.True(double.IsFinite(results.Last.Value));
|
||||
}
|
||||
|
||||
// ============== Edge Cases ==============
|
||||
|
||||
[Fact]
|
||||
public void SingleBar_ReturnsValidResult()
|
||||
{
|
||||
var ultosc = new Ultosc(7, 14, 28);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000);
|
||||
|
||||
var result = ultosc.Update(bar);
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
// BP = Close - Low = 105 - 90 = 15
|
||||
// TR = High - Low = 110 - 90 = 20
|
||||
// Avg = 15/20 = 0.75
|
||||
// UO = 100 * (4*0.75 + 2*0.75 + 0.75) / 7 = 75
|
||||
Assert.Equal(75.0, result.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlatBars_ReturnsFifty()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
// All bars have same OHLC values (flat market)
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 100, 100, 100, 1000);
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
// For flat bars: BP = 0, TR = 0, so BP/TR = 0/0 handled as 0.5
|
||||
// UO = 100 * 0.5 * 7 / 7 = 50
|
||||
Assert.Equal(50.0, ultosc.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtHigh_ReturnsHundred()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
// All bars have Close at High
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 110, 1000);
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
// BP = Close - TrueLow = 110 - 90 = 20
|
||||
// TR = TrueHigh - TrueLow = 110 - 90 = 20
|
||||
// Avg = 20/20 = 1.0
|
||||
// UO = 100 * (4*1 + 2*1 + 1) / 7 = 100
|
||||
Assert.Equal(100.0, ultosc.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtLow_ReturnsZero()
|
||||
{
|
||||
var ultosc = new Ultosc(3, 5, 7);
|
||||
|
||||
// All bars have Close at Low
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 90, 1000);
|
||||
ultosc.Update(bar);
|
||||
}
|
||||
|
||||
// BP = Close - TrueLow = 90 - 90 = 0
|
||||
// TR = TrueHigh - TrueLow = 110 - 90 = 20
|
||||
// Avg = 0/20 = 0.0
|
||||
// UO = 100 * (4*0 + 2*0 + 0) / 7 = 0
|
||||
Assert.Equal(0.0, ultosc.Last.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
using Skender.Stock.Indicators;
|
||||
using TALib;
|
||||
using Tulip;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class UltoscValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public UltoscValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Batch()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (batch TBarSeries)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResult = ultosc.Update(_testData.Bars);
|
||||
|
||||
// Calculate Skender Ultimate Oscillator
|
||||
var sResult = _testData.SkenderQuotes.GetUltimate(p1, p2, p3).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, sResult, (s) => s.Ultimate, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Batch(TBarSeries) validated successfully against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_Streaming()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (streaming)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in _testData.Bars)
|
||||
{
|
||||
qResults.Add(ultosc.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate Skender Ultimate Oscillator
|
||||
var sResult = _testData.SkenderQuotes.GetUltimate(p1, p2, p3).ToList();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResults, sResult, (s) => s.Ultimate, tolerance: ValidationHelper.SkenderTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Streaming validated successfully against Skender");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Talib_Batch()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
// Prepare data for TA-Lib (double[])
|
||||
double[] hData = _testData.Bars.High.Select(x => x.Value).ToArray();
|
||||
double[] lData = _testData.Bars.Low.Select(x => x.Value).ToArray();
|
||||
double[] cData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
double[] output = new double[hData.Length];
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (batch TBarSeries)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResult = ultosc.Update(_testData.Bars);
|
||||
|
||||
// Calculate TA-Lib UltOsc
|
||||
var retCode = TALib.Functions.UltOsc(hData, lData, cData, 0..^0, output, out var outRange, p1, p2, p3);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.UltOscLookback(p1, p2, p3);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, output, outRange, lookback, tolerance: ValidationHelper.TalibTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Batch(TBarSeries) validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Talib_Streaming()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
// Prepare data for TA-Lib (double[])
|
||||
double[] hData = _testData.Bars.High.Select(x => x.Value).ToArray();
|
||||
double[] lData = _testData.Bars.Low.Select(x => x.Value).ToArray();
|
||||
double[] cData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
double[] output = new double[hData.Length];
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (streaming)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in _testData.Bars)
|
||||
{
|
||||
qResults.Add(ultosc.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate TA-Lib UltOsc
|
||||
var retCode = TALib.Functions.UltOsc(hData, lData, cData, 0..^0, output, out var outRange, p1, p2, p3);
|
||||
Assert.Equal(Core.RetCode.Success, retCode);
|
||||
|
||||
int lookback = TALib.Functions.UltOscLookback(p1, p2, p3);
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResults, output, outRange, lookback, tolerance: ValidationHelper.TalibTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Streaming validated successfully against TA-Lib");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Tulip_Batch()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
// Prepare data for Tulip (double[])
|
||||
double[] hData = _testData.Bars.High.Select(x => x.Value).ToArray();
|
||||
double[] lData = _testData.Bars.Low.Select(x => x.Value).ToArray();
|
||||
double[] cData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (batch TBarSeries)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResult = ultosc.Update(_testData.Bars);
|
||||
|
||||
// Calculate Tulip UltOsc
|
||||
var ultoscIndicator = Tulip.Indicators.ultosc;
|
||||
double[][] inputs = { hData, lData, cData };
|
||||
double[] options = { p1, p2, p3 };
|
||||
|
||||
// Tulip UltOsc lookback
|
||||
int lookback = ultoscIndicator.Start(options);
|
||||
double[][] outputs = { new double[hData.Length - lookback] };
|
||||
|
||||
ultoscIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, tResult, lookback, tolerance: ValidationHelper.TulipTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Batch(TBarSeries) validated successfully against Tulip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Tulip_Streaming()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
// Prepare data for Tulip (double[])
|
||||
double[] hData = _testData.Bars.High.Select(x => x.Value).ToArray();
|
||||
double[] lData = _testData.Bars.Low.Select(x => x.Value).ToArray();
|
||||
double[] cData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (streaming)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResults = new List<double>();
|
||||
foreach (var item in _testData.Bars)
|
||||
{
|
||||
qResults.Add(ultosc.Update(item).Value);
|
||||
}
|
||||
|
||||
// Calculate Tulip UltOsc
|
||||
var ultoscIndicator = Tulip.Indicators.ultosc;
|
||||
double[][] inputs = { hData, lData, cData };
|
||||
double[] options = { p1, p2, p3 };
|
||||
|
||||
// Tulip UltOsc lookback
|
||||
int lookback = ultoscIndicator.Start(options);
|
||||
double[][] outputs = { new double[hData.Length - lookback] };
|
||||
|
||||
ultoscIndicator.Run(inputs, options, outputs);
|
||||
var tResult = outputs[0];
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResults, tResult, lookback, tolerance: ValidationHelper.TulipTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Streaming validated successfully against Tulip");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Ooples_Batch()
|
||||
{
|
||||
int[][] periodSets = { [7, 14, 28] };
|
||||
|
||||
// Prepare data for Ooples (List<TickerData>)
|
||||
var ooplesData = _testData.SkenderQuotes.Select(q => new TickerData
|
||||
{
|
||||
Date = q.Date,
|
||||
Close = (double)q.Close,
|
||||
High = (double)q.High,
|
||||
Low = (double)q.Low,
|
||||
Open = (double)q.Open,
|
||||
Volume = (double)q.Volume
|
||||
}).ToList();
|
||||
|
||||
foreach (var periods in periodSets)
|
||||
{
|
||||
int p1 = periods[0];
|
||||
int p2 = periods[1];
|
||||
int p3 = periods[2];
|
||||
|
||||
// Calculate QuanTAlib Ultosc (batch TBarSeries)
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var qResult = ultosc.Update(_testData.Bars);
|
||||
|
||||
// Calculate Ooples Ultimate Oscillator
|
||||
var stockData = new StockData(ooplesData);
|
||||
var sResult = Calculations.CalculateUltimateOscillator(stockData, p1, p2, p3).OutputValues.Values.First();
|
||||
|
||||
// Compare last 100 records
|
||||
ValidationHelper.VerifyData(qResult, sResult, (s) => s, 100, ValidationHelper.OoplesTolerance);
|
||||
}
|
||||
_output.WriteLine("Ultosc Batch(TBarSeries) validated successfully against Ooples");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Span_MatchesTBarSeries()
|
||||
{
|
||||
int p1 = 7;
|
||||
int p2 = 14;
|
||||
int p3 = 28;
|
||||
|
||||
// Prepare data
|
||||
double[] hData = _testData.Bars.High.Select(x => x.Value).ToArray();
|
||||
double[] lData = _testData.Bars.Low.Select(x => x.Value).ToArray();
|
||||
double[] cData = _testData.Bars.Close.Select(x => x.Value).ToArray();
|
||||
double[] spanOutput = new double[hData.Length];
|
||||
|
||||
// Calculate using span method
|
||||
Ultosc.Calculate(hData, lData, cData, spanOutput, p1, p2, p3);
|
||||
|
||||
// Calculate using TBarSeries batch
|
||||
var ultosc = new Ultosc(p1, p2, p3);
|
||||
var tbarResult = ultosc.Update(_testData.Bars);
|
||||
|
||||
// Compare results
|
||||
for (int i = 0; i < tbarResult.Count; i++)
|
||||
{
|
||||
Assert.Equal(tbarResult[i].Value, spanOutput[i], 1e-10);
|
||||
}
|
||||
_output.WriteLine("Ultosc Span calculation matches TBarSeries batch calculation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// ULTOSC: Ultimate Oscillator
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Ultimate Oscillator, developed by Larry Williams in 1976, is a momentum oscillator
|
||||
/// that uses weighted averages of three different time periods to reduce volatility and
|
||||
/// false signals inherent in single-period oscillators.
|
||||
///
|
||||
/// Calculation:
|
||||
/// 1. Buying Pressure (BP) = Close - True Low
|
||||
/// True Low = Min(Low, Previous Close)
|
||||
/// 2. True Range (TR) = True High - True Low
|
||||
/// True High = Max(High, Previous Close)
|
||||
/// 3. Average for each period = Sum(BP) / Sum(TR)
|
||||
/// 4. Ultimate Oscillator = 100 * (4*Avg7 + 2*Avg14 + Avg28) / (4 + 2 + 1)
|
||||
///
|
||||
/// Key Features:
|
||||
/// - Three time frames reduce false signals
|
||||
/// - Buying pressure concept measures demand
|
||||
/// - Weighted average gives priority to shorter-term movements
|
||||
///
|
||||
/// Sources:
|
||||
/// - Larry Williams, "The Ultimate Oscillator" (1985 Stocks & Commodities)
|
||||
/// - https://www.investopedia.com/terms/u/ultimateoscillator.asp
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Ultosc : AbstractBase
|
||||
{
|
||||
private readonly int _period1;
|
||||
private readonly int _period2;
|
||||
private readonly int _period3;
|
||||
private readonly RingBuffer _bp1;
|
||||
private readonly RingBuffer _bp2;
|
||||
private readonly RingBuffer _bp3;
|
||||
private readonly RingBuffer _tr1;
|
||||
private readonly RingBuffer _tr2;
|
||||
private readonly RingBuffer _tr3;
|
||||
private double _prevClose;
|
||||
private double _p_prevClose;
|
||||
private int _index;
|
||||
private int _p_index;
|
||||
|
||||
// Weights: 4:2:1
|
||||
private const double Weight1 = 4.0;
|
||||
private const double Weight2 = 2.0;
|
||||
private const double Weight3 = 1.0;
|
||||
private const double WeightSum = Weight1 + Weight2 + Weight3; // 7.0
|
||||
|
||||
public override bool IsHot => _index >= _period3;
|
||||
|
||||
/// <summary>
|
||||
/// Creates Ultimate Oscillator with specified periods.
|
||||
/// </summary>
|
||||
/// <param name="period1">Short period (default: 7)</param>
|
||||
/// <param name="period2">Intermediate period (default: 14)</param>
|
||||
/// <param name="period3">Long period (default: 28)</param>
|
||||
public Ultosc(int period1 = 7, int period2 = 14, int period3 = 28)
|
||||
{
|
||||
if (period1 <= 0)
|
||||
throw new ArgumentException("Period1 must be greater than 0", nameof(period1));
|
||||
if (period2 <= 0)
|
||||
throw new ArgumentException("Period2 must be greater than 0", nameof(period2));
|
||||
if (period3 <= 0)
|
||||
throw new ArgumentException("Period3 must be greater than 0", nameof(period3));
|
||||
if (period1 >= period2)
|
||||
throw new ArgumentException("Period1 must be less than Period2", nameof(period1));
|
||||
if (period2 >= period3)
|
||||
throw new ArgumentException("Period2 must be less than Period3", nameof(period2));
|
||||
|
||||
_period1 = period1;
|
||||
_period2 = period2;
|
||||
_period3 = period3;
|
||||
_bp1 = new RingBuffer(period1);
|
||||
_bp2 = new RingBuffer(period2);
|
||||
_bp3 = new RingBuffer(period3);
|
||||
_tr1 = new RingBuffer(period1);
|
||||
_tr2 = new RingBuffer(period2);
|
||||
_tr3 = new RingBuffer(period3);
|
||||
_prevClose = double.NaN;
|
||||
_p_prevClose = double.NaN;
|
||||
_index = 0;
|
||||
_p_index = 0;
|
||||
|
||||
Name = $"Ultosc({period1},{period2},{period3})";
|
||||
WarmupPeriod = period3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Ultimate Oscillator with source subscription and specified periods.
|
||||
/// </summary>
|
||||
public Ultosc(TBarSeries source, int period1 = 7, int period2 = 14, int period3 = 28) : this(period1, period2, period3)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TBarEventArgs args)
|
||||
{
|
||||
Update(args.Value, args.IsNew);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_prevClose = _prevClose;
|
||||
_p_index = _index;
|
||||
}
|
||||
else
|
||||
{
|
||||
_prevClose = _p_prevClose;
|
||||
_index = _p_index;
|
||||
}
|
||||
|
||||
double high = input.High;
|
||||
double low = input.Low;
|
||||
double close = input.Close;
|
||||
|
||||
// Handle invalid inputs
|
||||
if (!double.IsFinite(high) || !double.IsFinite(low) || !double.IsFinite(close))
|
||||
{
|
||||
Last = new TValue(input.Time, Last.Value);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
double bp, tr;
|
||||
if (double.IsNaN(_prevClose))
|
||||
{
|
||||
// First bar: True Range = High - Low, BP = Close - Low
|
||||
bp = close - low;
|
||||
tr = high - low;
|
||||
}
|
||||
else
|
||||
{
|
||||
// True Low = Min(Low, Previous Close)
|
||||
double trueLow = Math.Min(low, _prevClose);
|
||||
// True High = Max(High, Previous Close)
|
||||
double trueHigh = Math.Max(high, _prevClose);
|
||||
// Buying Pressure = Close - True Low
|
||||
bp = close - trueLow;
|
||||
// True Range = True High - True Low
|
||||
tr = trueHigh - trueLow;
|
||||
}
|
||||
|
||||
// Add to all three period buffers
|
||||
_bp1.Add(bp, isNew);
|
||||
_bp2.Add(bp, isNew);
|
||||
_bp3.Add(bp, isNew);
|
||||
_tr1.Add(tr, isNew);
|
||||
_tr2.Add(tr, isNew);
|
||||
_tr3.Add(tr, isNew);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_prevClose = close;
|
||||
_index++;
|
||||
}
|
||||
|
||||
// Calculate sums
|
||||
double bpSum1 = _bp1.Sum();
|
||||
double bpSum2 = _bp2.Sum();
|
||||
double bpSum3 = _bp3.Sum();
|
||||
double trSum1 = _tr1.Sum();
|
||||
double trSum2 = _tr2.Sum();
|
||||
double trSum3 = _tr3.Sum();
|
||||
|
||||
// Calculate averages (handle division by zero)
|
||||
const double epsilon = 1e-10;
|
||||
double avg1 = trSum1 > epsilon ? bpSum1 / trSum1 : 0.5;
|
||||
double avg2 = trSum2 > epsilon ? bpSum2 / trSum2 : 0.5;
|
||||
double avg3 = trSum3 > epsilon ? bpSum3 / trSum3 : 0.5;
|
||||
|
||||
// Ultimate Oscillator = 100 * (4*Avg1 + 2*Avg2 + Avg3) / 7
|
||||
double ultosc = 100.0 * Math.FusedMultiplyAdd(Weight1, avg1, Math.FusedMultiplyAdd(Weight2, avg2, Weight3 * avg3)) / WeightSum;
|
||||
|
||||
Last = new TValue(input.Time, ultosc);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update for TValue input - not recommended for Ultimate Oscillator as it needs OHLC.
|
||||
/// This method will return 50 (neutral) since proper calculation requires OHLC data.
|
||||
/// </summary>
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
// Ultimate Oscillator requires OHLC data
|
||||
// Return neutral value if called with TValue
|
||||
Last = new TValue(input.Time, 50.0);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public TSeries Update(TBarSeries 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 using span method
|
||||
Calculate(source.High.Values, source.Low.Values, source.Close.Values,
|
||||
vSpan, _period1, _period2, _period3);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Restore state for streaming
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i]);
|
||||
}
|
||||
|
||||
Last = new TValue(tSpan[len - 1], vSpan[len - 1]);
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
// Cannot properly calculate Ultimate Oscillator from single-value series
|
||||
// Return series of neutral values
|
||||
if (source.Count == 0) return [];
|
||||
|
||||
var t = new List<long>(source.Count);
|
||||
var v = new List<double>(source.Count);
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
t.Add(source.Times[i]);
|
||||
v.Add(50.0);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
// Cannot properly prime Ultimate Oscillator from single-value array
|
||||
// This method is a no-op for OHLC indicators
|
||||
}
|
||||
|
||||
public static TSeries Batch(TBarSeries source, int period1 = 7, int period2 = 14, int period3 = 28)
|
||||
{
|
||||
var ultosc = new Ultosc(period1, period2, period3);
|
||||
return ultosc.Update(source);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Calculate(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> output,
|
||||
int period1 = 7,
|
||||
int period2 = 14,
|
||||
int period3 = 28)
|
||||
{
|
||||
int len = high.Length;
|
||||
if (len != low.Length || len != close.Length || len != output.Length)
|
||||
throw new ArgumentException("All arrays must have the same length", nameof(output));
|
||||
if (period1 <= 0)
|
||||
throw new ArgumentException("Period1 must be greater than 0", nameof(period1));
|
||||
if (period2 <= 0)
|
||||
throw new ArgumentException("Period2 must be greater than 0", nameof(period2));
|
||||
if (period3 <= 0)
|
||||
throw new ArgumentException("Period3 must be greater than 0", nameof(period3));
|
||||
if (period1 >= period2)
|
||||
throw new ArgumentException("Period1 must be less than Period2", nameof(period1));
|
||||
if (period2 >= period3)
|
||||
throw new ArgumentException("Period2 must be less than Period3", nameof(period2));
|
||||
|
||||
if (len == 0) return;
|
||||
|
||||
// Allocate buffers for BP and TR
|
||||
double[] bpArray = System.Buffers.ArrayPool<double>.Shared.Rent(len);
|
||||
double[] trArray = System.Buffers.ArrayPool<double>.Shared.Rent(len);
|
||||
|
||||
try
|
||||
{
|
||||
Span<double> bp = bpArray.AsSpan(0, len);
|
||||
Span<double> tr = trArray.AsSpan(0, len);
|
||||
|
||||
// First bar
|
||||
bp[0] = close[0] - low[0];
|
||||
tr[0] = high[0] - low[0];
|
||||
|
||||
// Calculate BP and TR for remaining bars
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
double h = high[i];
|
||||
double l = low[i];
|
||||
double c = close[i];
|
||||
double prevC = close[i - 1];
|
||||
|
||||
double trueLow = Math.Min(l, prevC);
|
||||
double trueHigh = Math.Max(h, prevC);
|
||||
|
||||
bp[i] = c - trueLow;
|
||||
tr[i] = trueHigh - trueLow;
|
||||
}
|
||||
|
||||
// Calculate running sums and output
|
||||
double bpSum1 = 0, bpSum2 = 0, bpSum3 = 0;
|
||||
double trSum1 = 0, trSum2 = 0, trSum3 = 0;
|
||||
|
||||
const double epsilon = 1e-10;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
// Add current values
|
||||
bpSum1 += bp[i];
|
||||
bpSum2 += bp[i];
|
||||
bpSum3 += bp[i];
|
||||
trSum1 += tr[i];
|
||||
trSum2 += tr[i];
|
||||
trSum3 += tr[i];
|
||||
|
||||
// Remove old values for each period window
|
||||
if (i >= period1)
|
||||
{
|
||||
bpSum1 -= bp[i - period1];
|
||||
trSum1 -= tr[i - period1];
|
||||
}
|
||||
if (i >= period2)
|
||||
{
|
||||
bpSum2 -= bp[i - period2];
|
||||
trSum2 -= tr[i - period2];
|
||||
}
|
||||
if (i >= period3)
|
||||
{
|
||||
bpSum3 -= bp[i - period3];
|
||||
trSum3 -= tr[i - period3];
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
double avg1 = trSum1 > epsilon ? bpSum1 / trSum1 : 0.5;
|
||||
double avg2 = trSum2 > epsilon ? bpSum2 / trSum2 : 0.5;
|
||||
double avg3 = trSum3 > epsilon ? bpSum3 / trSum3 : 0.5;
|
||||
|
||||
// Ultimate Oscillator
|
||||
output[i] = 100.0 * Math.FusedMultiplyAdd(Weight1, avg1, Math.FusedMultiplyAdd(Weight2, avg2, Weight3 * avg3)) / WeightSum;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(bpArray);
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(trArray);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_bp1.Clear();
|
||||
_bp2.Clear();
|
||||
_bp3.Clear();
|
||||
_tr1.Clear();
|
||||
_tr2.Clear();
|
||||
_tr3.Clear();
|
||||
_prevClose = double.NaN;
|
||||
_p_prevClose = double.NaN;
|
||||
_index = 0;
|
||||
_p_index = 0;
|
||||
Last = default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# UltOsc: Ultimate Oscillator
|
||||
|
||||
> "Why use one timeframe when three can save you from yourself?"
|
||||
|
||||
The Ultimate Oscillator is Larry Williams' answer to the fundamental flaw of single-period momentum oscillators: they whipsaw. By combining buying pressure across three distinct timeframes with a weighted average, UltOsc filters out the noise that traps traders who rely on RSI or Stochastics alone.
|
||||
|
||||
The indicator oscillates between 0 and 100. Readings above 70 suggest overbought conditions; readings below 30 suggest oversold. But the real power lies in **divergence detection**: when price makes a new high but UltOsc does not, the trend is exhausted.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Larry Williams introduced the Ultimate Oscillator in his 1985 article for *Technical Analysis of Stocks & Commodities* magazine. Williams, a legendary trader who famously turned \$10,000 into over \$1 million in a single year of trading, designed UltOsc to solve a specific problem.
|
||||
|
||||
Single-period oscillators like RSI suffer from two fatal flaws:
|
||||
|
||||
1. **False signals during trends**: In a strong uptrend, RSI can stay overbought for weeks, generating endless "sell" signals.
|
||||
2. **Period sensitivity**: A 7-period RSI behaves differently from a 14-period RSI. Which one is "right"?
|
||||
|
||||
Williams' solution was elegant: use three periods (7, 14, 28) and weight them so the shortest period has the most influence (4:2:1). This gives responsiveness to recent price action while still respecting the broader context.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
UltOsc is built on two core concepts: **Buying Pressure (BP)** and **True Range (TR)**.
|
||||
|
||||
### Buying Pressure
|
||||
|
||||
Buying Pressure measures how much of today's price movement was "bought." It is the distance from the True Low (the lower of today's Low or yesterday's Close) to today's Close.
|
||||
|
||||
$$
|
||||
BP = Close - TrueLow
|
||||
$$
|
||||
|
||||
If the close is at the high of the day, BP is maximized. If the close is at the low, BP is zero.
|
||||
|
||||
### True Range
|
||||
|
||||
True Range captures the full volatility of the day, including overnight gaps.
|
||||
|
||||
$$
|
||||
TR = TrueHigh - TrueLow
|
||||
$$
|
||||
|
||||
Where:
|
||||
|
||||
- $TrueHigh = \max(High, Close_{t-1})$
|
||||
- $TrueLow = \min(Low, Close_{t-1})$
|
||||
|
||||
### The Multi-Timeframe Fusion
|
||||
|
||||
For each of the three periods, UltOsc calculates the ratio of accumulated Buying Pressure to accumulated True Range:
|
||||
|
||||
$$
|
||||
Avg_n = \frac{\sum_{i=1}^{n} BP_i}{\sum_{i=1}^{n} TR_i}
|
||||
$$
|
||||
|
||||
This ratio represents the "efficiency" of buying over that period. A value of 1.0 means all volatility was captured by buyers; 0.0 means sellers dominated.
|
||||
|
||||
The final oscillator applies a 4:2:1 weighting:
|
||||
|
||||
$$
|
||||
UltOsc = 100 \times \frac{4 \times Avg_7 + 2 \times Avg_{14} + 1 \times Avg_{28}}{4 + 2 + 1}
|
||||
$$
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### 1. True Low and True High
|
||||
|
||||
$$
|
||||
TrueLow_t = \min(Low_t, Close_{t-1})
|
||||
$$
|
||||
|
||||
$$
|
||||
TrueHigh_t = \max(High_t, Close_{t-1})
|
||||
$$
|
||||
|
||||
### 2. Buying Pressure and True Range
|
||||
|
||||
$$
|
||||
BP_t = Close_t - TrueLow_t
|
||||
$$
|
||||
|
||||
$$
|
||||
TR_t = TrueHigh_t - TrueLow_t
|
||||
$$
|
||||
|
||||
### 3. Period Averages
|
||||
|
||||
For periods $n_1 = 7$, $n_2 = 14$, $n_3 = 28$:
|
||||
|
||||
$$
|
||||
Avg_n = \frac{\sum_{i=t-n+1}^{t} BP_i}{\sum_{i=t-n+1}^{t} TR_i}
|
||||
$$
|
||||
|
||||
### 4. Ultimate Oscillator
|
||||
|
||||
$$
|
||||
UltOsc = 100 \times \frac{4 \cdot Avg_7 + 2 \cdot Avg_{14} + 1 \cdot Avg_{28}}{7}
|
||||
$$
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Throughput** | 8 | Moderate; requires six running sums (BP and TR for each period). |
|
||||
| **Allocations** | 0 | Zero-allocation in hot paths using ring buffers. |
|
||||
| **Complexity** | O(1) | Constant time via running sums. |
|
||||
| **Accuracy** | 10 | Matches TA-Lib and Skender exactly. |
|
||||
| **Timeliness** | 6 | Balanced; short-period weighting provides responsiveness. |
|
||||
| **Overshoot** | 2 | Bounded to [0, 100]; minimal overshoot by design. |
|
||||
| **Smoothness** | 7 | Multi-period averaging provides inherent smoothing. |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **QuanTAlib** | ✅ | Validated. |
|
||||
| **TA-Lib** | ✅ | Matches `TA_ULTOSC` exactly. |
|
||||
| **Skender** | ✅ | Matches `GetUltimate` exactly. |
|
||||
| **Tulip** | ✅ | Matches `ultosc` exactly. |
|
||||
| **Ooples** | ⚠️ | Minor deviations in warmup period handling. |
|
||||
|
||||
### Trading Signals
|
||||
|
||||
Williams outlined specific rules for trading UltOsc:
|
||||
|
||||
1. **Bullish Divergence**: Price makes a lower low, UltOsc makes a higher low (UltOsc < 30).
|
||||
2. **Breakout Confirmation**: After divergence, UltOsc breaks above the divergence high.
|
||||
3. **Exit**: UltOsc reaches 70, or price hits target.
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- **Ignoring Divergence**: UltOsc is designed for divergence trading. Using it as a simple overbought/oversold indicator misses the point.
|
||||
- **Wrong Timeframes**: The default 7/14/28 works for daily charts. For intraday, consider scaling down proportionally.
|
||||
- **Trending Markets**: Like all oscillators, UltOsc struggles in strong trends. Use trend filters (ADX, moving averages) to avoid fighting the tide.
|
||||
- **Division by Zero**: If True Range is zero (flat line), the ratio is undefined. QuanTAlib handles this by returning 0.5 (neutral).
|
||||
@@ -31,7 +31,6 @@ public sealed class Bilateral : AbstractBase
|
||||
private record struct State(double SumSq, double LastValidValue);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
private readonly TValuePublishedHandler _handler;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Bilateral Filter with specified parameters.
|
||||
@@ -50,7 +49,6 @@ public sealed class Bilateral : AbstractBase
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Bilateral({period}, {sigmaSRatio:F2}, {sigmaRMult:F2})";
|
||||
WarmupPeriod = period;
|
||||
_handler = Handle;
|
||||
|
||||
_spatialWeights = new double[period];
|
||||
PrecalculateSpatialWeights();
|
||||
@@ -59,7 +57,7 @@ public sealed class Bilateral : AbstractBase
|
||||
public Bilateral(ITValuePublisher source, int period, double sigmaSRatio = 0.5, double sigmaRMult = 1.0)
|
||||
: this(period, sigmaSRatio, sigmaRMult)
|
||||
{
|
||||
source.Pub += _handler;
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
@@ -142,6 +140,25 @@ public sealed class Bilateral : AbstractBase
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new value.
|
||||
/// </summary>
|
||||
/// <param name="input">The input value with timestamp.</param>
|
||||
/// <param name="isNew">True for a new bar, false to update the current bar (intra-bar correction).</param>
|
||||
/// <returns>The calculated bilateral filter value.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bar Correction Limitation:</b> For windowed indicators like Bilateral, the isNew=false
|
||||
/// behavior only corrects the most recent value in the buffer. It does NOT restore the full
|
||||
/// buffer state from before the last isNew=true call. This means multiple consecutive
|
||||
/// isNew=false calls work correctly, but the correction is limited to the current bar only.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For scalar-state indicators (EMA, SMA running sum), full state rollback is possible.
|
||||
/// For buffer-based indicators, consider using Batch/Calculate methods for historical
|
||||
/// recalculation if perfect state restoration is required.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
@@ -212,7 +229,9 @@ public sealed class Bilateral : AbstractBase
|
||||
|
||||
// Variance = (SumSq - (Sum*Sum)/N) / N
|
||||
// Use Math.Max(0, ...) to handle potential floating point negative zero
|
||||
double variance = Math.Max(0, (_state.SumSq - (sum * sum) / count) / count);
|
||||
// Pre-compute inverse for efficiency
|
||||
double invCount = 1.0 / count;
|
||||
double variance = Math.Max(0, (_state.SumSq - sum * sum * invCount) * invCount);
|
||||
double stdev = Math.Sqrt(variance);
|
||||
|
||||
double sigmaR = Math.Max(stdev * _sigmaRMult, 1e-10);
|
||||
@@ -276,6 +295,19 @@ public sealed class Bilateral : AbstractBase
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates bilateral filter values for a TSeries and returns both results and a primed indicator.
|
||||
/// </summary>
|
||||
public static (TSeries Results, Bilateral Indicator) Calculate(TSeries source, int period, double sigmaSRatio = 0.5, double sigmaRMult = 1.0)
|
||||
{
|
||||
var indicator = new Bilateral(period, sigmaSRatio, sigmaRMult);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates bilateral filter values using spans (zero allocation in hot path).
|
||||
/// </summary>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> destination, int period, double sigmaSRatio = 0.5, double sigmaRMult = 1.0)
|
||||
{
|
||||
if (period <= 0)
|
||||
@@ -349,7 +381,8 @@ public sealed class Bilateral : AbstractBase
|
||||
if (count < period) count++;
|
||||
|
||||
// Calculate StDev
|
||||
double variance = Math.Max(0, (sumSq - (sum * sum) / count) / count);
|
||||
double invCount = 1.0 / count;
|
||||
double variance = Math.Max(0, (sumSq - sum * sum * invCount) * invCount);
|
||||
double stdev = Math.Sqrt(variance);
|
||||
|
||||
double sigmaR = Math.Max(stdev * sigmaRMult, 1e-10);
|
||||
@@ -380,4 +413,21 @@ public sealed class Bilateral : AbstractBase
|
||||
destination[i] = sumWeights < 1e-10 ? centerVal : sumWeightedSrc / sumWeights;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculates bilateral filter values for a TSeries.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source, int period, double sigmaSRatio = 0.5, double sigmaRMult = 1.0)
|
||||
{
|
||||
var indicator = new Bilateral(period, sigmaSRatio, sigmaRMult);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculates bilateral filter values using spans (zero allocation in hot path).
|
||||
/// </summary>
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> destination, int period, double sigmaSRatio = 0.5, double sigmaRMult = 1.0)
|
||||
{
|
||||
Calculate(source, destination, period, sigmaSRatio, sigmaRMult);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-57
@@ -1,18 +1,19 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
public sealed class Blma : AbstractBase, IDisposable
|
||||
/// <summary>
|
||||
/// BLMA: Blackman Moving Average
|
||||
/// A weighted moving average using the Blackman window function for smoother transitions.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Blma : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
private readonly double[] _weights;
|
||||
private readonly double _weightSum;
|
||||
private readonly TValuePublishedHandler _handler;
|
||||
private ITValuePublisher? _publisher;
|
||||
private bool _hasLast;
|
||||
|
||||
public override bool IsHot => _buffer.Count >= _period;
|
||||
|
||||
@@ -31,24 +32,14 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
|
||||
// Pre-calculate weights for the full period
|
||||
_weightSum = CalculateWeights(period, _weights);
|
||||
_handler = Handle;
|
||||
}
|
||||
|
||||
public Blma(ITValuePublisher source, int period) : this(period)
|
||||
{
|
||||
_publisher = source;
|
||||
source.Pub += _handler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_publisher != null)
|
||||
{
|
||||
_publisher.Pub -= _handler;
|
||||
_publisher = null;
|
||||
}
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs args)
|
||||
{
|
||||
Update(args.Value, args.IsNew);
|
||||
@@ -57,7 +48,7 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_hasLast = false;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
@@ -81,12 +72,14 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (double.IsNaN(input.Value) || double.IsInfinity(input.Value))
|
||||
// Handle NaN/Infinity - return last result without changing state
|
||||
double val = input.Value;
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
return _hasLast ? Last : default;
|
||||
return Last;
|
||||
}
|
||||
|
||||
_buffer.Add(input.Value, isNew);
|
||||
_buffer.Add(val, isNew);
|
||||
|
||||
double result;
|
||||
if (_buffer.Count < _period)
|
||||
@@ -95,31 +88,29 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
int count = _buffer.Count;
|
||||
if (count == 1)
|
||||
{
|
||||
result = input.Value;
|
||||
result = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
Span<double> currentWeights = stackalloc double[count];
|
||||
double currentWeightSum = CalculateWeights(count, currentWeights);
|
||||
|
||||
// Fallback for cases where weights sum to zero (e.g. N=2)
|
||||
result = Math.Abs(currentWeightSum) < double.Epsilon
|
||||
? _buffer.Average()
|
||||
: CalculateWeightedSum(_buffer, currentWeights) / currentWeightSum;
|
||||
result = ComputeWeightedAverage(
|
||||
currentWeightSum,
|
||||
CalculateWeightedSum(_buffer, currentWeights),
|
||||
_buffer.Average());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Full period, use pre-calculated weights
|
||||
// Fallback for cases where weights sum to zero (e.g. N=2)
|
||||
result = Math.Abs(_weightSum) < double.Epsilon
|
||||
? _buffer.Average()
|
||||
: CalculateWeightedSum(_buffer, _weights) / _weightSum;
|
||||
result = ComputeWeightedAverage(
|
||||
_weightSum,
|
||||
CalculateWeightedSum(_buffer, _weights),
|
||||
_buffer.Average());
|
||||
}
|
||||
|
||||
var tValue = new TValue(input.Time, result);
|
||||
Last = tValue;
|
||||
_hasLast = true;
|
||||
PubEvent(tValue, isNew);
|
||||
return tValue;
|
||||
}
|
||||
@@ -147,6 +138,15 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes weighted average with fallback for zero weight sum.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeWeightedAverage(double weightSum, double weightedSum, double fallbackAverage)
|
||||
{
|
||||
return Math.Abs(weightSum) < double.Epsilon ? fallbackAverage : weightedSum / weightSum;
|
||||
}
|
||||
|
||||
private static double CalculateWeights(int n, Span<double> weights)
|
||||
{
|
||||
if (n == 1)
|
||||
@@ -176,6 +176,7 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
return totalWeight;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double CalculateWeightedSum(RingBuffer buffer, ReadOnlySpan<double> weights)
|
||||
{
|
||||
int start = buffer.StartIndex;
|
||||
@@ -196,6 +197,19 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
return sum1 + sum2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates BLMA values for a TSeries and returns both results and a primed indicator.
|
||||
/// </summary>
|
||||
public static (TSeries Results, Blma Indicator) Calculate(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Blma(period);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates BLMA values using spans (high-performance batch API).
|
||||
/// </summary>
|
||||
public static void Calculate(ReadOnlySpan<double> source, Span<double> destination, int period)
|
||||
{
|
||||
if (period < 1)
|
||||
@@ -215,8 +229,29 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
// Buffer for warmup weights to avoid stackalloc in loop
|
||||
Span<double> warmupWeightsBuffer = period <= 256 ? stackalloc double[period] : new double[period];
|
||||
|
||||
// Handle NaN via last-valid-value substitution
|
||||
double lastValid = double.NaN;
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
if (double.IsFinite(source[i]))
|
||||
{
|
||||
lastValid = source[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val))
|
||||
{
|
||||
val = double.IsNaN(lastValid) ? 0 : lastValid;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValid = val;
|
||||
}
|
||||
|
||||
int count = Math.Min(i + 1, period);
|
||||
|
||||
if (count < period)
|
||||
@@ -224,49 +259,69 @@ public sealed class Blma : AbstractBase, IDisposable
|
||||
// Warmup: dynamic weights
|
||||
if (count == 1)
|
||||
{
|
||||
destination[i] = source[i];
|
||||
destination[i] = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
Span<double> currentWeights = warmupWeightsBuffer.Slice(0, count);
|
||||
double currentWeightSum = CalculateWeights(count, currentWeights);
|
||||
|
||||
if (Math.Abs(currentWeightSum) < double.Epsilon)
|
||||
double sum = 0;
|
||||
for (int j = 0; j < count; j++)
|
||||
{
|
||||
// Fallback for zero sum weights (e.g. N=2)
|
||||
double sum = 0;
|
||||
for (int j = 0; j < count; j++)
|
||||
{
|
||||
sum += source[i - count + 1 + j];
|
||||
}
|
||||
destination[i] = sum / count;
|
||||
int srcIdx = i - count + 1 + j;
|
||||
double srcVal = source[srcIdx];
|
||||
if (!double.IsFinite(srcVal)) srcVal = lastValid;
|
||||
sum += srcVal * currentWeights[j];
|
||||
}
|
||||
else
|
||||
|
||||
double avg = 0;
|
||||
for (int j = 0; j < count; j++)
|
||||
{
|
||||
double sum = source.Slice(i - count + 1, count).DotProduct(currentWeights);
|
||||
destination[i] = sum / currentWeightSum;
|
||||
int srcIdx = i - count + 1 + j;
|
||||
double srcVal = source[srcIdx];
|
||||
if (!double.IsFinite(srcVal)) srcVal = lastValid;
|
||||
avg += srcVal;
|
||||
}
|
||||
avg /= count;
|
||||
|
||||
destination[i] = ComputeWeightedAverage(currentWeightSum, sum, avg);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Full period
|
||||
if (Math.Abs(weightSum) < double.Epsilon)
|
||||
double sum = 0;
|
||||
double avg = 0;
|
||||
for (int j = 0; j < period; j++)
|
||||
{
|
||||
// Fallback for zero sum weights (e.g. N=2)
|
||||
double sum = 0;
|
||||
for (int j = 0; j < period; j++)
|
||||
{
|
||||
sum += source[i - period + 1 + j];
|
||||
}
|
||||
destination[i] = sum / period;
|
||||
}
|
||||
else
|
||||
{
|
||||
double sum = source.Slice(i - period + 1, period).DotProduct(weights);
|
||||
destination[i] = sum / weightSum;
|
||||
int srcIdx = i - period + 1 + j;
|
||||
double srcVal = source[srcIdx];
|
||||
if (!double.IsFinite(srcVal)) srcVal = lastValid;
|
||||
sum += srcVal * weights[j];
|
||||
avg += srcVal;
|
||||
}
|
||||
avg /= period;
|
||||
|
||||
destination[i] = ComputeWeightedAverage(weightSum, sum, avg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculates BLMA values for a TSeries.
|
||||
/// </summary>
|
||||
public static TSeries Batch(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Blma(period);
|
||||
return indicator.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculates BLMA values using spans.
|
||||
/// </summary>
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> destination, int period)
|
||||
{
|
||||
Calculate(source, destination, period);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using Xunit;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class AmatIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void AmatIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new AmatIndicator();
|
||||
|
||||
Assert.Equal(10, indicator.FastPeriod);
|
||||
Assert.Equal(50, indicator.SlowPeriod);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("AMAT - Archer Moving Averages Trends", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.False(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_MinHistoryDepths_IsSlowPeriod()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 10, SlowPeriod = 50 };
|
||||
Assert.Equal(50, indicator.MinHistoryDepths);
|
||||
|
||||
indicator = new AmatIndicator { FastPeriod = 5, SlowPeriod = 100 };
|
||||
Assert.Equal(100, indicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 10, SlowPeriod = 50 };
|
||||
Assert.Equal("AMAT(10,50)", indicator.ShortName);
|
||||
|
||||
indicator = new AmatIndicator { FastPeriod = 5, SlowPeriod = 20 };
|
||||
Assert.Equal("AMAT(5,20)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_Initialize_CreatesLineSeries()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 10, SlowPeriod = 50 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Should have 5 line series: Trend, Strength, Fast EMA, Slow EMA, Zero
|
||||
Assert.Equal(5, indicator.LinesSeries.Count);
|
||||
Assert.Equal("Trend", indicator.LinesSeries[0].Name);
|
||||
Assert.Equal("Strength", indicator.LinesSeries[1].Name);
|
||||
Assert.Equal("Fast EMA", indicator.LinesSeries[2].Name);
|
||||
Assert.Equal("Slow EMA", indicator.LinesSeries[3].Name);
|
||||
Assert.Equal("Zero", indicator.LinesSeries[4].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
|
||||
// After one bar, all 5 series should have values
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
Assert.Equal(1, indicator.LinesSeries[1].Count);
|
||||
Assert.Equal(1, indicator.LinesSeries[2].Count);
|
||||
Assert.Equal(1, indicator.LinesSeries[3].Count);
|
||||
Assert.Equal(1, indicator.LinesSeries[4].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
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.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
// NewTick should update without crashing
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_MultipleUpdates_ProducesCorrectSequence()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Add bars in uptrend
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(
|
||||
now.AddMinutes(i),
|
||||
100 + i * 2,
|
||||
105 + i * 2,
|
||||
95 + i * 2,
|
||||
102 + i * 2);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
Assert.Equal(20, indicator.LinesSeries[0].Count);
|
||||
|
||||
// Check that values are finite
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(i)));
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[2].GetValue(i)));
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[3].GetValue(i)));
|
||||
Assert.Equal(0, indicator.LinesSeries[4].GetValue(i)); // Zero line
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_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 AmatIndicator
|
||||
{
|
||||
FastPeriod = 3,
|
||||
SlowPeriod = 10,
|
||||
Source = source
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
indicator.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// All source types should produce values without crashing
|
||||
Assert.Equal(1, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_FastPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new AmatIndicator();
|
||||
indicator.FastPeriod = 5;
|
||||
|
||||
Assert.Equal(5, indicator.FastPeriod);
|
||||
Assert.Equal("AMAT(5,50)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_SlowPeriod_CanBeChanged()
|
||||
{
|
||||
var indicator = new AmatIndicator();
|
||||
indicator.SlowPeriod = 100;
|
||||
|
||||
Assert.Equal(100, indicator.SlowPeriod);
|
||||
Assert.Equal(100, indicator.MinHistoryDepths);
|
||||
Assert.Equal("AMAT(10,100)", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_ShowColdValues_False_SetsNaN()
|
||||
{
|
||||
var indicator = new AmatIndicator
|
||||
{
|
||||
FastPeriod = 3,
|
||||
SlowPeriod = 100,
|
||||
ShowColdValues = false
|
||||
};
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Add a few bars (less than warmup)
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 102);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// With ShowColdValues = false, cold values should be NaN
|
||||
// (before warmup is complete)
|
||||
Assert.True(double.IsNaN(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_Uptrend_ProducesBullishSignal()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Create a strong uptrend
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100 + i * 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// After warmup in uptrend, should show bullish (+1)
|
||||
double lastTrend = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(1.0, lastTrend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmatIndicator_Downtrend_ProducesBearishSignal()
|
||||
{
|
||||
var indicator = new AmatIndicator { FastPeriod = 3, SlowPeriod = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Create a strong downtrend
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 200 - i * 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
// After warmup in downtrend, should show bearish (-1)
|
||||
double lastTrend = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.Equal(-1.0, lastTrend);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// AMAT (Archer Moving Averages Trends) Quantower indicator.
|
||||
/// Uses dual EMAs to identify trend direction and strength.
|
||||
/// </summary>
|
||||
public class AmatIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Fast Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1, decimalPlaces: 0)]
|
||||
public int FastPeriod { get; set; } = 10;
|
||||
|
||||
[InputParameter("Slow Period", sortIndex: 11, minimum: 2, maximum: 1000, increment: 1, decimalPlaces: 0)]
|
||||
public int SlowPeriod { get; set; } = 50;
|
||||
|
||||
[DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Amat? _amat;
|
||||
private Func<IHistoryItem, double>? _selector;
|
||||
|
||||
public int MinHistoryDepths => SlowPeriod;
|
||||
public override string ShortName => $"AMAT({FastPeriod},{SlowPeriod})";
|
||||
|
||||
public AmatIndicator()
|
||||
{
|
||||
Name = "AMAT - Archer Moving Averages Trends";
|
||||
Description = "Identifies trend direction using dual EMA alignment";
|
||||
SeparateWindow = true;
|
||||
OnBackGround = false;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_amat = new Amat(FastPeriod, SlowPeriod);
|
||||
_selector = Source.GetPriceSelector();
|
||||
|
||||
// Trend line: +1 = bullish, -1 = bearish, 0 = neutral
|
||||
AddLineSeries(new LineSeries("Trend", Momentum, 2, LineStyle.Histogramm));
|
||||
|
||||
// Strength line: percentage separation
|
||||
AddLineSeries(new LineSeries("Strength", Color.FromArgb(255, 200, 128), 1, LineStyle.Solid));
|
||||
|
||||
// Fast EMA line
|
||||
AddLineSeries(new LineSeries("Fast EMA", Color.FromArgb(100, 200, 100), 1, LineStyle.Solid));
|
||||
|
||||
// Slow EMA line
|
||||
AddLineSeries(new LineSeries("Slow EMA", Color.FromArgb(200, 100, 100), 1, LineStyle.Solid));
|
||||
|
||||
// Zero line reference
|
||||
AddLineSeries(new LineSeries("Zero", Color.Gray, 1, LineStyle.Dot));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_amat == null || _selector == null) return;
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double value = _selector(item);
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TValue input = new(item.TimeLeft, value);
|
||||
_amat.Update(input, isNew);
|
||||
|
||||
bool isHot = _amat.IsHot;
|
||||
|
||||
// Trend line
|
||||
LinesSeries[0].SetValue(_amat.Last.Value, isHot, ShowColdValues);
|
||||
|
||||
// Strength line
|
||||
LinesSeries[1].SetValue(_amat.Strength.Value, isHot, ShowColdValues);
|
||||
|
||||
// Fast EMA line
|
||||
LinesSeries[2].SetValue(_amat.FastEma.Value, isHot, ShowColdValues);
|
||||
|
||||
// Slow EMA line
|
||||
LinesSeries[3].SetValue(_amat.SlowEma.Value, isHot, ShowColdValues);
|
||||
|
||||
// Zero reference line
|
||||
LinesSeries[4].SetValue(0);
|
||||
|
||||
// Color the trend histogram based on direction
|
||||
if (isHot || ShowColdValues)
|
||||
{
|
||||
double trend = _amat.Last.Value;
|
||||
Color trendColor = trend > 0 ? Color.Green :
|
||||
trend < 0 ? Color.Red :
|
||||
Color.Gray;
|
||||
LinesSeries[0].SetMarker(0, new IndicatorLineMarker(trendColor));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@
|
||||
<Compile Include="..\lib\volatility\**\*.cs" Exclude="..\lib\volatility\**\*.Tests.cs;..\lib\volatility\**\*.Validation.Tests.cs;..\lib\volatility\**\obj\**;..\lib\volatility\**\bin\**" />
|
||||
<!-- Include IndicatorExtensions -->
|
||||
<Compile Include="IndicatorExtensions.cs" />
|
||||
<!-- Include Quantower adapter implementations -->
|
||||
<Compile Include="Momentum\*.cs" Exclude="Momentum\*.Tests.cs" />
|
||||
<Compile Include="Volume\*.cs" Exclude="Volume\*.Tests.cs" />
|
||||
<Compile Include="Statistics\*.cs" Exclude="Statistics\*.Tests.cs" />
|
||||
<Compile Include="Volatility\*.cs" Exclude="Volatility\*.Tests.cs" />
|
||||
<Compile Include="Trends\*.cs" Exclude="Trends\*.Tests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user