using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace QuanTAlib; /// /// SAREXT: Parabolic SAR Extended (TA-Lib) /// /// /// Extended Parabolic Stop And Reverse with asymmetric acceleration factors. /// Separate AF initialization, increment, and maximum for long vs short positions. /// Sign-encoded output: positive = long (SAR below price), negative = short (SAR above price). /// /// Calculation extends Wilder's PSAR: /// /// Long: SAR = SAR + AF_long × (EP - SAR), output = +SAR /// Short: SAR = SAR + AF_short × (EP - SAR), output = -SAR /// /// Bar 0: Collect OHLC data /// Bar 1: Determine direction from startValue or DM auto-detect /// Bar 2+: Standard SAR state machine with asymmetric AF parameters /// /// /// Key characteristics: /// - O(1) per-bar state machine with long/short mode transitions /// - Asymmetric acceleration factors for long and short positions /// - startValue parameter forces initial direction (0 = auto-detect from DM) /// - offsetOnReverse adds gap buffer on trend reversal /// - Sign-encoded output matches TA-Lib SAREXT convention /// - Default parameters: afInitLong/Short=0.02, afLong/Short=0.02, afMaxLong/Short=0.20 /// /// Detailed documentation [SkipLocalsInit] public sealed class Sarext : ITValuePublisher { private const double DefaultStartValue = 0; private const double DefaultOffsetOnReverse = 0; private const double DefaultAfInitLong = 0.02; private const double DefaultAfLong = 0.02; private const double DefaultAfMaxLong = 0.20; private const double DefaultAfInitShort = 0.02; private const double DefaultAfShort = 0.02; private const double DefaultAfMaxShort = 0.20; private readonly double _startValue; private readonly double _offsetOnReverse; private readonly double _afInitLong; private readonly double _afLong; private readonly double _afMaxLong; private readonly double _afInitShort; private readonly double _afShort; private readonly double _afMaxShort; private int _samples; private int _p_samples; [StructLayout(LayoutKind.Auto)] private record struct State( bool IsLong, double Sar, double Ep, double Af, double Prev1High, double Prev1Low, double Prev2High, double Prev2Low, double LastValidOpen, double LastValidHigh, double LastValidLow, double LastValidClose); private State _s; private State _ps; private readonly TBarPublishedHandler _barHandler; /// Display name for the indicator. public string Name { get; } /// Bars required for the indicator to warm up. public int WarmupPeriod { get; } /// Current SAR value (unsigned). public double Sar => _s.Sar; /// True when the SAREXT is in long (uptrend) mode. public bool IsLong => _s.IsLong; /// Primary output value (sign-encoded SAR: positive = long, negative = short). public TValue Last { get; private set; } /// True when enough bars have been processed for valid output. public bool IsHot => _samples >= 2; /// public event TValuePublishedHandler? Pub; /// /// Creates a Parabolic SAR Extended indicator. /// /// Initial direction: positive = long, negative = short, 0 = auto-detect from DM. /// Gap added to SAR on reversal (default 0). /// Initial acceleration factor for long positions (default 0.02). /// AF increment per new EP in long positions (default 0.02). /// Maximum AF for long positions (default 0.20). /// Initial acceleration factor for short positions (default 0.02). /// AF increment per new EP in short positions (default 0.02). /// Maximum AF for short positions (default 0.20). public Sarext( double startValue = DefaultStartValue, double offsetOnReverse = DefaultOffsetOnReverse, double afInitLong = DefaultAfInitLong, double afLong = DefaultAfLong, double afMaxLong = DefaultAfMaxLong, double afInitShort = DefaultAfInitShort, double afShort = DefaultAfShort, double afMaxShort = DefaultAfMaxShort) { if (afInitLong <= 0) { throw new ArgumentException("afInitLong must be > 0.", nameof(afInitLong)); } if (afLong <= 0) { throw new ArgumentException("afLong must be > 0.", nameof(afLong)); } if (afMaxLong <= afInitLong) { throw new ArgumentException("afMaxLong must be > afInitLong.", nameof(afMaxLong)); } if (afInitShort <= 0) { throw new ArgumentException("afInitShort must be > 0.", nameof(afInitShort)); } if (afShort <= 0) { throw new ArgumentException("afShort must be > 0.", nameof(afShort)); } if (afMaxShort <= afInitShort) { throw new ArgumentException("afMaxShort must be > afInitShort.", nameof(afMaxShort)); } if (offsetOnReverse < 0) { throw new ArgumentException("offsetOnReverse must be >= 0.", nameof(offsetOnReverse)); } _startValue = startValue; _offsetOnReverse = offsetOnReverse; _afInitLong = afInitLong; _afLong = afLong; _afMaxLong = afMaxLong; _afInitShort = afInitShort; _afShort = afShort; _afMaxShort = afMaxShort; _samples = 0; _p_samples = 0; _s = new State( IsLong: true, Sar: double.NaN, Ep: double.NaN, Af: afInitLong, Prev1High: double.NaN, Prev1Low: double.NaN, Prev2High: double.NaN, Prev2Low: double.NaN, LastValidOpen: double.NaN, LastValidHigh: double.NaN, LastValidLow: double.NaN, LastValidClose: double.NaN); _ps = _s; Name = "Sarext"; WarmupPeriod = 2; _barHandler = HandleBar; } /// /// Creates a SAREXT indicator chained to a TBarSeries source. /// public Sarext(TBarSeries source, double startValue = DefaultStartValue, double offsetOnReverse = DefaultOffsetOnReverse, double afInitLong = DefaultAfInitLong, double afLong = DefaultAfLong, double afMaxLong = DefaultAfMaxLong, double afInitShort = DefaultAfInitShort, double afShort = DefaultAfShort, double afMaxShort = DefaultAfMaxShort) : this(startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort) { Prime(source); source.Pub += _barHandler; } private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew); [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PubEvent(TValue value, bool isNew = true) => Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew }); /// /// Updates the SAREXT with a new OHLC bar. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TBar input, bool isNew = true) { if (isNew) { _ps = _s; _p_samples = _samples; _samples++; } else { _s = _ps; _samples = _p_samples + 1; } var s = _s; // Validate inputs — substitute last-valid on NaN/Infinity double open = input.Open; double high = input.High; double low = input.Low; double close = input.Close; if (double.IsFinite(open)) { s.LastValidOpen = open; } else { open = s.LastValidOpen; } if (double.IsFinite(high)) { s.LastValidHigh = high; } else { high = s.LastValidHigh; } if (double.IsFinite(low)) { s.LastValidLow = low; } else { low = s.LastValidLow; } if (double.IsFinite(close)) { s.LastValidClose = close; } else { close = s.LastValidClose; } // If still no valid data, return NaN if (double.IsNaN(open) || double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close)) { _s = s; Last = new TValue(input.Time, double.NaN); PubEvent(Last, isNew); return Last; } double sarResult; if (_samples == 1) { // Bar 0: Collect first bar's OHLC, no output yet s.Prev1High = high; s.Prev1Low = low; s.Prev2High = high; s.Prev2Low = low; s.LastValidOpen = open; s.LastValidHigh = high; s.LastValidLow = low; s.LastValidClose = close; // Tentative initialization — will be finalized on bar 1 s.Sar = high; s.Ep = low; s.Af = _afInitShort; s.IsLong = false; _s = s; Last = new TValue(input.Time, double.NaN); PubEvent(Last, isNew); return Last; } else if (_samples == 2) { // Bar 1: Determine initial direction double prevHigh = s.Prev1High; double prevLow = s.Prev1Low; if (_startValue > 0) { // Force long s.IsLong = true; s.Sar = Math.Min(prevLow, low); s.Ep = Math.Max(prevHigh, high); s.Af = _afInitLong; } else if (_startValue < 0) { // Force short s.IsLong = false; s.Sar = Math.Max(prevHigh, high); s.Ep = Math.Min(prevLow, low); s.Af = _afInitShort; } else { // Auto-detect from DM: compare plusDM vs minusDM double plusDM = high - prevHigh; double minusDM = prevLow - low; if (plusDM > minusDM && plusDM > 0) { // Long s.IsLong = true; s.Sar = Math.Min(prevLow, low); s.Ep = Math.Max(prevHigh, high); s.Af = _afInitLong; } else { // Short (default when equal or minusDM dominates) s.IsLong = false; s.Sar = Math.Max(prevHigh, high); s.Ep = Math.Min(prevLow, low); s.Af = _afInitShort; } } sarResult = s.Sar; // Update prev-bar tracking s.Prev2High = s.Prev1High; s.Prev2Low = s.Prev1Low; s.Prev1High = high; s.Prev1Low = low; _s = s; double output = s.IsLong ? sarResult : -sarResult; Last = new TValue(input.Time, output); PubEvent(Last, isNew); return Last; } // Bar 2+: Standard SAR state machine with asymmetric AF // Compute new SAR using FMA: sar + af * (ep - sar) double newSar = Math.FusedMultiplyAdd(s.Af, s.Ep - s.Sar, s.Sar); if (s.IsLong) { // Long mode: SAR must be at or below prior two bars' lows newSar = Math.Min(newSar, s.Prev1Low); newSar = Math.Min(newSar, s.Prev2Low); // Check for reversal: price crosses below SAR if (low <= newSar) { // Reverse to short s.IsLong = false; newSar = s.Ep + _offsetOnReverse; s.Ep = low; s.Af = _afInitShort; } else { // Check for new extreme point (new high) if (high > s.Ep) { s.Ep = high; s.Af = Math.Min(s.Af + _afLong, _afMaxLong); } } } else { // Short mode: SAR must be at or above prior two bars' highs newSar = Math.Max(newSar, s.Prev1High); newSar = Math.Max(newSar, s.Prev2High); // Check for reversal: price crosses above SAR if (high >= newSar) { // Reverse to long s.IsLong = true; newSar = s.Ep - _offsetOnReverse; s.Ep = high; s.Af = _afInitLong; } else { // Check for new extreme point (new low) if (low < s.Ep) { s.Ep = low; s.Af = Math.Min(s.Af + _afShort, _afMaxShort); } } } s.Sar = newSar; sarResult = newSar; // Shift prior bar tracking if (isNew) { s.Prev2High = s.Prev1High; s.Prev2Low = s.Prev1Low; s.Prev1High = high; s.Prev1Low = low; } else { // Bar correction: update current bar's values s.Prev1High = high; s.Prev1Low = low; } _s = s; double signedResult = s.IsLong ? sarResult : -sarResult; Last = new TValue(input.Time, signedResult); PubEvent(Last, isNew); return Last; } /// /// Updates the SAREXT with a TValue (uses value as OHLC proxy). /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public TValue Update(TValue input, bool isNew = true) => Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew); /// /// Processes a full TBarSeries and returns sign-encoded SAREXT output. /// public TSeries Update(TBarSeries source) { if (source.Count == 0) { return new TSeries([], []); } int len = source.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); Batch(source.OpenValues, source.HighValues, source.LowValues, source.CloseValues, CollectionsMarshal.AsSpan(v), len, _startValue, _offsetOnReverse, _afInitLong, _afLong, _afMaxLong, _afInitShort, _afShort, _afMaxShort); source.Times.CopyTo(CollectionsMarshal.AsSpan(t)); // Prime internal state for continued streaming Prime(source); var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc); Last = new TValue(lastTime, CollectionsMarshal.AsSpan(v)[^1]); return new TSeries(t, v); } /// /// Primes the indicator from a TBarSeries (replays all bars to set state). /// public void Prime(TBarSeries source) { Reset(); if (source.Count == 0) { return; } for (int i = 0; i < source.Count; i++) { Update(source[i], isNew: true); } } /// /// Primes the indicator from a span of doubles (uses each value as OHLC proxy). /// public void Prime(ReadOnlySpan source, TimeSpan? step = null) { Reset(); if (source.Length == 0) { return; } long t = DateTime.UtcNow.Ticks; long stepTicks = (step ?? TimeSpan.FromMinutes(1)).Ticks; for (int i = 0; i < source.Length; i++) { double val = source[i]; Update(new TBar(t, val, val, val, val, 0), isNew: true); t += stepTicks; } } /// /// Resets the indicator to its initial state. /// public void Reset() { _samples = 0; _p_samples = 0; _s = new State( IsLong: true, Sar: double.NaN, Ep: double.NaN, Af: _afInitLong, Prev1High: double.NaN, Prev1Low: double.NaN, Prev2High: double.NaN, Prev2Low: double.NaN, LastValidOpen: double.NaN, LastValidHigh: double.NaN, LastValidLow: double.NaN, LastValidClose: double.NaN); _ps = _s; Last = default; } /// /// Span-based batch computation of SAREXT. /// /// Input open prices. /// Input high prices. /// Input low prices. /// Input close prices. /// Output span for sign-encoded SAR values. /// Number of bars to process. /// Initial direction: positive = long, negative = short, 0 = auto-detect. /// Gap added to SAR on reversal. /// Initial AF for long positions. /// AF increment for long positions. /// Maximum AF for long positions. /// Initial AF for short positions. /// AF increment for short positions. /// Maximum AF for short positions. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Batch( ReadOnlySpan open, ReadOnlySpan high, ReadOnlySpan low, ReadOnlySpan close, Span output, int n, double startValue = DefaultStartValue, double offsetOnReverse = DefaultOffsetOnReverse, double afInitLong = DefaultAfInitLong, double afLong = DefaultAfLong, double afMaxLong = DefaultAfMaxLong, double afInitShort = DefaultAfInitShort, double afShort = DefaultAfShort, double afMaxShort = DefaultAfMaxShort) { if (afInitLong <= 0 || afInitLong >= afMaxLong) { throw new ArgumentException("afInitLong must be > 0 and < afMaxLong.", nameof(afInitLong)); } if (afLong <= 0) { throw new ArgumentException("afLong must be > 0.", nameof(afLong)); } if (afInitShort <= 0 || afInitShort >= afMaxShort) { throw new ArgumentException("afInitShort must be > 0 and < afMaxShort.", nameof(afInitShort)); } if (afShort <= 0) { throw new ArgumentException("afShort must be > 0.", nameof(afShort)); } if (high.Length != low.Length || high.Length != close.Length || high.Length != open.Length) { throw new ArgumentException("Input spans must have the same length.", nameof(high)); } if (output.Length < n) { throw new ArgumentException("Output span must be at least n elements.", nameof(output)); } if (n == 0) { return; } // State machine prevents SIMD — compute via streaming instance var indicator = new Sarext(startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort); long baseTime = DateTime.UtcNow.Ticks; for (int i = 0; i < n; i++) { _ = indicator.Update( new TBar(baseTime + i, open[i], high[i], low[i], close[i], 0), isNew: true); output[i] = indicator.Last.Value; } } /// /// Batch computation from a TBarSeries. /// public static TSeries Batch( TBarSeries source, double startValue = DefaultStartValue, double offsetOnReverse = DefaultOffsetOnReverse, double afInitLong = DefaultAfInitLong, double afLong = DefaultAfLong, double afMaxLong = DefaultAfMaxLong, double afInitShort = DefaultAfInitShort, double afShort = DefaultAfShort, double afMaxShort = DefaultAfMaxShort) { if (source == null || source.Count == 0) { return new TSeries([], []); } int len = source.Count; var t = new List(len); var v = new List(len); CollectionsMarshal.SetCount(t, len); CollectionsMarshal.SetCount(v, len); Batch(source.OpenValues, source.HighValues, source.LowValues, source.CloseValues, CollectionsMarshal.AsSpan(v), len, startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort); source.Times.CopyTo(CollectionsMarshal.AsSpan(t)); return new TSeries(t, v); } /// /// Calculates SAREXT and returns both the result series and the primed indicator. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static (TSeries Results, Sarext Indicator) Calculate( TBarSeries source, double startValue = DefaultStartValue, double offsetOnReverse = DefaultOffsetOnReverse, double afInitLong = DefaultAfInitLong, double afLong = DefaultAfLong, double afMaxLong = DefaultAfMaxLong, double afInitShort = DefaultAfInitShort, double afShort = DefaultAfShort, double afMaxShort = DefaultAfMaxShort) { var indicator = new Sarext(startValue, offsetOnReverse, afInitLong, afLong, afMaxLong, afInitShort, afShort, afMaxShort); var results = indicator.Update(source); return (results, indicator); } }