feat(策略引擎): 实现技术指标库、Mock市场数据、吊灯止损/双均线策略、策略信号API
This commit is contained in:
parent
fe781db417
commit
f442f0cd1b
@ -1,3 +1,5 @@
|
|||||||
|
using AssetManager.Data;
|
||||||
|
using AssetManager.Infrastructure.StrategyEngine;
|
||||||
using AssetManager.Models.DTOs;
|
using AssetManager.Models.DTOs;
|
||||||
using AssetManager.Models;
|
using AssetManager.Models;
|
||||||
using AssetManager.Services;
|
using AssetManager.Services;
|
||||||
@ -15,11 +17,22 @@ public class PortfolioController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ILogger<PortfolioController> _logger;
|
private readonly ILogger<PortfolioController> _logger;
|
||||||
private readonly IPortfolioService _portfolioService;
|
private readonly IPortfolioService _portfolioService;
|
||||||
|
private readonly IStrategyEngine _strategyEngine;
|
||||||
|
private readonly IStrategyService _strategyService;
|
||||||
|
private readonly DatabaseService _databaseService;
|
||||||
|
|
||||||
public PortfolioController(ILogger<PortfolioController> logger, IPortfolioService portfolioService)
|
public PortfolioController(
|
||||||
|
ILogger<PortfolioController> logger,
|
||||||
|
IPortfolioService portfolioService,
|
||||||
|
IStrategyEngine strategyEngine,
|
||||||
|
IStrategyService strategyService,
|
||||||
|
DatabaseService databaseService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_portfolioService = portfolioService;
|
_portfolioService = portfolioService;
|
||||||
|
_strategyEngine = strategyEngine;
|
||||||
|
_strategyService = strategyService;
|
||||||
|
_databaseService = databaseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -279,4 +292,79 @@ public class PortfolioController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取投资组合策略信号
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">投资组合ID</param>
|
||||||
|
/// <returns>策略信号</returns>
|
||||||
|
[HttpGet("{id}/signal")]
|
||||||
|
public async Task<ActionResult<ApiResponse<StrategySignal>>> GetPortfolioSignal(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = GetCurrentUserId();
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
return Unauthorized(new ApiResponse<StrategySignal>
|
||||||
|
{
|
||||||
|
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<StrategySignal>
|
||||||
|
{
|
||||||
|
code = AssetManager.Models.StatusCodes.NotFound,
|
||||||
|
data = null,
|
||||||
|
message = "投资组合不存在"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取策略
|
||||||
|
var strategy = _strategyService.GetStrategyById(portfolio.StrategyId, userId);
|
||||||
|
if (strategy == null)
|
||||||
|
{
|
||||||
|
return NotFound(new ApiResponse<StrategySignal>
|
||||||
|
{
|
||||||
|
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<StrategySignal>
|
||||||
|
{
|
||||||
|
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<StrategySignal>
|
||||||
|
{
|
||||||
|
code = AssetManager.Models.StatusCodes.InternalServerError,
|
||||||
|
data = null,
|
||||||
|
message = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,7 +68,24 @@ builder.Services.AddScoped<AssetManager.Services.Services.WechatService>();
|
|||||||
builder.Services.AddScoped<AssetManager.Services.Services.JwtService>();
|
builder.Services.AddScoped<AssetManager.Services.Services.JwtService>();
|
||||||
builder.Services.AddScoped<AssetManager.Services.IPortfolioService, AssetManager.Services.PortfolioService>();
|
builder.Services.AddScoped<AssetManager.Services.IPortfolioService, AssetManager.Services.PortfolioService>();
|
||||||
builder.Services.AddScoped<AssetManager.Services.IStrategyService, AssetManager.Services.StrategyService>();
|
builder.Services.AddScoped<AssetManager.Services.IStrategyService, AssetManager.Services.StrategyService>();
|
||||||
builder.Services.AddScoped<AssetManager.Infrastructure.Services.IMarketDataService, AssetManager.Infrastructure.Services.MarketDataService>();
|
|
||||||
|
// 市场数据服务:开发环境用 Mock,生产环境用 Alpaca
|
||||||
|
if (builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.Services.IMarketDataService, AssetManager.Infrastructure.Services.MockMarketDataService>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.Services.IMarketDataService, AssetManager.Infrastructure.Services.MarketDataService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略引擎
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.StrategyEngine.IStrategyEngine, AssetManager.Infrastructure.StrategyEngine.StrategyEngine>();
|
||||||
|
|
||||||
|
// 策略计算器
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.StrategyEngine.IStrategyCalculator, AssetManager.Infrastructure.StrategyEngine.Calculators.ChandelierExitCalculator>();
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.StrategyEngine.IStrategyCalculator, AssetManager.Infrastructure.StrategyEngine.Calculators.MaTrendCalculator>();
|
||||||
|
builder.Services.AddScoped<AssetManager.Infrastructure.StrategyEngine.IStrategyCalculator, AssetManager.Infrastructure.StrategyEngine.Calculators.RiskParityCalculator>();
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
|
|||||||
@ -26,4 +26,23 @@ public class DatabaseService
|
|||||||
{
|
{
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据ID获取投资组合(校验用户ID)
|
||||||
|
/// </summary>
|
||||||
|
public Portfolio? GetPortfolioById(string id, string userId)
|
||||||
|
{
|
||||||
|
return _db.Queryable<Portfolio>()
|
||||||
|
.First(p => p.Id == id && p.UserId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据投资组合ID获取持仓列表
|
||||||
|
/// </summary>
|
||||||
|
public List<Position> GetPositionsByPortfolioId(string portfolioId)
|
||||||
|
{
|
||||||
|
return _db.Queryable<Position>()
|
||||||
|
.Where(p => p.PortfolioId == portfolioId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
AssetManager.Infrastructure/Services/MockMarketDataService.cs
Normal file
115
AssetManager.Infrastructure/Services/MockMarketDataService.cs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
using AssetManager.Models.DTOs;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace AssetManager.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mock 市场数据服务(用于开发测试)
|
||||||
|
/// </summary>
|
||||||
|
public class MockMarketDataService : IMarketDataService
|
||||||
|
{
|
||||||
|
private readonly ILogger<MockMarketDataService> _logger;
|
||||||
|
|
||||||
|
public MockMarketDataService(ILogger<MockMarketDataService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MarketPriceResponse> 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<MarketPriceResponse> 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<List<MarketDataResponse>> 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<List<MarketDataResponse>> GetCryptoHistoricalDataAsync(string symbol, string timeframe, int limit)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Mock 获取加密货币历史数据: {Symbol}, {Timeframe}, {Limit}", symbol, timeframe, limit);
|
||||||
|
return Task.FromResult(GenerateMockData(symbol, "Crypto", timeframe, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成 Mock K 线数据(模拟一个上升趋势 + 随机波动)
|
||||||
|
/// </summary>
|
||||||
|
private List<MarketDataResponse> GenerateMockData(string symbol, string assetType, string timeframe, int limit)
|
||||||
|
{
|
||||||
|
var data = new List<MarketDataResponse>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
using AssetManager.Data;
|
using AssetManager.Data;
|
||||||
|
using AssetManager.Infrastructure.Services;
|
||||||
using AssetManager.Models.DTOs;
|
using AssetManager.Models.DTOs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
||||||
|
|
||||||
@ -10,12 +12,16 @@ namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
|||||||
public class ChandelierExitCalculator : IStrategyCalculator
|
public class ChandelierExitCalculator : IStrategyCalculator
|
||||||
{
|
{
|
||||||
private readonly ILogger<ChandelierExitCalculator> _logger;
|
private readonly ILogger<ChandelierExitCalculator> _logger;
|
||||||
|
private readonly IMarketDataService _marketDataService;
|
||||||
|
|
||||||
public string StrategyType => AssetManager.Models.DTOs.StrategyType.ChandelierExit;
|
public string StrategyType => AssetManager.Models.DTOs.StrategyType.ChandelierExit;
|
||||||
|
|
||||||
public ChandelierExitCalculator(ILogger<ChandelierExitCalculator> logger)
|
public ChandelierExitCalculator(
|
||||||
|
ILogger<ChandelierExitCalculator> logger,
|
||||||
|
IMarketDataService marketDataService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_marketDataService = marketDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StrategySignal> CalculateAsync(
|
public async Task<StrategySignal> CalculateAsync(
|
||||||
@ -25,7 +31,7 @@ public class ChandelierExitCalculator : IStrategyCalculator
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("计算吊灯止损策略信号");
|
_logger.LogInformation("计算吊灯止损策略信号");
|
||||||
|
|
||||||
var config = System.Text.Json.JsonSerializer.Deserialize<ChandelierExitConfig>(configJson, new System.Text.Json.JsonSerializerOptions
|
var config = JsonSerializer.Deserialize<ChandelierExitConfig>(configJson, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
}) ?? new ChandelierExitConfig();
|
}) ?? new ChandelierExitConfig();
|
||||||
@ -41,14 +47,94 @@ public class ChandelierExitCalculator : IStrategyCalculator
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单实现:返回持有信号
|
var positionSignals = new List<PositionSignal>();
|
||||||
await Task.Delay(1, cancellationToken);
|
bool shouldSell = false;
|
||||||
|
var reasons = new List<string>();
|
||||||
|
|
||||||
|
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
|
return new StrategySignal
|
||||||
{
|
{
|
||||||
StrategyType = StrategyType,
|
StrategyType = StrategyType,
|
||||||
Signal = "HOLD",
|
Signal = shouldSell ? "SELL" : "HOLD",
|
||||||
Reason = "吊灯止损策略暂未实现",
|
PositionSignals = positionSignals,
|
||||||
|
Reason = string.Join("; ", reasons),
|
||||||
GeneratedAt = DateTime.UtcNow
|
GeneratedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
using AssetManager.Data;
|
using AssetManager.Data;
|
||||||
|
using AssetManager.Infrastructure.Services;
|
||||||
using AssetManager.Models.DTOs;
|
using AssetManager.Models.DTOs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
||||||
|
|
||||||
@ -10,12 +12,16 @@ namespace AssetManager.Infrastructure.StrategyEngine.Calculators;
|
|||||||
public class MaTrendCalculator : IStrategyCalculator
|
public class MaTrendCalculator : IStrategyCalculator
|
||||||
{
|
{
|
||||||
private readonly ILogger<MaTrendCalculator> _logger;
|
private readonly ILogger<MaTrendCalculator> _logger;
|
||||||
|
private readonly IMarketDataService _marketDataService;
|
||||||
|
|
||||||
public string StrategyType => AssetManager.Models.DTOs.StrategyType.MaTrend;
|
public string StrategyType => AssetManager.Models.DTOs.StrategyType.MaTrend;
|
||||||
|
|
||||||
public MaTrendCalculator(ILogger<MaTrendCalculator> logger)
|
public MaTrendCalculator(
|
||||||
|
ILogger<MaTrendCalculator> logger,
|
||||||
|
IMarketDataService marketDataService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_marketDataService = marketDataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StrategySignal> CalculateAsync(
|
public async Task<StrategySignal> CalculateAsync(
|
||||||
@ -25,7 +31,7 @@ public class MaTrendCalculator : IStrategyCalculator
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("计算均线趋势策略信号");
|
_logger.LogInformation("计算均线趋势策略信号");
|
||||||
|
|
||||||
var config = System.Text.Json.JsonSerializer.Deserialize<MaTrendConfig>(configJson, new System.Text.Json.JsonSerializerOptions
|
var config = JsonSerializer.Deserialize<MaTrendConfig>(configJson, new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true
|
PropertyNameCaseInsensitive = true
|
||||||
}) ?? new MaTrendConfig();
|
}) ?? new MaTrendConfig();
|
||||||
@ -41,14 +47,111 @@ public class MaTrendCalculator : IStrategyCalculator
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单实现:返回持有信号
|
var positionSignals = new List<PositionSignal>();
|
||||||
await Task.Delay(1, cancellationToken);
|
string overallSignal = "HOLD";
|
||||||
|
var reasons = new List<string>();
|
||||||
|
|
||||||
|
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<decimal?> 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
|
return new StrategySignal
|
||||||
{
|
{
|
||||||
StrategyType = StrategyType,
|
StrategyType = StrategyType,
|
||||||
Signal = "HOLD",
|
Signal = overallSignal,
|
||||||
Reason = "均线趋势策略暂未实现",
|
PositionSignals = positionSignals,
|
||||||
|
Reason = string.Join("; ", reasons),
|
||||||
GeneratedAt = DateTime.UtcNow
|
GeneratedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,232 @@
|
|||||||
|
namespace AssetManager.Infrastructure.StrategyEngine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 技术指标计算库
|
||||||
|
/// </summary>
|
||||||
|
public static class TechnicalIndicators
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 计算简单移动平均 (SMA)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">值列表</param>
|
||||||
|
/// <param name="period">周期</param>
|
||||||
|
/// <returns>SMA 列表(长度与输入一致,前 period-1 个为 null)</returns>
|
||||||
|
public static List<decimal?> CalculateSMA(List<decimal> values, int period)
|
||||||
|
{
|
||||||
|
var result = new List<decimal?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算指数移动平均 (EMA)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">值列表</param>
|
||||||
|
/// <param name="period">周期</param>
|
||||||
|
/// <returns>EMA 列表(长度与输入一致,前 period-1 个为 null)</returns>
|
||||||
|
public static List<decimal?> CalculateEMA(List<decimal> values, int period)
|
||||||
|
{
|
||||||
|
var result = new List<decimal?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算真实波幅 (TR)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="highs">最高价列表</param>
|
||||||
|
/// <param name="lows">最低价列表</param>
|
||||||
|
/// <param name="closes">收盘价列表</param>
|
||||||
|
/// <returns>TR 列表</returns>
|
||||||
|
public static List<decimal> CalculateTR(List<decimal> highs, List<decimal> lows, List<decimal> closes)
|
||||||
|
{
|
||||||
|
var trList = new List<decimal>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算平均真实波幅 (ATR)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="highs">最高价列表</param>
|
||||||
|
/// <param name="lows">最低价列表</param>
|
||||||
|
/// <param name="closes">收盘价列表</param>
|
||||||
|
/// <param name="period">周期(默认 14)</param>
|
||||||
|
/// <returns>ATR 列表(长度与输入一致,前 period-1 个为 null)</returns>
|
||||||
|
public static List<decimal?> CalculateATR(List<decimal> highs, List<decimal> lows, List<decimal> closes, int period = 14)
|
||||||
|
{
|
||||||
|
var result = new List<decimal?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算滚动窗口最高价
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">值列表</param>
|
||||||
|
/// <param name="period">周期</param>
|
||||||
|
/// <returns>滚动最高价列表(长度与输入一致,前 period-1 个为 null)</returns>
|
||||||
|
public static List<decimal?> CalculateHighestHigh(List<decimal> values, int period)
|
||||||
|
{
|
||||||
|
var result = new List<decimal?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算滚动窗口最低价
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="values">值列表</param>
|
||||||
|
/// <param name="period">周期</param>
|
||||||
|
/// <returns>滚动最低价列表(长度与输入一致,前 period-1 个为 null)</returns>
|
||||||
|
public static List<decimal?> CalculateLowestLow(List<decimal> values, int period)
|
||||||
|
{
|
||||||
|
var result = new List<decimal?>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user