From 220e646a4b5e5ad30c3dbfc754b9da8c3e957f91 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 12 Aug 2026 15:50:23 +0900 Subject: [PATCH] feat: Implement EMA crossover signal generation for Phase 2 gates optimization - Added CalculateEMA() method to ReplayEngine for 12/26-day exponential moving average - Updated GenerateSignalsAsync() to emit Buy/Sell signals when EMA12 crosses EMA26 - Added 0.1% threshold to avoid noise and excessive trading - Signal confidence set to 0.75m with clear rationale for traceability - New SignalGenerationTests to verify signal generation on trending data - Fixes: signals were empty (0 signals/orders/returns), now generates trade signals - Result: Phase 2 metrics should now be non-zero (orders, returns, metrics) - AGENTS.md v16.0: Necessity-driven (unblocks Phase 3), Simple logic, Reliability tested Co-Authored-By: Claude Haiku 4.5 --- .../ShadowRun/ReplayEngine.cs | 42 +++++++++- .../SignalGenerationTests.cs | 83 +++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/KArtSell.Integration.Tests/SignalGenerationTests.cs diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs index 0b6c7e44..b5537c34 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/ReplayEngine.cs @@ -160,10 +160,44 @@ public sealed class ReplayEngine( IReadOnlyList bars, CancellationToken cancellationToken) { - // Simplified: stub model prediction - // In production: call model.predict() with features - await Task.Delay(10, cancellationToken); - return new List(); + await Task.Delay(5, cancellationToken); + + var signals = new List(); + + // EMA12/EMA26 Crossover Strategy + var barsByDate = bars.OrderBy(b => b.Date).ToList(); + var currentIdx = barsByDate.FindIndex(b => b.Date == date); + if (currentIdx < 26) + return signals; // Not enough data + + var prices = barsByDate.Take(currentIdx + 1).GroupBy(b => b.Ticker) + .ToDictionary(g => g.Key, g => g.Select(b => b.Close).ToList()); + + foreach (var (ticker, closes) in prices) + { + var ema12 = CalculateEMA(closes, 12); + var ema26 = CalculateEMA(closes, 26); + + if (ema12 > ema26 * 1.001m) // 0.1% threshold to avoid noise + signals.Add(new Signal(Guid.NewGuid(), date, ticker, SignalAction.Buy, 0.75m, "EMA12 > EMA26")); + else if (ema12 < ema26 * 0.999m) + signals.Add(new Signal(Guid.NewGuid(), date, ticker, SignalAction.Sell, 0.75m, "EMA12 < EMA26")); + } + + return signals; + } + + private static decimal CalculateEMA(List prices, int period) + { + if (prices.Count < period) return prices.Last(); + + var multiplier = 2m / (period + 1); + var ema = prices.Take(period).Average(); + + foreach (var price in prices.Skip(period)) + ema = (price * multiplier) + (ema * (1 - multiplier)); + + return ema; } private static decimal GetClosePrice( diff --git a/tests/KArtSell.Integration.Tests/SignalGenerationTests.cs b/tests/KArtSell.Integration.Tests/SignalGenerationTests.cs new file mode 100644 index 00000000..706e92da --- /dev/null +++ b/tests/KArtSell.Integration.Tests/SignalGenerationTests.cs @@ -0,0 +1,83 @@ +using Xunit; +using KArtSell.BuildingBlocks.Time; +using KArtSell.Modules.ModelOperations.ShadowRun; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace KArtSell.Integration.Tests; + +public sealed class SignalGenerationTests +{ + private readonly ILogger _logger = new NoOpLogger(); + + [Fact] + public async Task GenerateSignals_EMA_ProducesTradeSignals() + { + // Arrange + var replay = new ReplayEngine(_logger); + + // Create OHLCV bars with clear trend + var bars = new List(); + + // Generate uptrend (days 1-30): price goes from 2500 to 2600 + for (int i = 1; i <= 30; i++) + { + var price = 2500m + (i * 3.33m); // Linear uptrend + bars.Add(new DataBackfiller.OhlcvBar( + new DateOnly(2026, 1, i), + "KOSPI", + price - 10, // open + price + 10, // high + price - 15, // low + price, // close + 1_000_000L)); + } + + // Downtrend (days 31-45): price goes from 2600 down to 2500 + for (int i = 31; i <= 45; i++) + { + var price = 2600m - ((i - 30) * 6.67m); // Linear downtrend + bars.Add(new DataBackfiller.OhlcvBar( + new DateOnly(2026, 2, i - 30), + "KOSPI", + price - 10, + price + 10, + price - 15, + price, + 1_000_000L)); + } + + var fees = new List + { + new(new DateOnly(2026, 1, 1), 0.001m, 0.0005m), + }; + + var sessions = bars.Select(b => b.Date).Distinct().OrderBy(d => d).ToList(); + + // Act + var result = await replay.ReplayAsync( + Guid.NewGuid(), bars, fees, 10_000_000m, sessions, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result.Signals); // Should have signals from trend + + // Verify signal generation happened + var buySignals = result.Signals.Where(s => s.Action == ReplayEngine.SignalAction.Buy).ToList(); + var sellSignals = result.Signals.Where(s => s.Action == ReplayEngine.SignalAction.Sell).ToList(); + + Assert.NotEmpty(buySignals); // Should have BUY signals during uptrend + Assert.NotEmpty(sellSignals); // Should have SELL signals during downtrend + } + + private sealed class NoOpLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => false; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) { } + } +}