- 修改ApiResponse、RiskParityConfig等DTO类的属性为可空类型 - 在策略计算器中添加空值检查逻辑 - 更新服务层代码处理可能的空值情况 - 添加发布配置文件FolderProfile.pubxml
257 lines
8.7 KiB
C#
257 lines
8.7 KiB
C#
using System.Text.Json;
|
||
using AssetManager.Data;
|
||
using AssetManager.Infrastructure.Services;
|
||
using AssetManager.Models.DTOs;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
||
|
||
/// <summary>
|
||
/// 风险平价策略计算器
|
||
/// </summary>
|
||
public class RiskParityCalculator : IStrategyCalculator
|
||
{
|
||
private readonly IMarketDataService _marketDataService;
|
||
private readonly ILogger<RiskParityCalculator> _logger;
|
||
|
||
public string StrategyType => AssetManager.Models.DTOs.StrategyType.RiskParity;
|
||
|
||
public RiskParityCalculator(IMarketDataService marketDataService, ILogger<RiskParityCalculator> logger)
|
||
{
|
||
_marketDataService = marketDataService;
|
||
_logger = logger;
|
||
}
|
||
|
||
public async Task<StrategySignal> CalculateAsync(
|
||
string configJson,
|
||
List<Position> positions,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
var config = JsonSerializer.Deserialize<RiskParityConfig>(configJson, new JsonSerializerOptions
|
||
{
|
||
PropertyNameCaseInsensitive = true
|
||
}) ?? new RiskParityConfig();
|
||
|
||
// 确定目标资产列表
|
||
var targetAssets = new List<string>();
|
||
var userDefinedWeights = new Dictionary<string, decimal>();
|
||
|
||
if (config.Assets?.Count >0)
|
||
{
|
||
targetAssets = config.Assets.Where(a => a.Symbol != null).Select(a => a.Symbol!).ToList();
|
||
userDefinedWeights = config.Assets.Where(a => a.Symbol != null).ToDictionary(a => a.Symbol!, a => a.TargetWeight);
|
||
}
|
||
else
|
||
{
|
||
targetAssets = positions.Where(p => p.StockCode != null).Select(p => p.StockCode!).ToList();
|
||
}
|
||
|
||
if (targetAssets.Count == 0)
|
||
{
|
||
return new StrategySignal
|
||
{
|
||
StrategyType = StrategyType,
|
||
Signal = "HOLD",
|
||
Reason = "无目标资产",
|
||
GeneratedAt = DateTime.UtcNow
|
||
};
|
||
}
|
||
|
||
// 计算资产的波动率和目标权重
|
||
var assetVolatilities = new Dictionary<string, decimal>();
|
||
var positionSignals = new List<PositionSignal>();
|
||
|
||
foreach (var symbol in targetAssets)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
try
|
||
{
|
||
var volatility = await CalculateVolatilityAsync(symbol, config.LookbackPeriod, cancellationToken);
|
||
assetVolatilities[symbol] = volatility;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "计算 {Symbol} 的波动率失败", symbol);
|
||
assetVolatilities[symbol] = 0.2m; // 默认波动率20%
|
||
}
|
||
}
|
||
|
||
// 计算风险平价的目标权重,若无则等权初始化
|
||
var targetWeights = CalculateRiskParityWeights(assetVolatilities);
|
||
|
||
// 如果用户定义了权重,则使用用户定义的权重
|
||
foreach (var kvp in userDefinedWeights)
|
||
{
|
||
targetWeights[kvp.Key] = kvp.Value;
|
||
}
|
||
|
||
// 计算当前权重(使用实时市值)
|
||
var currentWeights = await CalculateCurrentWeightsAsync(positions, targetAssets, cancellationToken);
|
||
|
||
// 根据偏差决定是否再平衡
|
||
var maxDeviation = 0m;
|
||
var needRebalance = false;
|
||
var reasons = new List<string>();
|
||
|
||
foreach (var symbol in targetAssets)
|
||
{
|
||
var targetWeight = targetWeights.GetValueOrDefault(symbol, 0m);
|
||
var currentWeight = currentWeights.GetValueOrDefault(symbol, 0m);
|
||
var deviation = Math.Abs(targetWeight - currentWeight);
|
||
|
||
if (deviation > config.RebalanceThreshold)
|
||
{
|
||
needRebalance = true;
|
||
}
|
||
|
||
maxDeviation = Math.Max(maxDeviation, deviation);
|
||
|
||
var positionSignal = new PositionSignal
|
||
{
|
||
Symbol = symbol,
|
||
TargetWeight = targetWeight,
|
||
Signal = deviation > config.RebalanceThreshold
|
||
? (targetWeight > currentWeight ? "BUY" : "SELL")
|
||
: "HOLD",
|
||
Reason = $"目标权重: {targetWeight:P2}, 当前权重: {currentWeight:P2}, 偏差: {deviation:P2}"
|
||
};
|
||
|
||
positionSignals.Add(positionSignal);
|
||
|
||
if (deviation > config.RebalanceThreshold)
|
||
{
|
||
reasons.Add($"{symbol}: 调整 {(targetWeight > currentWeight ? "+" : "")}{(targetWeight - currentWeight):P2}");
|
||
}
|
||
}
|
||
|
||
return new StrategySignal
|
||
{
|
||
StrategyType = StrategyType,
|
||
Signal = needRebalance ? "REBALANCE" : "HOLD",
|
||
PositionSignals = positionSignals,
|
||
Reason = needRebalance
|
||
? $"最大偏差 {maxDeviation:P2} 超过阈值 {config.RebalanceThreshold:P2}。{string.Join("; ", reasons)}"
|
||
: $"资产偏差在阈值范围内。(最大偏差: {maxDeviation:P2})",
|
||
GeneratedAt = DateTime.UtcNow
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算资产波动率(年化标准差)
|
||
/// </summary>
|
||
private async Task<decimal> CalculateVolatilityAsync(
|
||
string symbol,
|
||
int lookbackPeriod,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var historicalData = await _marketDataService.GetStockHistoricalDataAsync(
|
||
symbol, "1d", lookbackPeriod + 1);
|
||
|
||
if (historicalData.Count < 2)
|
||
{
|
||
throw new InvalidOperationException($"历史数据不足以计算波动率");
|
||
}
|
||
|
||
// 按时间排序并计算日收益率
|
||
var sortedData = historicalData.OrderBy(d => d.Timestamp).ToList();
|
||
var returns = new List<decimal>();
|
||
|
||
for (int i = 1; i < sortedData.Count; i++)
|
||
{
|
||
var dailyReturn = (sortedData[i].Close - sortedData[i - 1].Close) / sortedData[i - 1].Close;
|
||
returns.Add(dailyReturn);
|
||
}
|
||
|
||
// 计算标准差
|
||
var avgReturn = returns.Average();
|
||
var variance = returns.Sum(r => (r - avgReturn) * (r - avgReturn)) / returns.Count;
|
||
var dailyStdDev = (decimal)Math.Sqrt((double)variance);
|
||
|
||
// 年化波动率,假设252个交易日
|
||
return dailyStdDev * (decimal)Math.Sqrt(252);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算风险平价权重,若无则等权初始化
|
||
/// </summary>
|
||
private Dictionary<string, decimal> CalculateRiskParityWeights(Dictionary<string, decimal> volatilities)
|
||
{
|
||
var weights = new Dictionary<string, decimal>();
|
||
|
||
// 计算倒数波动率之和
|
||
var inverseVolSum = volatilities.Values.Where(v => v > 0).Sum(v => 1m / v);
|
||
|
||
if (inverseVolSum == 0)
|
||
{
|
||
// 如果无法计算,采用等权分配
|
||
var equalWeight = 1m / volatilities.Count;
|
||
foreach (var symbol in volatilities.Keys)
|
||
{
|
||
weights[symbol] = equalWeight;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var kvp in volatilities)
|
||
{
|
||
weights[kvp.Key] = kvp.Value > 0 ? (1m / kvp.Value) / inverseVolSum : 0m;
|
||
}
|
||
}
|
||
|
||
return weights;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算当前持仓权重(使用实时市值)
|
||
/// </summary>
|
||
private async Task<Dictionary<string, decimal>> CalculateCurrentWeightsAsync(
|
||
List<Position> positions,
|
||
List<string> targetAssets,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var weights = new Dictionary<string, decimal>();
|
||
var marketValues = new Dictionary<string, decimal>();
|
||
decimal totalValue = 0;
|
||
|
||
// 获取每个持仓的实时价格并计算市值
|
||
foreach (var position in positions)
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
if (position.StockCode == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
MarketPriceResponse priceResponse;
|
||
if (position.AssetType?.Equals("Crypto", StringComparison.OrdinalIgnoreCase) == true)
|
||
{
|
||
priceResponse = await _marketDataService.GetCryptoPriceAsync(position.StockCode);
|
||
}
|
||
else
|
||
{
|
||
priceResponse = await _marketDataService.GetStockPriceAsync(position.StockCode);
|
||
}
|
||
|
||
decimal marketValue = position.Shares * priceResponse.Price;
|
||
marketValues[position.StockCode] = marketValue;
|
||
totalValue += marketValue;
|
||
}
|
||
|
||
// 计算权重
|
||
foreach (var symbol in targetAssets)
|
||
{
|
||
if (marketValues.TryGetValue(symbol, out var marketValue) && totalValue > 0)
|
||
{
|
||
weights[symbol] = marketValue / totalValue;
|
||
}
|
||
else
|
||
{
|
||
weights[symbol] = 0m;
|
||
}
|
||
}
|
||
|
||
return weights;
|
||
}
|
||
} |