Behebt #534. Die vier schreibenden Endpunkte verschluckten die Ausnahme des Repositories und meldeten anschliessend unbedingt Erfolg. Das Verschlucken war unschoen; die Reihenfolge war gefaehrlich: copyState wurde auch dann geaendert, wenn die Datenbank abgelehnt hatte. Das Modul handelte danach mit Risikolimits, die nirgends standen, und nach dem naechsten Neustart konnte niemand mehr rekonstruieren, womit es gehandelt hatte. Neu geht der Schreibvorgang zuerst, und der Hauptspeicher wird nur nachgezogen, wenn er gelang. Sonst 500 mit Klartext und unveraenderter Zustand. Fehlt die Ablage ganz, ist die Aenderung ebenso rein fluechtig — auch das wird abgelehnt, mit 503. Betroffen: CopyTrading-Kontoeinstellungen, ResolutionFarming-Kontoeinstellungen, Master-Trader anlegen/aendern, Master-Trader loeschen. Dass alle vier denselben Helfer (PersistenceGuard) benutzen, ist der eigentliche Punkt: Aus je eigenem try/catch je Endpunkt ist die Ungleichheit ueberhaupt erst entstanden. Bewusst anders behandelt bleibt das Archivieren eines Supervisor-Berichts: Die Antwort des Agenten steht dort bereits und hat Geld gekostet: sie wegen eines fehlgeschlagenen Archiveintrags zu verwerfen, waere schlimmer als der fehlende Eintrag, und an diesem Schreibvorgang haengt kein Verhalten des Systems. Stillschweigend verschwindet er aber nicht mehr, sondern wird protokolliert. In der Oberflaeche zeigten die Schreibwege bisher nur "HTTP 500" an und verschenkten damit genau die Begruendung, die der Server jetzt mitliefert. errorText() liest sie aus der Antwort. Entscheidung von Richard: ablehnen statt fluechtig uebernehmen. Damit sind waehrend eines Datenbankausfalls keine Aenderungen an den Einstellungen mehr moeglich; das Handeln selbst laeuft weiter. Geprueft auf der Linux-Test-VM mit angehaltener MariaDB: alle drei Schreibwege antworten mit 500 und nennen den Grund, das Limit bleibt bei 11,25 statt auf 99,99 zu springen, der Phantom-Trader entsteht weder im Hauptspeicher noch in der Datenbank, jeder Fehlschlag steht im Protokoll. Nach dem Wiederanlauf speichert es wieder. 556 von 556 Tests bestanden (5 neue). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
375 lines
16 KiB
C#
375 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using PolyTrader.Modules.CopyTrading.Persistence;
|
|
using PolyTraderSharp;
|
|
using PolyTraderSharp.Models;
|
|
using PolyTraderSharp.Services;
|
|
|
|
namespace PolyTrader.Web.Endpoints
|
|
{
|
|
public static class CopyTradingEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapCopyTradingEndpoints(this IEndpointRouteBuilder routes)
|
|
{
|
|
var group = routes.MapGroup("/api/modules/copytrading");
|
|
|
|
// 1. Master-Trader abrufen
|
|
group.MapGet("/traders", (IServiceProvider sp, CopyTradingState copyState) =>
|
|
{
|
|
var repo = sp.GetService<ITrackedTraderRepository>();
|
|
List<TrackedTrader> traders;
|
|
try
|
|
{
|
|
traders = repo?.GetAll() ?? copyState.Traders.Values.OrderBy(t => t.Id).ToList();
|
|
if (traders.Count == 0 && copyState.Traders.Count > 0)
|
|
traders = copyState.Traders.Values.OrderBy(t => t.Id).ToList();
|
|
}
|
|
catch
|
|
{
|
|
traders = copyState.Traders.Values.OrderBy(t => t.Id).ToList();
|
|
}
|
|
|
|
var list = traders.Select(t => new
|
|
{
|
|
id = t.Id,
|
|
displayName = t.DisplayName,
|
|
walletAddress = t.WalletAddress,
|
|
category = t.Category,
|
|
description = t.Description,
|
|
reasoning = t.Reasoning,
|
|
isActive = t.IsActive,
|
|
isHidden = t.IsHidden,
|
|
autoPauseEnabled = t.AutoPauseEnabled,
|
|
makerEntry = t.MakerEntry,
|
|
assignedAccountIds = t.AssignedAccountIds?.ToList() ?? new List<int>(),
|
|
totalTrades = t.TotalTrades,
|
|
winningTrades = t.WinningTrades,
|
|
winrate30t = t.Winrate30t,
|
|
totalPnl = t.TotalPnl,
|
|
copyPnl30d = t.CopyPnl30d,
|
|
copyProfitFactor = t.CopyProfitFactor,
|
|
copyAvgPnlPerTrade = t.CopyAvgPnlPerTrade,
|
|
copyTradeCount30d = t.CopyTradeCount30d
|
|
}).ToList();
|
|
|
|
return Results.Ok(list);
|
|
});
|
|
|
|
// 2. Master-Trader anlegen / aktualisieren
|
|
group.MapPost("/traders", (IServiceProvider sp, CopyTradingState copyState, TerminalLogger logger, TrackedTraderInput input) =>
|
|
{
|
|
var repo = sp.GetService<ITrackedTraderRepository>();
|
|
|
|
int id = input.Id;
|
|
if (id <= 0)
|
|
{
|
|
int maxId = copyState.Traders.Count > 0 ? copyState.Traders.Keys.Max() : 0;
|
|
if (repo != null)
|
|
{
|
|
try
|
|
{
|
|
var existing = repo.GetAll();
|
|
if (existing.Count > 0)
|
|
maxId = Math.Max(maxId, existing.Max(t => t.Id));
|
|
}
|
|
catch { }
|
|
}
|
|
id = maxId + 1;
|
|
}
|
|
|
|
var trader = new TrackedTrader
|
|
{
|
|
Id = id,
|
|
DisplayName = input.DisplayName ?? $"Trader #{id}",
|
|
WalletAddress = (input.WalletAddress ?? string.Empty).Trim(),
|
|
Category = string.IsNullOrWhiteSpace(input.Category) ? "NEW_BIG_BET" : input.Category.Trim(),
|
|
Description = input.Description ?? string.Empty,
|
|
Reasoning = input.Reasoning ?? string.Empty,
|
|
IsActive = input.IsActive,
|
|
IsHidden = input.IsHidden,
|
|
AutoPauseEnabled = input.AutoPauseEnabled,
|
|
MakerEntry = input.MakerEntry,
|
|
AssignedAccountIds = input.AssignedAccountIds != null ? new HashSet<int>(input.AssignedAccountIds) : new HashSet<int>()
|
|
};
|
|
|
|
// Wenn bereits vorhanden, bestehende Stats übernehmen
|
|
if (copyState.Traders.TryGetValue(id, out var existingTrader))
|
|
{
|
|
trader.TotalTrades = existingTrader.TotalTrades;
|
|
trader.WinningTrades = existingTrader.WinningTrades;
|
|
trader.Winrate30t = existingTrader.Winrate30t;
|
|
trader.TotalPnl = existingTrader.TotalPnl;
|
|
trader.CopyPnl30d = existingTrader.CopyPnl30d;
|
|
trader.CopyProfitFactor = existingTrader.CopyProfitFactor;
|
|
trader.CopyAvgPnlPerTrade = existingTrader.CopyAvgPnlPerTrade;
|
|
trader.CopyTradeCount30d = existingTrader.CopyTradeCount30d;
|
|
}
|
|
|
|
var failed = PersistenceGuard.Write(repo != null, () => repo!.Upsert(trader), logger,
|
|
$"Master-Trader #{id} ({trader.DisplayName})");
|
|
if (failed != null) return failed;
|
|
|
|
// Erst nach erfolgreichem Schreiben in den Hauptspeicher (#534).
|
|
copyState.Traders[id] = trader;
|
|
|
|
return Results.Ok(new
|
|
{
|
|
success = true,
|
|
trader = new
|
|
{
|
|
id = trader.Id,
|
|
displayName = trader.DisplayName,
|
|
walletAddress = trader.WalletAddress,
|
|
category = trader.Category,
|
|
description = trader.Description,
|
|
reasoning = trader.Reasoning,
|
|
isActive = trader.IsActive,
|
|
isHidden = trader.IsHidden,
|
|
autoPauseEnabled = trader.AutoPauseEnabled,
|
|
makerEntry = trader.MakerEntry,
|
|
assignedAccountIds = trader.AssignedAccountIds.ToList()
|
|
}
|
|
});
|
|
});
|
|
|
|
// 3. Master-Trader löschen
|
|
group.MapDelete("/traders/{id:int}", (IServiceProvider sp, CopyTradingState copyState, TerminalLogger logger, int id) =>
|
|
{
|
|
var repo = sp.GetService<ITrackedTraderRepository>();
|
|
|
|
var failed = PersistenceGuard.Write(repo != null, () => repo!.Delete(id), logger,
|
|
$"Loeschen von Master-Trader #{id}");
|
|
if (failed != null) return failed;
|
|
|
|
copyState.Traders.TryRemove(id, out _);
|
|
return Results.Ok(new { success = true, id });
|
|
});
|
|
|
|
// 4. Offene Trades
|
|
group.MapGet("/open-trades", (TradingState state, CopyTradingState copyState) =>
|
|
{
|
|
var openTrades = new List<object>();
|
|
|
|
foreach (var acc in state.Accounts.Values.OrderBy(a => a.AccountId))
|
|
{
|
|
foreach (var p in acc.OpenPositions.Values.OrderByDescending(p => p.OpenedAt))
|
|
{
|
|
decimal unrealized = p.CurrentValueUsd - p.AmountUsd;
|
|
decimal pct = p.AmountUsd > 0m ? 100m * unrealized / p.AmountUsd : 0m;
|
|
|
|
string traderName = p.SourceTraderName;
|
|
if (string.IsNullOrEmpty(traderName) && p.SourceTraderId > 0)
|
|
{
|
|
if (copyState.Traders.TryGetValue(p.SourceTraderId, out var tr))
|
|
traderName = tr.DisplayName;
|
|
else
|
|
traderName = $"#{p.SourceTraderId}";
|
|
}
|
|
|
|
openTrades.Add(new
|
|
{
|
|
account = (string.IsNullOrEmpty(acc.Name) ? $"#{acc.AccountId}" : acc.Name) + (acc.IsDemo ? " (Demo)" : ""),
|
|
accountId = acc.AccountId,
|
|
isDemo = acc.IsDemo,
|
|
sourceTraderId = p.SourceTraderId,
|
|
trader = string.IsNullOrEmpty(traderName) ? "—" : traderName,
|
|
market = p.MarketQuestion ?? "—",
|
|
outcome = p.Outcome ?? "—",
|
|
side = p.Side ?? "BUY",
|
|
entryPrice = p.EntryPrice,
|
|
currentPrice = p.CurrentPrice,
|
|
size = p.Size,
|
|
amountUsd = p.AmountUsd,
|
|
currentValueUsd = p.CurrentValueUsd,
|
|
unrealizedPnl = unrealized,
|
|
unrealizedPct = pct,
|
|
status = p.ExitPending ? "Exit läuft" : "Offen",
|
|
openedAt = p.OpenedAt.ToString("yyyy-MM-dd HH:mm")
|
|
});
|
|
}
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
count = openTrades.Count,
|
|
totalAmount = openTrades.Count > 0 ? state.Accounts.Values.SelectMany(a => a.OpenPositions.Values).Sum(p => p.AmountUsd) : 0m,
|
|
totalUnrealized = openTrades.Count > 0 ? state.Accounts.Values.SelectMany(a => a.OpenPositions.Values).Sum(p => p.CurrentValueUsd - p.AmountUsd) : 0m,
|
|
trades = openTrades
|
|
});
|
|
});
|
|
|
|
// 5. Geschlossene Trades (mit Filter & Paginierung)
|
|
group.MapGet("/closed-trades", (
|
|
IServiceProvider sp,
|
|
TradingState state,
|
|
CopyTradingState copyState,
|
|
int? traderId,
|
|
string? result,
|
|
string? search,
|
|
int? page,
|
|
int? pageSize) =>
|
|
{
|
|
var repo = sp.GetService<ICopyTradeLogRepository>();
|
|
List<ClosedTrade> trades;
|
|
try
|
|
{
|
|
trades = repo?.Find(_ => true) ?? new List<ClosedTrade>();
|
|
}
|
|
catch
|
|
{
|
|
trades = new List<ClosedTrade>();
|
|
}
|
|
|
|
var query = trades.AsEnumerable();
|
|
|
|
if (traderId.HasValue && traderId.Value > 0)
|
|
{
|
|
query = query.Where(t => t.SourceTraderId == traderId.Value);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(result))
|
|
{
|
|
if (result.Equals("Gewinner", StringComparison.OrdinalIgnoreCase))
|
|
query = query.Where(t => t.RealizedPnl > 0);
|
|
else if (result.Equals("Verlierer", StringComparison.OrdinalIgnoreCase))
|
|
query = query.Where(t => t.RealizedPnl < 0);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
string q = search.Trim();
|
|
query = query.Where(t =>
|
|
(t.MarketQuestion != null && t.MarketQuestion.Contains(q, StringComparison.OrdinalIgnoreCase)) ||
|
|
(t.Outcome != null && t.Outcome.Contains(q, StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
|
|
var filtered = query.OrderByDescending(t => t.ClosedAt).ToList();
|
|
int totalCount = filtered.Count;
|
|
decimal totalPnl = filtered.Sum(t => t.RealizedPnl);
|
|
int winCount = filtered.Count(t => t.RealizedPnl > 0);
|
|
decimal winrate = totalCount > 0 ? 100m * winCount / totalCount : 0m;
|
|
|
|
int curPage = Math.Max(1, page.GetValueOrDefault(1));
|
|
int curPageSize = Math.Clamp(pageSize.GetValueOrDefault(50), 10, 200);
|
|
int totalPages = (int)Math.Ceiling(totalCount / (double)curPageSize);
|
|
|
|
var items = filtered
|
|
.Skip((curPage - 1) * curPageSize)
|
|
.Take(curPageSize)
|
|
.Select(t =>
|
|
{
|
|
string accName = $"#{t.AccountId}";
|
|
if (state.Accounts.TryGetValue(t.AccountId, out var acc) && !string.IsNullOrEmpty(acc.Name))
|
|
accName = acc.Name + (t.IsDemo ? " (Demo)" : "");
|
|
|
|
string traderName = $"#{t.SourceTraderId}";
|
|
if (copyState.Traders.TryGetValue(t.SourceTraderId, out var tr) && !string.IsNullOrEmpty(tr.DisplayName))
|
|
traderName = tr.DisplayName;
|
|
|
|
return new
|
|
{
|
|
tradeId = t.TradeId,
|
|
account = accName,
|
|
accountId = t.AccountId,
|
|
isDemo = t.IsDemo,
|
|
sourceTraderId = t.SourceTraderId,
|
|
trader = traderName,
|
|
market = t.MarketQuestion ?? "—",
|
|
outcome = t.Outcome ?? "—",
|
|
side = t.Side ?? "—",
|
|
entryPrice = t.EntryPrice,
|
|
exitPrice = t.ExitPrice,
|
|
size = t.Size,
|
|
realizedPnl = t.RealizedPnl,
|
|
pnlPercent = t.PnlPercent,
|
|
totalFees = t.TotalFees,
|
|
openedAt = t.OpenedAt.ToString("yyyy-MM-dd HH:mm"),
|
|
closedAt = t.ClosedAt.ToString("yyyy-MM-dd HH:mm"),
|
|
exitReason = t.ExitReason
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
summary = new
|
|
{
|
|
totalTrades = totalCount,
|
|
totalPnl,
|
|
winCount,
|
|
winRate = winrate
|
|
},
|
|
totalCount,
|
|
page = curPage,
|
|
pageSize = curPageSize,
|
|
totalPages,
|
|
items
|
|
});
|
|
});
|
|
|
|
// 6. Account-Einstellungen abrufen
|
|
group.MapGet("/settings/{accountId:int}", (IServiceProvider sp, CopyTradingState copyState, int accountId) =>
|
|
{
|
|
var repo = sp.GetService<ICopyTradingAccountSettingsRepository>();
|
|
CopyTradingAccountSettings? settings = null;
|
|
try
|
|
{
|
|
settings = repo?.Get(accountId);
|
|
}
|
|
catch { }
|
|
|
|
if (settings == null)
|
|
settings = copyState.GetAccountSettings(accountId);
|
|
|
|
return Results.Ok(settings);
|
|
});
|
|
|
|
// 7. Account-Einstellungen speichern
|
|
group.MapPost("/settings/{accountId:int}", (
|
|
IServiceProvider sp,
|
|
CopyTradingState copyState,
|
|
TerminalLogger logger,
|
|
int accountId,
|
|
CopyTradingAccountSettings input) =>
|
|
{
|
|
input.AccountId = accountId;
|
|
|
|
var repo = sp.GetService<ICopyTradingAccountSettingsRepository>();
|
|
|
|
var failed = PersistenceGuard.Write(repo != null, () => repo!.Upsert(input), logger,
|
|
$"CopyTrading-Einstellungen fuer Konto {accountId}");
|
|
if (failed != null) return failed;
|
|
|
|
copyState.AccountSettings[accountId] = input;
|
|
|
|
return Results.Ok(new
|
|
{
|
|
success = true,
|
|
settings = input
|
|
});
|
|
});
|
|
|
|
return routes;
|
|
}
|
|
|
|
public record TrackedTraderInput(
|
|
int Id,
|
|
string? DisplayName,
|
|
string? WalletAddress,
|
|
string? Category,
|
|
string? Description,
|
|
string? Reasoning,
|
|
bool IsActive,
|
|
bool IsHidden,
|
|
bool AutoPauseEnabled,
|
|
bool MakerEntry,
|
|
List<int>? AssignedAccountIds
|
|
);
|
|
}
|
|
}
|