diff --git a/AssetManager.API/Controllers/PortfolioController.cs b/AssetManager.API/Controllers/PortfolioController.cs index 127e67e..d664ddf 100644 --- a/AssetManager.API/Controllers/PortfolioController.cs +++ b/AssetManager.API/Controllers/PortfolioController.cs @@ -1,3 +1,5 @@ +using AssetManager.Data; +using AssetManager.Infrastructure.StrategyEngine; using AssetManager.Models.DTOs; using AssetManager.Models; using AssetManager.Services; @@ -15,11 +17,22 @@ public class PortfolioController : ControllerBase { private readonly ILogger _logger; private readonly IPortfolioService _portfolioService; + private readonly IStrategyEngine _strategyEngine; + private readonly IStrategyService _strategyService; + private readonly DatabaseService _databaseService; - public PortfolioController(ILogger logger, IPortfolioService portfolioService) + public PortfolioController( + ILogger logger, + IPortfolioService portfolioService, + IStrategyEngine strategyEngine, + IStrategyService strategyService, + DatabaseService databaseService) { _logger = logger; _portfolioService = portfolioService; + _strategyEngine = strategyEngine; + _strategyService = strategyService; + _databaseService = databaseService; } @@ -279,4 +292,79 @@ public class PortfolioController : ControllerBase }); } } + + /// + /// 获取投资组合策略信号 + /// + /// 投资组合ID + /// 策略信号 + [HttpGet("{id}/signal")] + public async Task>> GetPortfolioSignal(string id) + { + try + { + var userId = GetCurrentUserId(); + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Unauthorized, + data = null, + message = "用户未授权" + }); + } + + _logger.LogInformation("Request to get portfolio signal: {PortfolioId}", id); + + // 1. 获取投资组合 + var portfolio = _databaseService.GetPortfolioById(id, userId); + if (portfolio == null) + { + return NotFound(new ApiResponse + { + code = AssetManager.Models.StatusCodes.NotFound, + data = null, + message = "投资组合不存在" + }); + } + + // 2. 获取策略 + var strategy = _strategyService.GetStrategyById(portfolio.StrategyId, userId); + if (strategy == null) + { + return NotFound(new ApiResponse + { + code = AssetManager.Models.StatusCodes.NotFound, + data = null, + message = "策略不存在" + }); + } + + // 3. 获取持仓 + var positions = _databaseService.GetPositionsByPortfolioId(id); + + // 4. 计算策略信号 + var signal = await _strategyEngine.CalculateSignalAsync(strategy, positions); + + _logger.LogInformation("Portfolio signal retrieved successfully: {PortfolioId}", id); + + return Ok(new ApiResponse + { + code = AssetManager.Models.StatusCodes.Success, + data = signal, + message = "success" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting portfolio signal: {PortfolioId}", id); + + return StatusCode(AssetManager.Models.StatusCodes.InternalServerError, new ApiResponse + { + code = AssetManager.Models.StatusCodes.InternalServerError, + data = null, + message = ex.Message + }); + } + } } diff --git a/AssetManager.API/Program.cs b/AssetManager.API/Program.cs index b5dd482..c5b3c05 100644 --- a/AssetManager.API/Program.cs +++ b/AssetManager.API/Program.cs @@ -68,7 +68,24 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); + +// 市场数据服务:开发环境用 Mock,生产环境用 Alpaca +if (builder.Environment.IsDevelopment()) +{ + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddScoped(); +} + +// 策略引擎 +builder.Services.AddScoped(); + +// 策略计算器 +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); diff --git a/AssetManager.Data/DatabaseService.cs b/AssetManager.Data/DatabaseService.cs index 12a650b..9a55f30 100644 --- a/AssetManager.Data/DatabaseService.cs +++ b/AssetManager.Data/DatabaseService.cs @@ -26,4 +26,23 @@ public class DatabaseService { return _db; } + + /// + /// 根据ID获取投资组合(校验用户ID) + /// + public Portfolio? GetPortfolioById(string id, string userId) + { + return _db.Queryable() + .First(p => p.Id == id && p.UserId == userId); + } + + /// + /// 根据投资组合ID获取持仓列表 + /// + public List GetPositionsByPortfolioId(string portfolioId) + { + return _db.Queryable() + .Where(p => p.PortfolioId == portfolioId) + .ToList(); + } } diff --git a/AssetManager.Infrastructure/Services/MockMarketDataService.cs b/AssetManager.Infrastructure/Services/MockMarketDataService.cs new file mode 100644 index 0000000..0c78b53 --- /dev/null +++ b/AssetManager.Infrastructure/Services/MockMarketDataService.cs @@ -0,0 +1,115 @@ +using AssetManager.Models.DTOs; +using Microsoft.Extensions.Logging; + +namespace AssetManager.Infrastructure.Services; + +/// +/// Mock 市场数据服务(用于开发测试) +/// +public class MockMarketDataService : IMarketDataService +{ + private readonly ILogger _logger; + + public MockMarketDataService(ILogger logger) + { + _logger = logger; + } + + public Task GetStockPriceAsync(string symbol) + { + _logger.LogInformation("Mock 获取股票价格: {Symbol}", symbol); + + // Mock 价格:基于标的代码生成一个稳定的价格 + decimal basePrice = symbol.GetHashCode() % 1000 + 50; + return Task.FromResult(new MarketPriceResponse + { + Symbol = symbol, + Price = basePrice, + Timestamp = DateTime.UtcNow, + AssetType = "Stock" + }); + } + + public Task GetCryptoPriceAsync(string symbol) + { + _logger.LogInformation("Mock 获取加密货币价格: {Symbol}", symbol); + + decimal basePrice = symbol.GetHashCode() % 50000 + 10000; + return Task.FromResult(new MarketPriceResponse + { + Symbol = symbol, + Price = basePrice, + Timestamp = DateTime.UtcNow, + AssetType = "Crypto" + }); + } + + public Task> GetStockHistoricalDataAsync(string symbol, string timeframe, int limit) + { + _logger.LogInformation("Mock 获取股票历史数据: {Symbol}, {Timeframe}, {Limit}", symbol, timeframe, limit); + return Task.FromResult(GenerateMockData(symbol, "Stock", timeframe, limit)); + } + + public Task> GetCryptoHistoricalDataAsync(string symbol, string timeframe, int limit) + { + _logger.LogInformation("Mock 获取加密货币历史数据: {Symbol}, {Timeframe}, {Limit}", symbol, timeframe, limit); + return Task.FromResult(GenerateMockData(symbol, "Crypto", timeframe, limit)); + } + + /// + /// 生成 Mock K 线数据(模拟一个上升趋势 + 随机波动) + /// + private List GenerateMockData(string symbol, string assetType, string timeframe, int limit) + { + var data = new List(); + var random = new Random(symbol.GetHashCode()); // 基于 symbol 的稳定随机数 + + // 基础价格:基于 symbol 生成 + decimal basePrice = symbol.GetHashCode() % 500 + 100; + DateTime currentTime = DateTime.UtcNow; + + // 根据 timeframe 转换为时间增量 + TimeSpan increment = timeframe.ToLower() switch + { + "1min" => TimeSpan.FromMinutes(1), + "5min" => TimeSpan.FromMinutes(5), + "15min" => TimeSpan.FromMinutes(15), + "1h" => TimeSpan.FromHours(1), + "1d" => TimeSpan.FromDays(1), + "1w" => TimeSpan.FromDays(7), + "1m" => TimeSpan.FromDays(30), + _ => TimeSpan.FromDays(1) + }; + + // 倒序生成(从过去到现在) + for (int i = limit - 1; i >= 0; i--) + { + DateTime timestamp = currentTime - increment * i; + + // 模拟上升趋势 + 随机波动 + decimal trend = (limit - i) * 0.1m; // 上升趋势 + decimal noise = (decimal)(random.NextDouble() - 0.5) * 2; // ±1 的随机波动 + decimal close = basePrice + trend + noise; + + // 生成 OHLC + decimal open = close + (decimal)(random.NextDouble() - 0.5) * 1; + decimal high = Math.Max(open, close) + (decimal)random.NextDouble() * 0.5m; + decimal low = Math.Min(open, close) - (decimal)random.NextDouble() * 0.5m; + decimal volume = (decimal)(random.NextDouble() * 1000000 + 100000); + + data.Add(new MarketDataResponse + { + Symbol = symbol, + Timestamp = timestamp, + Open = open, + High = high, + Low = low, + Close = close, + Volume = volume, + AssetType = assetType + }); + } + + return data; + } +} diff --git a/AssetManager.Infrastructure/StrategyEngine/Calculators/ChandelierExitCalculator.cs b/AssetManager.Infrastructure/StrategyEngine/Calculators/ChandelierExitCalculator.cs index 842aecc..34cbdaa 100644 --- a/AssetManager.Infrastructure/StrategyEngine/Calculators/ChandelierExitCalculator.cs +++ b/AssetManager.Infrastructure/StrategyEngine/Calculators/ChandelierExitCalculator.cs @@ -1,6 +1,8 @@ using AssetManager.Data; +using AssetManager.Infrastructure.Services; using AssetManager.Models.DTOs; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace AssetManager.Infrastructure.StrategyEngine.Calculators; @@ -10,12 +12,16 @@ namespace AssetManager.Infrastructure.StrategyEngine.Calculators; public class ChandelierExitCalculator : IStrategyCalculator { private readonly ILogger _logger; + private readonly IMarketDataService _marketDataService; public string StrategyType => AssetManager.Models.DTOs.StrategyType.ChandelierExit; - public ChandelierExitCalculator(ILogger logger) + public ChandelierExitCalculator( + ILogger logger, + IMarketDataService marketDataService) { _logger = logger; + _marketDataService = marketDataService; } public async Task CalculateAsync( @@ -25,7 +31,7 @@ public class ChandelierExitCalculator : IStrategyCalculator { _logger.LogInformation("计算吊灯止损策略信号"); - var config = System.Text.Json.JsonSerializer.Deserialize(configJson, new System.Text.Json.JsonSerializerOptions + var config = JsonSerializer.Deserialize(configJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ChandelierExitConfig(); @@ -41,14 +47,94 @@ public class ChandelierExitCalculator : IStrategyCalculator }; } - // 简单实现:返回持有信号 - await Task.Delay(1, cancellationToken); + var positionSignals = new List(); + bool shouldSell = false; + var reasons = new List(); + + foreach (var position in positions) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // 获取历史 K 线数据(需要 period + 1 根来计算 ATR) + var historicalData = position.AssetType?.ToLower() == "crypto" + ? await _marketDataService.GetCryptoHistoricalDataAsync(position.StockCode, "1d", config.Period + 1) + : await _marketDataService.GetStockHistoricalDataAsync(position.StockCode, "1d", config.Period + 1); + + if (historicalData.Count < config.Period) + { + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = $"历史数据不足(需要 {config.Period} 根,实际 {historicalData.Count} 根)" + }); + continue; + } + + // 按时间排序(确保从旧到新) + var sortedData = historicalData.OrderBy(d => d.Timestamp).ToList(); + var highs = sortedData.Select(d => d.High).ToList(); + var lows = sortedData.Select(d => d.Low).ToList(); + var closes = sortedData.Select(d => d.Close).ToList(); + + // 计算 ATR + var atrList = TechnicalIndicators.CalculateATR(highs, lows, closes, config.Period); + var latestAtr = atrList.Last(); + + // 计算最高价(最近 period 根) + var priceSeries = config.UseClose ? closes : highs; + var highestHighList = TechnicalIndicators.CalculateHighestHigh(priceSeries, config.Period); + var latestHighestHigh = highestHighList.Last(); + + if (!latestAtr.HasValue || !latestHighestHigh.HasValue) + { + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = "无法计算指标(数据不足)" + }); + continue; + } + + // 计算吊灯止损价 + decimal stopLossPrice = latestHighestHigh.Value - latestAtr.Value * config.Multiplier; + decimal currentPrice = closes.Last(); + + // 判断信号:如果当前价格跌破止损价,卖出 + string signal = currentPrice < stopLossPrice ? "SELL" : "HOLD"; + if (signal == "SELL") shouldSell = true; + + reasons.Add($"{position.StockCode}: 止损价 {stopLossPrice:F2}, 当前价 {currentPrice:F2}, 信号 {signal}"); + + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = signal, + Reason = $"止损价: {stopLossPrice:F2}, 当前价: {currentPrice:F2}, ATR({config.Period}): {latestAtr.Value:F2}", + SuggestedQuantity = signal == "SELL" ? position.Shares : null + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "计算 {Symbol} 的吊灯止损信号失败", position.StockCode); + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = $"计算失败: {ex.Message}" + }); + } + } return new StrategySignal { StrategyType = StrategyType, - Signal = "HOLD", - Reason = "吊灯止损策略暂未实现", + Signal = shouldSell ? "SELL" : "HOLD", + PositionSignals = positionSignals, + Reason = string.Join("; ", reasons), GeneratedAt = DateTime.UtcNow }; } diff --git a/AssetManager.Infrastructure/StrategyEngine/Calculators/MaTrendCalculator.cs b/AssetManager.Infrastructure/StrategyEngine/Calculators/MaTrendCalculator.cs index 0e59335..6a9752b 100644 --- a/AssetManager.Infrastructure/StrategyEngine/Calculators/MaTrendCalculator.cs +++ b/AssetManager.Infrastructure/StrategyEngine/Calculators/MaTrendCalculator.cs @@ -1,6 +1,8 @@ using AssetManager.Data; +using AssetManager.Infrastructure.Services; using AssetManager.Models.DTOs; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace AssetManager.Infrastructure.StrategyEngine.Calculators; @@ -10,12 +12,16 @@ namespace AssetManager.Infrastructure.StrategyEngine.Calculators; public class MaTrendCalculator : IStrategyCalculator { private readonly ILogger _logger; + private readonly IMarketDataService _marketDataService; public string StrategyType => AssetManager.Models.DTOs.StrategyType.MaTrend; - public MaTrendCalculator(ILogger logger) + public MaTrendCalculator( + ILogger logger, + IMarketDataService marketDataService) { _logger = logger; + _marketDataService = marketDataService; } public async Task CalculateAsync( @@ -25,7 +31,7 @@ public class MaTrendCalculator : IStrategyCalculator { _logger.LogInformation("计算均线趋势策略信号"); - var config = System.Text.Json.JsonSerializer.Deserialize(configJson, new System.Text.Json.JsonSerializerOptions + var config = JsonSerializer.Deserialize(configJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new MaTrendConfig(); @@ -41,14 +47,111 @@ public class MaTrendCalculator : IStrategyCalculator }; } - // 简单实现:返回持有信号 - await Task.Delay(1, cancellationToken); + var positionSignals = new List(); + string overallSignal = "HOLD"; + var reasons = new List(); + + foreach (var position in positions) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // 获取历史 K 线数据(需要 longPeriod + 1 根) + var historicalData = position.AssetType?.ToLower() == "crypto" + ? await _marketDataService.GetCryptoHistoricalDataAsync(position.StockCode, "1d", config.LongPeriod + 1) + : await _marketDataService.GetStockHistoricalDataAsync(position.StockCode, "1d", config.LongPeriod + 1); + + if (historicalData.Count < config.LongPeriod) + { + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = $"历史数据不足(需要 {config.LongPeriod} 根,实际 {historicalData.Count} 根)" + }); + continue; + } + + // 按时间排序 + var sortedData = historicalData.OrderBy(d => d.Timestamp).ToList(); + var closes = sortedData.Select(d => d.Close).ToList(); + + // 计算均线 + List shortMaList, longMaList; + if (config.MaType?.ToUpper() == "EMA") + { + shortMaList = TechnicalIndicators.CalculateEMA(closes, config.ShortPeriod); + longMaList = TechnicalIndicators.CalculateEMA(closes, config.LongPeriod); + } + else + { + shortMaList = TechnicalIndicators.CalculateSMA(closes, config.ShortPeriod); + longMaList = TechnicalIndicators.CalculateSMA(closes, config.LongPeriod); + } + + // 获取最新和前一个值 + var latestShortMa = shortMaList.Last(); + var latestLongMa = longMaList.Last(); + var prevShortMa = shortMaList.Count >= 2 ? shortMaList[^2] : null; + var prevLongMa = longMaList.Count >= 2 ? longMaList[^2] : null; + + if (!latestShortMa.HasValue || !latestLongMa.HasValue || !prevShortMa.HasValue || !prevLongMa.HasValue) + { + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = "无法计算均线(数据不足)" + }); + continue; + } + + // 判断金叉/死叉 + string signal = "HOLD"; + // 金叉:短期均线上穿长期均线(前一个短期 < 长期,现在短期 > 长期) + bool goldenCross = prevShortMa < prevLongMa && latestShortMa > latestLongMa; + // 死叉:短期均线下穿长期均线(前一个短期 > 长期,现在短期 < 长期) + bool deathCross = prevShortMa > prevLongMa && latestShortMa < latestLongMa; + + if (goldenCross) + { + signal = "BUY"; + overallSignal = "BUY"; + } + else if (deathCross) + { + signal = "SELL"; + if (overallSignal != "BUY") overallSignal = "SELL"; + } + + reasons.Add($"{position.StockCode}: 短均线({config.ShortPeriod}) {latestShortMa.Value:F2}, 长均线({config.LongPeriod}) {latestLongMa.Value:F2}, 信号 {signal}"); + + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = signal, + Reason = $"{config.MaType?.ToUpper() ?? "SMA"}({config.ShortPeriod}): {latestShortMa.Value:F2}, {config.MaType?.ToUpper() ?? "SMA"}({config.LongPeriod}): {latestLongMa.Value:F2}" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "计算 {Symbol} 的均线趋势信号失败", position.StockCode); + positionSignals.Add(new PositionSignal + { + Symbol = position.StockCode, + Signal = "HOLD", + Reason = $"计算失败: {ex.Message}" + }); + } + } return new StrategySignal { StrategyType = StrategyType, - Signal = "HOLD", - Reason = "均线趋势策略暂未实现", + Signal = overallSignal, + PositionSignals = positionSignals, + Reason = string.Join("; ", reasons), GeneratedAt = DateTime.UtcNow }; } diff --git a/AssetManager.Infrastructure/StrategyEngine/TechnicalIndicators.cs b/AssetManager.Infrastructure/StrategyEngine/TechnicalIndicators.cs new file mode 100644 index 0000000..d4a1551 --- /dev/null +++ b/AssetManager.Infrastructure/StrategyEngine/TechnicalIndicators.cs @@ -0,0 +1,232 @@ +namespace AssetManager.Infrastructure.StrategyEngine; + +/// +/// 技术指标计算库 +/// +public static class TechnicalIndicators +{ + /// + /// 计算简单移动平均 (SMA) + /// + /// 值列表 + /// 周期 + /// SMA 列表(长度与输入一致,前 period-1 个为 null) + public static List CalculateSMA(List values, int period) + { + var result = new List(); + if (values.Count < period) + { + result.AddRange(values.Select(_ => (decimal?)null)); + return result; + } + + // 前 period-1 个为 null + for (int i = 0; i < period - 1; i++) + { + result.Add(null); + } + + // 计算第一个 SMA + decimal sum = 0; + for (int i = 0; i < period; i++) + { + sum += values[i]; + } + result.Add(sum / period); + + // 滑动窗口计算后续 SMA + for (int i = period; i < values.Count; i++) + { + sum = sum - values[i - period] + values[i]; + result.Add(sum / period); + } + + return result; + } + + /// + /// 计算指数移动平均 (EMA) + /// + /// 值列表 + /// 周期 + /// EMA 列表(长度与输入一致,前 period-1 个为 null) + public static List CalculateEMA(List values, int period) + { + var result = new List(); + if (values.Count < period) + { + result.AddRange(values.Select(_ => (decimal?)null)); + return result; + } + + // 前 period-1 个为 null + for (int i = 0; i < period - 1; i++) + { + result.Add(null); + } + + // 第一个 EMA 使用 SMA + decimal sma = 0; + for (int i = 0; i < period; i++) + { + sma += values[i]; + } + sma /= period; + result.Add(sma); + + // 计算后续 EMA:EMA = (当前值 - 前一个 EMA) * 乘数 + 前一个 EMA + decimal multiplier = 2.0m / (period + 1); + decimal prevEma = sma; + + for (int i = period; i < values.Count; i++) + { + decimal ema = (values[i] - prevEma) * multiplier + prevEma; + result.Add(ema); + prevEma = ema; + } + + return result; + } + + /// + /// 计算真实波幅 (TR) + /// + /// 最高价列表 + /// 最低价列表 + /// 收盘价列表 + /// TR 列表 + public static List CalculateTR(List highs, List lows, List closes) + { + var trList = new List(); + if (highs.Count == 0) return trList; + + // 第一个 TR:High - Low + trList.Add(highs[0] - lows[0]); + + // 后续 TR:Max(High-Low, High-PrevClose, PrevClose-Low) + for (int i = 1; i < highs.Count; i++) + { + decimal prevClose = closes[i - 1]; + decimal tr1 = highs[i] - lows[i]; + decimal tr2 = Math.Abs(highs[i] - prevClose); + decimal tr3 = Math.Abs(prevClose - lows[i]); + trList.Add(Math.Max(Math.Max(tr1, tr2), tr3)); + } + + return trList; + } + + /// + /// 计算平均真实波幅 (ATR) + /// + /// 最高价列表 + /// 最低价列表 + /// 收盘价列表 + /// 周期(默认 14) + /// ATR 列表(长度与输入一致,前 period-1 个为 null) + public static List CalculateATR(List highs, List lows, List closes, int period = 14) + { + var result = new List(); + var trList = CalculateTR(highs, lows, closes); + + if (trList.Count < period) + { + result.AddRange(trList.Select(_ => (decimal?)null)); + return result; + } + + // 前 period-1 个为 null + for (int i = 0; i < period - 1; i++) + { + result.Add(null); + } + + // 第一个 ATR:TR 的简单平均 + decimal sum = 0; + for (int i = 0; i < period; i++) + { + sum += trList[i]; + } + decimal atr = sum / period; + result.Add(atr); + + // 后续 ATR:(前一个 ATR * (period-1) + 当前 TR) / period + for (int i = period; i < trList.Count; i++) + { + atr = (atr * (period - 1) + trList[i]) / period; + result.Add(atr); + } + + return result; + } + + /// + /// 计算滚动窗口最高价 + /// + /// 值列表 + /// 周期 + /// 滚动最高价列表(长度与输入一致,前 period-1 个为 null) + public static List CalculateHighestHigh(List values, int period) + { + var result = new List(); + if (values.Count < period) + { + result.AddRange(values.Select(_ => (decimal?)null)); + return result; + } + + // 前 period-1 个为 null + for (int i = 0; i < period - 1; i++) + { + result.Add(null); + } + + // 滑动窗口计算 + for (int i = period - 1; i < values.Count; i++) + { + decimal max = decimal.MinValue; + for (int j = i - period + 1; j <= i; j++) + { + if (values[j] > max) max = values[j]; + } + result.Add(max); + } + + return result; + } + + /// + /// 计算滚动窗口最低价 + /// + /// 值列表 + /// 周期 + /// 滚动最低价列表(长度与输入一致,前 period-1 个为 null) + public static List CalculateLowestLow(List values, int period) + { + var result = new List(); + if (values.Count < period) + { + result.AddRange(values.Select(_ => (decimal?)null)); + return result; + } + + // 前 period-1 个为 null + for (int i = 0; i < period - 1; i++) + { + result.Add(null); + } + + // 滑动窗口计算 + for (int i = period - 1; i < values.Count; i++) + { + decimal min = decimal.MaxValue; + for (int j = i - period + 1; j <= i; j++) + { + if (values[j] < min) min = values[j]; + } + result.Add(min); + } + + return result; + } +}