AssetManager.API/AssetManager.Services/PortfolioService.cs
OpenClaw Agent 64c1fe60e7 fix: 修复多个金融计算问题
1. CreatePortfolioAsync: 初始建仓交易保存汇率信息
   - 设置 ExchangeRate 和 TotalAmountBase 字段
   - 支持跨币种初始建仓

2. ExchangeRateService: 增强 Mock 汇率降级
   - 扩展支持 EUR、GBP、JPY
   - 未知货币对记录 Error 级别日志

3. PositionItem: 增加 Shares 属性
   - 保留完整精度(解决 Amount int 截断问题)
2026-03-25 05:31:53 +00:00

991 lines
39 KiB
C#
Executable File
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.Models.DTOs;
using AssetManager.Infrastructure.Services;
using SqlSugar;
using Microsoft.Extensions.Logging;
namespace AssetManager.Services;
public class PortfolioService : IPortfolioService
{
private readonly ISqlSugarClient _db;
private readonly IMarketDataService _marketDataService;
private readonly IExchangeRateService _exchangeRateService;
private readonly IPortfolioNavService _navService;
private readonly ILogger<PortfolioService> _logger;
public PortfolioService(
ISqlSugarClient db,
IMarketDataService marketDataService,
IExchangeRateService exchangeRateService,
IPortfolioNavService navService,
ILogger<PortfolioService> logger)
{
_db = db;
_marketDataService = marketDataService;
_exchangeRateService = exchangeRateService;
_navService = navService;
_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<StockItem>();
if (!string.IsNullOrEmpty(request.StrategyId))
{
var Strategy = _db.Queryable<Strategy>()
.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<string>(configJson);
}
var config = System.Text.Json.JsonSerializer.Deserialize<RiskParityConfig>(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<StockItem>()).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,
TotalCost = (decimal)(stock.Price * stock.Amount), // 初始成本 = 价格 × 数量
Currency = request.Currency,
CreatedAt = buyTime,
UpdatedAt = DateTime.Now
};
_logger.LogInformation("创建持仓: {StockCode}, 数量={Shares}, 均价={AvgPrice}, 成本={TotalCost}",
stock.Code, stock.Amount, stock.Price, position.TotalCost);
_db.Insertable(position).ExecuteCommand();
// 创建交易记录(保存汇率信息)
decimal totalAmount = (decimal)(stock.Price * stock.Amount);
decimal? exchangeRate = null;
decimal? totalAmountBase = null;
// 如果持仓币种与组合币种不同,需要保存汇率
if (!string.IsNullOrEmpty(stock.Currency) && !stock.Currency.Equals(request.Currency, StringComparison.OrdinalIgnoreCase))
{
exchangeRate = await _exchangeRateService.GetExchangeRateAsync(stock.Currency, request.Currency);
totalAmountBase = totalAmount * exchangeRate.Value;
}
else
{
exchangeRate = 1.0m;
totalAmountBase = totalAmount;
}
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 = totalAmount,
Currency = stock.Currency ?? request.Currency,
ExchangeRate = exchangeRate,
TotalAmountBase = totalAmountBase,
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 async Task<List<PortfolioListItem>> GetPortfolioListAsync(string userId)
{
var portfolios = _db.Queryable<Portfolio>()
.Where(p => p.UserId == userId)
.ToList();
if (!portfolios.Any())
{
return new List<PortfolioListItem>();
}
// ===== 第一步:收集所有需要查询价格的股票代码 =====
var portfolioIds = portfolios.Select(p => p.Id).ToList();
var allPositions = _db.Queryable<Position>()
.Where(pos => portfolioIds.Contains(pos.PortfolioId))
.ToList();
// 去重获取所有股票代码
var stockCodes = allPositions
.Where(p => p.StockCode != null)
.Select(p => p.StockCode!)
.Distinct()
.ToList();
// ===== 第二步:批量并行获取价格(利用缓存) =====
var priceDict = new Dictionary<string, MarketPriceResponse>();
var priceTasks = stockCodes.Select(async code =>
{
try
{
var pos = allPositions.First(p => p.StockCode == code);
var assetType = pos.AssetType ?? "Stock";
_logger.LogDebug("批量获取价格: {Code}, AssetType={AssetType}", code, assetType);
var price = await _marketDataService.GetPriceAsync(code, assetType);
return (code, price);
}
catch (Exception ex)
{
_logger.LogError(ex, "批量获取价格失败: {Code}, 错误详情: {Message}", code, ex.Message);
return (code, null);
}
}).ToList();
var priceResults = await Task.WhenAll(priceTasks);
foreach (var (code, price) in priceResults)
{
if (price != null)
{
priceDict[code] = price;
_logger.LogInformation("批量获取价格成功: {Code} -> Price={Price}, PreviousClose={PreviousClose}", code, price.Price, price.PreviousClose);
}
else
{
_logger.LogWarning("批量获取价格返回 null: {Code}", code);
}
}
// ===== 第三步:计算每个组合的数据 =====
var result = new List<PortfolioListItem>();
foreach (var p in portfolios)
{
var positions = allPositions.Where(pos => pos.PortfolioId == p.Id).ToList();
int positionCount = positions.Count;
decimal totalValue = 0;
decimal totalCost = 0;
decimal todayProfit = 0;
string portfolioCurrency = p.Currency ?? "CNY";
foreach (var pos in positions)
{
if (pos.StockCode == null || pos.Currency == null)
{
continue;
}
// 从预获取的价格字典中获取
decimal currentPrice = pos.AvgPrice;
decimal previousClose = pos.AvgPrice;
if (priceDict.TryGetValue(pos.StockCode, out var priceResponse))
{
if (priceResponse.Price > 0)
{
currentPrice = priceResponse.Price;
previousClose = priceResponse.PreviousClose > 0 ? priceResponse.PreviousClose : currentPrice;
_logger.LogInformation("组合 {PortfolioId} 持仓 {StockCode} 使用实时价格: {Price}", p.Id, pos.StockCode, currentPrice);
}
else
{
_logger.LogWarning("组合 {PortfolioId} 持仓 {StockCode} 价格为 0使用成本价", p.Id, pos.StockCode);
}
}
else
{
_logger.LogWarning("组合 {PortfolioId} 持仓 {StockCode} 未在价格字典中,使用成本价", p.Id, pos.StockCode);
}
decimal positionValue = pos.Shares * currentPrice;
decimal positionCost = pos.TotalCost; // 使用 TotalCost 字段
decimal positionTodayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0;
// 汇率转换(汇率服务有缓存,速度很快)
var valueInTarget = await _exchangeRateService.ConvertAmountAsync(positionValue, pos.Currency, portfolioCurrency);
var costInTarget = await _exchangeRateService.ConvertAmountAsync(positionCost, pos.Currency, portfolioCurrency);
var todayProfitInTarget = await _exchangeRateService.ConvertAmountAsync(positionTodayProfit, pos.Currency, portfolioCurrency);
totalValue += valueInTarget;
totalCost += costInTarget;
todayProfit += todayProfitInTarget;
}
double returnRate = totalCost > 0 ? (double)((totalValue - totalCost) / totalCost * 100) : 0;
returnRate = Math.Round(returnRate, 2);
result.Add(new PortfolioListItem
{
Id = p.Id,
Name = p.Name,
Tags = $"{p.Status} · {p.Currency}" + (positionCount > 0 ? $" · {positionCount}只" : ""),
Status = p.Status,
StatusType = p.Status == "运行中" ? "green" : p.Status == "已暂停" ? "yellow" : "gray",
IconChar = p.Name?.Substring(0, 1).ToUpper() ?? "P",
IconBgClass = "bg-blue-100",
IconTextClass = "text-blue-700",
Value = (double)totalValue,
Currency = p.Currency,
ReturnRate = returnRate,
ReturnType = returnRate >= 0 ? "positive" : "negative",
TodayProfit = (double)todayProfit,
TodayProfitCurrency = p.Currency
});
}
return result;
}
public List<PortfolioListItem> GetPortfolios(string userId)
{
return GetPortfolioListAsync(userId).GetAwaiter().GetResult();
}
public async Task<TotalAssetsResponse> GetTotalAssetsAsync(string userId)
{
// 获取用户信息
var user = _db.Queryable<User>()
.Where(u => u.Id == userId)
.First();
if (user == null)
{
throw new Exception("User not found");
}
string targetCurrency = !string.IsNullOrEmpty(user.DefaultCurrency) ? user.DefaultCurrency : "CNY";
_logger.LogInformation("用户 {UserId} 默认本位币: {Currency}", userId, targetCurrency);
// 获取所有组合和持仓
var portfolios = _db.Queryable<Portfolio>()
.Where(p => p.UserId == userId)
.ToList();
if (!portfolios.Any())
{
return new TotalAssetsResponse
{
TotalValue = 0,
Currency = targetCurrency,
TodayProfit = 0,
TodayProfitCurrency = targetCurrency,
TotalReturnRate = 0
};
}
var portfolioIds = portfolios.Select(p => p.Id).ToList();
var allPositions = _db.Queryable<Position>()
.Where(pos => portfolioIds.Contains(pos.PortfolioId))
.ToList();
if (!allPositions.Any())
{
return new TotalAssetsResponse
{
TotalValue = 0,
Currency = targetCurrency,
TodayProfit = 0,
TodayProfitCurrency = targetCurrency,
TotalReturnRate = 0
};
}
// ===== 批量并行获取价格 =====
var stockCodes = allPositions
.Where(p => p.StockCode != null)
.Select(p => p.StockCode!)
.Distinct()
.ToList();
var priceDict = new Dictionary<string, MarketPriceResponse>();
var priceTasks = stockCodes.Select(async code =>
{
try
{
var pos = allPositions.First(p => p.StockCode == code);
var price = await _marketDataService.GetPriceAsync(code, pos.AssetType ?? "Stock");
return (code, price);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "批量获取价格失败: {Code}", code);
return (code, null);
}
}).ToList();
var priceResults = await Task.WhenAll(priceTasks);
foreach (var (code, price) in priceResults)
{
if (price != null)
{
priceDict[code] = price;
}
}
// ===== 计算总资产 =====
decimal totalValueInTargetCurrency = 0;
decimal totalCostInTargetCurrency = 0;
decimal totalTodayProfitInTargetCurrency = 0;
foreach (var pos in allPositions)
{
if (pos.StockCode == null || pos.Currency == null)
{
continue;
}
decimal currentPrice = pos.AvgPrice;
decimal previousClose = pos.AvgPrice;
if (priceDict.TryGetValue(pos.StockCode, out var priceResponse))
{
if (priceResponse.Price > 0)
{
currentPrice = priceResponse.Price;
previousClose = priceResponse.PreviousClose > 0 ? priceResponse.PreviousClose : currentPrice;
}
}
decimal positionValue = pos.Shares * currentPrice;
decimal costValue = pos.TotalCost; // 使用 TotalCost 字段
decimal todayProfit = previousClose > 0 ? pos.Shares * (currentPrice - previousClose) : 0;
// 汇率转换
var currentInTarget = await _exchangeRateService.ConvertAmountAsync(positionValue, pos.Currency, targetCurrency);
var costInTarget = await _exchangeRateService.ConvertAmountAsync(costValue, pos.Currency, targetCurrency);
var 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 = Math.Round(totalReturnRate, 2)
};
}
// 保留同步方法作为兼容
public TotalAssetsResponse GetTotalAssets(string userId)
{
return GetTotalAssetsAsync(userId).GetAwaiter().GetResult();
}
public async Task<PortfolioDetailResponse> GetPortfolioByIdAsync(string id, string userId)
{
var portfolio = _db.Queryable<Portfolio>()
.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<Position>()
.Where(pos => pos.PortfolioId == id)
.ToList();
// 获取每个持仓的实时价格并转换为组合本位币
string targetCurrency = portfolio.Currency ?? "CNY";
decimal totalPortfolioValue = 0;
decimal totalCost = 0;
decimal totalTodayProfit = 0;
var positionItems = new List<PositionItem>();
foreach (var pos in Positions)
{
if (pos.StockCode == null || pos.Currency == null)
{
continue;
}
// 获取实时价格(自动路由数据源),失败则降级使用成本价
decimal CurrentPrice = pos.AvgPrice;
decimal previousClose = pos.AvgPrice;
try
{
var priceResponse = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock");
if (priceResponse.Price > 0)
{
CurrentPrice = priceResponse.Price;
previousClose = priceResponse.PreviousClose > 0 ? priceResponse.PreviousClose : CurrentPrice;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取标的 {StockCode} 实时价格失败,使用成本价作为当前价", pos.StockCode);
}
decimal positionValue = pos.Shares * CurrentPrice;
decimal cost = pos.TotalCost; // 使用 TotalCost 字段,精确追踪卖出后的剩余成本
decimal TodayProfit = previousClose > 0 ? pos.Shares * (CurrentPrice - previousClose) : 0;
_logger.LogDebug("持仓 {StockCode}: 数量={Shares}, 当前价={CurrentPrice}, 市值={Value}, 成本={Cost}",
pos.StockCode, pos.Shares, CurrentPrice, positionValue, cost);
// 转换为组合本位币(先转换,再计算盈亏率,避免汇率变化影响)
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);
// 用目标币种计算盈亏率(正确处理汇率变化)
decimal ProfitInTarget = positionValueInTarget - costInTarget;
double ProfitRate = costInTarget > 0 ? (double)(ProfitInTarget / costInTarget * 100) : 0;
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, // 注意:此处精度丢失,仅用于显示
Shares = (double)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<Portfolio>()
.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<Transaction>()
.Where(t => t.PortfolioId == portfolioId)
.OrderByDescending(t => t.TransactionTime)
.Skip(offset)
.Take(limit)
.ToList();
var total = _db.Queryable<Transaction>()
.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,
StockCode = t.StockCode,
Amount = (double)t.TotalAmount,
Currency = t.Currency,
Status = t.Status,
Remark = t.Remark
}).ToList(),
Total = total,
Page = offset / limit + 1,
PageSize = limit
};
}
public async Task<CreateTransactionResponse> CreateTransaction(CreateTransactionRequest request, string userId)
{
// 验证投资组合是否属于该用户
var portfolio = _db.Queryable<Portfolio>()
.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<Position>()
.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;
}
}
// 组合时间部分
if (!string.IsNullOrEmpty(request.TransactionTime))
{
if (TimeSpan.TryParse(request.TransactionTime, out var parsedTime))
{
transactionTime = transactionTime.Date + parsedTime;
}
}
// 获取交易时汇率并保存(用于历史净值计算)
string baseCurrency = portfolio.Currency ?? "CNY";
decimal? exchangeRate = null;
decimal? totalAmountBase = null;
if (!string.IsNullOrEmpty(request.Currency) && !request.Currency.Equals(baseCurrency, StringComparison.OrdinalIgnoreCase))
{
exchangeRate = await _exchangeRateService.GetExchangeRateAsync(request.Currency, baseCurrency);
totalAmountBase = (decimal)(request.Price * request.Amount) * exchangeRate.Value;
_logger.LogInformation("交易汇率: {FromCurrency} -> {ToCurrency} = {Rate}, 原始金额={Amount}, 本位币金额={BaseAmount}",
request.Currency, baseCurrency, exchangeRate, request.Price * request.Amount, totalAmountBase);
}
else
{
exchangeRate = 1.0m;
totalAmountBase = (decimal)(request.Price * request.Amount);
}
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,
ExchangeRate = exchangeRate,
TotalAmountBase = totalAmountBase,
Status = "completed",
Remark = request.Remark ?? string.Empty,
TransactionTime = transactionTime,
CreatedAt = DateTime.Now
};
_db.Insertable(transaction).ExecuteCommand();
// 更新持仓
var position = _db.Queryable<Position>()
.Where(pos => pos.PortfolioId == request.PortfolioId && pos.StockCode == request.StockCode)
.First();
if (position != null)
{
if (request.Type == "buy")
{
// 计算新的平均价格和总成本
var buyAmount = (decimal)request.Amount * (decimal)request.Price;
var newTotalShares = position.Shares + (decimal)request.Amount;
var newTotalCost = position.TotalCost + buyAmount;
position.AvgPrice = newTotalCost / newTotalShares;
position.TotalCost = newTotalCost;
position.Shares = newTotalShares;
position.UpdatedAt = DateTime.Now;
_logger.LogInformation("买入更新持仓: {StockCode}, +{Amount}股@{Price}, 新成本={TotalCost}, 新均价={AvgPrice}",
position.StockCode, request.Amount, request.Price, position.TotalCost, position.AvgPrice);
_db.Updateable(position).ExecuteCommand();
}
else if (request.Type == "sell")
{
// 按比例减少成本
var sellRatio = (decimal)request.Amount / position.Shares;
var costToReduce = position.TotalCost * sellRatio;
position.Shares -= (decimal)request.Amount;
position.TotalCost -= costToReduce;
position.UpdatedAt = DateTime.Now;
_logger.LogInformation("卖出更新持仓: {StockCode}, -{Amount}股@{Price}, 减少成本={CostToReduce}, 剩余成本={TotalCost}",
position.StockCode, request.Amount, request.Price, costToReduce, position.TotalCost);
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,
TotalCost = (decimal)(request.Price * request.Amount),
Currency = request.Currency,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
_logger.LogInformation("创建新持仓: {StockCode}, 数量={Shares}, 均价={AvgPrice}, 成本={TotalCost}",
position.StockCode, position.Shares, position.AvgPrice, position.TotalCost);
_db.Insertable(position).ExecuteCommand();
}
// 更新投资组合总价值(使用实时市值而不是成本价)
var Positions = _db.Queryable<Position>()
.Where(pos => pos.PortfolioId == request.PortfolioId)
.ToList();
decimal totalPortfolioValue = 0;
foreach (var pos in Positions)
{
if (pos.StockCode == null)
{
continue;
}
// 获取实时价格(自动路由数据源),失败则降级使用成本价
decimal CurrentPrice = pos.AvgPrice;
try
{
var priceResponse = _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock").GetAwaiter().GetResult();
if (priceResponse.Price > 0)
{
CurrentPrice = priceResponse.Price;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "获取标的 {StockCode} 实时价格失败,使用成本价计算组合总价值", pos.StockCode);
}
totalPortfolioValue += pos.Shares * CurrentPrice;
}
portfolio.TotalValue = totalPortfolioValue;
portfolio.UpdatedAt = DateTime.Now;
_db.Updateable(portfolio).ExecuteCommand();
// 删除该交易日期之后的净值历史记录,下次请求收益曲线时会自动重新计算
try
{
var deletedCount = await _navService.DeleteNavHistoryAfterDateAsync(request.PortfolioId, transactionTime.Date);
if (deletedCount > 0)
{
_logger.LogInformation("交易创建后删除净值历史: PortfolioId={PortfolioId}, Date={Date}, Count={Count}",
request.PortfolioId, transactionTime.Date, deletedCount);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "删除净值历史失败,将在下次请求时重新计算");
}
return new CreateTransactionResponse
{
Id = transaction.Id,
TotalAmount = (double)transaction.TotalAmount,
Status = transaction.Status,
CreatedAt = transaction.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")
};
}
// ===== 异步方法实现 =====
public Task<CreatePortfolioResponse> CreatePortfolioAsync(CreatePortfolioRequest request, string userId)
{
return Task.FromResult(CreatePortfolio(request, userId));
}
public Task<PortfolioDetailResponse> GetPortfolioDetailAsync(string portfolioId, string userId)
{
return GetPortfolioByIdAsync(portfolioId, userId);
}
public Task<List<TransactionItem>> GetTransactionsAsync(string portfolioId, GetTransactionsRequest request, string userId)
{
var response = GetTransactions(portfolioId, userId, request.Limit, request.Offset);
return Task.FromResult(response.Items ?? new List<TransactionItem>());
}
public async Task<TransactionItem> CreateTransactionAsync(string portfolioId, CreateTransactionRequest request, string userId)
{
request.PortfolioId = portfolioId;
var response = await CreateTransaction(request, userId);
// 使用实际交易时间
var transactionDate = request.TransactionDate ?? DateTime.Now.ToString("yyyy-MM-dd");
var transactionTime = request.TransactionTime ?? DateTime.Now.ToString("HH:mm");
return new TransactionItem
{
Id = response.Id,
PortfolioId = portfolioId,
Date = transactionDate,
Time = transactionTime,
Type = request.Type,
StockCode = request.StockCode,
Amount = response.TotalAmount,
Currency = request.Currency,
Status = response.Status,
Remark = request.Remark
};
}
public async Task<bool> UpdatePortfolioAsync(string portfolioId, UpdatePortfolioRequest request, string userId)
{
var portfolio = _db.Queryable<Portfolio>()
.Where(p => p.Id == portfolioId && p.UserId == userId)
.First();
if (portfolio == null)
{
return false;
}
// 更新字段
if (!string.IsNullOrEmpty(request.Name))
{
portfolio.Name = request.Name;
}
// 策略可以为空(解绑策略)
portfolio.StrategyId = request.StrategyId;
if (!string.IsNullOrEmpty(request.Status))
{
portfolio.Status = request.Status;
}
portfolio.UpdatedAt = DateTime.Now;
_db.Updateable(portfolio).ExecuteCommand();
_logger.LogInformation("更新投资组合: {PortfolioId}, 名称: {Name}, 策略: {StrategyId}",
portfolioId, portfolio.Name, portfolio.StrategyId ?? "无");
return true;
}
public async Task<bool> DeletePortfolioAsync(string portfolioId, string userId)
{
var portfolio = _db.Queryable<Portfolio>()
.Where(p => p.Id == portfolioId && p.UserId == userId)
.First();
if (portfolio == null)
{
return false;
}
// 删除相关持仓
var Positions = _db.Queryable<Position>()
.Where(pos => pos.PortfolioId == portfolioId)
.ToList();
if (Positions.Any())
{
_db.Deleteable(Positions).ExecuteCommand();
}
// 删除相关交易
var transactions = _db.Queryable<Transaction>()
.Where(t => t.PortfolioId == portfolioId)
.ToList();
if (transactions.Any())
{
_db.Deleteable(transactions).ExecuteCommand();
}
// 删除净值历史
var navHistory = _db.Queryable<PortfolioNavHistory>()
.Where(n => n.PortfolioId == portfolioId)
.ToList();
if (navHistory.Any())
{
_db.Deleteable(navHistory).ExecuteCommand();
}
// 删除组合
_db.Deleteable(portfolio).ExecuteCommand();
return true;
}
}