Persistenza dati e logging avanzato con UI e Unraid

- Aggiunto TradeHistoryService per persistenza trade/posizioni attive su disco (JSON, auto-save/restore)
- Logging centralizzato (LoggingService) con livelli, categorie, simbolo e buffer circolare (500 log)
- Nuova pagina Logs: monitoraggio real-time, filtri avanzati, cancellazione log, colorazione livelli
- Sezione "Dati Persistenti" in Settings: conteggio trade, dimensione dati, reset con conferma modale
- Background service per salvataggio sicuro su shutdown/stop container
- Aggiornata sidebar, stili modali/bottoni danger, .gitignore e documentazione (README, CHANGELOG, UNRAID_INSTALL, checklist)
- Versione 1.3.0
This commit is contained in:
2025-12-22 11:24:17 +01:00
parent d7ae3e5d44
commit 92c8e57a8c
15 changed files with 1697 additions and 36 deletions

View File

@@ -0,0 +1,122 @@
using TradingBot.Models;
using System.Collections.Concurrent;
namespace TradingBot.Services;
/// <summary>
/// Centralized logging service for application events
/// </summary>
public class LoggingService
{
private readonly ConcurrentQueue<LogEntry> _logs = new();
private const int MaxLogEntries = 500;
public event Action? OnLogAdded;
/// <summary>
/// Get all log entries
/// </summary>
public IReadOnlyList<LogEntry> GetLogs()
{
return _logs.ToList().AsReadOnly();
}
/// <summary>
/// Add a debug log entry
/// </summary>
public void LogDebug(string category, string message, string? details = null)
{
AddLog(Models.LogLevel.Debug, category, message, details);
}
/// <summary>
/// Add an info log entry
/// </summary>
public void LogInfo(string category, string message, string? details = null, string? symbol = null)
{
AddLog(Models.LogLevel.Info, category, message, details, symbol);
}
/// <summary>
/// Add a warning log entry
/// </summary>
public void LogWarning(string category, string message, string? details = null, string? symbol = null)
{
AddLog(Models.LogLevel.Warning, category, message, details, symbol);
}
/// <summary>
/// Add an error log entry
/// </summary>
public void LogError(string category, string message, string? details = null, string? symbol = null)
{
AddLog(Models.LogLevel.Error, category, message, details, symbol);
}
/// <summary>
/// Add a trade log entry
/// </summary>
public void LogTrade(string symbol, string message, string? details = null)
{
AddLog(Models.LogLevel.Trade, "Trading", message, details, symbol);
}
/// <summary>
/// Clear all logs
/// </summary>
public void ClearLogs()
{
_logs.Clear();
OnLogAdded?.Invoke();
}
/// <summary>
/// Get logs filtered by level
/// </summary>
public IReadOnlyList<LogEntry> GetLogsByLevel(Models.LogLevel level)
{
return _logs.Where(l => l.Level == level).ToList().AsReadOnly();
}
/// <summary>
/// Get logs filtered by category
/// </summary>
public IReadOnlyList<LogEntry> GetLogsByCategory(string category)
{
return _logs.Where(l => l.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
.ToList()
.AsReadOnly();
}
/// <summary>
/// Get logs filtered by symbol
/// </summary>
public IReadOnlyList<LogEntry> GetLogsBySymbol(string symbol)
{
return _logs.Where(l => l.Symbol != null && l.Symbol.Equals(symbol, StringComparison.OrdinalIgnoreCase))
.ToList()
.AsReadOnly();
}
private void AddLog(Models.LogLevel level, string category, string message, string? details = null, string? symbol = null)
{
var logEntry = new LogEntry
{
Level = level,
Category = category,
Message = message,
Details = details,
Symbol = symbol
};
_logs.Enqueue(logEntry);
// Maintain max size
while (_logs.Count > MaxLogEntries)
{
_logs.TryDequeue(out _);
}
OnLogAdded?.Invoke();
}
}

View File

@@ -0,0 +1,164 @@
using System.Text.Json;
using TradingBot.Models;
namespace TradingBot.Services;
/// <summary>
/// Service for persisting trade history and active positions to disk
/// </summary>
public class TradeHistoryService
{
private readonly string _dataDirectory;
private readonly string _tradesFilePath;
private readonly string _activePositionsFilePath;
private readonly ILogger<TradeHistoryService> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public TradeHistoryService(ILogger<TradeHistoryService> logger)
{
_logger = logger;
_dataDirectory = Path.Combine(Directory.GetCurrentDirectory(), "data");
_tradesFilePath = Path.Combine(_dataDirectory, "trade-history.json");
_activePositionsFilePath = Path.Combine(_dataDirectory, "active-positions.json");
_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
EnsureDataDirectoryExists();
}
private void EnsureDataDirectoryExists()
{
if (!Directory.Exists(_dataDirectory))
{
Directory.CreateDirectory(_dataDirectory);
_logger.LogInformation("Created data directory: {Directory}", _dataDirectory);
}
}
/// <summary>
/// Save complete trade history to disk
/// </summary>
public async Task SaveTradeHistoryAsync(List<Trade> trades)
{
try
{
var json = JsonSerializer.Serialize(trades, _jsonOptions);
await File.WriteAllTextAsync(_tradesFilePath, json);
_logger.LogInformation("Saved {Count} trades to history", trades.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save trade history");
}
}
/// <summary>
/// Load trade history from disk
/// </summary>
public async Task<List<Trade>> LoadTradeHistoryAsync()
{
try
{
if (!File.Exists(_tradesFilePath))
{
_logger.LogInformation("No trade history file found, starting fresh");
return new List<Trade>();
}
var json = await File.ReadAllTextAsync(_tradesFilePath);
var trades = JsonSerializer.Deserialize<List<Trade>>(json, _jsonOptions);
_logger.LogInformation("Loaded {Count} trades from history", trades?.Count ?? 0);
return trades ?? new List<Trade>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load trade history, starting fresh");
return new List<Trade>();
}
}
/// <summary>
/// Save active positions (open trades) to disk
/// </summary>
public async Task SaveActivePositionsAsync(Dictionary<string, Trade> activePositions)
{
try
{
var json = JsonSerializer.Serialize(activePositions, _jsonOptions);
await File.WriteAllTextAsync(_activePositionsFilePath, json);
_logger.LogInformation("Saved {Count} active positions", activePositions.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save active positions");
}
}
/// <summary>
/// Load active positions from disk
/// </summary>
public async Task<Dictionary<string, Trade>> LoadActivePositionsAsync()
{
try
{
if (!File.Exists(_activePositionsFilePath))
{
_logger.LogInformation("No active positions file found");
return new Dictionary<string, Trade>();
}
var json = await File.ReadAllTextAsync(_activePositionsFilePath);
var positions = JsonSerializer.Deserialize<Dictionary<string, Trade>>(json, _jsonOptions);
_logger.LogInformation("Loaded {Count} active positions", positions?.Count ?? 0);
return positions ?? new Dictionary<string, Trade>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load active positions");
return new Dictionary<string, Trade>();
}
}
/// <summary>
/// Clear all persisted data
/// </summary>
public void ClearAll()
{
try
{
if (File.Exists(_tradesFilePath))
File.Delete(_tradesFilePath);
if (File.Exists(_activePositionsFilePath))
File.Delete(_activePositionsFilePath);
_logger.LogInformation("Cleared all persisted trade data");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear persisted data");
}
}
/// <summary>
/// Get total file size of persisted data
/// </summary>
public long GetDataSize()
{
long size = 0;
if (File.Exists(_tradesFilePath))
size += new FileInfo(_tradesFilePath).Length;
if (File.Exists(_activePositionsFilePath))
size += new FileInfo(_activePositionsFilePath).Length;
return size;
}
}

View File

@@ -0,0 +1,58 @@
namespace TradingBot.Services;
/// <summary>
/// Background service for automatic data persistence on application shutdown
/// </summary>
public class TradingBotBackgroundService : BackgroundService
{
private readonly TradingBotService _tradingBotService;
private readonly ILogger<TradingBotBackgroundService> _logger;
private readonly IHostApplicationLifetime _lifetime;
public TradingBotBackgroundService(
TradingBotService tradingBotService,
ILogger<TradingBotBackgroundService> logger,
IHostApplicationLifetime lifetime)
{
_tradingBotService = tradingBotService;
_logger = logger;
_lifetime = lifetime;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("TradingBot Background Service started");
// Register shutdown handler
_lifetime.ApplicationStopping.Register(OnShutdown);
// Keep service running
await Task.Delay(Timeout.Infinite, stoppingToken);
}
private void OnShutdown()
{
_logger.LogInformation("Application shutdown detected, saving trade data...");
try
{
// Stop bot if running
if (_tradingBotService.Status.IsRunning)
{
_tradingBotService.Stop();
}
_logger.LogInformation("Trade data saved successfully on shutdown");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving data on shutdown");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("TradingBot Background Service stopping");
await base.StopAsync(cancellationToken);
}
}

View File

@@ -6,17 +6,22 @@ public class TradingBotService
{
private readonly IMarketDataService _marketDataService;
private readonly ITradingStrategy _strategy;
private readonly TradeHistoryService _historyService;
private readonly LoggingService _loggingService;
private readonly Dictionary<string, AssetConfiguration> _assetConfigs = new();
private readonly Dictionary<string, AssetStatistics> _assetStats = new();
private readonly List<Trade> _trades = new();
private readonly Dictionary<string, List<MarketPrice>> _priceHistory = new();
private readonly Dictionary<string, TechnicalIndicators> _indicators = new();
private readonly Dictionary<string, Trade> _activePositions = new();
private Timer? _timer;
private Timer? _persistenceTimer;
public BotStatus Status { get; private set; } = new();
public IReadOnlyList<Trade> Trades => _trades.AsReadOnly();
public IReadOnlyDictionary<string, AssetConfiguration> AssetConfigurations => _assetConfigs;
public IReadOnlyDictionary<string, AssetStatistics> AssetStatistics => _assetStats;
public IReadOnlyDictionary<string, Trade> ActivePositions => _activePositions;
public event Action? OnStatusChanged;
public event Action<TradingSignal>? OnSignalGenerated;
@@ -25,10 +30,16 @@ public class TradingBotService
public event Action<string, MarketPrice>? OnPriceUpdated;
public event Action? OnStatisticsUpdated;
public TradingBotService(IMarketDataService marketDataService, ITradingStrategy strategy)
public TradingBotService(
IMarketDataService marketDataService,
ITradingStrategy strategy,
TradeHistoryService historyService,
LoggingService loggingService)
{
_marketDataService = marketDataService;
_strategy = strategy;
_historyService = historyService;
_loggingService = loggingService;
Status.CurrentStrategy = strategy.Name;
// Subscribe to simulated market updates if available
@@ -38,6 +49,52 @@ public class TradingBotService
}
InitializeDefaultAssets();
// Load persisted data
_ = LoadPersistedDataAsync();
_loggingService.LogInfo("System", "TradingBot Service initialized");
}
private async Task LoadPersistedDataAsync()
{
try
{
// Load trade history
var trades = await _historyService.LoadTradeHistoryAsync();
_trades.AddRange(trades);
// Load active positions
var positions = await _historyService.LoadActivePositionsAsync();
foreach (var kvp in positions)
{
_activePositions[kvp.Key] = kvp.Value;
}
// Restore asset configurations from active positions
RestoreAssetConfigurationsFromTrades();
OnStatusChanged?.Invoke();
}
catch (Exception ex)
{
Console.WriteLine($"Error loading persisted data: {ex.Message}");
}
}
private void RestoreAssetConfigurationsFromTrades()
{
foreach (var position in _activePositions.Values)
{
if (_assetConfigs.TryGetValue(position.Symbol, out var config))
{
if (position.Type == TradeType.Buy)
{
config.CurrentHoldings += position.Amount;
config.AverageEntryPrice = position.Price;
}
}
}
}
private void InitializeDefaultAssets()
@@ -64,7 +121,7 @@ public class TradingBotService
{
Symbol = symbol,
Name = assetNames.TryGetValue(symbol, out var name) ? name : symbol,
IsEnabled = true, // Enable ALL assets by default for full simulation
IsEnabled = true,
InitialBalance = 1000m,
CurrentBalance = 1000m
};
@@ -122,6 +179,8 @@ public class TradingBotService
Status.IsRunning = true;
Status.StartedAt = DateTime.UtcNow;
_loggingService.LogInfo("Bot", "Trading Bot started", $"Strategy: {_strategy.Name}");
// Reset daily trade counts
foreach (var config in _assetConfigs.Values)
{
@@ -135,10 +194,17 @@ public class TradingBotService
// Start update timer (every 3 seconds for simulation)
_timer = new Timer(async _ => await UpdateAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(3));
// Start persistence timer (save every 30 seconds)
_persistenceTimer = new Timer(
async _ => await SaveDataAsync(),
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30));
OnStatusChanged?.Invoke();
}
public void Stop()
public async void Stop()
{
if (!Status.IsRunning) return;
@@ -146,9 +212,30 @@ public class TradingBotService
_timer?.Dispose();
_timer = null;
_persistenceTimer?.Dispose();
_persistenceTimer = null;
_loggingService.LogInfo("Bot", "Trading Bot stopped", $"Total trades: {_trades.Count}");
// Save data on stop
await SaveDataAsync();
OnStatusChanged?.Invoke();
}
private async Task SaveDataAsync()
{
try
{
await _historyService.SaveTradeHistoryAsync(_trades);
await _historyService.SaveActivePositionsAsync(_activePositions);
}
catch (Exception ex)
{
Console.WriteLine($"Error saving data: {ex.Message}");
}
}
private void HandleSimulatedPriceUpdate()
{
if (Status.IsRunning)
@@ -192,7 +279,6 @@ public class TradingBotService
private async Task ProcessAssetUpdate(MarketPrice price)
{
// Add null check for price
if (price == null || price.Price <= 0)
return;
@@ -228,7 +314,6 @@ public class TradingBotService
// Generate trading signal
var signal = await _strategy.AnalyzeAsync(price.Symbol, _priceHistory[price.Symbol]);
// Add null check for signal
if (signal != null)
{
OnSignalGenerated?.Invoke(signal);
@@ -266,7 +351,7 @@ public class TradingBotService
if (tradeAmount >= config.MinTradeAmount)
{
ExecuteBuy(symbol, price.Price, tradeAmount, config);
await ExecuteBuyAsync(symbol, price.Price, tradeAmount, config);
}
}
// Sell logic
@@ -283,14 +368,12 @@ public class TradingBotService
if (profitPercentage >= config.TakeProfitPercentage ||
profitPercentage <= -config.StopLossPercentage)
{
ExecuteSell(symbol, price.Price, config.CurrentHoldings, config);
await ExecuteSellAsync(symbol, price.Price, config.CurrentHoldings, config);
}
}
await Task.CompletedTask;
}
private void ExecuteBuy(string symbol, decimal price, decimal amountUSD, AssetConfiguration config)
private async Task ExecuteBuyAsync(string symbol, decimal price, decimal amountUSD, AssetConfiguration config)
{
var amount = amountUSD / price;
@@ -316,14 +399,24 @@ public class TradingBotService
};
_trades.Add(trade);
_activePositions[symbol] = trade;
UpdateAssetStatistics(symbol, trade);
Status.TradesExecuted++;
_loggingService.LogTrade(
symbol,
$"BUY {amount:F6} {symbol} @ ${price:N2}",
$"Value: ${amountUSD:N2} | Balance: ${config.CurrentBalance:N2}");
OnTradeExecuted?.Invoke(trade);
OnStatusChanged?.Invoke();
// Save immediately after trade
await SaveDataAsync();
}
private void ExecuteSell(string symbol, decimal price, decimal amount, AssetConfiguration config)
private async Task ExecuteSellAsync(string symbol, decimal price, decimal amount, AssetConfiguration config)
{
var amountUSD = amount * price;
var profit = (price - config.AverageEntryPrice) * amount;
@@ -346,11 +439,21 @@ public class TradingBotService
};
_trades.Add(trade);
_activePositions.Remove(symbol);
UpdateAssetStatistics(symbol, trade, profit);
Status.TradesExecuted++;
_loggingService.LogTrade(
symbol,
$"SELL {amount:F6} {symbol} @ ${price:N2}",
$"Value: ${amountUSD:N2} | Profit: ${profit:N2} | Balance: ${config.CurrentBalance:N2}");
OnTradeExecuted?.Invoke(trade);
OnStatusChanged?.Invoke();
// Save immediately after trade
await SaveDataAsync();
}
private void UpdateIndicators(string symbol)
@@ -358,13 +461,11 @@ public class TradingBotService
if (!_priceHistory.TryGetValue(symbol, out var history) || history == null || history.Count < 26)
return;
// Filter out null prices and extract valid price values
var prices = history
.Where(p => p != null && p.Price > 0)
.Select(p => p.Price)
.ToList();
// Ensure we still have enough data after filtering
if (prices.Count < 26)
return;
@@ -512,4 +613,22 @@ public class TradingBotService
var history = GetPriceHistory(symbol);
return history?.LastOrDefault();
}
public async Task ClearAllDataAsync()
{
_trades.Clear();
_activePositions.Clear();
_historyService.ClearAll();
foreach (var config in _assetConfigs.Values)
{
config.CurrentBalance = config.InitialBalance;
config.CurrentHoldings = 0;
config.AverageEntryPrice = 0;
config.DailyTradeCount = 0;
}
OnStatusChanged?.Invoke();
await Task.CompletedTask;
}
}