AssetManager.API/AssetManager.Infrastructure/StrategyEngine/Calculators/ChandelierExitCalculator.cs
niannian zheng b5499ef7fe refactor: 将模型属性改为可为空类型以增强健壮性
- 修改ApiResponse、RiskParityConfig等DTO类的属性为可空类型
- 在策略计算器中添加空值检查逻辑
- 更新服务层代码处理可能的空值情况
- 添加发布配置文件FolderProfile.pubxml
2026-03-06 15:51:59 +08:00

146 lines
5.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AssetManager.Data;
using AssetManager.Infrastructure.Services;
using AssetManager.Models.DTOs;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
/// <summary>
/// 吊灯止损策略计算器
/// </summary>
public class ChandelierExitCalculator : IStrategyCalculator
{
private readonly ILogger<ChandelierExitCalculator> _logger;
private readonly IMarketDataService _marketDataService;
public string StrategyType => AssetManager.Models.DTOs.StrategyType.ChandelierExit;
public ChandelierExitCalculator(
ILogger<ChandelierExitCalculator> logger,
IMarketDataService marketDataService)
{
_logger = logger;
_marketDataService = marketDataService;
}
public async Task<StrategySignal> CalculateAsync(
string configJson,
List<Position> positions,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("计算吊灯止损策略信号");
var config = JsonSerializer.Deserialize<ChandelierExitConfig>(configJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
}) ?? new ChandelierExitConfig();
if (positions.Count == 0)
{
return new StrategySignal
{
StrategyType = StrategyType,
Signal = "HOLD",
Reason = "无持仓",
GeneratedAt = DateTime.UtcNow
};
}
var positionSignals = new List<PositionSignal>();
bool shouldSell = false;
var reasons = new List<string>();
foreach (var position in positions)
{
cancellationToken.ThrowIfCancellationRequested();
if (position.StockCode == null)
{
continue;
}
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 = shouldSell ? "SELL" : "HOLD",
PositionSignals = positionSignals,
Reason = string.Join("; ", reasons),
GeneratedAt = DateTime.UtcNow
};
}
}