Files
Finlytic/FinlyticTechnicals/Strategies/CoreStrategies.cs
T

1123 lines
49 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticTechnicals.Indicators;
namespace FinlyticTechnicals.Strategies;
/// <summary>
/// 1. Trend Pullback into Fair Value Gap with Staged Scale-Out & Free-Roll Break-Even exit.
/// </summary>
public class TrendPullbackFvgStrategy : ITechnicalStrategy
{
public string StrategyKey => "TrendPullbackFvg";
public string StrategyName => "Trend Pullback FVG Retracement";
public int Priority => 1;
public bool IsApplicable(MarketRegime regime) =>
regime == MarketRegime.BullishTrending || regime == MarketRegime.BearishTrending;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
// Tunable for backtesting only (see TechnicalContext.ParameterOverrides doc comment) - defaults match
// this strategy's original hardcoded values, so live scanning behavior is unchanged.
int emaFastPeriod = (int)context.GetParameter(StrategyKey, "EmaFast", 20m);
int emaMidPeriod = (int)context.GetParameter(StrategyKey, "EmaMid", 50m);
int emaSlowPeriod = (int)context.GetParameter(StrategyKey, "EmaSlow", 200m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.2m);
var current = candles.Last();
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(candles, emaFastPeriod);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(candles, emaMidPeriod);
decimal ema200 = TechnicalIndicatorsEngine.CalculateEma(candles, emaSlowPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
// Long Setup: Bullish Trend (EMA20 > EMA50 > EMA200) + Bullish FVG retracement.
bool isBullishTrend = ema20 > ema50 && ema50 > ema200 && current.Close > ema50;
var fvgBullish = activePatterns.FirstOrDefault(p => p.Type == PatternType.FairValueGapBullish && p.Bias == PatternBias.Bullish);
if (isBullishTrend && fvgBullish != null)
{
decimal entry = current.Close;
decimal stopLoss = Math.Min(fvgBullish.InvalidationLevel, entry - (stopAtrMultiplier * atr));
decimal risk = entry - stopLoss;
if (risk <= 0) return null;
decimal tp1 = entry + (1.5m * risk);
decimal tp2 = entry + (3.0m * risk);
decimal rrr = (tp2 - entry) / risk;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, tp1, 0.50m, 1.5m, "TP1: Scale-out 50% & Trigger Break-Even"),
new TakeProfitStage(2, tp2, 0.30m, 3.0m, "TP2: Scale-out 30%"),
],
BreakEvenRule: new BreakEvenRule(
Enabled: true,
TriggerPrice: tp1,
OffsetToCoverFees: entry + (risk * 0.05m)
),
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.AtrMultiplier,
Multiplier: 1.5m,
ActivationPrice: tp1,
IndicatorKey: "ATR_14"
),
MaxHoldingBars: 50
);
var triggers = new List<PatternResultDto> { fvgBullish };
var indicators = new Dictionary<string, decimal>
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = ema200,
["ATR_14"] = atr
};
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Buy,
QualityScore: 88m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: rrr,
ExitPlan: exitPlan,
TechnicalRationale: $"Bullish trend alignment (EMA20 > EMA50 > EMA200) with retracement into 15m FVG zone [{fvgBullish.LowerBoundary:F2} - {fvgBullish.UpperBoundary:F2}].",
TriggeringPatterns: triggers,
IndicatorSnapshot: indicators,
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(6),
IsTopPick: true,
Rating: "A+"
);
}
// Short Setup (mirror image): Bearish Trend (EMA20 < EMA50 < EMA200) + Bearish FVG retracement.
bool isBearishTrend = ema20 < ema50 && ema50 < ema200 && current.Close < ema50;
var fvgBearish = activePatterns.FirstOrDefault(p => p.Type == PatternType.FairValueGapBearish && p.Bias == PatternBias.Bearish);
if (isBearishTrend && fvgBearish != null)
{
decimal entry = current.Close;
decimal stopLoss = Math.Max(fvgBearish.InvalidationLevel, entry + (stopAtrMultiplier * atr));
decimal risk = stopLoss - entry;
if (risk <= 0) return null;
decimal tp1 = entry - (1.5m * risk);
decimal tp2 = entry - (3.0m * risk);
decimal rrr = (entry - tp2) / risk;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, tp1, 0.50m, 1.5m, "TP1: Scale-out 50% & Trigger Break-Even"),
new TakeProfitStage(2, tp2, 0.30m, 3.0m, "TP2: Scale-out 30%"),
],
BreakEvenRule: new BreakEvenRule(
Enabled: true,
TriggerPrice: tp1,
OffsetToCoverFees: entry - (risk * 0.05m)
),
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.AtrMultiplier,
Multiplier: 1.5m,
ActivationPrice: tp1,
IndicatorKey: "ATR_14"
),
MaxHoldingBars: 50
);
var triggers = new List<PatternResultDto> { fvgBearish };
var indicators = new Dictionary<string, decimal>
{
["EMA_20"] = ema20,
["EMA_50"] = ema50,
["EMA_200"] = ema200,
["ATR_14"] = atr
};
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Sell,
QualityScore: 88m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: rrr,
ExitPlan: exitPlan,
TechnicalRationale: $"Bearish trend alignment (EMA20 < EMA50 < EMA200) with retracement into 15m FVG zone [{fvgBearish.LowerBoundary:F2} - {fvgBearish.UpperBoundary:F2}].",
TriggeringPatterns: triggers,
IndicatorSnapshot: indicators,
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(6),
IsTopPick: true,
Rating: "A+"
);
}
return null;
}
}
/// <summary>
/// 2. Volatility Squeeze Breakout with Fixed Single Target (+2.0 ATR).
/// </summary>
public class VolatilitySqueezeStrategy : ITechnicalStrategy
{
public string StrategyKey => "VolatilitySqueeze";
public string StrategyName => "Bollinger/Keltner Squeeze Breakout";
public int Priority => 2;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 25) return null;
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.0m);
decimal targetAtrMultiplier = context.GetParameter(StrategyKey, "TargetAtrMultiplier", 2.0m);
var current = candles.Last();
var squeeze = TechnicalIndicatorsEngine.CalculateVolatilitySqueeze(candles);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
// Fired Bullish: Squeeze fired out of compression with positive momentum
if (squeeze.SqueezeState == "FIRED_BULLISH" && squeeze.MomentumHistogram > 0)
{
decimal entry = current.Close;
decimal stopLoss = entry - (stopAtrMultiplier * atr);
decimal target = entry + (targetAtrMultiplier * atr);
decimal risk = entry - stopLoss;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, target, 1.00m, 2.0m, "Target: 100% exit at +2.0 ATR")
],
MaxHoldingBars: 20
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Buy,
QualityScore: 84m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: exitPlan,
TechnicalRationale: $"Bollinger compression inside Keltner Channels fired bullish momentum ({squeeze.MomentumHistogram:F3}).",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["ATR_14"] = atr, ["SqueezeMomentum"] = squeeze.MomentumHistogram },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(4),
IsTopPick: true,
Rating: "A"
);
}
// Fired Bearish: Squeeze fired out of compression with negative momentum (mirror image of above).
if (squeeze.SqueezeState == "FIRED_BEARISH" && squeeze.MomentumHistogram < 0)
{
decimal entry = current.Close;
decimal stopLoss = entry + (stopAtrMultiplier * atr);
decimal target = entry - (targetAtrMultiplier * atr);
decimal risk = stopLoss - entry;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, target, 1.00m, 2.0m, "Target: 100% exit at -2.0 ATR")
],
MaxHoldingBars: 20
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Sell,
QualityScore: 84m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: exitPlan,
TechnicalRationale: $"Bollinger compression inside Keltner Channels fired bearish momentum ({squeeze.MomentumHistogram:F3}).",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["ATR_14"] = atr, ["SqueezeMomentum"] = squeeze.MomentumHistogram },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(4),
IsTopPick: true,
Rating: "A"
);
}
return null;
}
}
/// <summary>
/// 3. SMC Liquidity Sweep & Structural Flip with tight SL over the sweep wick.
/// </summary>
public class SmcLiquiditySweepStrategy : ITechnicalStrategy
{
public string StrategyKey => "SmcLiquiditySweep";
public string StrategyName => "Smart Money Liquidity Sweep & CHoCH";
public int Priority => 3;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var sweepLow = activePatterns.FirstOrDefault(p => p.Type == PatternType.LiquiditySweepLow);
var choch = activePatterns.FirstOrDefault(p => p.Type == PatternType.ChangeOfCharacter && p.Bias == PatternBias.Bullish);
var sweepHigh = activePatterns.FirstOrDefault(p => p.Type == PatternType.LiquiditySweepHigh);
var chochBearish = activePatterns.FirstOrDefault(p => p.Type == PatternType.ChangeOfCharacter && p.Bias == PatternBias.Bearish);
decimal stopBufferPercent = context.GetParameter(StrategyKey, "StopBufferPercent", 0.2m);
if (sweepLow != null || choch != null)
{
var candles = context.PrimaryCandles;
var current = candles.Last();
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
decimal entry = current.Close;
decimal stopLoss = (sweepLow?.LowerBoundary ?? current.Low) * (1m - (stopBufferPercent / 100m));
decimal risk = entry - stopLoss;
if (risk <= 0) return null;
decimal tp1 = entry + (2.0m * risk);
decimal tp2 = entry + (4.0m * risk);
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, tp1, 0.60m, 2.0m, "TP1: 60% Scale-Out & Instant Free-Roll"),
new TakeProfitStage(2, tp2, 0.40m, 4.0m, "TP2: 40% Final Target")
],
BreakEvenRule: new BreakEvenRule(true, tp1, entry + (risk * 0.05m)),
MaxHoldingBars: 35
);
var triggers = new List<PatternResultDto>();
if (sweepLow != null) triggers.Add(sweepLow);
if (choch != null) triggers.Add(choch);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Buy,
QualityScore: 91m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: (tp2 - entry) / risk,
ExitPlan: exitPlan,
TechnicalRationale: $"Institutional liquidity sweep below {sweepLow?.KeyPriceLevel:F2} followed by buyer absorption and structural rejection.",
TriggeringPatterns: triggers,
IndicatorSnapshot: new Dictionary<string, decimal> { ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(5),
IsTopPick: true,
Rating: "A+"
);
}
// Mirror image: a sweep above a known high (stop-loss hunt against shorts/breakout buyers) followed by
// a bearish Change-of-Character - interpreted as institutional sellers absorbing that liquidity.
if (sweepHigh != null || chochBearish != null)
{
var candles = context.PrimaryCandles;
var current = candles.Last();
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
decimal entry = current.Close;
decimal stopLoss = (sweepHigh?.UpperBoundary ?? current.High) * (1m + (stopBufferPercent / 100m));
decimal risk = stopLoss - entry;
if (risk <= 0) return null;
decimal tp1 = entry - (2.0m * risk);
decimal tp2 = entry - (4.0m * risk);
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.StagedScaleOutWithBreakEven,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, tp1, 0.60m, 2.0m, "TP1: 60% Scale-Out & Instant Free-Roll"),
new TakeProfitStage(2, tp2, 0.40m, 4.0m, "TP2: 40% Final Target")
],
BreakEvenRule: new BreakEvenRule(true, tp1, entry - (risk * 0.05m)),
MaxHoldingBars: 35
);
var triggersBearish = new List<PatternResultDto>();
if (sweepHigh != null) triggersBearish.Add(sweepHigh);
if (chochBearish != null) triggersBearish.Add(chochBearish);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Sell,
QualityScore: 91m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: (entry - tp2) / risk,
ExitPlan: exitPlan,
TechnicalRationale: $"Institutional liquidity sweep above {sweepHigh?.KeyPriceLevel:F2} followed by seller absorption and structural rejection.",
TriggeringPatterns: triggersBearish,
IndicatorSnapshot: new Dictionary<string, decimal> { ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(5),
IsTopPick: true,
Rating: "A+"
);
}
return null;
}
}
/// <summary>
/// 4. Mean Reversion from 2.5-Sigma Bollinger Band in Rangebound markets.
/// </summary>
public class MeanReversionStrategy : ITechnicalStrategy
{
public string StrategyKey => "MeanReversion";
public string StrategyName => "Bollinger 2.5-Sigma Mean Reversion";
public int Priority => 4;
public bool IsApplicable(MarketRegime regime) =>
regime == MarketRegime.LowVolatilityRangebound || regime == MarketRegime.HighVolatilityChoppy;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 25) return null;
decimal bollingerMultiplier = context.GetParameter(StrategyKey, "BollingerMultiplier", 2.5m);
decimal adxThreshold = context.GetParameter(StrategyKey, "AdxThreshold", 22m);
decimal rsiOversold = context.GetParameter(StrategyKey, "RsiOversold", 32m);
decimal rsiOverbought = context.GetParameter(StrategyKey, "RsiOverbought", 68m);
var current = candles.Last();
var bb = TechnicalIndicatorsEngine.CalculateBollingerBands(candles, 20, bollingerMultiplier);
decimal rsi = TechnicalIndicatorsEngine.CalculateRsi(candles, 14);
var adx = TechnicalIndicatorsEngine.CalculateAdx(candles, 14);
// Rangebound with low ADX and oversold RSI touching the lower band
if (adx.Adx < adxThreshold && rsi <= rsiOversold && current.Low <= bb.LowerBand)
{
decimal entry = current.Close;
decimal vwapTarget = TechnicalIndicatorsEngine.CalculateVwap(candles);
if (vwapTarget <= entry) vwapTarget = bb.MiddleBand;
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
decimal stopLoss = current.Low - (0.8m * atr);
decimal risk = entry - stopLoss;
if (risk <= 0 || vwapTarget <= entry) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.DynamicBandTouch,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, vwapTarget, 1.00m, (vwapTarget - entry) / risk, "Target: 100% Exit at VWAP / SMA20")
],
MaxHoldingBars: 15
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Buy,
QualityScore: 79m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: (vwapTarget - entry) / risk,
ExitPlan: exitPlan,
TechnicalRationale: $"Oversold {bollingerMultiplier:F1}-sigma Bollinger stretch (RSI {rsi:F1}, ADX {adx.Adx:F1}) targeting mean reversion back to VWAP {vwapTarget:F2}.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal>
{
["RSI_14"] = rsi,
["ADX_14"] = adx.Adx,
["BB_Lower"] = bb.LowerBand,
["VWAP"] = vwapTarget
},
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(3),
IsTopPick: false,
Rating: "B"
);
}
// Mirror image: rangebound with low ADX and overbought RSI touching the upper band.
if (adx.Adx < adxThreshold && rsi >= rsiOverbought && current.High >= bb.UpperBand)
{
decimal entry = current.Close;
decimal vwapTarget = TechnicalIndicatorsEngine.CalculateVwap(candles);
if (vwapTarget >= entry) vwapTarget = bb.MiddleBand;
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
decimal stopLoss = current.High + (0.8m * atr);
decimal risk = stopLoss - entry;
if (risk <= 0 || vwapTarget >= entry) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.DynamicBandTouch,
InitialStopLoss: stopLoss,
TakeProfitStages:
[
new TakeProfitStage(1, vwapTarget, 1.00m, (entry - vwapTarget) / risk, "Target: 100% Exit at VWAP / SMA20")
],
MaxHoldingBars: 15
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Sell,
QualityScore: 79m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: (entry - vwapTarget) / risk,
TechnicalRationale: $"Overbought {bollingerMultiplier:F1}-sigma Bollinger stretch (RSI {rsi:F1}, ADX {adx.Adx:F1}) targeting mean reversion back to VWAP {vwapTarget:F2}.",
ExitPlan: exitPlan,
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal>
{
["RSI_14"] = rsi,
["ADX_14"] = adx.Adx,
["BB_Upper"] = bb.UpperBand,
["VWAP"] = vwapTarget
},
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(3),
IsTopPick: false,
Rating: "B"
);
}
return null;
}
}
/// <summary>
/// 5. SuperTrend Multi-Timeframe Trend Follower with Pure Trailing Stop.
/// </summary>
public class SuperTrendMultiTfStrategy : ITechnicalStrategy
{
public string StrategyKey => "SuperTrendMultiTf";
public string StrategyName => "SuperTrend Multi-Timeframe Alignment";
public int Priority => 5;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles15m = context.GetCandles("15m");
var candles1h = context.GetCandles("1h");
if (candles15m.Count < 15 || candles1h.Count < 15) return null;
int stPeriod = (int)context.GetParameter(StrategyKey, "Period", 10m);
decimal stMultiplier = context.GetParameter(StrategyKey, "Multiplier", 3.0m);
var st1h = TechnicalIndicatorsEngine.CalculateSuperTrend(candles1h, stPeriod, stMultiplier);
var st15m = TechnicalIndicatorsEngine.CalculateSuperTrend(candles15m, stPeriod, stMultiplier);
// Bullish Confluence: 1h SuperTrend is BUY and 15m SuperTrend just flipped to BUY or is bullish
if (st1h.Direction == SignalDirection.Buy && st15m.Direction == SignalDirection.Buy)
{
var current = candles15m.Last();
decimal entry = current.Close;
decimal stopLoss = st15m.Value;
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles15m, 14);
decimal risk = entry - stopLoss;
if (risk <= 0) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.PureTrailingStop,
InitialStopLoss: stopLoss,
TakeProfitStages: [],
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.SuperTrendLine,
Multiplier: stMultiplier,
ActivationPrice: entry,
IndicatorKey: "SuperTrend_15m"
),
ReversalCondition: new ReversalCondition(
RuleDescription: "Exit immediately if 15m SuperTrend flips to Bearish",
IndicatorTrigger: "SuperTrend_15m_Flip_Sell"
)
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: "15m",
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Buy,
QualityScore: 86m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 3.0m,
ExitPlan: exitPlan,
TechnicalRationale: $"1h macro SuperTrend and 15m micro SuperTrend in bullish confluence with dynamic trailing stop at {stopLoss:F2}.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal>
{
["SuperTrend_1h"] = st1h.Value,
["SuperTrend_15m"] = st15m.Value,
["ATR_14"] = atr
},
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(8),
IsTopPick: true,
Rating: "A"
);
}
// Bearish Confluence (mirror image): 1h SuperTrend is SELL and 15m SuperTrend is also bearish.
if (st1h.Direction == SignalDirection.Sell && st15m.Direction == SignalDirection.Sell)
{
var current = candles15m.Last();
decimal entry = current.Close;
decimal stopLoss = st15m.Value;
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles15m, 14);
decimal risk = stopLoss - entry;
if (risk <= 0) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.PureTrailingStop,
InitialStopLoss: stopLoss,
TakeProfitStages: [],
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.SuperTrendLine,
Multiplier: stMultiplier,
ActivationPrice: entry,
IndicatorKey: "SuperTrend_15m"
),
ReversalCondition: new ReversalCondition(
RuleDescription: "Exit immediately if 15m SuperTrend flips to Bullish",
IndicatorTrigger: "SuperTrend_15m_Flip_Buy"
)
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: "15m",
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: SignalDirection.Sell,
QualityScore: 86m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 3.0m,
ExitPlan: exitPlan,
TechnicalRationale: $"1h macro SuperTrend and 15m micro SuperTrend in bearish confluence with dynamic trailing stop at {stopLoss:F2}.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal>
{
["SuperTrend_1h"] = st1h.Value,
["SuperTrend_15m"] = st15m.Value,
["ATR_14"] = atr
},
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(8),
IsTopPick: true,
Rating: "A"
);
}
return null;
}
}
/// <summary>
/// 6. MACD Signal Line Crossover - classic momentum-shift strategy. Fires when the MACD line crosses the
/// signal line (compared against the same calculation one bar earlier) with the histogram confirming direction.
/// </summary>
public class MacdCrossoverStrategy : ITechnicalStrategy
{
public string StrategyKey => "MacdCrossover";
public string StrategyName => "MACD Signal Line Crossover";
public int Priority => 6;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 40) return null;
var current = candles.Last();
var previousCandles = candles.Take(candles.Count - 1).ToList();
if (previousCandles.Count < 35) return null;
int fastPeriod = (int)context.GetParameter(StrategyKey, "FastPeriod", 12m);
int slowPeriod = (int)context.GetParameter(StrategyKey, "SlowPeriod", 26m);
int signalPeriod = (int)context.GetParameter(StrategyKey, "SignalPeriod", 9m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.5m);
var macdNow = TechnicalIndicatorsEngine.CalculateMacd(candles, fastPeriod, slowPeriod, signalPeriod);
var macdPrev = TechnicalIndicatorsEngine.CalculateMacd(previousCandles, fastPeriod, slowPeriod, signalPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
bool bullishCross = macdPrev.MacdLine <= macdPrev.SignalLine && macdNow.MacdLine > macdNow.SignalLine && macdNow.Histogram > 0;
bool bearishCross = macdPrev.MacdLine >= macdPrev.SignalLine && macdNow.MacdLine < macdNow.SignalLine && macdNow.Histogram < 0;
if (!bullishCross && !bearishCross) return null;
decimal entry = current.Close;
SignalDirection direction = bullishCross ? SignalDirection.Buy : SignalDirection.Sell;
decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr);
decimal risk = Math.Abs(entry - stopLoss);
if (risk <= 0) return null;
decimal tp1 = direction == SignalDirection.Buy ? entry + (2.0m * risk) : entry - (2.0m * risk);
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 2.0m, "Target: 100% exit at +2.0R")],
MaxHoldingBars: 30
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: direction,
QualityScore: 80m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: exitPlan,
TechnicalRationale: bullishCross
? $"MACD line ({macdNow.MacdLine:F3}) crossed above the signal line ({macdNow.SignalLine:F3}) with a positive histogram."
: $"MACD line ({macdNow.MacdLine:F3}) crossed below the signal line ({macdNow.SignalLine:F3}) with a negative histogram.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal>
{
["MACD_Line"] = macdNow.MacdLine,
["MACD_Signal"] = macdNow.SignalLine,
["MACD_Histogram"] = macdNow.Histogram,
["ATR_14"] = atr
},
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(5),
IsTopPick: false,
Rating: "B"
);
}
}
/// <summary>
/// 7. EMA50/EMA200 Golden Cross &amp; Death Cross - the textbook long-horizon trend-change signal.
/// </summary>
public class MovingAverageCrossoverStrategy : ITechnicalStrategy
{
public string StrategyKey => "MovingAverageCrossover";
public string StrategyName => "EMA50/EMA200 Golden & Death Cross";
public int Priority => 7;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
int fastPeriod = (int)context.GetParameter(StrategyKey, "FastPeriod", 50m);
int slowPeriod = (int)context.GetParameter(StrategyKey, "SlowPeriod", 200m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 2.0m);
if (candles.Count < slowPeriod + 10) return null;
var current = candles.Last();
var previousCandles = candles.Take(candles.Count - 1).ToList();
decimal ema50Now = TechnicalIndicatorsEngine.CalculateEma(candles, fastPeriod);
decimal ema200Now = TechnicalIndicatorsEngine.CalculateEma(candles, slowPeriod);
decimal ema50Prev = TechnicalIndicatorsEngine.CalculateEma(previousCandles, fastPeriod);
decimal ema200Prev = TechnicalIndicatorsEngine.CalculateEma(previousCandles, slowPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
bool goldenCross = ema50Prev <= ema200Prev && ema50Now > ema200Now;
bool deathCross = ema50Prev >= ema200Prev && ema50Now < ema200Now;
if (!goldenCross && !deathCross) return null;
decimal entry = current.Close;
SignalDirection direction = goldenCross ? SignalDirection.Buy : SignalDirection.Sell;
decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr);
decimal risk = Math.Abs(entry - stopLoss);
if (risk <= 0) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.PureTrailingStop,
InitialStopLoss: stopLoss,
TakeProfitStages: [],
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.AtrMultiplier,
Multiplier: 2.5m,
ActivationPrice: entry,
IndicatorKey: "ATR_14"
),
MaxHoldingBars: 100
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: direction,
QualityScore: 82m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.5m,
ExitPlan: exitPlan,
TechnicalRationale: goldenCross
? $"Golden Cross: EMA50 ({ema50Now:F2}) crossed above EMA200 ({ema200Now:F2})."
: $"Death Cross: EMA50 ({ema50Now:F2}) crossed below EMA200 ({ema200Now:F2}).",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["EMA_50"] = ema50Now, ["EMA_200"] = ema200Now, ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(24),
IsTopPick: true,
Rating: "A"
);
}
}
/// <summary>
/// 8. RSI Overbought/Oversold Threshold Cross - simple, direction-agnostic momentum-reversal strategy
/// (distinct from <see cref="MeanReversionStrategy"/>, which additionally requires Bollinger-band + ADX confluence).
/// </summary>
public class RsiReversalStrategy : ITechnicalStrategy
{
public string StrategyKey => "RsiReversal";
public string StrategyName => "RSI Overbought/Oversold Threshold Cross";
public int Priority => 8;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
var current = candles.Last();
var previousCandles = candles.Take(candles.Count - 1).ToList();
if (previousCandles.Count < 15) return null;
int rsiPeriod = (int)context.GetParameter(StrategyKey, "Period", 14m);
decimal oversoldThreshold = context.GetParameter(StrategyKey, "OversoldThreshold", 30m);
decimal overboughtThreshold = context.GetParameter(StrategyKey, "OverboughtThreshold", 70m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.2m);
decimal rsiNow = TechnicalIndicatorsEngine.CalculateRsi(candles, rsiPeriod);
decimal rsiPrev = TechnicalIndicatorsEngine.CalculateRsi(previousCandles, rsiPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
bool bullishCross = rsiPrev <= oversoldThreshold && rsiNow > oversoldThreshold;
bool bearishCross = rsiPrev >= overboughtThreshold && rsiNow < overboughtThreshold;
if (!bullishCross && !bearishCross) return null;
decimal entry = current.Close;
SignalDirection direction = bullishCross ? SignalDirection.Buy : SignalDirection.Sell;
decimal stopLoss = direction == SignalDirection.Buy ? entry - (stopAtrMultiplier * atr) : entry + (stopAtrMultiplier * atr);
decimal risk = Math.Abs(entry - stopLoss);
if (risk <= 0) return null;
decimal tp1 = direction == SignalDirection.Buy ? entry + (1.5m * risk) : entry - (1.5m * risk);
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 1.5m, "Target: 100% exit at +1.5R")],
MaxHoldingBars: 20
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: direction,
QualityScore: 75m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 1.5m,
ExitPlan: exitPlan,
TechnicalRationale: bullishCross
? $"RSI ({rsiNow:F1}) crossed back above the oversold threshold of 30."
: $"RSI ({rsiNow:F1}) crossed back below the overbought threshold of 70.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["RSI_14"] = rsiNow, ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(3),
IsTopPick: false,
Rating: "B"
);
}
}
/// <summary>
/// 9. 20-Period Donchian Channel Breakout - the classic "Turtle Trading" breakout system.
/// </summary>
public class DonchianBreakoutStrategy : ITechnicalStrategy
{
public string StrategyKey => "DonchianBreakout";
public string StrategyName => "20-Period Donchian Channel Breakout";
public int Priority => 9;
public bool IsApplicable(MarketRegime regime) => true;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
int period = (int)context.GetParameter(StrategyKey, "Period", 20m);
var candles = context.PrimaryCandles;
if (candles.Count < period + 2) return null;
var current = candles.Last();
// Prior N bars, excluding the current bar itself - a breakout is a close beyond the range that had
// already formed BEFORE this bar, not beyond a range that includes the breakout bar itself.
var priorWindow = candles.Skip(candles.Count - 1 - period).Take(period).ToList();
decimal highestHigh = priorWindow.Max(c => c.High);
decimal lowestLow = priorWindow.Min(c => c.Low);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0) return null;
bool bullishBreakout = current.Close > highestHigh;
bool bearishBreakout = current.Close < lowestLow;
if (!bullishBreakout && !bearishBreakout) return null;
decimal entry = current.Close;
SignalDirection direction = bullishBreakout ? SignalDirection.Buy : SignalDirection.Sell;
decimal stopLoss = direction == SignalDirection.Buy ? lowestLow : highestHigh;
decimal risk = Math.Abs(entry - stopLoss);
if (risk <= 0) return null;
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.PureTrailingStop,
InitialStopLoss: stopLoss,
TakeProfitStages: [],
TrailingStopRule: new TrailingStopRule(
Type: TrailingStopType.AtrMultiplier,
Multiplier: 2.0m,
ActivationPrice: entry,
IndicatorKey: "ATR_14"
),
MaxHoldingBars: 40
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: direction,
QualityScore: 83m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: exitPlan,
TechnicalRationale: bullishBreakout
? $"Breakout above the {period}-period high at {highestHigh:F2} (Donchian channel)."
: $"Breakdown below the {period}-period low at {lowestLow:F2} (Donchian channel).",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["DonchianHigh"] = highestHigh, ["DonchianLow"] = lowestLow, ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(8),
IsTopPick: true,
Rating: "A"
);
}
}
/// <summary>
/// 10. VWAP Pullback &amp; Bounce Confirmation - trades a retest of the session VWAP in the direction of the
/// prevailing short-term trend once price rejects back away from it.
/// </summary>
public class VwapBounceStrategy : ITechnicalStrategy
{
public string StrategyKey => "VwapBounce";
public string StrategyName => "VWAP Pullback & Bounce Confirmation";
public int Priority => 10;
public bool IsApplicable(MarketRegime regime) =>
regime == MarketRegime.BullishTrending || regime == MarketRegime.BearishTrending;
public StrategyResultDto? Evaluate(TechnicalContext context, IReadOnlyList<PatternResultDto> activePatterns)
{
var candles = context.PrimaryCandles;
if (candles.Count < 30) return null;
int emaFastPeriod = (int)context.GetParameter(StrategyKey, "EmaFast", 20m);
int emaSlowPeriod = (int)context.GetParameter(StrategyKey, "EmaSlow", 50m);
decimal stopAtrMultiplier = context.GetParameter(StrategyKey, "StopAtrMultiplier", 1.0m);
var current = candles.Last();
var previous = candles[^2];
decimal vwap = TechnicalIndicatorsEngine.CalculateVwap(candles);
decimal ema20 = TechnicalIndicatorsEngine.CalculateEma(candles, emaFastPeriod);
decimal ema50 = TechnicalIndicatorsEngine.CalculateEma(candles, emaSlowPeriod);
decimal atr = context.CurrentAtr > 0 ? context.CurrentAtr : TechnicalIndicatorsEngine.CalculateAtr(candles, 14);
if (atr <= 0 || vwap <= 0) return null;
// Bullish: uptrend, prior bar dipped to/below VWAP, current bar closed back above it (rejection/bounce).
bool bullishBounce = ema20 > ema50 && previous.Low <= vwap && current.Close > vwap;
// Bearish: downtrend, prior bar rallied to/above VWAP, current bar closed back below it (rejection).
bool bearishBounce = ema20 < ema50 && previous.High >= vwap && current.Close < vwap;
if (!bullishBounce && !bearishBounce) return null;
decimal entry = current.Close;
SignalDirection direction = bullishBounce ? SignalDirection.Buy : SignalDirection.Sell;
decimal stopLoss = direction == SignalDirection.Buy
? Math.Min(previous.Low, entry - (stopAtrMultiplier * atr))
: Math.Max(previous.High, entry + (stopAtrMultiplier * atr));
decimal risk = Math.Abs(entry - stopLoss);
if (risk <= 0) return null;
decimal tp1 = direction == SignalDirection.Buy ? entry + (2.0m * risk) : entry - (2.0m * risk);
var exitPlan = new ExitPlan(
StrategyType: ExitStrategyType.FixedSingleTarget,
InitialStopLoss: stopLoss,
TakeProfitStages: [new TakeProfitStage(1, tp1, 1.00m, 2.0m, "Target: 100% exit at +2.0R")],
MaxHoldingBars: 25
);
return new StrategyResultDto(
SetupId: Guid.NewGuid(),
Isin: context.Isin,
Symbol: context.Symbol,
Timeframe: context.Timeframe,
StrategyKey: StrategyKey,
StrategyName: StrategyName,
Direction: direction,
QualityScore: 81m,
CurrentPrice: current.Close,
EntryPrice: entry,
InvalidationPrice: stopLoss,
CurrentAtr: atr,
EstimatedRiskRewardRatio: 2.0m,
ExitPlan: exitPlan,
TechnicalRationale: bullishBounce
? $"Uptrend (EMA20>EMA50), pullback to VWAP ({vwap:F2}) with a bounce back above it."
: $"Downtrend (EMA20<EMA50), rally to VWAP ({vwap:F2}) with a rejection back below it.",
TriggeringPatterns: activePatterns.ToList(),
IndicatorSnapshot: new Dictionary<string, decimal> { ["VWAP"] = vwap, ["EMA_20"] = ema20, ["EMA_50"] = ema50, ["ATR_14"] = atr },
CreatedAt: current.Timestamp,
ExpiresAt: current.Timestamp.AddHours(4),
IsTopPick: false,
Rating: "B"
);
}
}