506 lines
19 KiB
C#
506 lines
19 KiB
C#
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;
|
|
|
|
public PortfolioService(ISqlSugarClient db, IMarketDataService marketDataService, IExchangeRateService exchangeRateService)
|
|
{
|
|
_db = db;
|
|
_marketDataService = marketDataService;
|
|
_exchangeRateService = exchangeRateService;
|
|
}
|
|
|
|
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();
|
|
|
|
// 创建初始持仓
|
|
foreach (var stock in request.stocks ?? new List<StockItem>())
|
|
{
|
|
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<PortfolioListItem> GetPortfolios(string userId)
|
|
{
|
|
var portfolios = _db.Queryable<Portfolio>()
|
|
.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<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 = user.DefaultCurrency ?? "CNY";
|
|
decimal totalValueInTargetCurrency = 0;
|
|
decimal totalCostInTargetCurrency = 0;
|
|
decimal totalTodayProfitInTargetCurrency = 0;
|
|
|
|
// 获取用户所有投资组合
|
|
var portfolios = _db.Queryable<Portfolio>()
|
|
.Where(p => p.UserId == userId)
|
|
.ToList();
|
|
|
|
foreach (var portfolio in portfolios)
|
|
{
|
|
// 获取该组合的所有持仓
|
|
var positions = _db.Queryable<Position>()
|
|
.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<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;
|
|
}
|
|
|
|
// 获取实时价格(自动路由数据源)
|
|
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<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,
|
|
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<Portfolio>()
|
|
.Where(p => p.Id == request.portfolioId && p.UserId == userId)
|
|
.First();
|
|
|
|
if (portfolio == null)
|
|
{
|
|
throw new Exception("Portfolio not found or access denied");
|
|
}
|
|
|
|
// 卖出操作校验
|
|
if (request.type?.ToLower() == "sell")
|
|
{
|
|
// 校验是否有该持仓
|
|
var position = _db.Queryable<Position>()
|
|
.Where(pos => pos.PortfolioId == request.portfolioId && pos.StockCode == request.stockCode)
|
|
.First();
|
|
|
|
if (position == null)
|
|
{
|
|
throw new Exception($"该组合中不存在标的 {request.stockCode},无法卖出");
|
|
}
|
|
|
|
// 校验卖出数量不超过持仓数量
|
|
if (request.amount > (double)position.Shares)
|
|
{
|
|
throw new Exception($"卖出数量不能超过持仓数量,当前持仓 {position.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<Position>()
|
|
.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<Position>()
|
|
.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")
|
|
};
|
|
}
|
|
}
|