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:
@@ -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
|
||||
/// (0–23, 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 0–23).
|
||||
services.AddSingleton<Counterfactual.ICounterfactualResolutionSource, Counterfactual.ApiCounterfactualResolutionSource>();
|
||||
services.AddHostedService<Counterfactual.CounterfactualJob>();
|
||||
|
||||
Reference in New Issue
Block a user