@
Phase 3: Trading-Kern (Risk, Execution, Portfolio) mit sicherem Broker-Default - Core/Trading/TradingModels: Signal, Order(Request/Result), RiskContext/Decision, Account, Position, Quote, ExecutionResult, Enums (Side/OrderType/Mode) - IBrokerClient + NullBrokerClient (sicherer Default, handelt NIE bis IBKR-Adapter verifiziert) - RiskService (+IRiskService): Sizing nach MaxTrade%, Modul-Limit, Slippage; Buy/Sell - PortfolioService (+IPortfolioService): core_position + core_trade_history + core_budget - ExecutionService (+IExecutionService): Signal -> Kurs -> Konto -> Risiko -> Order -> Buchung - TradingSettings in AppSettings (Paper/Live, TradingEnabled, Risikoparameter) - CoreMigrations: core_position; DI-Registrierung der Trading-Services - Tests: RiskService (11) + ExecutionService (6, NSubstitute) -> 38/38 gruen Offen (bewusst gekapselt): echter IbkrBrokerClient gegen Client-Portal-Gateway (manuell verifizieren). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> @
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using IBKRTrader.Core.Budget;
|
||||
using IBKRTrader.Core.Database;
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>
|
||||
/// DB-gestützte Buchführung über offene Positionen (core_position),
|
||||
/// Trade-Historie (core_trade_history) und Budget (core_budget).
|
||||
/// </summary>
|
||||
public sealed class PortfolioService : IPortfolioService
|
||||
{
|
||||
private readonly DatabaseService _db;
|
||||
private readonly TradeHistoryService _history;
|
||||
private readonly BudgetService _budget;
|
||||
private readonly LoggingService _logger;
|
||||
|
||||
public PortfolioService(
|
||||
DatabaseService db,
|
||||
TradeHistoryService history,
|
||||
BudgetService budget,
|
||||
LoggingService logger)
|
||||
{
|
||||
_db = db;
|
||||
_history = history;
|
||||
_budget = budget;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private sealed class PosDto
|
||||
{
|
||||
public int Quantity { get; set; }
|
||||
public decimal AvgPrice { get; set; }
|
||||
}
|
||||
|
||||
public async Task<decimal> GetModuleExposureAsync(string module, CancellationToken ct = default)
|
||||
{
|
||||
var sum = await _db.ExecuteScalarAsync<decimal?>(
|
||||
"SELECT SUM(quantity * avg_price) FROM `core_position` WHERE module = @module",
|
||||
new { module });
|
||||
return sum ?? 0m;
|
||||
}
|
||||
|
||||
public async Task<int> GetPositionQuantityAsync(string module, string symbol, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||
"WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
return row?.Quantity ?? 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Position>> GetPositionsAsync(string module, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.QueryAsync<Position>(
|
||||
"SELECT module AS Module, symbol AS Symbol, quantity AS Quantity, avg_price AS AvgPrice " +
|
||||
"FROM `core_position` WHERE module = @module AND quantity > 0",
|
||||
new { module });
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task RecordFillAsync(
|
||||
string module, string symbol, TradeSide side,
|
||||
int quantity, decimal price, string? orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (quantity <= 0) return;
|
||||
|
||||
var action = side == TradeSide.Buy ? "BUY" : "SELL";
|
||||
await _history.RecordTradeAsync(module, symbol, action, quantity, price, orderId);
|
||||
|
||||
var current = await _db.QueryFirstOrDefaultAsync<PosDto>(
|
||||
"SELECT quantity AS Quantity, avg_price AS AvgPrice FROM `core_position` " +
|
||||
"WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
|
||||
var oldQty = current?.Quantity ?? 0;
|
||||
var oldAvg = current?.AvgPrice ?? 0m;
|
||||
|
||||
if (side == TradeSide.Buy)
|
||||
{
|
||||
var newQty = oldQty + quantity;
|
||||
var newAvg = oldQty > 0 ? (oldQty * oldAvg + quantity * price) / newQty : price;
|
||||
await UpsertPositionAsync(module, symbol, newQty, newAvg);
|
||||
await _budget.ReserveBudgetAsync(module, quantity * price);
|
||||
}
|
||||
else
|
||||
{
|
||||
var newQty = oldQty - quantity;
|
||||
if (newQty <= 0)
|
||||
await DeletePositionAsync(module, symbol);
|
||||
else
|
||||
await UpsertPositionAsync(module, symbol, newQty, oldAvg);
|
||||
await _budget.ReleaseBudgetAsync(module, quantity * price);
|
||||
}
|
||||
|
||||
_logger.Info(module, $"Position gebucht: {action} {quantity}x {symbol} @ {price:F2}");
|
||||
}
|
||||
|
||||
private Task UpsertPositionAsync(string module, string symbol, int quantity, decimal avgPrice) =>
|
||||
_db.ExecuteAsync(@"
|
||||
INSERT INTO `core_position` (module, symbol, quantity, avg_price)
|
||||
VALUES (@module, @symbol, @quantity, @avgPrice)
|
||||
ON DUPLICATE KEY UPDATE quantity = @quantity, avg_price = @avgPrice",
|
||||
new { module, symbol, quantity, avgPrice });
|
||||
|
||||
private Task DeletePositionAsync(string module, string symbol) =>
|
||||
_db.ExecuteAsync(
|
||||
"DELETE FROM `core_position` WHERE module = @module AND symbol = @symbol",
|
||||
new { module, symbol });
|
||||
}
|
||||
Reference in New Issue
Block a user