Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion server/WebApi.Tests/Endpoints/MainEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -753,13 +753,119 @@ public void StandardDeviationChannelsListing_MarksAllResultsSlopeSegmented()
});
}

// Benchmark-comparison endpoints (BETA, CORRELATION, PRS) read a second
// series for the market benchmark. These tests pin that behaviour, because
// when the benchmark resolves to the same bars as the evaluated security
// the maths still succeeds — it just returns a degenerate constant, which
// no status code or exception reveals.

[Theory]
[InlineData("CORRELATION")]
[InlineData("PRS")]
[InlineData("BETA")]
public async Task BenchmarkIndicators_RequestTheBenchmarkSeries(string indicator)
{
// Arrange — both series share one start date; the indicators require
// timestamp-aligned inputs.
SetupBenchmarkQuotes(
GenerateSampleQuotes(60, BenchmarkStart),
GenerateSampleQuotes(60, BenchmarkStart));

// Act
IActionResult result = indicator switch {
"CORRELATION" => await _controller.GetCorrelation(20),
"PRS" => await _controller.GetPrs(),
_ => await _controller.GetBeta(20, BetaType.Standard)
};

// Assert — the benchmark series is fetched by symbol, not inferred from
// the default feed.
Assert.IsType<OkObjectResult>(result);
_quoteServiceMock.Verify(
q => q.Get("SPY", It.IsAny<CancellationToken>()),
Times.Once);
}

[Fact]
public async Task GetCorrelation_WithDistinctBenchmark_DoesNotReturnPerfectCorrelation()
{
// Arrange — two genuinely different price paths. Correlating a series
// with itself yields exactly 1.0 at every point; that is what a
// symbol-agnostic benchmark silently produces, so this asserts the two
// series stay distinct all the way into the calculation.
SetupBenchmarkQuotes(
GenerateSampleQuotes(60, BenchmarkStart),
GenerateDivergentQuotes(60, BenchmarkStart));

// Act
IActionResult result = await _controller.GetCorrelation(20);

// Assert
OkObjectResult okResult = Assert.IsType<OkObjectResult>(result);
IEnumerable<CorrResult> results
= Assert.IsType<IEnumerable<CorrResult>>(okResult.Value, exactMatch: false);

List<double> correlations = results
.Where(r => r.Correlation is not null)
.Select(r => r.Correlation!.Value)
.ToList();

Assert.NotEmpty(correlations);
Assert.All(correlations, c => Assert.InRange(c, -1d, 1d));
Assert.Contains(correlations, c => Math.Abs(c - 1d) > 1e-9);
}

// Wires the default feed and the "SPY" benchmark feed to separate datasets.
private void SetupBenchmarkQuotes(IReadOnlyList<Bar> evaluated, IReadOnlyList<Bar> benchmark)
{
_quoteServiceMock
.Setup(q => q.Get(It.IsAny<CancellationToken>()))
.ReturnsAsync(evaluated);

_quoteServiceMock
.Setup(q => q.Get("SPY", It.IsAny<CancellationToken>()))
.ReturnsAsync(benchmark);

_controller.ControllerContext = new ControllerContext {
HttpContext = new DefaultHttpContext()
};
}

// Fixed so the evaluated and benchmark series carry identical timestamps;
// the comparison indicators reject misaligned inputs.
private static readonly DateTime BenchmarkStart = new(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc);

// A price path that rises and falls against GenerateSampleQuotes' steady
// climb, so the two series are not perfectly correlated.
private static List<Bar> GenerateDivergentQuotes(int count, DateTime startDate)
{
List<Bar> quotes = new(count);

for (int i = 0; i < count; i++)
{
decimal basePrice = 100m + ((decimal)Math.Sin(i / 3d) * 8m);

quotes.Add(new Bar(
startDate.AddDays(i),
basePrice,
basePrice + 2m,
basePrice - 2m,
basePrice + 1m,
1000000 + (i * 10000)));
}

return quotes;
}

/// <summary>
/// Helper to generate sample quote data for tests.
/// </summary>
private static List<Bar> GenerateSampleQuotes(int count)
=> GenerateSampleQuotes(count, DateTime.UtcNow.AddDays(-count));

private static List<Bar> GenerateSampleQuotes(int count, DateTime startDate)
{
List<Bar> quotes = new();
DateTime startDate = DateTime.UtcNow.AddDays(-count);

for (int i = 0; i < count; i++)
{
Expand Down
58 changes: 43 additions & 15 deletions server/WebApi/Endpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public class Main(
// GLOBALS
private const int limitLast = 120;

// Market benchmark for indicators that compare one security against another
// (BETA, CORRELATION, PRS). SPY is one of the two symbols the scheduled
// quote refresh maintains, and the demo evaluates QQQ against it.
private const string benchmarkSymbol = "SPY";

[HttpGet]
public string Get()
=> "API is functioning nominally.";
Expand Down Expand Up @@ -81,6 +86,32 @@ private async Task<IActionResult> Get<T>(Func<IReadOnlyList<Bar>, IEnumerable<T>
}
}

// Some indicators measure a security against a market benchmark and so need
// a second series. The benchmark is fixed rather than a request parameter:
// QuoteService serves only the symbols the scheduled refresh maintains, so
// an arbitrary caller-supplied symbol has no data behind it.
private async Task<IActionResult> GetVsBenchmark<T>(
Func<IReadOnlyList<Bar>, IReadOnlyList<Bar>, IEnumerable<T>> indicatorFunc)
{
try
{
// Fetched in sequence, not in parallel: both reads normally land in
// the in-memory quote cache, so the saving would be negligible and
// is not worth issuing concurrent requests against a shared cache.
IReadOnlyList<Bar> quotes = (await quoteFeed.Get(HttpContext.RequestAborted)).ToList();
IReadOnlyList<Bar> benchmark
= (await quoteFeed.Get(benchmarkSymbol, HttpContext.RequestAborted)).ToList();

IEnumerable<T> results = indicatorFunc(quotes, benchmark).TakeLast(limitLast);
SetClientCache();
return Ok(results);
}
catch (ArgumentOutOfRangeException rex)
{
return BadRequest(rex.Message);
}
}

// Emit a shared-cache directive so browsers and CDN/edge caches (e.g.
// Cloudflare in front of the doc site) can serve repeat requests without
// reaching the origin. Mirrors the server-side output-cache lifetime.
Expand Down Expand Up @@ -145,21 +176,8 @@ public Task<IActionResult> GetBollingerBands(int lookbackPeriods, double standar
=> Get(quotes => quotes.ToBollingerBands(lookbackPeriods, standardDeviations));

[HttpGet("BETA")]
public async Task<IActionResult> GetBeta(int lookbackPeriods, BetaType type)
{
try
{
IReadOnlyList<Bar> quotes = (await quoteFeed.Get(HttpContext.RequestAborted)).ToList();
IReadOnlyList<Bar> market = (await quoteFeed.Get("SPY", HttpContext.RequestAborted)).ToList();
IEnumerable<BetaResult> results = quotes.ToBeta(market, lookbackPeriods, type).TakeLast(limitLast);
SetClientCache();
return Ok(results);
}
catch (ArgumentOutOfRangeException rex)
{
return BadRequest(rex.Message);
}
}
public Task<IActionResult> GetBeta(int lookbackPeriods, BetaType type)
=> GetVsBenchmark((quotes, market) => quotes.ToBeta(market, lookbackPeriods, type));

[HttpGet("BOP")]
public Task<IActionResult> GetBop(int smoothPeriods)
Expand Down Expand Up @@ -193,6 +211,10 @@ public Task<IActionResult> GetCmf(int lookbackPeriods)
public Task<IActionResult> GetCmo(int lookbackPeriods)
=> Get(quotes => quotes.ToCmo(lookbackPeriods));

[HttpGet("CORRELATION")]
public Task<IActionResult> GetCorrelation(int lookbackPeriods)
=> GetVsBenchmark((quotes, market) => quotes.ToCorrelation(market, lookbackPeriods));

[HttpGet("CRSI")]
public Task<IActionResult> GetConnorsRsi(int rsiPeriods, int streakPeriods, int rankPeriods)
=> Get(quotes => quotes.ToConnorsRsi(rsiPeriods, streakPeriods, rankPeriods));
Expand Down Expand Up @@ -337,6 +359,12 @@ public Task<IActionResult> GetPivots(int leftSpan, int rightSpan, int maxTrendPe
public Task<IActionResult> GetPmo(int timePeriods, int smoothPeriods, int signalPeriods)
=> Get(quotes => quotes.ToPmo(timePeriods, smoothPeriods, signalPeriods));

// No lookbackPeriods parameter: it only drives PrsPercent, which this
// listing does not chart (see the catalog entry).
[HttpGet("PRS")]
public Task<IActionResult> GetPrs()
=> GetVsBenchmark((quotes, market) => quotes.ToPrs(market));

[HttpGet("PSAR")]
public Task<IActionResult> GetParabolicSar(double accelerationStep, double maxAccelerationFactor)
=> Get(quotes => quotes.ToParabolicSar(accelerationStep, maxAccelerationFactor));
Expand Down
81 changes: 81 additions & 0 deletions server/WebApi/Services/Service.Metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,61 @@ public static IEnumerable<IndicatorListing> IndicatorListing(string baseUrl)
]
},

// Correlation Coefficient
// Charted against the SPY benchmark the API fixes for comparison
// indicators; the caller does not choose it, so there is no symbol
// parameter. Correlation (-1 to 1) and R² (0 to 1) are both
// dimensionless, so they share one y-axis without misleading.
new IndicatorListing {
Name = "Correlation Coefficient (vs SPY)",
Uiid = "CORRELATION",
LegendTemplate = "CORRELATION([P1])",
Endpoint = $"{baseUrl}/CORRELATION/",
Category = "price-characteristic",
ChartType = "oscillator",
ChartConfig = new ChartConfig {
Thresholds =
[
// Zero is the meaningful reference: above is positive
// correlation with the benchmark, below is negative.
new() {
Value = 0,
Color = ChartColors.ThresholdGrayTransparent,
Style = "dash"
}
]
},
Parameters =
[
new() {
DisplayName = "Lookback Periods",
ParamName = "lookbackPeriods",
DataType = "int",
DefaultValue = 20,
Minimum = 1,
Maximum = 250
}
],
Results = [
new() {
DisplayName = "Correlation",
TooltipTemplate = "Correlation",
DataName = "correlation",
DataType = "number",
LineType = "solid",
DefaultColor = ChartColors.StandardBlue
},
new() {
DisplayName = "R²",
TooltipTemplate = "R-Squared",
DataName = "rSquared",
DataType = "number",
LineType = "dash",
DefaultColor = ChartColors.StandardGreen
}
]
},

// Detrended Price Oscillator (DPO)
new IndicatorListing {
Name = "Detrended Price Oscillator (DPO)",
Expand Down Expand Up @@ -2598,6 +2653,32 @@ public static IEnumerable<IndicatorListing> IndicatorListing(string baseUrl)
]
},

// Price Relative Strength (PRS)
// Charts the raw eval/benchmark price ratio only. PrsPercent is
// omitted deliberately: it is a percentage and would need its own
// y-axis, the same mixed-unit problem that split SMA analysis into
// per-metric listings. No threshold line either — PRS is an
// unnormalized ratio, so no fixed value marks equal performance.
new IndicatorListing {
Name = "Price Relative Strength (vs SPY)",
Uiid = "PRS",
LegendTemplate = "PRS",
Endpoint = $"{baseUrl}/PRS/",
Category = "price-characteristic",
ChartType = "oscillator",
Parameters = [],
Results = [
new() {
DisplayName = "PRS",
TooltipTemplate = "PRS",
DataName = "prs",
DataType = "number",
LineType = "solid",
DefaultColor = ChartColors.StandardBlue
}
]
},

// Bar transform: Median Price (HL2)
new IndicatorListing {
Name = "Median Price (HL2)",
Expand Down
Loading