Slice 6 (Fable-Fixes): Totcode/Demo-Balance/Settings-Validierung + Plan-Abschluss
- Totcode: services/SnapshotService.cs entfernt (nirgends registriert). - Demo-Balance/PnL-Reconciliation: Demo-BUY zieht jetzt die Entry-Fee ab, Demo-Close schreibt netto (exitUsd - Exit-Fee) gut -> Summe(Balance-Aenderungen) = Summe(PnL) statt um die Fees zu driften (Demo als Validierung konsistent). - Settings-Validierung: SellLogic.IsLadderConfigInverted (Max-Preisabstand >= SELL-Floor -> Leiter startet am Floor) + Warnung beim Settings-Laden (StartupHydration). Bewusst als Follow-up dokumentiert (Live-Verifikation/Risiko): M3-Autoincrement-Migration, PersistenceService-Dedup-Zeitfenster, Perf (UpsertLive-Dirty-Check, Leiter-Parallelitaet). Tests: +3 (IsLadderConfigInverted). Build 0 Fehler, 236 gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
84b2b276d9
commit
782c08a860
@@ -6,10 +6,21 @@
|
|||||||
> Hintergrund-Services (TraderMonitorService).
|
> Hintergrund-Services (TraderMonitorService).
|
||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
- ✅ **Slice 0** – IClobClient-Seam + FakeClobClient (verhaltensneutral). Commit.
|
- ✅ **Slice 0** – IClobClient-Seam + FakeClobClient (verhaltensneutral).
|
||||||
- ✅ **Slice 1** – K1/H2/H1: atomarer Claim, Cleanup+Engine schonen Leitern, Floor-Robustheit. 8 Tests. Commit.
|
- ✅ **Slice 1** – K1/H2/H1: atomarer Claim, Cleanup+Engine schonen Leitern, Floor-Robustheit. 8 Tests.
|
||||||
- ✅ **Slice 2** – K2: Startup-Reconciliation (GetOpenOrders ohne assetId = alle). 3 Tests. Commit.
|
- ✅ **Slice 2** – K2: Startup-Reconciliation (GetOpenOrders ohne assetId = alle). 3 Tests.
|
||||||
- ⏳ Slice 3 (K3+M5) → Slice 4 (H4/M1/M2/M3/M4/M6/Doku) → Slice 5 (H3) → Slice 6.
|
- ✅ **Slice 3** – K3 (System-SELL vom Ownership-Check ausgenommen + Resolved-Cache) + M5 (Demo-Score-Anzeige, stündl. Auto-Pause). 5 Tests.
|
||||||
|
- ✅ **Slice 4** – H4 (RoundToTick + Dust-Abbruch), M1 (GlobalPnl im Guard), M2 (TokenId), M3-min (serverseitiges Max + lauter Fehlschlag), M4 (Parser 9999), M6 (Fees in Orders), Doku. 10 Tests.
|
||||||
|
- ✅ **Slice 5** – H3: BUY-Skip während ExitPending (Entscheidung A).
|
||||||
|
- ✅ **Slice 6** – SnapshotService entfernt, Demo-Balance/PnL-Reconciliation, Settings-Validierung (IsLadderConfigInverted + Load-Warnung). 3 Tests.
|
||||||
|
|
||||||
|
**Stand: 236 Tests grün, Build/Smoke grün.**
|
||||||
|
|
||||||
|
### Bewusst aufgeschobene Follow-ups (Live-Verifikation/Risiko)
|
||||||
|
- **M3 Autoincrement-Migration**: `TradeId` auf DB-Autoincrement umstellen – Schema-Änderung an der Trade-Persistenz, erst im Zielland live verifizieren. (M3-Minimum ist umgesetzt.)
|
||||||
|
- **PersistenceService-Dedup-Zeitfenster**: `Exists(AccountId,TokenId)` blockt legit Re-Entries; robuster Fix (z.B. OpenedAt-basiert) braucht Live-Daten – Duplikat-Schutz nicht unverifiziert brechen.
|
||||||
|
- **Perf**: `UpsertLive`-Dirty-Check (Schreib-Amplifikation) und Leiter-Parallelität – laut Fable bei aktueller Größe unkritisch.
|
||||||
|
- **M6/K2**: fee-signierte Orders bzw. `/data/orders` ohne asset_id sind API-gated → im Zielland verifizieren.
|
||||||
|
|
||||||
## Arbeitsgrundsätze (für jeden Slice)
|
## Arbeitsgrundsätze (für jeden Slice)
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using PolyTraderSharp.Models;
|
|
||||||
|
|
||||||
namespace PolyTraderSharp.Services
|
|
||||||
{
|
|
||||||
public class SnapshotService : BackgroundService
|
|
||||||
{
|
|
||||||
private readonly TradingState _state;
|
|
||||||
private readonly CopyTradingState _copyState;
|
|
||||||
private readonly ILogger<SnapshotService> _logger;
|
|
||||||
private readonly string _snapshotPath = "snapshot.json";
|
|
||||||
private readonly TimeSpan _interval = TimeSpan.FromSeconds(30);
|
|
||||||
|
|
||||||
private readonly JobStatusRow _jobStatus;
|
|
||||||
|
|
||||||
public SnapshotService(TradingState state, CopyTradingState copyState, ILogger<SnapshotService> logger, JobManager jobManager)
|
|
||||||
{
|
|
||||||
_state = state;
|
|
||||||
_copyState = copyState;
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
_jobStatus = new JobStatusRow
|
|
||||||
{
|
|
||||||
JobName = "State Snapshot",
|
|
||||||
Description = "Saves active application state (balances, open pos) to snapshot.json.",
|
|
||||||
StatusText = "Pending Initial Delay..."
|
|
||||||
};
|
|
||||||
|
|
||||||
_jobStatus.ManualTriggerAction = async () =>
|
|
||||||
{
|
|
||||||
string oldStatus = _jobStatus.StatusText;
|
|
||||||
_jobStatus.StatusText = "Running (Manual)...";
|
|
||||||
await SaveSnapshotAsync();
|
|
||||||
_jobStatus.StatusText = "Idle";
|
|
||||||
};
|
|
||||||
|
|
||||||
jobManager.RegisterJob(_jobStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// Load state on startup
|
|
||||||
if (File.Exists(_snapshotPath))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string json = await File.ReadAllTextAsync(_snapshotPath, cancellationToken);
|
|
||||||
var snapshot = JsonConvert.DeserializeObject<StateSnapshot>(json);
|
|
||||||
|
|
||||||
if (snapshot != null)
|
|
||||||
{
|
|
||||||
_state.LiveTradingMode = snapshot.LiveMode;
|
|
||||||
_state.DemoTradingMode = snapshot.DemoMode;
|
|
||||||
_copyState.TotalCopyTrades = snapshot.CopyTrades;
|
|
||||||
_state.GlobalPnl = snapshot.GlobalPnl;
|
|
||||||
|
|
||||||
int restoredPositions = 0;
|
|
||||||
// Restore OpenPositions to matching accounts
|
|
||||||
foreach (var kvp in snapshot.OpenPositions)
|
|
||||||
{
|
|
||||||
if (_state.Accounts.TryGetValue(kvp.Key, out var acc))
|
|
||||||
{
|
|
||||||
foreach (var pos in kvp.Value)
|
|
||||||
{
|
|
||||||
acc.OpenPositions.TryAdd(pos.Key, pos.Value);
|
|
||||||
restoredPositions++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Snapshot loaded. Restored {restoredPositions} positions. Modes: Live={snapshot.LiveMode}, Demo={snapshot.DemoMode}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to load snapshot on startup");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await base.StartAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
_jobStatus.StatusText = "Idle";
|
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
if (_jobStatus.IsEnabled)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_jobStatus.StatusText = "Running (Scheduled)...";
|
|
||||||
await SaveSnapshotAsync();
|
|
||||||
_jobStatus.LastRun = DateTime.Now;
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving TradingState snapshot");
|
|
||||||
_jobStatus.StatusText = "Error!";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_jobStatus.StatusText = "Paused";
|
|
||||||
}
|
|
||||||
|
|
||||||
_jobStatus.NextRun = DateTime.Now.Add(_interval);
|
|
||||||
await Task.Delay(_interval, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveSnapshotAsync()
|
|
||||||
{
|
|
||||||
var snapshot = new StateSnapshot
|
|
||||||
{
|
|
||||||
LiveMode = _state.LiveTradingMode,
|
|
||||||
DemoMode = _state.DemoTradingMode,
|
|
||||||
CopyTrades = _copyState.TotalCopyTrades,
|
|
||||||
GlobalPnl = _state.GlobalPnl,
|
|
||||||
OpenPositions = _state.Accounts.ToDictionary(
|
|
||||||
a => a.Key,
|
|
||||||
a => a.Value.OpenPositions.ToDictionary(p => p.Key, p => p.Value)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented);
|
|
||||||
await File.WriteAllTextAsync(_snapshotPath, json);
|
|
||||||
|
|
||||||
_logger.LogTrace("TradingState snapshot saved.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private class StateSnapshot
|
|
||||||
{
|
|
||||||
public TradingMode LiveMode { get; set; }
|
|
||||||
public TradingMode DemoMode { get; set; }
|
|
||||||
public int CopyTrades { get; set; }
|
|
||||||
public decimal GlobalPnl { get; set; }
|
|
||||||
public Dictionary<int, Dictionary<string, Position>> OpenPositions { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using PolyTrader.Core.Persistence;
|
using PolyTrader.Core.Persistence;
|
||||||
|
using PolyTrader.Modules.CopyTrading.Logic;
|
||||||
using PolyTrader.Modules.CopyTrading.Persistence;
|
using PolyTrader.Modules.CopyTrading.Persistence;
|
||||||
using PolyTraderSharp.Models;
|
using PolyTraderSharp.Models;
|
||||||
|
|
||||||
@@ -92,6 +93,10 @@ namespace PolyTraderSharp.Services
|
|||||||
if (existing.TryGetValue(acc.AccountId, out var s))
|
if (existing.TryGetValue(acc.AccountId, out var s))
|
||||||
{
|
{
|
||||||
_copyState.AccountSettings[acc.AccountId] = s;
|
_copyState.AccountSettings[acc.AccountId] = s;
|
||||||
|
// Konfig-Plausibilität: BUY-Preisabstand ≥ SELL-Floor → Leiter startet am Floor.
|
||||||
|
if (SellLogic.IsLadderConfigInverted(s.MaxPriceDifference, s.SellFloorPct))
|
||||||
|
_logger.Warning($"⚠️ [Settings] {acc.Name}: Max. Preisabstand ({s.MaxPriceDifference:F1}%) ≥ SELL-Floor " +
|
||||||
|
$"({s.SellFloorPct:F1}%) – die SELL-Leiter startet direkt am Floor (keine echte Eskalation). Floor erhöhen oder Abstand senken.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ namespace PolyTrader.Modules.CopyTrading.Logic
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal RoundToTick(decimal price) => Math.Round(price, 3, MidpointRounding.AwayFromZero);
|
public static decimal RoundToTick(decimal price) => Math.Round(price, 3, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warnt vor invertierter Leiter-Konfiguration: ist der BUY-Preisabstand (%) ≥ dem SELL-Floor (%),
|
||||||
|
/// liegt das Start-SELL-Limit (reference × (1 − maxPriceDifference/100)) auf/unter dem Floor
|
||||||
|
/// (reference × (1 − sellFloor/100)). Die Leiter startet dann direkt am Floor → sofortige
|
||||||
|
/// "Floor erreicht"-Benachrichtigung statt echter Eskalation. Nur der Nicht-HF-Startpfad.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsLadderConfigInverted(decimal maxPriceDifferencePct, decimal sellFloorPct) =>
|
||||||
|
maxPriceDifferencePct >= sellFloorPct;
|
||||||
|
|
||||||
/// <summary>Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ".</summary>
|
/// <summary>Relative Schrittweite je Leiter-Stufe (%). Plan: „2 ¢ oder 3 % relativ".</summary>
|
||||||
public const decimal LadderStepPct = 3.0m;
|
public const decimal LadderStepPct = 3.0m;
|
||||||
|
|
||||||
|
|||||||
@@ -539,7 +539,12 @@ namespace PolyTraderSharp.Services
|
|||||||
|
|
||||||
_positionRepo.UpsertDemo(account.AccountId, finalPos);
|
_positionRepo.UpsertDemo(account.AccountId, finalPos);
|
||||||
|
|
||||||
account.UpdateBalance(account.AvailableBalance - exactUsdc);
|
// Entry-Fee auch im Demo abziehen, damit Balance und PnL konsistent bleiben (siehe Close).
|
||||||
|
int demoBuyFeeBps = _state.MarketCache.TryGetValue(signal.TokenId, out var demoBuyMd)
|
||||||
|
? FeeModel.ResolveBps(demoBuyMd.TakerFeeBps, demoBuyMd.Category)
|
||||||
|
: FeeModel.FallbackBps(null);
|
||||||
|
decimal demoEntryFee = FeeModel.FeeUsd(exactUsdc, demoBuyFeeBps);
|
||||||
|
account.UpdateBalance(account.AvailableBalance - exactUsdc - demoEntryFee);
|
||||||
_accountRepo.Upsert(account);
|
_accountRepo.Upsert(account);
|
||||||
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
|
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
|
||||||
$" Konto: {account.Name}\n" +
|
$" Konto: {account.Name}\n" +
|
||||||
@@ -678,7 +683,10 @@ namespace PolyTraderSharp.Services
|
|||||||
decimal demoExitPrice = DemoModel.ExitFillPrice(signal.Price, DemoModel.FallbackHalfSpread);
|
decimal demoExitPrice = DemoModel.ExitFillPrice(signal.Price, DemoModel.FallbackHalfSpread);
|
||||||
|
|
||||||
_state.GlobalPnl += realizedPnl;
|
_state.GlobalPnl += realizedPnl;
|
||||||
account.UpdateBalance(account.AvailableBalance + exitUsd);
|
// Balance netto gutschreiben (exitUsd − Exit-Fee), damit Σ(Balance-Änderungen) = Σ(PnL)
|
||||||
|
// statt um die Fees zu driften (Entry-Fee wurde beim BUY abgezogen).
|
||||||
|
decimal demoExitFee = FeeModel.FeeUsd(exitUsd, demoFeeBps);
|
||||||
|
account.UpdateBalance(account.AvailableBalance + exitUsd - demoExitFee);
|
||||||
_accountRepo.Upsert(account);
|
_accountRepo.Upsert(account);
|
||||||
|
|
||||||
var ct = new ClosedTrade
|
var ct = new ClosedTrade
|
||||||
|
|||||||
@@ -199,5 +199,16 @@ namespace PolyTrader.Tests
|
|||||||
decimal usdc = size * price;
|
decimal usdc = size * price;
|
||||||
Assert.Equal(size, usdc / price);
|
Assert.Equal(size, usdc / price);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- IsLadderConfigInverted (Slice 6) -----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2.0, 15.0, false)] // Default: Abstand < Floor -> ok
|
||||||
|
[InlineData(15.0, 15.0, true)] // gleich -> Start am Floor
|
||||||
|
[InlineData(20.0, 15.0, true)] // Abstand > Floor -> invertiert
|
||||||
|
public void IsLadderConfigInverted_flags_buydiff_ge_floor(double maxDiff, double floor, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, IsLadderConfigInverted((decimal)maxDiff, (decimal)floor));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user