using AssetManager.Data; using AssetManager.Models.DTOs; using AssetManager.Infrastructure.Services; using SqlSugar; namespace AssetManager.Services; public class PortfolioService : IPortfolioService { private readonly ISqlSugarClient _db; private readonly IMarketDataService _marketDataService; private readonly IExchangeRateService _exchangeRateService; private readonly ILogger _logger; public PortfolioService( ISqlSugarClient db, IMarketDataService marketDataService, IExchangeRateService exchangeRateService, ILogger logger) { _db = db; _marketDataService = marketDataService; _exchangeRateService = exchangeRateService; _logger = logger; } public CreatePortfolioResponse CreatePortfolio(CreatePortfolioRequest request, string userId) { var portfolio = new Portfolio { Id = "port-" + Guid.NewGuid().ToString().Substring(0, 8), UserId = userId, StrategyId = request.strategyId, Name = request.name, Currency = request.currency, TotalValue = (decimal)(request.stocks?.Sum(s => s.price * s.amount) ?? 0), ReturnRate = 0, Status = "运行中", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; _db.Insertable(portfolio).ExecuteCommand(); // 如果选择了策略,自动加载策略配置的标的作为初始持仓 var strategyStocks = new List(); if (!string.IsNullOrEmpty(request.strategyId)) { var strategy = _db.Queryable() .Where(s => s.Id == request.strategyId && s.UserId == userId) .First(); if (strategy != null && !string.IsNullOrEmpty(strategy.Config)) { try { // 风险平价策略 if (strategy.Type?.Equals("risk_parity", StringComparison.OrdinalIgnoreCase) == true) { // 处理可能的双层转义 string configJson = strategy.Config; if (configJson.StartsWith("\"") && configJson.EndsWith("\"")) { // 去掉外层的引号和转义 configJson = System.Text.Json.JsonSerializer.Deserialize(configJson); } var config = System.Text.Json.JsonSerializer.Deserialize(configJson); if (config?.Assets != null) { foreach (var asset in config.Assets) { if (!string.IsNullOrEmpty(asset.Symbol)) { strategyStocks.Add(new StockItem { code = asset.Symbol, name = asset.Symbol, price = 0, // 价格留空,用户后续填写 amount = 0, // 数量留空,用户后续填写 currency = request.currency, assetType = "Stock" }); } } } } // 其他策略类型可以在这里扩展 } catch (Exception ex) { _logger.LogError(ex, "解析策略配置失败,策略ID: {StrategyId}", request.strategyId); } } } // 合并用户传入的持仓和策略自动生成的持仓 var allStocks = (request.stocks ?? new List()).Concat(strategyStocks).DistinctBy(s => s.code).ToList(); // 创建初始持仓 foreach (var stock in allStocks) { if (stock.code == null || stock.name == null) { continue; } // 解析实际买入时间,如果解析失败则用当前时间 DateTime buyTime = DateTime.Now; if (!string.IsNullOrEmpty(stock.date)) { if (DateTime.TryParse(stock.date, out var parsedDate)) { buyTime = parsedDate; } } var position = new Position { Id = "pos-" + Guid.NewGuid().ToString().Substring(0, 8), PortfolioId = portfolio.Id, StockCode = stock.code, StockName = stock.name, AssetType = string.IsNullOrEmpty(stock.assetType) ? "Stock" : stock.assetType, Shares = (decimal)stock.amount, AvgPrice = (decimal)stock.price, Currency = request.currency, CreatedAt = buyTime, UpdatedAt = DateTime.Now }; _db.Insertable(position).ExecuteCommand(); // 创建交易记录 var transaction = new Transaction { Id = "trans-" + Guid.NewGuid().ToString().Substring(0, 8), PortfolioId = portfolio.Id, Type = "buy", StockCode = stock.code, AssetType = string.IsNullOrEmpty(stock.assetType) ? "Stock" : stock.assetType, Title = "初始建仓", Amount = (decimal)stock.amount, Price = (decimal)stock.price, TotalAmount = (decimal)(stock.price * stock.amount), Currency = request.currency, Status = "completed", Remark = "初始建仓", TransactionTime = buyTime, CreatedAt = DateTime.Now }; _db.Insertable(transaction).ExecuteCommand(); } return new CreatePortfolioResponse { id = portfolio.Id, totalValue = (double)portfolio.TotalValue, returnRate = 0, currency = portfolio.Currency, createdAt = portfolio.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") }; } public List GetPortfolios(string userId) { var portfolios = _db.Queryable() .Where(p => p.UserId == userId) .ToList(); return portfolios.Select(p => new PortfolioListItem { id = p.Id, name = p.Name, tags = $"{p.Status} · {p.Currency}", status = p.Status, statusType = p.Status == "运行中" ? "green" : "gray", iconChar = p.Name?.Substring(0, 1).ToUpper() ?? "P", iconBgClass = "bg-blue-100", iconTextClass = "text-blue-700", value = (double)p.TotalValue, currency = p.Currency, returnRate = (double)p.ReturnRate, returnType = p.ReturnRate >= 0 ? "positive" : "negative" }).ToList(); } public async Task GetTotalAssetsAsync(string userId) { // 获取用户信息(包含默认本位币) var user = _db.Queryable() .Where(u => u.Id == userId) .First(); if (user == null) { throw new Exception("User not found"); } string targetCurrency = user.DefaultCurrency ?? "CNY"; decimal totalValueInTargetCurrency = 0; decimal totalCostInTargetCurrency = 0; decimal totalTodayProfitInTargetCurrency = 0; // 获取用户所有投资组合 var portfolios = _db.Queryable() .Where(p => p.UserId == userId) .ToList(); foreach (var portfolio in portfolios) { // 获取该组合的所有持仓 var positions = _db.Queryable() .Where(pos => pos.PortfolioId == portfolio.Id) .ToList(); foreach (var pos in positions) { if (pos.StockCode == null || pos.Currency == null) { continue; } // 获取实时价格(自动路由数据源) var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock"); decimal currentPrice = priceResponse.Price; decimal previousClose = priceResponse.PreviousClose; decimal currentPositionValue = pos.Shares * currentPrice; decimal costPositionValue = pos.Shares * pos.AvgPrice; decimal todayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0; // 换算到目标本位币 decimal currentInTarget = await _exchangeRateService.ConvertAmountAsync(currentPositionValue, pos.Currency, targetCurrency); decimal costInTarget = await _exchangeRateService.ConvertAmountAsync(costPositionValue, pos.Currency, targetCurrency); decimal todayProfitInTarget = await _exchangeRateService.ConvertAmountAsync(todayProfit, pos.Currency, targetCurrency); totalValueInTargetCurrency += currentInTarget; totalCostInTargetCurrency += costInTarget; totalTodayProfitInTargetCurrency += todayProfitInTarget; } } double totalReturnRate = totalCostInTargetCurrency > 0 ? (double)((totalValueInTargetCurrency - totalCostInTargetCurrency) / totalCostInTargetCurrency * 100) : 0; return new TotalAssetsResponse { totalValue = (double)totalValueInTargetCurrency, currency = targetCurrency, todayProfit = (double)totalTodayProfitInTargetCurrency, todayProfitCurrency = targetCurrency, totalReturnRate = totalReturnRate }; } // 保留同步方法作为兼容 public TotalAssetsResponse GetTotalAssets(string userId) { return GetTotalAssetsAsync(userId).GetAwaiter().GetResult(); } public async Task GetPortfolioByIdAsync(string id, string userId) { var portfolio = _db.Queryable() .Where(p => p.Id == id && p.UserId == userId) .First(); if (portfolio == null) { throw new Exception("Portfolio not found or access denied"); } var positions = _db.Queryable() .Where(pos => pos.PortfolioId == id) .ToList(); // 获取每个持仓的实时价格并转换为组合本位币 string targetCurrency = portfolio.Currency ?? "CNY"; decimal totalPortfolioValue = 0; decimal totalCost = 0; decimal totalTodayProfit = 0; var positionItems = new List(); foreach (var pos in positions) { if (pos.StockCode == null || pos.Currency == null) { continue; } // 获取实时价格(自动路由数据源) var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock"); decimal currentPrice = priceResponse.Price; decimal previousClose = priceResponse.PreviousClose; decimal positionValue = pos.Shares * currentPrice; decimal cost = pos.Shares * pos.AvgPrice; decimal profit = positionValue - cost; double profitRate = cost > 0 ? (double)(profit / cost * 100) : 0; decimal todayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0; // 转换为组合本位币 decimal positionValueInTarget = await _exchangeRateService.ConvertAmountAsync(positionValue, pos.Currency, targetCurrency); decimal costInTarget = await _exchangeRateService.ConvertAmountAsync(cost, pos.Currency, targetCurrency); decimal todayProfitInTarget = await _exchangeRateService.ConvertAmountAsync(todayProfit, pos.Currency, targetCurrency); totalPortfolioValue += positionValueInTarget; totalCost += costInTarget; totalTodayProfit += todayProfitInTarget; positionItems.Add(new PositionItem { id = pos.Id, stockCode = pos.StockCode, stockName = pos.StockName, symbol = pos.StockCode, amount = (int)pos.Shares, averagePrice = (double)pos.AvgPrice, currentPrice = (double)currentPrice, totalValue = (double)positionValueInTarget, profit = (double)(positionValueInTarget - costInTarget), profitRate = profitRate, changeAmount = (double)todayProfitInTarget, ratio = 0, // 后面统一计算比例 deviationRatio = 0, // 后续实现 currency = targetCurrency }); } // 计算每个持仓的比例 foreach (var item in positionItems) { item.ratio = totalPortfolioValue > 0 ? (item.totalValue / (double)totalPortfolioValue) * 100 : 0; } decimal totalReturn = totalPortfolioValue - totalCost; double totalReturnRate = totalCost > 0 ? (double)(totalReturn / totalCost * 100) : 0; return new PortfolioDetailResponse { id = portfolio.Id, name = portfolio.Name, currency = targetCurrency, status = portfolio.Status, strategy = new StrategyInfo { id = portfolio.StrategyId, name = "策略名称", description = "策略描述" }, portfolioValue = (double)totalPortfolioValue, totalReturn = (double)totalReturn, todayProfit = (double)totalTodayProfit, historicalChange = totalReturnRate, dailyVolatility = 0, // 后续实现 todayProfitCurrency = targetCurrency, logicModel = "HFEA 风险平价逻辑", logicModelStatus = "监控中", logicModelDescription = "目标权重 季度调仓", totalItems = positions.Count, totalRatio = 100.0, positions = positionItems }; } // 保留同步方法作为兼容(内部调用异步) public PortfolioDetailResponse GetPortfolioById(string id, string userId) { return GetPortfolioByIdAsync(id, userId).GetAwaiter().GetResult(); } public GetTransactionsResponse GetTransactions(string portfolioId, string userId, int limit, int offset) { // 验证投资组合是否属于该用户 var portfolio = _db.Queryable() .Where(p => p.Id == portfolioId && p.UserId == userId) .First(); if (portfolio == null) { throw new Exception("Portfolio not found or access denied"); } var transactions = _db.Queryable() .Where(t => t.PortfolioId == portfolioId) .OrderByDescending(t => t.TransactionTime) .Skip(offset) .Take(limit) .ToList(); var total = _db.Queryable() .Where(t => t.PortfolioId == portfolioId) .Count(); return new GetTransactionsResponse { items = transactions.Select(t => new TransactionItem { id = t.Id, portfolioId = t.PortfolioId, date = t.TransactionTime.ToString("yyyy-MM-dd"), time = t.TransactionTime.ToString("HH:mm:ss"), type = t.Type, title = t.Title, amount = (double)t.TotalAmount, currency = t.Currency, status = t.Status, remark = t.Remark }).ToList(), total = total, page = offset / limit + 1, pageSize = limit }; } public CreateTransactionResponse CreateTransaction(CreateTransactionRequest request, string userId) { // 验证投资组合是否属于该用户 var portfolio = _db.Queryable() .Where(p => p.Id == request.portfolioId && p.UserId == userId) .First(); if (portfolio == null) { throw new Exception("Portfolio not found or access denied"); } // 校验交易币种必须和组合本位币一致(双重校验) if (!string.IsNullOrEmpty(request.currency) && !request.currency.Equals(portfolio.Currency, StringComparison.OrdinalIgnoreCase)) { throw new Exception($"该组合本位币为 {portfolio.Currency},只能添加相同币种的标的"); } // 卖出操作校验 if (request.type?.ToLower() == "sell") { // 校验是否有该持仓 var existingPosition = _db.Queryable() .Where(pos => pos.PortfolioId == request.portfolioId && pos.StockCode == request.stockCode) .First(); if (existingPosition == null) { throw new Exception($"该组合中不存在标的 {request.stockCode},无法卖出"); } // 校验卖出数量不超过持仓数量 if (request.amount > (double)existingPosition.Shares) { throw new Exception($"卖出数量不能超过持仓数量,当前持仓 {existingPosition.Shares} 份"); } } // 解析实际交易时间,如果解析失败则用当前时间 DateTime transactionTime = DateTime.Now; if (!string.IsNullOrEmpty(request.transactionDate)) { if (DateTime.TryParse(request.transactionDate, out var parsedDate)) { // 如果只传了日期,时间部分默认用当前时间 transactionTime = parsedDate.Date + DateTime.Now.TimeOfDay; } } else if (!string.IsNullOrEmpty(request.transactionTime)) { if (DateTime.TryParse(request.transactionTime, out var parsedTime)) { transactionTime = parsedTime; } } var transaction = new Transaction { Id = "trans-" + Guid.NewGuid().ToString().Substring(0, 8), PortfolioId = request.portfolioId, Type = request.type, StockCode = request.stockCode, AssetType = string.IsNullOrEmpty(request.assetType) ? "Stock" : request.assetType, Title = request.remark ?? "交易", Amount = (decimal)request.amount, Price = (decimal)request.price, TotalAmount = (decimal)(request.price * request.amount), Currency = request.currency, Status = "completed", Remark = request.remark ?? string.Empty, TransactionTime = transactionTime, CreatedAt = DateTime.Now }; _db.Insertable(transaction).ExecuteCommand(); // 更新持仓 var position = _db.Queryable() .Where(pos => pos.PortfolioId == request.portfolioId && pos.StockCode == request.stockCode) .First(); if (position != null) { if (request.type == "buy") { // 计算新的平均价格 var newTotalShares = position.Shares + (decimal)request.amount; var newTotalCost = (position.Shares * position.AvgPrice) + ((decimal)request.amount * (decimal)request.price); position.AvgPrice = newTotalCost / newTotalShares; position.Shares = newTotalShares; } else if (request.type == "sell") { position.Shares -= (decimal)request.amount; if (position.Shares <= 0) { _db.Deleteable(position).ExecuteCommand(); } else { _db.Updateable(position).ExecuteCommand(); } } } else if (request.type == "buy") { // 创建新持仓 position = new Position { Id = "pos-" + Guid.NewGuid().ToString().Substring(0, 8), PortfolioId = request.portfolioId, StockCode = request.stockCode, StockName = request.remark ?? request.stockCode, AssetType = string.IsNullOrEmpty(request.assetType) ? "Stock" : request.assetType, Shares = (decimal)request.amount, AvgPrice = (decimal)request.price, Currency = request.currency, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; _db.Insertable(position).ExecuteCommand(); } // 更新投资组合总价值(使用实时市值而不是成本价) var positions = _db.Queryable() .Where(pos => pos.PortfolioId == request.portfolioId) .ToList(); decimal totalPortfolioValue = 0; foreach (var pos in positions) { if (pos.StockCode == null) { continue; } // 获取实时价格(自动路由数据源) var priceResponse = _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock").GetAwaiter().GetResult(); decimal currentPrice = priceResponse.Price; totalPortfolioValue += pos.Shares * currentPrice; } portfolio.TotalValue = totalPortfolioValue; portfolio.UpdatedAt = DateTime.Now; _db.Updateable(portfolio).ExecuteCommand(); return new CreateTransactionResponse { id = transaction.Id, totalAmount = (double)transaction.TotalAmount, status = transaction.Status, createdAt = transaction.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss") }; } }