🔴 高优先级修复: 1. JWT 密钥安全 (Program.cs) - 移除硬编码默认密钥 - 启动时强制检查环境变量/配置 - 密钥长度必须 >= 32 字符 2. 数据库事务 (PortfolioService.cs) - CreateTransaction 添加事务保护 - 交易创建、持仓更新、组合更新原子性保证 - 异常时自动回滚 3. 异步方法改进 (PortfolioService.cs) - 移除 .GetAwaiter().GetResult() 阻塞调用 - 统一使用 async/await 模式 🟡 中优先级: 4. 接口统一 (IPortfolioService.cs) - 移除同步方法,只保留异步版本 - 简化接口,降低维护成本
974 lines
38 KiB
C#
Executable File
974 lines
38 KiB
C#
Executable File
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 = stock.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 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 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 async Task<GetTransactionsResponse> GetTransactionsAsync(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
|
||
};
|
||
|
||
// 使用事务包裹所有数据库操作
|
||
try
|
||
{
|
||
_db.BeginTran();
|
||
|
||
// 1. 插入交易记录
|
||
_db.Insertable(transaction).ExecuteCommand();
|
||
|
||
// 2. 更新持仓
|
||
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();
|
||
}
|
||
|
||
// 3. 更新投资组合总价值
|
||
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 = await _marketDataService.GetPriceAsync(pos.StockCode, pos.AssetType ?? "Stock");
|
||
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();
|
||
|
||
// 提交事务
|
||
_db.CommitTran();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_db.RollbackTran();
|
||
_logger.LogError(ex, "创建交易失败,已回滚: {PortfolioId}, {StockCode}", request.PortfolioId, request.StockCode);
|
||
throw;
|
||
}
|
||
|
||
// 删除该交易日期之后的净值历史记录,下次请求收益曲线时会自动重新计算
|
||
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 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;
|
||
}
|
||
}
|