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 <noreply@anthropic.com>
This commit is contained in:
@@ -160,10 +160,44 @@ public sealed class ReplayEngine(
|
|||||||
IReadOnlyList<DataBackfiller.OhlcvBar> bars,
|
IReadOnlyList<DataBackfiller.OhlcvBar> bars,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Simplified: stub model prediction
|
await Task.Delay(5, cancellationToken);
|
||||||
// In production: call model.predict() with features
|
|
||||||
await Task.Delay(10, cancellationToken);
|
var signals = new List<Signal>();
|
||||||
return new List<Signal>();
|
|
||||||
|
// 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<decimal> 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(
|
private static decimal GetClosePrice(
|
||||||
|
|||||||
@@ -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<ReplayEngine> _logger = new NoOpLogger<ReplayEngine>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateSignals_EMA_ProducesTradeSignals()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var replay = new ReplayEngine(_logger);
|
||||||
|
|
||||||
|
// Create OHLCV bars with clear trend
|
||||||
|
var bars = new List<DataBackfiller.OhlcvBar>();
|
||||||
|
|
||||||
|
// 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<DataBackfiller.FeeScheduleEntry>
|
||||||
|
{
|
||||||
|
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<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => false;
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user