Kultur-Bug behoben + Threema entfernt (INotificationSink)

- TraderMonitorService las API-Preise kulturabhaengig: unter de-DE wurde aus
  "0.53" der Wert 53 (Faktor-100-Fehler im Einstandspreis). Nutzt jetzt den
  bereits vorhandenen invarianten Helper ParseDecimal.
- Gleiche Fehlerklasse in PolymarketClobClient (6x) und MasterTraderAnalyticsJob
  vorsorglich auf InvariantCulture gestellt.
- Neuer Regressionstest ApiNumberParsingTests (10 Faelle unter erzwungener de-DE-Kultur).
- Threema komplett entfernt (Entscheidung Richard): ThreemaService, vendorte
  Bibliothek libs/Threema-MsgApi-Net-Core, ServerSettings-Block, DI-Verdrahtung.
- Ersetzt durch neutrale INotificationSink (No-Throw-Vertrag) + LogNotificationSink
  als Uebergang; RocketChat/Telegram folgen spaeter.
- Entfernt nebenbei libsodium 1.0.16, die einzige Registry-Nutzung im Build,
  den HttpListener-Webhook und System.Web.HttpUtility (alles Linux-Hindernisse).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 12:01:13 +02:00
co-authored by Claude Opus 5
parent ab0a494f29
commit fd6d62618f
56 changed files with 794 additions and 3734 deletions
+1 -37
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Xml.Serialization;
@@ -16,42 +16,6 @@ namespace PolyTraderSharp.Models
[XmlArrayItem("Module")]
public List<string> DisabledModules { get; set; } = new();
[Category("Threema Notifications")]
[DisplayName("Threema Enabled")]
[Description("Enable or disable Threema notifications.")]
public bool ThreemaEnabled { get; set; } = true;
[Category("Threema Notifications")]
[DisplayName("Gateway ID")]
[Description("The Threema Gateway ID (e.g. *3MAGW01).")]
public string ThreemaGatewayId { get; set; } = "*3MAGW01";
[Category("Threema Notifications")]
[DisplayName("Gateway Secret")]
[Description("The secret for the Threema Gateway integration.")]
public string ThreemaSecret { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Private Key")]
[Description("The Private Key (hex) for End-to-End encryption.")]
public string ThreemaPrivateKey { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Group ID")]
[Description("The Threema Group ID to send messages to.")]
public string ThreemaGroupId { get; set; } = "";
[Category("Threema Notifications")]
[DisplayName("Webhook Port")]
[Description("The local port to listen on for incoming Threema messages (e.g. 8080).")]
public int ThreemaWebhookPort { get; set; } = 8080;
[Category("Threema Notifications")]
[DisplayName("Report Interval (Hours)")]
[Description("Interval for the automatic summary report.")]
public int ThreemaReportIntervalHours { get; set; } = 6;
[Category("Mullvad VPN")]
[DisplayName("VPN Enabled")]
[Description("Enable or disable automatic VPN rotation.")]
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
namespace PolyTrader.Core.Notifications
{
/// <summary>Einstufung einer Benachrichtigung steuert später die Kanalwahl und das Alarmverhalten.</summary>
public enum NotificationSeverity
{
/// <summary>Reine Information (z.B. Tagesbericht).</summary>
Info,
/// <summary>Auffälligkeit, die Beachtung verdient (z.B. Auto-Pause eines Master-Traders).</summary>
Warning,
/// <summary>Kritisch erfordert Eingreifen (z.B. Verlust-Eskalation der Sell-Ladder).</summary>
Critical
}
/// <summary>
/// Neutraler Ausgang für Benachrichtigungen an den Betreiber. Ersetzt die frühere direkte
/// Abhängigkeit der Dienste auf den (verworfenen) Threema-Versand: die Fachlogik kennt nur noch
/// diese Schnittstelle, der tatsächliche Kanal wird per DI gesetzt. Geplante Implementierungen
/// sind RocketChat und Telegram; bis dahin schreibt <see cref="LogNotificationSink"/> ins Terminal.
///
/// <para><b>Vertrag für JEDE Implementierung bindend:</b> <see cref="SendAsync"/> wirft NIEMALS.
/// Netzwerk- oder Kanalfehler werden intern abgefangen und protokolliert. Ein ausgefallener
/// Benachrichtigungskanal darf den Handelsablauf unter keinen Umständen unterbrechen die
/// Aufrufer verzichten deshalb bewusst auf eigene try/catch-Blöcke.</para>
/// </summary>
public interface INotificationSink
{
/// <summary>
/// Sendet eine Benachrichtigung. Wirft nie (siehe Vertrag in der Schnittstellen-Doku).
/// </summary>
Task SendAsync(string message, NotificationSeverity severity = NotificationSeverity.Info,
CancellationToken ct = default);
}
}
@@ -0,0 +1,49 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using PolyTraderSharp.Services;
namespace PolyTrader.Core.Notifications
{
/// <summary>
/// Übergangs-Implementierung von <see cref="INotificationSink"/>: schreibt Benachrichtigungen ins
/// Terminal-Log (und damit in die Tages-JSONL-Datei), bis die echten Kanäle (RocketChat, Telegram)
/// angebunden sind. Damit gehen Auto-Pause- und Eskalationsmeldungen nach dem Threema-Ausbau nicht
/// verloren sie sind im Terminal-Fenster und in den Logs auffindbar.
///
/// Erfüllt den No-Throw-Vertrag der Schnittstelle.
/// </summary>
public sealed class LogNotificationSink : INotificationSink
{
private readonly TerminalLogger _logger;
public LogNotificationSink(TerminalLogger logger) => _logger = logger;
public Task SendAsync(string message, NotificationSeverity severity = NotificationSeverity.Info,
CancellationToken ct = default)
{
try
{
string text = $"🔔 [Benachrichtigung] {message}";
switch (severity)
{
case NotificationSeverity.Critical:
case NotificationSeverity.Warning:
_logger.Warning(text);
break;
default:
_logger.Info(text);
break;
}
}
catch (Exception ex)
{
// No-Throw-Vertrag: ein defekter Benachrichtigungsweg darf den Handel nie unterbrechen.
try { Console.WriteLine($"[NotificationSink] Zustellung fehlgeschlagen (ignoriert): {ex.Message}"); }
catch { /* selbst Console kann in Sonderfällen fehlschlagen */ }
}
return Task.CompletedTask;
}
}
}
@@ -21,10 +21,6 @@
<PackageReference Include="Nethereum.Web3" Version="6.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\libs\Threema-MsgApi-Net-Core\IcgSoftware.Threema.CoreMsgApi\IcgSoftware.Threema.CoreMsgApi.csproj" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Helfer (z.B. SecretProtection.Reset) zu testen. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
@@ -767,7 +767,10 @@ namespace PolyTraderSharp.Services
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
if (int.TryParse(decMatch.Groups[1].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newMaker)
&& int.TryParse(decMatch.Groups[2].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
@@ -778,7 +781,8 @@ namespace PolyTraderSharp.Services
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
if (int.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
@@ -789,7 +793,8 @@ namespace PolyTraderSharp.Services
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
if (decimal.TryParse(sizeMatch.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out decimal minReq))
{
if (orderType != "MARKET")
{
@@ -812,12 +817,15 @@ namespace PolyTraderSharp.Services
decimal totalBal = 0m, activeOrders = 0m;
if (balMatch1.Success)
{
_ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal);
_ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders);
_ = decimal.TryParse(balMatch1.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out totalBal);
_ = decimal.TryParse(balMatch1.Groups[2].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out activeOrders);
}
else if (balMatch2.Success)
{
_ = decimal.TryParse(balMatch2.Groups[1].Value, out totalBal);
_ = decimal.TryParse(balMatch2.Groups[1].Value, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out totalBal);
activeOrders = 0m;
}
@@ -1,270 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
using IcgSoftware.Threema.CoreMsgApi;
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
namespace PolyTraderSharp.Services
{
public class ThreemaService : BackgroundService
{
public event Action<string>? OnCommandReceived;
private readonly TerminalLogger _logger;
private ServerSettings _settings;
private readonly string _settingsPath = "server_settings.xml";
private readonly JobStatusRow _jobStatus;
private HttpListener? _httpListener;
private APIConnector? _apiConnector;
public ThreemaService(TerminalLogger logger, JobManager jobManager)
{
_logger = logger;
_settings = ServerSettings.Load(_settingsPath);
_jobStatus = new JobStatusRow
{
JobName = "Threema Webhook Listener",
Description = "Listens for incoming Threema Gateway Webhooks on the configured port.",
StatusText = "Pending Initial Delay..."
};
_jobStatus.ManualTriggerAction = async () =>
{
_jobStatus.StatusText = "Manual trigger not supported for Webhook";
await Task.Delay(2000);
};
jobManager.RegisterJob(_jobStatus);
InitConnector();
}
public void ReloadSettings()
{
_settings = ServerSettings.Load(_settingsPath);
InitConnector();
}
private void InitConnector()
{
if (_settings.ThreemaEnabled && !string.IsNullOrEmpty(_settings.ThreemaGatewayId) && !string.IsNullOrEmpty(_settings.ThreemaSecret))
{
// Initialize the pt-icg SDK APIConnector
_apiConnector = new APIConnector(_settings.ThreemaGatewayId, _settings.ThreemaSecret, new PublicKeyStoreNone());
}
}
public async Task<bool> SendMessageAsync(string text, string parseMode = "")
{
if (!_settings.ThreemaEnabled || _apiConnector == null)
{
return false;
}
// Using the GroupID field as the target (could be a Threema ID)
string targetId = _settings.ThreemaGroupId;
if (string.IsNullOrEmpty(targetId))
{
_logger.Error("Threema send failed: Target ID (Group ID) is not configured.");
return false;
}
try
{
return await Task.Run(() =>
{
// If no private key is configured, fallback to Basic mode (SendTextMessageSimple)
// Note: Basic mode does not support actual Group messaging, so targetId must be a personal Threema ID.
// If a private key IS configured, we would use E2E mode, but without the official 2.0 SDK's
// SendGroupTextMessage, we just send a direct E2E message.
if (string.IsNullOrEmpty(_settings.ThreemaPrivateKey))
{
string msgId = _apiConnector.SendTextMessageSimple(targetId, text);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema basic message sent. ID: {msgId}");
return true;
}
}
else
{
// E2E Mode Direct Message
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(targetId);
if (publicKey == null)
{
_logger.Error($"Threema E2E failed: Could not lookup public key for {targetId}");
return false;
}
byte[] nonce = CryptTool.RandomNonce();
var encryptResult = CryptTool.EncryptTextMessage(text, privateKey, publicKey);
if (encryptResult != null && encryptResult.Result != null)
{
byte[] box = encryptResult.Result;
string msgId = _apiConnector.SendE2EMessage(targetId, encryptResult.Nonce, box);
if (!string.IsNullOrEmpty(msgId))
{
_logger.Info($"Threema E2E message sent. ID: {msgId}");
return true;
}
}
}
return false;
});
}
catch (Exception ex)
{
_logger.Error($"Threema send failed: {ex.Message}");
return false;
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_jobStatus.StatusText = "Idle";
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.ThreemaEnabled || !_jobStatus.IsEnabled)
{
_jobStatus.StatusText = "Paused / Disabled";
if (_httpListener != null && _httpListener.IsListening)
{
_httpListener.Stop();
}
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
if (_httpListener == null || !_httpListener.IsListening)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_settings.ThreemaWebhookPort}/");
_httpListener.Start();
_jobStatus.StatusText = $"Listening on port {_settings.ThreemaWebhookPort}...";
_logger.Info($"[Threema] Webhook listener started on port {_settings.ThreemaWebhookPort}");
}
_jobStatus.LastRun = DateTime.Now;
var getContextTask = _httpListener.GetContextAsync();
var delayTask = Task.Delay(5000, stoppingToken);
var completedTask = await Task.WhenAny(getContextTask, delayTask);
if (completedTask == getContextTask)
{
var context = await getContextTask;
_ = Task.Run(() => HandleIncomingWebhook(context), stoppingToken);
}
}
catch (TaskCanceledException) { }
catch (Exception ex)
{
if (ex is HttpListenerException hle && hle.ErrorCode == 5)
{
_logger.Error($"[Threema] Access Denied starting Webhook. Try running as Administrator or run: netsh http add urlacl url=http://*:{_settings.ThreemaWebhookPort}/ user=Everyone");
}
else
{
_logger.Error($"[Threema] Listener error: {ex.Message}");
}
_jobStatus.StatusText = "Error! Retrying in 5s...";
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
_httpListener = null;
}
await Task.Delay(5000, stoppingToken);
}
_jobStatus.NextRun = DateTime.Now;
}
if (_httpListener != null)
{
try { _httpListener.Close(); } catch { }
}
}
private void HandleIncomingWebhook(HttpListenerContext context)
{
try
{
var request = context.Request;
var response = context.Response;
if (request.HttpMethod == "POST")
{
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
string body = reader.ReadToEnd();
var parsedParams = System.Web.HttpUtility.ParseQueryString(body);
string? from = parsedParams["from"];
string? to = parsedParams["to"];
string? nonceStr = parsedParams["nonce"];
string? boxStr = parsedParams["box"];
string? macStr = parsedParams["mac"];
// Decrypt the message if E2E
if (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(nonceStr) && !string.IsNullOrEmpty(boxStr) && !string.IsNullOrEmpty(_settings.ThreemaPrivateKey) && _apiConnector != null)
{
try
{
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
byte[] publicKey = _apiConnector.LookupKey(from);
byte[] nonce = DataUtils.HexStringToByteArray(nonceStr);
byte[] box = DataUtils.HexStringToByteArray(boxStr);
if (publicKey != null)
{
var msg = CryptTool.DecryptMessage(box, privateKey, publicKey, nonce);
if (msg is IcgSoftware.Threema.CoreMsgApi.Messages.TextMessage textMsg)
{
string text = textMsg.Text;
string logText = text.Length > 200 ? text.Substring(0, 200) + "..." : text;
_logger.Info($"[Threema] Empfangen von {from}: {logText}");
if (text.StartsWith("/"))
{
OnCommandReceived?.Invoke(text);
}
}
}
}
catch (Exception dex)
{
_logger.Warning($"[Threema] Failed to decrypt incoming message: {dex.Message}");
}
}
else
{
_logger.Info($"[Threema] Received webhook, but cannot process (Basic mode doesn't support incoming, or missing E2E keys).");
}
}
}
response.StatusCode = 200;
response.Close();
}
catch (Exception ex)
{
_logger.Error($"[Threema] Webhook handling error: {ex.Message}");
}
}
}
}
@@ -1,4 +1,5 @@
using System;
using PolyTrader.Core.Notifications;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTrader.Modules.CopyTrading.Persistence;
using System.Collections.Generic;
@@ -19,11 +20,11 @@ namespace PolyTraderSharp.Services
private readonly IMasterTraderHistoryRepository _historyRepo;
private readonly ITrackedTraderRepository _traderRepo;
private readonly ICopyTradeLogRepository _tradeLog;
private readonly ThreemaService _threema;
private readonly INotificationSink _notify;
private readonly JobStatusRow _jobStatus;
private readonly PolymarketApiService _api;
public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, ICopyTradeLogRepository tradeLog, ThreemaService threema, JobManager jobManager, PolymarketApiService api)
public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, ICopyTradeLogRepository tradeLog, INotificationSink notify, JobManager jobManager, PolymarketApiService api)
{
_state = state;
_copyState = copyState;
@@ -31,7 +32,7 @@ namespace PolyTraderSharp.Services
_historyRepo = historyRepo;
_traderRepo = traderRepo;
_tradeLog = tradeLog;
_threema = threema;
_notify = notify;
_api = api;
_jobStatus = new JobStatusRow
@@ -137,7 +138,9 @@ namespace PolyTraderSharp.Services
if (tsRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsRaw).UtcDateTime;
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsRaw).UtcDateTime;
}
else if (tsProp.ValueKind == JsonValueKind.String && long.TryParse(tsProp.GetString(), out long tsStrRaw))
else if (tsProp.ValueKind == JsonValueKind.String
&& long.TryParse(tsProp.GetString(), System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out long tsStrRaw))
{
if (tsStrRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsStrRaw).UtcDateTime;
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsStrRaw).UtcDateTime;
@@ -258,8 +261,12 @@ namespace PolyTraderSharp.Services
trader.IsActive = false;
trader.Reasoning = $"[Auto-Pause {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC] Copy-PnL {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Live-Trades (30T) unter Schwelle (-{_copyState.AutoPauseDrawdownUsd:F0}). Reaktivierung manuell.";
_logger.Warning($"🛑 [AUTO-PAUSE] Master '{trader.DisplayName}' deaktiviert. Live-Copy-PnL {liveMetrics.CopyPnl:F2} / {liveMetrics.TradeCount} Trades. Reaktivierung nur manuell.");
try { await _threema.SendMessageAsync($"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\nLive-Copy-PnL 30T: {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Trades.\nReaktivierung manuell."); }
catch (Exception ex) { _logger.Error($"Threema Auto-Pause-Benachrichtigung fehlgeschlagen: {ex.Message}"); }
// Kein try/catch nötig: INotificationSink wirft laut Vertrag nie.
await _notify.SendAsync(
$"🛑 Auto-Pause: Master '{trader.DisplayName}' deaktiviert.\n" +
$"Live-Copy-PnL 30T: {liveMetrics.CopyPnl:F2} USDC über {liveMetrics.TradeCount} Trades.\n" +
"Reaktivierung manuell.",
NotificationSeverity.Warning);
}
_copyState.Traders[trader.Id] = trader; // Hot-Path-State synchron halten
@@ -1,9 +1,10 @@
using System;
using System;
using System.Linq;
using System.Threading;
using PolyTrader.Core.Trading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Notifications;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Logic;
using PolyTraderSharp.Models;
@@ -16,7 +17,7 @@ namespace PolyTraderSharp.Services
/// Master-Exit und markiert die Position <see cref="Position.ExitPending"/>. Dieser Service
/// senkt das Limit stufenweise (relative Schrittweite) bis zum Floor, wenn kein Fill kommt:
/// canceln → eine Stufe tiefer neu platzieren. Am Floor ohne Fill: Position halten +
/// Threema-Benachrichtigung. Fills erkennt der bestehende Sync (TraderMonitorService), der die
/// Benachrichtigung über <see cref="INotificationSink"/>. Fills erkennt der bestehende Sync (TraderMonitorService), der die
/// Position entfernt → die Leiter endet dann von selbst.
///
/// Bewusst separat vom CLOB-Order-Code gehalten; die Preislogik ist in <see cref="SellLogic"/>
@@ -30,7 +31,7 @@ namespace PolyTraderSharp.Services
private readonly TradingState _state;
private readonly IClobClient _clob;
private readonly TerminalLogger _logger;
private readonly ThreemaService _threema;
private readonly INotificationSink _notify;
private readonly IPositionRepository _positionRepo;
private readonly IOrderEventLog _orderEvents;
@@ -39,7 +40,7 @@ namespace PolyTraderSharp.Services
TradingState state,
IClobClient clob,
TerminalLogger logger,
ThreemaService threema,
INotificationSink notify,
IPositionRepository positionRepo,
IOrderEventLog orderEvents)
{
@@ -47,7 +48,7 @@ namespace PolyTraderSharp.Services
_state = state;
_clob = clob;
_logger = logger;
_threema = threema;
_notify = notify;
_positionRepo = positionRepo;
_orderEvents = orderEvents;
}
@@ -269,7 +270,8 @@ namespace PolyTraderSharp.Services
$"Konto: {account.Name}\nMarkt: {ladder.MarketQuestion}\n" +
$"Floor: {ladder.Floor:F3} (Master-Exit war {ladder.ReferencePrice:F3})";
_logger.Warning($"🛑 [SELL-LEITER Floor] {account.Name} | {ladder.MarketQuestion} | Floor {ladder.Floor:F3} ohne Fill halte Position.");
try { await _threema.SendMessageAsync(msg); } catch (Exception ex) { _logger.Error($"Threema-Benachrichtigung fehlgeschlagen: {ex.Message}"); }
// Kein try/catch nötig: INotificationSink wirft laut Vertrag nie.
await _notify.SendAsync(msg, NotificationSeverity.Critical);
}
ladder.LastActionAt = DateTime.UtcNow; // Re-Notify-Spam vermeiden
return;
@@ -1220,7 +1220,13 @@ namespace PolyTraderSharp.Services
}
}
private decimal ParseDecimal(JsonElement prop)
/// <summary>
/// Liest einen Zahlenwert, der von der API je nach Endpunkt als JSON-Zahl ODER als String
/// ("0.53") kommt. Das String-Parsing ist bewusst invariant: unter de-DE gilt "." als
/// Tausendertrennzeichen, "0.53" würde sonst als 53 gelesen (Faktor-100-Fehler im Preis).
/// <c>internal static</c>, damit die Kultur-Unabhängigkeit testbar bleibt.
/// </summary>
internal static decimal ParseDecimal(JsonElement prop)
{
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
@@ -1273,19 +1279,11 @@ namespace PolyTraderSharp.Services
foreach (var act in elements)
{
decimal price = 0m;
if (act.TryGetProperty("price", out var priceProp))
{
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
}
decimal size = 0m;
if (act.TryGetProperty("size", out var sizeProp))
{
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
}
// Über ParseDecimal (invariant) statt inline: die API liefert Preis/Size je nach
// Endpunkt als Zahl ODER als String ("0.53"). Kulturabhängiges Parsen läse "."
// unter de-DE als Tausendertrennzeichen aus 0,53 würde 53 (Faktor-100-Fehler).
decimal price = act.TryGetProperty("price", out var priceProp) ? ParseDecimal(priceProp) : 0m;
decimal size = act.TryGetProperty("size", out var sizeProp) ? ParseDecimal(sizeProp) : 0m;
totalSize += size;
weightedPriceSum += (price * size);
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
namespace PolyTrader.Modules.ResolutionFarming.Models
{
@@ -72,7 +72,7 @@ namespace PolyTrader.Modules.ResolutionFarming.Models
[Category("03. Risiko")]
[DisplayName("Tagesverlust-Kill-Switch (USDC)")]
[Description("Überschreitet der realisierte Tagesverlust diesen Betrag, pausiert das Modul + Threema-Warnung. 0 = deaktiviert.")]
[Description("Überschreitet der realisierte Tagesverlust diesen Betrag, pausiert das Modul + Warnmeldung. 0 = deaktiviert.")]
public decimal DailyLossKillSwitchUsd { get; set; } = 0m;
// ----- Ausführung -----
@@ -1,7 +1,8 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Notifications;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTraderSharp.Services;
@@ -10,7 +11,8 @@ namespace PolyTrader.Modules.Supervisor.Services
{
/// <summary>
/// Täglicher Supervisor-Bericht (S-3): lässt den Agenten einmal am Tag eine Kurz-Analyse der
/// letzten 24 h erstellen (KPIs, Auffälligkeiten, Fehler) und sendet sie via Threema; zusätzlich
/// letzten 24 h erstellen (KPIs, Auffälligkeiten, Fehler) und sendet sie über den konfigurierten
/// Benachrichtigungskanal (<see cref="INotificationSink"/>); zusätzlich
/// als sup_report gespeichert. OPT-IN: läuft nur, wenn POLYTRADER_SUPERVISOR_DAILY die Ziel-Stunde
/// (023, lokale Zeit) enthält — und braucht den OpenRouter-Key (sonst still übersprungen).
/// </summary>
@@ -25,12 +27,12 @@ namespace PolyTrader.Modules.Supervisor.Services
private readonly SupervisorAgent _agent;
private readonly ISupervisorReportRepository _reports;
private readonly Func<string, Task> _sendAsync; // Threema-Versand (injizierbar für Tests)
private readonly Func<string, Task> _sendAsync; // Versand (injizierbar für Tests)
private readonly TerminalLogger _logger;
public DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
ThreemaService threema, TerminalLogger logger)
: this(agent, reports, async msg => await threema.SendMessageAsync(msg), logger) { }
INotificationSink notify, TerminalLogger logger)
: this(agent, reports, async msg => await notify.SendAsync(msg, NotificationSeverity.Info), logger) { }
internal DailyReportService(SupervisorAgent agent, ISupervisorReportRepository reports,
Func<string, Task> sendAsync, TerminalLogger logger)
@@ -1,4 +1,4 @@
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -43,7 +43,7 @@ namespace PolyTrader.Modules.Supervisor
services.AddSingleton<Persistence.ISupervisorReportRepository, Persistence.EfSupervisorReportRepository>();
services.AddSingleton<Persistence.ISupervisorCounterfactualRepository, Persistence.EfSupervisorCounterfactualRepository>();
// S-3: Counterfactual-Job (abgelehnte BUYs vs. Marktausgang) + täglicher Threema-Bericht
// S-3: Counterfactual-Job (abgelehnte BUYs vs. Marktausgang) + täglicher Bericht
// (OPT-IN via POLYTRADER_SUPERVISOR_DAILY = Stunde 023).
services.AddSingleton<Counterfactual.ICounterfactualResolutionSource, Counterfactual.ApiCounterfactualResolutionSource>();
services.AddHostedService<Counterfactual.CounterfactualJob>();