mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 12:08:05 +00:00
Remove multiple Pine Script indicators: SSFDSP, STARCHANNEL, STBANDS, STC, UBANDS, UCHANNEL, VWAPBANDS, and VWAPSD. These indicators were deleted to streamline the library and remove unused or redundant code.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class PslIndicatorTests
|
||||
{
|
||||
[Fact] public void PslIndicator_Constructor_SetsDefaults() { var i = new PslIndicator(); Assert.Equal(12, i.Period); Assert.Equal(SourceType.Close, i.Source); Assert.True(i.ShowColdValues); Assert.Equal("PSL - Psychological Line", i.Name); Assert.True(i.SeparateWindow); }
|
||||
[Fact] public void PslIndicator_MinHistoryDepths_EqualsZero() { Assert.Equal(0, PslIndicator.MinHistoryDepths); IWatchlistIndicator w = new PslIndicator(); Assert.Equal(0, w.MinHistoryDepths); }
|
||||
[Fact] public void PslIndicator_ShortName_IncludesParameters() { var i = new PslIndicator { Period = 20 }; i.Initialize(); Assert.Contains("PSL", i.ShortName, StringComparison.Ordinal); Assert.Contains("20", i.ShortName, StringComparison.Ordinal); }
|
||||
[Fact] public void PslIndicator_SourceCodeLink_IsValid() { var i = new PslIndicator(); Assert.Contains("github.com", i.SourceCodeLink, StringComparison.Ordinal); Assert.Contains("Psl.Quantower.cs", i.SourceCodeLink, StringComparison.Ordinal); }
|
||||
[Fact] public void PslIndicator_Initialize_CreatesInternalPsl() { var i = new PslIndicator { Period = 10 }; i.Initialize(); Assert.Single(i.LinesSeries); }
|
||||
[Fact]
|
||||
public void PslIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PslIndicator { Period = 5 }; indicator.Initialize();
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++) { indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i); indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar)); }
|
||||
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
|
||||
}
|
||||
[Fact]
|
||||
public void PslIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new PslIndicator { Period = 5 }; indicator.Initialize();
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
}
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 120, 130, 110, 125);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
[Fact] public void PslIndicator_Parameters_CanBeChanged() { var i = new PslIndicator { Period = 20 }; i.Initialize(); Assert.Equal(20, i.Period); }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class PslIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 5000, 1, 0)]
|
||||
public int Period { get; set; } = 12;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput(sortIndex: 2)]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Psl _psl = null!;
|
||||
private readonly LineSeries _pslLine;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"PSL ({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/psl/Psl.Quantower.cs";
|
||||
|
||||
public PslIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "PSL - Psychological Line";
|
||||
Description = "Percentage of up-bars over a lookback period";
|
||||
|
||||
_pslLine = new LineSeries("PSL", Color.Yellow, 2, LineStyle.Solid);
|
||||
AddLineSeries(_pslLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_psl = new Psl(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var priceSelector = Source.GetPriceSelector();
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
double price = priceSelector(item);
|
||||
|
||||
TValue input = new(item.TimeLeft, price);
|
||||
TValue result = _psl.Update(input, args.IsNewBar());
|
||||
|
||||
if (!_psl.IsHot && !ShowColdValues)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pslLine.SetValue(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class PslTests
|
||||
{
|
||||
private const double Tolerance = 1e-9;
|
||||
|
||||
// ───── A) Constructor validation ─────
|
||||
[Fact] public void Constructor_DefaultPeriod_IsValid() { var p = new Psl(); Assert.Equal(12, p.Period); Assert.Equal("Psl(12)", p.Name); }
|
||||
[Fact] public void Constructor_InvalidPeriod_Throws() { var ex = Assert.Throws<ArgumentException>(() => new Psl(period: 0)); Assert.Equal("period", ex.ParamName); }
|
||||
[Fact] public void Constructor_NegativePeriod_Throws() { var ex = Assert.Throws<ArgumentException>(() => new Psl(period: -5)); Assert.Equal("period", ex.ParamName); }
|
||||
[Fact] public void Constructor_CustomPeriod_SetsCorrectly() { var p = new Psl(period: 20); Assert.Equal(20, p.Period); Assert.Equal("Psl(20)", p.Name); }
|
||||
|
||||
// ───── B) Basic calculation ─────
|
||||
[Fact] public void Update_ReturnsTValue() { var p = new Psl(5); Assert.IsType<TValue>(p.Update(new TValue(DateTime.UtcNow, 100.0))); }
|
||||
[Fact] public void Update_Last_IsAccessible() { var p = new Psl(5); p.Update(new TValue(DateTime.UtcNow, 100.0)); Assert.True(double.IsFinite(p.Last.Value)); }
|
||||
[Fact]
|
||||
public void Update_RisingPrices_PslAbove50()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i * 2));
|
||||
}
|
||||
Assert.True(p.Last.Value > 50, "Rising prices should produce PSL > 50");
|
||||
}
|
||||
[Fact]
|
||||
public void Update_FallingPrices_PslBelow50()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 200.0 - i * 2));
|
||||
}
|
||||
Assert.True(p.Last.Value < 50, "Falling prices should produce PSL < 50");
|
||||
}
|
||||
[Fact]
|
||||
public void Update_OutputInRange()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
Assert.InRange(p.Last.Value, 0.0, 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── C) State + bar correction ─────
|
||||
[Fact]
|
||||
public void Update_IsNew_False_RollsBack()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i), isNew: true);
|
||||
}
|
||||
p.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
|
||||
var c1 = p.Last;
|
||||
p.Update(new TValue(DateTime.UtcNow, 105.0), isNew: false);
|
||||
Assert.Equal(c1.Value, p.Last.Value, Tolerance);
|
||||
}
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_Restore()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
double[] data = new double[15];
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
data[i] = 100 + i * 2;
|
||||
}
|
||||
for (int i = 0; i < data.Length; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, data[i]), isNew: true);
|
||||
}
|
||||
var baseline = p.Last.Value;
|
||||
p.Update(new TValue(DateTime.UtcNow, 999.0), isNew: false);
|
||||
p.Update(new TValue(DateTime.UtcNow, 888.0), isNew: false);
|
||||
p.Update(new TValue(DateTime.UtcNow, data[^1]), isNew: false);
|
||||
Assert.Equal(baseline, p.Last.Value, Tolerance);
|
||||
}
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
p.Reset();
|
||||
Assert.False(p.IsHot);
|
||||
Assert.Equal(0.0, p.Last.Value);
|
||||
}
|
||||
|
||||
// ───── D) Warmup/convergence ─────
|
||||
[Fact]
|
||||
public void IsHot_FlipsAfterPeriod()
|
||||
{
|
||||
int period = 10;
|
||||
var p = new Psl(period);
|
||||
for (int i = 0; i < period - 1; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
Assert.False(p.IsHot);
|
||||
}
|
||||
p.Update(new TValue(DateTime.UtcNow, 110.0));
|
||||
Assert.True(p.IsHot);
|
||||
}
|
||||
[Fact] public void WarmupPeriod_MatchesPeriod() { Assert.Equal(12, new Psl(12).WarmupPeriod); }
|
||||
|
||||
// ───── E) Robustness ─────
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValid()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
p.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
p.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
[Fact]
|
||||
public void Update_BatchNaN_RemainsFinite()
|
||||
{
|
||||
var p = new Psl(5);
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
p.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
}
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
|
||||
// ───── F) Consistency (4 modes match) ─────
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResults()
|
||||
{
|
||||
int period = 10;
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
TSeries source = bars.Close;
|
||||
|
||||
var streaming = new Psl(period);
|
||||
var streamResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
streamResults[i] = streaming.Update(source[i]).Value;
|
||||
}
|
||||
|
||||
TSeries batchSeries = Psl.Batch(source, period);
|
||||
var spanOutput = new double[source.Count];
|
||||
Psl.Batch(source.Values, spanOutput, period);
|
||||
|
||||
var eventSource = new TSeries();
|
||||
var eventIndicator = new Psl(eventSource, period);
|
||||
var eventResults = new double[source.Count];
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
eventSource.Add(source[i]);
|
||||
eventResults[i] = eventIndicator.Last.Value;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamResults[i], batchSeries.Values[i], Tolerance);
|
||||
Assert.Equal(streamResults[i], spanOutput[i], Tolerance);
|
||||
Assert.Equal(streamResults[i], eventResults[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── G) Span API tests ─────
|
||||
[Fact] public void Batch_Span_MismatchedLength_Throws() { var ex = Assert.Throws<ArgumentException>(() => Psl.Batch(new double[10], new double[5], 5)); Assert.Equal("output", ex.ParamName); }
|
||||
[Fact] public void Batch_Span_InvalidPeriod_Throws() { var ex = Assert.Throws<ArgumentException>(() => Psl.Batch(new double[10], new double[10], 0)); Assert.Equal("period", ex.ParamName); }
|
||||
[Fact] public void Batch_Span_Empty_NoException() { Psl.Batch(ReadOnlySpan<double>.Empty, Span<double>.Empty, 5); Assert.True(true); }
|
||||
[Fact]
|
||||
public void Batch_Span_NaN_Handled()
|
||||
{
|
||||
double[] src = [100, double.NaN, 102, 103, 104, 105, 106, 107, 108, 109];
|
||||
var output = new double[src.Length];
|
||||
Psl.Batch(src, output, 5);
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── H) Chainability ─────
|
||||
[Fact]
|
||||
public void Pub_Fires_OnUpdate()
|
||||
{
|
||||
var p = new Psl(5); int f = 0;
|
||||
p.Pub += (object? _, in TValueEventArgs _) => f++;
|
||||
p.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal(1, f);
|
||||
}
|
||||
[Fact]
|
||||
public void EventBased_Chaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var p = new Psl(source, 5);
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.True(double.IsFinite(p.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// PSL: Psychological Line
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Percentage of up-bars over a lookback period:
|
||||
/// <c>PSL = 100 × (count of up-bars in period) / period</c>
|
||||
///
|
||||
/// An "up-bar" is when source > source[1].
|
||||
/// Uses a circular buffer storing 1.0 (up) or 0.0 (down/unchanged) with running sum.
|
||||
/// Output range: [0, 100].
|
||||
///
|
||||
/// References:
|
||||
/// Japanese technical analysis tradition
|
||||
/// PineScript reference: psl.pine
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Psl : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double UpSum,
|
||||
double PrevValue,
|
||||
double LastValid,
|
||||
int Count);
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
/// <summary>
|
||||
/// Creates Psychological Line with specified period.
|
||||
/// </summary>
|
||||
/// <param name="period">Lookback period (must be > 0)</param>
|
||||
public Psl(int period = 12)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Psl({period})";
|
||||
WarmupPeriod = period;
|
||||
_state = new State(UpSum: 0, PrevValue: double.NaN, LastValid: 0, Count: 0);
|
||||
_p_state = _state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates PSL with specified source and period.
|
||||
/// </summary>
|
||||
public Psl(ITValuePublisher source, int period = 12) : this(period)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
/// <summary>Period of the indicator.</summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_p_state = _state;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state = _p_state;
|
||||
}
|
||||
|
||||
double value = input.Value;
|
||||
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = double.IsFinite(_state.LastValid) ? _state.LastValid : 0.0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_state.LastValid = value;
|
||||
}
|
||||
|
||||
double upVal = double.IsFinite(_state.PrevValue) && value > _state.PrevValue ? 1.0 : 0.0;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
if (_buffer.IsFull)
|
||||
{
|
||||
_state.UpSum -= _buffer[0];
|
||||
}
|
||||
_state.UpSum += upVal;
|
||||
_buffer.Add(upVal);
|
||||
_state.PrevValue = value;
|
||||
_state.Count = _buffer.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
_buffer.UpdateNewest(upVal);
|
||||
// Recompute sum from buffer to avoid drift from mismatched eviction state
|
||||
double sum = 0;
|
||||
for (int j = 0; j < _buffer.Count; j++)
|
||||
{
|
||||
sum += _buffer[j];
|
||||
}
|
||||
_state.UpSum = sum;
|
||||
_state.PrevValue = value;
|
||||
_state.Count = _buffer.Count;
|
||||
}
|
||||
|
||||
double result = 100.0 * _state.UpSum / Math.Max(1, _state.Count);
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
Batch(source.Values, CollectionsMarshal.AsSpan(v), _period);
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
TimeSpan interval = step ?? TimeSpan.FromTicks(1);
|
||||
DateTime baseTime = DateTime.UtcNow - (interval * (source.Length - 1));
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(baseTime + (interval * i), source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_state = new State(UpSum: 0, PrevValue: double.NaN, LastValid: 0, Count: 0);
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>Calculates PSL for entire series.</summary>
|
||||
public static TSeries Batch(TSeries source, int period = 12)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
Batch(source.Values, CollectionsMarshal.AsSpan(v), period);
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>Batch PSL via circular buffer with running sum of up-bars.</summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 12)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer = new RingBuffer(period);
|
||||
double upSum = 0.0;
|
||||
double lastValid = 0.0;
|
||||
double prevValue = double.NaN;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double val = source[i];
|
||||
if (!double.IsFinite(val)) { val = lastValid; } else { lastValid = val; }
|
||||
|
||||
double upVal = double.IsFinite(prevValue) && val > prevValue ? 1.0 : 0.0;
|
||||
|
||||
if (buffer.IsFull) { upSum -= buffer[0]; }
|
||||
upSum += upVal;
|
||||
buffer.Add(upVal);
|
||||
prevValue = val;
|
||||
|
||||
output[i] = 100.0 * upSum / Math.Max(1, buffer.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a PSL indicator, processes source, returns results with indicator.</summary>
|
||||
public static (TSeries Results, Psl Indicator) Calculate(TSeries source, int period = 12)
|
||||
{
|
||||
var indicator = new Psl(period);
|
||||
return (indicator.Update(source), indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# PSL: Psychological Line
|
||||
|
||||
> "Markets are crowds, and crowds have moods. Count the up days; you will know the mood." -- Japanese proverb (adapted)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Category** | Oscillator |
|
||||
| **Inputs** | Source (close) |
|
||||
| **Parameters** | `period` (default 12) |
|
||||
| **Outputs** | Single series (percentage of up-bars) |
|
||||
| **Output range** | $0$ to $100$ |
|
||||
| **Warmup** | `period` bars |
|
||||
|
||||
### Key takeaways
|
||||
|
||||
- Counts the percentage of bars where price closed higher than the previous bar over a lookback window.
|
||||
- Output of $100$ means every bar in the window was an up-bar; $0$ means every bar was a down-bar or unchanged.
|
||||
- A purely sentiment-driven indicator: it measures market psychology (bullish/bearish streak) rather than magnitude of moves.
|
||||
- Uses a circular buffer storing $1.0$ (up) or $0.0$ (down/unchanged) with a running sum for O(1) updates.
|
||||
- Readings above $75$ suggest excessive optimism; below $25$ suggest excessive pessimism.
|
||||
|
||||
## Historical Context
|
||||
|
||||
The Psychological Line (PSL) comes from the Japanese technical analysis tradition, where it has been used for decades as a simple gauge of market sentiment. The idea is rooted in crowd psychology: when too many consecutive bars close higher, the crowd is euphorically bullish and likely to reverse. When too many close lower, the crowd is excessively bearish and due for a bounce.
|
||||
|
||||
PSL is one of the simplest possible oscillators. It ignores the magnitude of price changes entirely, caring only about direction. A 0.01% gain counts the same as a 5% gain. This deliberate blindness to magnitude is the indicator's defining characteristic: it measures mood, not movement.
|
||||
|
||||
The indicator never gained wide adoption in Western technical analysis, where RSI and Stochastic dominate. But it fills a unique niche. No other standard oscillator measures purely the fraction of positive bars in a window.
|
||||
|
||||
## What It Measures and Why It Matters
|
||||
|
||||
PSL answers one question: what percentage of the last $N$ bars were up-bars? An up-bar is defined as one where the close exceeds the previous close. Down-bars and unchanged bars both count as "not up."
|
||||
|
||||
A PSL of $75$ means three out of four recent bars closed higher. This does not tell you *how much* price rose, only that the direction was consistently up. The value lies in identifying streaks. Markets that have been consistently closing higher (or lower) without interruption tend to be overextended in that direction.
|
||||
|
||||
PSL is a contrarian indicator at extremes. When PSL exceeds $75$, the market has been relentlessly bullish: historically, such streaks tend to exhaust themselves. When PSL drops below $25$, the consistent selling may be running out of momentum. Between those thresholds, PSL provides less actionable information.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Core Formula
|
||||
|
||||
Define:
|
||||
|
||||
$$
|
||||
U_t = \begin{cases} 1 & \text{if } P_t > P_{t-1} \\ 0 & \text{otherwise} \end{cases}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{PSL}_t = 100 \times \frac{\sum_{i=0}^{N-1} U_{t-i}}{N}
|
||||
$$
|
||||
|
||||
where:
|
||||
|
||||
- $P_t$ = current price (close)
|
||||
- $N$ = lookback period (default 12)
|
||||
- $U_t$ = up-bar indicator (binary: 1 or 0)
|
||||
|
||||
### Parameter Mapping
|
||||
|
||||
| Parameter | Symbol | Default | Constraint |
|
||||
|-----------|--------|---------|------------|
|
||||
| `period` | $N$ | 12 | $N \geq 1$ |
|
||||
|
||||
### Warmup Period
|
||||
|
||||
$$
|
||||
W = N
|
||||
$$
|
||||
|
||||
The buffer must fill with $N$ up/down classifications before the percentage is computed over the full window.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Binary Circular Buffer
|
||||
|
||||
A `RingBuffer` of capacity $N$ stores $1.0$ (up-bar) or $0.0$ (down/unchanged). A running sum (`UpSum`) counts the number of up-bars currently in the window. The percentage is simply $100 \times \text{UpSum} / \text{Count}$.
|
||||
|
||||
### 2. Up-Bar Classification
|
||||
|
||||
The comparison `value > PrevValue` determines the binary classification. The first bar has no previous value, so it defaults to $0.0$ (not an up-bar). `PrevValue` is tracked in the state struct.
|
||||
|
||||
### 3. State Management
|
||||
|
||||
A `record struct State` holds `UpSum`, `PrevValue`, `LastValid`, and `Count`. The `_state` / `_p_state` pattern supports bar correction via `isNew` flag.
|
||||
|
||||
### 4. Batch Path
|
||||
|
||||
`Batch(ReadOnlySpan, Span, int)` uses a local `RingBuffer` and running sum, producing identical results without indicator instantiation.
|
||||
|
||||
### 5. Edge Cases
|
||||
|
||||
| Condition | Behavior |
|
||||
|-----------|----------|
|
||||
| `period <= 0` | `ArgumentException` with `nameof(period)` |
|
||||
| `NaN` / `Infinity` input | Substitutes last valid value |
|
||||
| No previous value (first bar) | Classified as $0$ (not up) |
|
||||
| All bars identical | All classified as $0$ (not up); PSL = $0$ |
|
||||
| During warmup | Percentage computed over available bars |
|
||||
|
||||
## Interpretation and Signals
|
||||
|
||||
### Signal Zones
|
||||
|
||||
| Zone | Condition | Interpretation |
|
||||
|------|-----------|----------------|
|
||||
| Excessive optimism | PSL > 75 | Market has been consistently closing higher; bullish exhaustion likely |
|
||||
| Neutral | 25 - 75 | Mixed direction; no clear sentiment extreme |
|
||||
| Excessive pessimism | PSL < 25 | Market has been consistently closing lower; bearish exhaustion likely |
|
||||
|
||||
### Signal Patterns
|
||||
|
||||
- **Overbought reversal**: PSL rises above $75$ then drops back below. The streak of up-bars has broken, suggesting selling pressure is emerging.
|
||||
- **Oversold reversal**: PSL falls below $25$ then rises back above. The streak of down-bars has broken, suggesting buying interest is returning.
|
||||
- **Divergence**: Price making new highs while PSL is declining means the highs are being achieved with fewer consecutive up-bars, suggesting fatigue.
|
||||
- **Extreme readings**: PSL of $100$ (every bar up) or $0$ (every bar down) are rare and typically mark climactic moves.
|
||||
|
||||
### Practical Notes
|
||||
|
||||
PSL works best on daily timeframes where the "close" concept is well-defined. On intraday data, the up-bar/down-bar classification becomes noisier and less meaningful. Combine PSL with a volatility indicator (ATR, Bollinger Width) to distinguish between genuine sentiment extremes and low-volatility drift where price ticks up marginally each bar without real conviction.
|
||||
|
||||
## Related Indicators
|
||||
|
||||
- [**Stoch**](../stoch/Stoch.md): Measures position within range rather than direction frequency.
|
||||
- [**Willr**](../willr/Willr.md): Williams %R, measures close relative to range; complementary to PSL's direction-counting approach.
|
||||
- [**Er**](../er/Er.md): Efficiency Ratio, measures directional efficiency but considers magnitude, not just direction.
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Batch | Streaming | Span | Notes |
|
||||
|---------|:-----:|:---------:|:----:|-------|
|
||||
| **TA-Lib** | -- | -- | -- | No PSL function |
|
||||
| **Skender** | -- | -- | -- | Not available |
|
||||
| **Tulip** | -- | -- | -- | Not available |
|
||||
| **Ooples** | -- | -- | -- | Not available |
|
||||
|
||||
Validated via internal consistency across all four API modes (batch, streaming, span, eventing).
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
- **O(1) streaming**: Running sum of binary values avoids window re-counting each bar.
|
||||
- **Binary buffer**: Stores only $1.0$ or $0.0$, minimizing computation per element.
|
||||
- **Zero allocation**: Pre-allocated `RingBuffer` and `record struct State`.
|
||||
- **Aggressive inlining**: `[MethodImpl(AggressiveInlining)]` on `Update` and `Batch`.
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
| Operation | Count per bar |
|
||||
|-----------|---------------|
|
||||
| Comparison | 1 (value > prevValue) |
|
||||
| SUB | 1 (remove oldest from sum) |
|
||||
| ADD | 1 (add newest to sum) |
|
||||
| MUL | 1 (100 * upSum) |
|
||||
| DIV | 1 (/ count) |
|
||||
| NaN check | 1 |
|
||||
| **Total** | **~6 ops** |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Magnitude blindness**: A PSL of $80$ does not mean the market rose significantly. It means 80% of bars were technically up-bars, even if each up-bar moved only a fraction of a point. Always check actual price change alongside PSL.
|
||||
2. **Unchanged bars count as "not up"**: If price closes exactly flat versus the prior bar, it counts as $0$ (not up). In illiquid instruments with many unchanged closes, PSL will structurally read lower.
|
||||
3. **First bar is always $0$**: With no previous value to compare, the first bar is classified as "not up." This is a design choice, not a bug.
|
||||
4. **Period sensitivity**: Short periods (e.g., 5) make PSL jumpy. Each bar flips between 80 and 60 with a single direction change. The default 12 is a reasonable balance.
|
||||
5. **Not a trend indicator**: PSL of $70$ does not mean the trend is up. It means recent bars were mostly up. In a volatile range, you can have 70% up-bars while price goes nowhere.
|
||||
6. **Daily data is ideal**: PSL was designed for daily charts where "up day" and "down day" are meaningful concepts. On tick data, the up/down classification becomes noise.
|
||||
|
||||
## References
|
||||
|
||||
- Japanese Technical Analysis tradition, various sources.
|
||||
- Colby, R. W. *The Encyclopedia of Technical Market Indicators*, 2nd ed. McGraw-Hill, 2003.
|
||||
- Achelis, S. B. *Technical Analysis from A to Z*. McGraw-Hill, 2000.
|
||||
@@ -0,0 +1,55 @@
|
||||
// The MIT License (MIT)
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Psychological Line (PSL)", "PSL", overlay=false)
|
||||
|
||||
//@function Calculates Psychological Line — percentage of up-bars over lookback
|
||||
//@param source Series to evaluate (typically close)
|
||||
//@param period Lookback period (number of bars to count)
|
||||
//@returns PSL value in [0, 100]
|
||||
//@description PSL counts the number of bars where source > source[1] (up-bars)
|
||||
// within the lookback window and expresses it as a percentage:
|
||||
// PSL = 100 × (count of up-bars in period) / period
|
||||
// Uses a circular buffer storing 1.0 (up) or 0.0 (down/unchanged) with
|
||||
// a running sum for O(1) updates per bar.
|
||||
// Readings above 50 indicate bullish sentiment (more up-bars than down).
|
||||
// Extreme readings (>75 or <25) suggest overbought/oversold conditions.
|
||||
// First bar has no prior close reference — defaults to 0 (not an up-bar).
|
||||
psl(series float source, simple int period) =>
|
||||
if period <= 0
|
||||
runtime.error("Period must be greater than 0")
|
||||
|
||||
var array<float> buffer = array.new_float(period, na)
|
||||
var int head = 0
|
||||
var float upSum = 0.0
|
||||
var int count = 0
|
||||
|
||||
float prev = source[1]
|
||||
float upVal = not na(prev) and source > prev ? 1.0 : 0.0
|
||||
|
||||
float oldest = array.get(buffer, head)
|
||||
if not na(oldest)
|
||||
upSum -= oldest
|
||||
else
|
||||
count += 1
|
||||
|
||||
upSum += upVal
|
||||
array.set(buffer, head, upVal)
|
||||
head := (head + 1) % period
|
||||
|
||||
100.0 * upSum / math.max(1, count)
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_period = input.int(12, "Period", minval=1)
|
||||
i_source = input.source(close, "Source")
|
||||
|
||||
// Calculation
|
||||
psl_value = psl(i_source, period=i_period)
|
||||
|
||||
// Plot
|
||||
plot(psl_value, "PSL", color=color.yellow, linewidth=2)
|
||||
hline(50, "Midline", color=color.gray, linestyle=hline.style_dotted)
|
||||
hline(75, "Overbought", color=color.red, linestyle=hline.style_dashed)
|
||||
hline(25, "Oversold", color=color.green, linestyle=hline.style_dashed)
|
||||
Reference in New Issue
Block a user