Gestione massiva limiti prodotto e ottimizzazione ticker

Aggiunta barra azioni per gestione massiva limiti prodotto in Statistics.razor (applica, salva, attiva/disattiva, copia consigliati). Uniformati simboli euro e messaggi in italiano. Ottimizzata la logica del ticker: controllo puntata ora avviene prima del polling, gestione fine asta differita tramite PendingEndState. Introdotto controllo esplicito su MaxClicks per asta. Implementata cache delle impostazioni in SettingsManager per ridurre accessi disco. Vari fix minori e miglioramenti di robustezza.
This commit is contained in:
2026-03-03 08:53:38 +01:00
parent f3262a0497
commit e18a09e1da
5 changed files with 472 additions and 71 deletions

View File

@@ -40,10 +40,10 @@ namespace AutoBidder.Models
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati) public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
/// <summary> /// <summary>
/// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI /// Numero massimo di puntate consentite per questa asta (0 = illimitato).
/// Mantenuto per retrocompatibilità con salvataggi JSON esistenti /// Impostato dall'utente nella griglia statistiche o dai limiti prodotto.
/// Controllato in ShouldBid contro BidsUsedOnThisAuction.
/// </summary> /// </summary>
[Obsolete("MaxClicks non è più utilizzato. Usa invece la logica di limiti per prodotto.")]
[JsonPropertyName("MaxClicks")] [JsonPropertyName("MaxClicks")]
public int MaxClicks { get; set; } = 0; public int MaxClicks { get; set; } = 0;
@@ -107,6 +107,13 @@ namespace AutoBidder.Models
[JsonIgnore] [JsonIgnore]
public double LastScheduledTimerMs { get; set; } public double LastScheduledTimerMs { get; set; }
/// <summary>
/// Stato di fine asta ricevuto dal poll ma non ancora processato.
/// Il ticker ha un'ultima occasione di puntare prima che venga gestito.
/// </summary>
[JsonIgnore]
public AuctionState? PendingEndState { get; set; }
// Storico // Storico
public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>(); public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>();
public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase);
@@ -505,6 +512,7 @@ namespace AutoBidder.Models
// Pulisci oggetti complessi // Pulisci oggetti complessi
LastState = null; LastState = null;
PendingEndState = null;
CalculatedValue = null; CalculatedValue = null;
DuelOpponent = null; DuelOpponent = null;
WinLimitDescription = null; WinLimitDescription = null;

View File

@@ -102,7 +102,6 @@ namespace AutoBidder.Pages
private string? sessionUsername; private string? sessionUsername;
private int sessionRemainingBids; private int sessionRemainingBids;
private double sessionShopCredit; private double sessionShopCredit;
private int sessionAuctionsWon;
// Recommended limits // Recommended limits
private bool isLoadingRecommendations = false; private bool isLoadingRecommendations = false;
@@ -530,6 +529,7 @@ namespace AutoBidder.Pages
// Carica limiti dal database prodotti se disponibili // Carica limiti dal database prodotti se disponibili
double minPrice = settings.DefaultMinPrice; double minPrice = settings.DefaultMinPrice;
double maxPrice = settings.DefaultMaxPrice; double maxPrice = settings.DefaultMaxPrice;
int maxClicks = settings.DefaultMaxClicks;
int bidDeadlineMs = settings.DefaultBidBeforeDeadlineMs; int bidDeadlineMs = settings.DefaultBidBeforeDeadlineMs;
// Se abilitato, cerca limiti salvati per questo prodotto // Se abilitato, cerca limiti salvati per questo prodotto
@@ -551,10 +551,12 @@ namespace AutoBidder.Pages
minPrice = productStats.UserDefaultMinPrice.Value; minPrice = productStats.UserDefaultMinPrice.Value;
if (productStats.UserDefaultMaxPrice.HasValue) if (productStats.UserDefaultMaxPrice.HasValue)
maxPrice = productStats.UserDefaultMaxPrice.Value; maxPrice = productStats.UserDefaultMaxPrice.Value;
if (productStats.UserDefaultMaxBids.HasValue)
maxClicks = productStats.UserDefaultMaxBids.Value;
if (productStats.UserDefaultBidBeforeDeadlineMs.HasValue) if (productStats.UserDefaultBidBeforeDeadlineMs.HasValue)
bidDeadlineMs = productStats.UserDefaultBidBeforeDeadlineMs.Value; bidDeadlineMs = productStats.UserDefaultBidBeforeDeadlineMs.Value;
AddLog($"[STATS] Limiti prodotto (da URL): €{minPrice:F2}-€{maxPrice:F2}"); AddLog($"[STATS] Limiti prodotto (da URL): €{minPrice:F2}-€{maxPrice:F2}, MaxClicks={maxClicks}");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -573,6 +575,7 @@ namespace AutoBidder.Pages
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid, CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
MinPrice = minPrice, MinPrice = minPrice,
MaxPrice = maxPrice, MaxPrice = maxPrice,
MaxClicks = maxClicks,
IsActive = isActive, IsActive = isActive,
IsPaused = isPaused IsPaused = isPaused
}; };
@@ -675,7 +678,7 @@ namespace AutoBidder.Pages
} }
// 3. Cerca limiti prodotto dal database con il nome REALE // 3. Cerca limiti prodotto dal database con il nome REALE
if (updated && !string.IsNullOrWhiteSpace(auction.Name)) if (!string.IsNullOrWhiteSpace(auction.Name))
{ {
var settings = AutoBidder.Utilities.SettingsManager.Load(); var settings = AutoBidder.Utilities.SettingsManager.Load();
if (settings.NewAuctionLimitsPriority == "ProductStats" && StatsService.IsAvailable) if (settings.NewAuctionLimitsPriority == "ProductStats" && StatsService.IsAvailable)
@@ -1543,7 +1546,6 @@ namespace AutoBidder.Pages
sessionUsername = savedSession.Username; sessionUsername = savedSession.Username;
sessionRemainingBids = savedSession.RemainingBids; sessionRemainingBids = savedSession.RemainingBids;
sessionShopCredit = savedSession.ShopCredit; sessionShopCredit = savedSession.ShopCredit;
sessionAuctionsWon = 0; // TODO: add to BidooSession model
// Inizializza AuctionMonitor con la sessione salvata // Inizializza AuctionMonitor con la sessione salvata
if (!string.IsNullOrEmpty(savedSession.CookieString)) if (!string.IsNullOrEmpty(savedSession.CookieString))
@@ -1557,7 +1559,6 @@ namespace AutoBidder.Pages
sessionUsername = session?.Username; sessionUsername = session?.Username;
sessionRemainingBids = session?.RemainingBids ?? 0; sessionRemainingBids = session?.RemainingBids ?? 0;
sessionShopCredit = session?.ShopCredit ?? 0; sessionShopCredit = session?.ShopCredit ?? 0;
sessionAuctionsWon = 0;
} }
} }
@@ -1574,7 +1575,6 @@ namespace AutoBidder.Pages
sessionUsername = session.Username; sessionUsername = session.Username;
sessionRemainingBids = session.RemainingBids; sessionRemainingBids = session.RemainingBids;
sessionShopCredit = session.ShopCredit; sessionShopCredit = session.ShopCredit;
sessionAuctionsWon = 0; // TODO: add to BidooSession model
// Salva sessione aggiornata // Salva sessione aggiornata
AutoBidder.Services.SessionManager.SaveSession(session); AutoBidder.Services.SessionManager.SaveSession(session);

View File

@@ -1,4 +1,4 @@
@page "/statistics" @page "/statistics"
@attribute [Microsoft.AspNetCore.Authorization.Authorize] @attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using AutoBidder.Models @using AutoBidder.Models
@using AutoBidder.Services @using AutoBidder.Services
@@ -39,7 +39,7 @@
<i class="bi bi-database-x"></i> <i class="bi bi-database-x"></i>
<div> <div>
<strong>Statistiche non disponibili</strong> <strong>Statistiche non disponibili</strong>
<small>Il database non è stato configurato o non è accessibile.</small> <small>Il database non è stato configurato o non è accessibile.</small>
</div> </div>
</div> </div>
} }
@@ -68,6 +68,34 @@
</div> </div>
</div> </div>
</div> </div>
<div class="ps-bulk-bar">
<span class="ps-bulk-label">Azioni su @(filteredProducts?.Count ?? 0) prodotti visibili:</span>
<button class="btn-xs btn-outline-info" @onclick="BulkCopyRecommended"
disabled="@isBulkOperating"
title="Applica valori consigliati dall'algoritmo a tutti i prodotti visibili">
<i class="bi bi-magic"></i> Consigliati
</button>
<button class="btn-xs btn-success" @onclick="BulkSaveDefaults"
disabled="@isBulkOperating"
title="Salva i limiti di tutti i prodotti visibili nel database">
<i class="bi bi-floppy"></i> Salva tutto
</button>
<button class="btn-xs btn-primary" @onclick="BulkApplyToAuctions"
disabled="@isBulkOperating"
title="Applica i limiti dei prodotti visibili alle aste monitorate corrispondenti">
<i class="bi bi-check2-all"></i> Applica alle aste
</button>
<button class="btn-xs btn-outline-toggle" @onclick="BulkToggleOn"
disabled="@isBulkOperating"
title="Attiva limiti personalizzati per tutti i prodotti visibili">
<i class="bi bi-toggle-on"></i> Attiva tutti
</button>
<button class="btn-xs btn-outline-toggle" @onclick="BulkToggleOff"
disabled="@isBulkOperating"
title="Disattiva limiti personalizzati per tutti i prodotti visibili">
<i class="bi bi-toggle-off"></i> Disattiva tutti
</button>
</div>
<div class="panel-content"> <div class="panel-content">
@if (filteredProducts == null || !filteredProducts.Any()) @if (filteredProducts == null || !filteredProducts.Any())
{ {
@@ -94,20 +122,20 @@
Win% @GetProductSortIndicator("winrate") Win% @GetProductSortIndicator("winrate")
</th> </th>
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("minprice")'> <th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("minprice")'>
€ Min @GetProductSortIndicator("minprice") € Min @GetProductSortIndicator("minprice")
</th> </th>
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("avgprice")'> <th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("avgprice")'>
€ Med @GetProductSortIndicator("avgprice") € Med @GetProductSortIndicator("avgprice")
</th> </th>
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("medianprice")'> <th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("medianprice")'>
€ Mdn @GetProductSortIndicator("medianprice") € Mdn @GetProductSortIndicator("medianprice")
</th> </th>
<th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("maxprice")'> <th class="ps-col-price sortable-header text-end" @onclick='() => SortProductsBy("maxprice")'>
€ Max @GetProductSortIndicator("maxprice") € Max @GetProductSortIndicator("maxprice")
</th> </th>
<th class="ps-col-toggle text-center">On</th> <th class="ps-col-toggle text-center">On</th>
<th class="ps-col-input text-center">Min €</th> <th class="ps-col-input text-center">Min €</th>
<th class="ps-col-input text-center">Max €</th> <th class="ps-col-input text-center">Max €</th>
<th class="ps-col-input text-center">Max Punt.</th> <th class="ps-col-input text-center">Max Punt.</th>
<th class="ps-col-actions text-center">Azioni</th> <th class="ps-col-actions text-center">Azioni</th>
</tr> </tr>
@@ -129,16 +157,16 @@
<span class="stats-badge @(winRate >= 50 ? "badge-success" : "badge-danger")">@winRate.ToString("F0")%</span> <span class="stats-badge @(winRate >= 50 ? "badge-success" : "badge-danger")">@winRate.ToString("F0")%</span>
</td> </td>
<td class="ps-col-price text-end"> <td class="ps-col-price text-end">
<span class="ps-price-stat">€@(product.MinFinalPrice?.ToString("F2") ?? "-")</span> <span class="ps-price-stat">€@(product.MinFinalPrice?.ToString("F2") ?? "-")</span>
</td> </td>
<td class="ps-col-price text-end"> <td class="ps-col-price text-end">
<span class="ps-price-stat ps-price-avg">€@product.AvgFinalPrice.ToString("F2")</span> <span class="ps-price-stat ps-price-avg">€@product.AvgFinalPrice.ToString("F2")</span>
</td> </td>
<td class="ps-col-price text-end"> <td class="ps-col-price text-end">
<span class="ps-price-stat">€@(product.MedianFinalPrice?.ToString("F2") ?? "-")</span> <span class="ps-price-stat">€@(product.MedianFinalPrice?.ToString("F2") ?? "-")</span>
</td> </td>
<td class="ps-col-price text-end"> <td class="ps-col-price text-end">
<span class="ps-price-stat">€@(product.MaxFinalPrice?.ToString("F2") ?? "-")</span> <span class="ps-price-stat">€@(product.MaxFinalPrice?.ToString("F2") ?? "-")</span>
</td> </td>
<td class="ps-col-toggle text-center" @onclick:stopPropagation="true"> <td class="ps-col-toggle text-center" @onclick:stopPropagation="true">
<label class="ps-switch"> <label class="ps-switch">
@@ -263,7 +291,7 @@
{ {
<tr class="@(auction.Won ? "row-won" : "")"> <tr class="@(auction.Won ? "row-won" : "")">
<td><span class="cell-name">@TruncateName(auction.AuctionName, 40)</span></td> <td><span class="cell-name">@TruncateName(auction.AuctionName, 40)</span></td>
<td class="text-end cell-price">€@auction.FinalPrice.ToString("F2")</td> <td class="text-end cell-price">€@auction.FinalPrice.ToString("F2")</td>
<td class="text-end"> <td class="text-end">
@auction.BidsUsed @auction.BidsUsed
@if (auction.WinnerBidsUsed.HasValue && auction.WinnerBidsUsed != auction.BidsUsed) @if (auction.WinnerBidsUsed.HasValue && auction.WinnerBidsUsed != auction.BidsUsed)
@@ -312,12 +340,225 @@ private bool productSortDescending = false;
// Editing limiti default prodotto // Editing limiti default prodotto
private bool isSavingDefaults = false; private bool isSavingDefaults = false;
private bool isBulkOperating = false;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await RefreshStats(); await RefreshStats();
} }
// ═══════════════════════════════════════════════════════════════
// OPERAZIONI MASSIVE SUI PRODOTTI FILTRATI
// ═══════════════════════════════════════════════════════════════
private async Task BulkSaveDefaults()
{
if (filteredProducts == null || !filteredProducts.Any()) return;
var count = filteredProducts.Count;
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"Salvare i limiti di {count} prodotti nel database?");
if (!confirmed) return;
isBulkOperating = true;
StateHasChanged();
try
{
int saved = 0;
foreach (var product in filteredProducts)
{
await DatabaseService.UpdateProductUserDefaultsAsync(
product.ProductKey,
product.UserDefaultMinPrice,
product.UserDefaultMaxPrice,
null, null,
product.UserDefaultMaxBids,
null,
product.UseCustomLimits
);
saved++;
}
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti salvati per {saved} prodotti!");
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
finally
{
isBulkOperating = false;
StateHasChanged();
}
}
private async Task BulkApplyToAuctions()
{
if (filteredProducts == null || !filteredProducts.Any()) return;
var activeProducts = filteredProducts.Where(p => p.UseCustomLimits && HasUserDefaults(p)).ToList();
if (!activeProducts.Any())
{
await JSRuntime.InvokeVoidAsync("alert", "Nessun prodotto visibile ha limiti personalizzati attivi.");
return;
}
var productKeys = activeProducts.Select(p => p.ProductKey).ToHashSet();
var matchingAuctions = AppState.Auctions
.Where(a => productKeys.Contains(ProductStatisticsService.GenerateProductKey(a.Name)))
.ToList();
if (!matchingAuctions.Any())
{
await JSRuntime.InvokeVoidAsync("alert", "Nessuna asta monitorata corrisponde ai prodotti visibili.");
return;
}
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"Applicare i limiti di {activeProducts.Count} prodotti a {matchingAuctions.Count} aste monitorate?");
if (!confirmed) return;
isBulkOperating = true;
StateHasChanged();
try
{
var productLookup = activeProducts.ToDictionary(p => p.ProductKey);
int applied = 0;
foreach (var auction in matchingAuctions)
{
var key = ProductStatisticsService.GenerateProductKey(auction.Name);
if (productLookup.TryGetValue(key, out var product))
{
if (product.UserDefaultMinPrice.HasValue)
auction.MinPrice = product.UserDefaultMinPrice.Value;
if (product.UserDefaultMaxPrice.HasValue)
auction.MaxPrice = product.UserDefaultMaxPrice.Value;
if (product.UserDefaultMaxBids.HasValue)
auction.MaxClicks = product.UserDefaultMaxBids.Value;
applied++;
}
}
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti applicati a {applied} aste!");
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
finally
{
isBulkOperating = false;
StateHasChanged();
}
}
private async Task BulkCopyRecommended()
{
if (filteredProducts == null || !filteredProducts.Any()) return;
var withRecommendations = filteredProducts.Where(p => p.RecommendedMinPrice.HasValue).ToList();
if (!withRecommendations.Any())
{
await JSRuntime.InvokeVoidAsync("alert", "Nessun prodotto visibile ha valori consigliati.");
return;
}
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"Applicare i valori consigliati a {withRecommendations.Count} prodotti e salvarli nel database?");
if (!confirmed) return;
isBulkOperating = true;
StateHasChanged();
try
{
foreach (var product in withRecommendations)
{
product.UserDefaultMinPrice = product.RecommendedMinPrice;
product.UserDefaultMaxPrice = product.RecommendedMaxPrice;
product.UserDefaultMaxBids = product.RecommendedMaxBids;
product.UseCustomLimits = true;
await DatabaseService.UpdateProductUserDefaultsAsync(
product.ProductKey,
product.UserDefaultMinPrice,
product.UserDefaultMaxPrice,
null, null,
product.UserDefaultMaxBids,
null,
true
);
}
await JSRuntime.InvokeVoidAsync("alert", $"✅ Valori consigliati applicati e salvati per {withRecommendations.Count} prodotti!");
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
finally
{
isBulkOperating = false;
StateHasChanged();
}
}
private async Task BulkToggleOn()
{
await BulkToggle(true);
}
private async Task BulkToggleOff()
{
await BulkToggle(false);
}
private async Task BulkToggle(bool enabled)
{
if (filteredProducts == null || !filteredProducts.Any()) return;
var label = enabled ? "Attivare" : "Disattivare";
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"{label} i limiti personalizzati per {filteredProducts.Count} prodotti visibili?");
if (!confirmed) return;
isBulkOperating = true;
StateHasChanged();
try
{
foreach (var product in filteredProducts)
{
product.UseCustomLimits = enabled;
await DatabaseService.UpdateProductUserDefaultsAsync(
product.ProductKey,
product.UserDefaultMinPrice,
product.UserDefaultMaxPrice,
null, null,
product.UserDefaultMaxBids,
null,
enabled
);
}
var labelDone = enabled ? "attivati" : "disattivati";
await JSRuntime.InvokeVoidAsync("alert", $"✅ Limiti {labelDone} per {filteredProducts.Count} prodotti!");
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
finally
{
isBulkOperating = false;
StateHasChanged();
}
}
private async Task RefreshStats() private async Task RefreshStats()
{ {
isLoading = true; isLoading = true;
@@ -444,8 +685,8 @@ private bool isSavingDefaults = false;
// Conferma eliminazione // Conferma eliminazione
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"Eliminare il prodotto '{product.ProductName}'?\n\n" + $"Eliminare il prodotto '{product.ProductName}'?\n\n" +
$"Questo rimuoverà le statistiche di {product.TotalAuctions} aste.\n" + $"Questo rimuoverà le statistiche di {product.TotalAuctions} aste.\n" +
$"L'operazione NON può essere annullata!"); $"L'operazione NON può essere annullata!");
if (!confirmed) if (!confirmed)
return; return;
@@ -469,7 +710,7 @@ private bool isSavingDefaults = false;
else else
{ {
await JSRuntime.InvokeVoidAsync("alert", await JSRuntime.InvokeVoidAsync("alert",
"Nessuna riga eliminata. Il prodotto potrebbe essere già stato rimosso."); "Nessuna riga eliminata. Il prodotto potrebbe essere già stato rimosso.");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -639,7 +880,7 @@ private bool isSavingDefaults = false;
await JSRuntime.InvokeVoidAsync("alert", await JSRuntime.InvokeVoidAsync("alert",
$"? Limiti salvati per '{product.ProductName}'!\n" + $"? Limiti salvati per '{product.ProductName}'!\n" +
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" + $"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
$"Max Puntate: {product.UserDefaultMaxBids ?? 0}"); $"Max Puntate: {product.UserDefaultMaxBids ?? 0}");
} }
catch (Exception ex) catch (Exception ex)
@@ -669,7 +910,7 @@ private bool isSavingDefaults = false;
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", var confirmed = await JSRuntime.InvokeAsync<bool>("confirm",
$"Applicare i limiti default a {matchingAuctions.Count} aste di '{product.ProductName}'?\n\n" + $"Applicare i limiti default a {matchingAuctions.Count} aste di '{product.ProductName}'?\n\n" +
$"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" + $"Min: €{product.UserDefaultMinPrice:F2} - Max: €{product.UserDefaultMaxPrice:F2}\n" +
$"Max Puntate: {product.UserDefaultMaxBids}"); $"Max Puntate: {product.UserDefaultMaxBids}");
if (!confirmed) return; if (!confirmed) return;
@@ -1120,4 +1361,43 @@ private bool isSavingDefaults = false;
opacity: 0.25; opacity: 0.25;
cursor: not-allowed; cursor: not-allowed;
} }
/* ═══ BULK ACTION BAR ═══ */
.ps-bulk-bar {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
flex-shrink: 0;
flex-wrap: wrap;
}
.ps-bulk-label {
font-size: 0.62rem;
color: var(--text-secondary);
margin-right: 0.25rem;
white-space: nowrap;
}
.ps-bulk-bar .btn-xs {
padding: 0.12rem 0.4rem;
font-size: 0.62rem;
line-height: 1.3;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.ps-bulk-bar .btn-xs i { font-size: 0.6rem; margin-right: 0.15rem; }
.ps-bulk-bar .btn-xs:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-outline-toggle {
background: transparent;
border-color: rgba(168, 162, 158, 0.25);
color: var(--text-secondary);
}
.btn-outline-toggle:hover { background: rgba(168, 162, 158, 0.1); }
</style> </style>

View File

@@ -310,42 +310,97 @@ namespace AutoBidder.Services
} }
} }
// === FASE 2: Poll API solo ogni pollingIntervalMs === // === FASE 2: PRE-BID CHECK (zero I/O, solo timer locale) ===
var now = DateTime.UtcNow; // CRITICO: Controlla il ticker PRIMA del poll per le aste nella zona di puntata.
bool shouldPoll = (now - lastPoll).TotalMilliseconds >= pollingIntervalMs; // Il poll prende 40-100ms di rete. Con più aste near-deadline,
// Task.WhenAll aspetta il poll più lento → il tick effettivo è 100-200ms.
// Poll più frequente se vicino alla scadenza // Se l'offset è 200ms, la finestra di puntata è PIÙ STRETTA del tick:
bool anyNearDeadline = activeAuctions.Any(a => // Tick N: estimated=300ms > offset → non trigghera
GetEstimatedTimerMs(a) < settings.StrategyCheckThresholdMs); // [poll prende 150ms]
// Tick N+1: estimated=-50ms → fuori finestra → MANCATA!
if (shouldPoll || anyNearDeadline) // Il pre-bid check usa lo stato dal poll PRECEDENTE (< 100ms fa),
{ // che è sufficientemente fresco per IsMyBid, prezzo e ultimo puntatore.
var pollTasks = activeAuctions.Select(a => PollAuctionState(a, token));
await Task.WhenAll(pollTasks);
lastPoll = now;
}
// === FASE 3: TICKER CHECK - Verifica timing per ogni asta ===
foreach (var auction in activeAuctions) foreach (var auction in activeAuctions)
{ {
if (auction.IsPaused || auction.LastState == null) continue; if (auction.IsPaused || auction.LastState == null) continue;
// Calcola timer stimato LOCALMENTE (più preciso del polling)
double estimatedTimerMs = GetEstimatedTimerMs(auction); double estimatedTimerMs = GetEstimatedTimerMs(auction);
// Offset configurato dall'utente (SENZA compensazioni)
int offsetMs = auction.BidBeforeDeadlineMs > 0 int offsetMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs ? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs; : settings.DefaultBidBeforeDeadlineMs;
// TRIGGER: Timer <= Offset configurato dall'utente if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > 0)
{ {
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, token); await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
} }
} }
// === FASE 4: Delay fisso del ticker === // === FASE 3: Poll API ===
// Il poll aggiorna dati (prezzo, ultimo puntatore, timer) per il prossimo tick.
// Se rileva la fine dell'asta, la salva in PendingEndState (non la processa subito).
// Le aste nella zona critica (< offset*2 ms) NON vengono pollate:
// il poll prenderebbe tempo prezioso e i dati locali sono sufficienti.
var now = DateTime.UtcNow;
bool shouldPollAll = (now - lastPoll).TotalMilliseconds >= pollingIntervalMs;
if (shouldPollAll)
{
// Poll normale: tutte le aste attive
var pollTasks = activeAuctions.Select(a => PollAuctionState(a, token));
await Task.WhenAll(pollTasks);
lastPoll = now;
}
else
{
// Poll rapido: SOLO le aste vicine alla scadenza MA non nella zona critica
// Se il timer è < offset*2, il poll ruberebbe tempo al prossimo pre-bid check
var nearDeadlineAuctions = activeAuctions.Where(a =>
{
double est = GetEstimatedTimerMs(a);
int off = a.BidBeforeDeadlineMs > 0
? a.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
return est < settings.StrategyCheckThresholdMs && est > off * 2;
}).ToList();
if (nearDeadlineAuctions.Count > 0)
{
var pollTasks = nearDeadlineAuctions.Select(a => PollAuctionState(a, token));
await Task.WhenAll(pollTasks);
}
}
// === FASE 4: POST-POLL TICKER CHECK ===
// Per aste che sono entrate nella zona di puntata DURANTE il poll.
// Le aste già puntate dal pre-bid check vengono skippate da BidScheduled.
foreach (var auction in activeAuctions)
{
if (auction.IsPaused || auction.LastState == null) continue;
double estimatedTimerMs = GetEstimatedTimerMs(auction);
int offsetMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs;
if (estimatedTimerMs <= offsetMs && estimatedTimerMs > -tickerIntervalMs)
{
await TryPlaceBidTicker(auction, estimatedTimerMs, offsetMs, settings, token);
}
}
// === FASE 4: Processa aste terminate (deferred) ===
// Solo ORA gestiamo le aste che il poll ha marcato come terminate.
// Il ticker ha avuto la sua ultima occasione nella Fase 3.
foreach (var auction in activeAuctions)
{
if (auction.PendingEndState != null)
{
HandleAuctionEnded(auction, auction.PendingEndState);
auction.PendingEndState = null;
}
}
// === FASE 5: Delay fisso del ticker ===
await Task.Delay(tickerIntervalMs, token); await Task.Delay(tickerIntervalMs, token);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -376,9 +431,11 @@ namespace AutoBidder.Services
var elapsed = (DateTime.UtcNow - auction.LastDeadlineUpdateUtc.Value).TotalMilliseconds; var elapsed = (DateTime.UtcNow - auction.LastDeadlineUpdateUtc.Value).TotalMilliseconds;
// Timer stimato = timer raw - tempo trascorso // Timer stimato = timer raw - tempo trascorso
// NON clampare a 0: il ticker usa valori leggermente negativi
// per catturare la finestra quando il timer scade tra due tick
double estimated = auction.LastRawTimer - elapsed; double estimated = auction.LastRawTimer - elapsed;
return Math.Max(0, estimated); return estimated;
} }
/// <summary> /// <summary>
@@ -419,12 +476,19 @@ namespace AutoBidder.Services
EnsureCurrentBidInHistory(auction, state); EnsureCurrentBidInHistory(auction, state);
} }
// Gestione fine asta // Gestione fine asta — DIFFERITA
// Non chiamare HandleAuctionEnded qui: il ticker deve avere
// un'ultima occasione di puntare con i dati freschi del poll.
// Lo stato di fine viene salvato in PendingEndState e processato
// dal loop principale DOPO il ticker check.
if (state.Status == AuctionStatus.EndedWon || if (state.Status == AuctionStatus.EndedWon ||
state.Status == AuctionStatus.EndedLost || state.Status == AuctionStatus.EndedLost ||
state.Status == AuctionStatus.Closed) state.Status == AuctionStatus.Closed)
{ {
HandleAuctionEnded(auction, state); // Aggiorna LastState con i dati più freschi (ultimo puntatore, prezzo)
// così il ticker può fare controlli accurati (es. IsMyBid, prezzo)
auction.LastState = state;
auction.PendingEndState = state;
return; return;
} }
@@ -615,9 +679,8 @@ namespace AutoBidder.Services
/// TICKER: Tenta la puntata quando il timer locale raggiunge l'offset configurato. /// TICKER: Tenta la puntata quando il timer locale raggiunge l'offset configurato.
/// NESSUNA compensazione automatica della latenza - l'utente decide il timing esatto. /// NESSUNA compensazione automatica della latenza - l'utente decide il timing esatto.
/// </summary> /// </summary>
private async Task TryPlaceBidTicker(AuctionInfo auction, double estimatedTimerMs, int offsetMs, CancellationToken token) private async Task TryPlaceBidTicker(AuctionInfo auction, double estimatedTimerMs, int offsetMs, AppSettings settings, CancellationToken token)
{ {
var settings = SettingsManager.Load();
var state = auction.LastState; var state = auction.LastState;
if (state == null || state.IsMyBid || estimatedTimerMs <= 0) return; if (state == null || state.IsMyBid || estimatedTimerMs <= 0) return;
@@ -671,7 +734,7 @@ namespace AutoBidder.Services
auction.AddLog($"→ Inizio controlli puntata (timer={estimatedTimerMs:F0}ms ≤ soglia={settings.StrategyCheckThresholdMs}ms)", auction.AddLog($"→ Inizio controlli puntata (timer={estimatedTimerMs:F0}ms ≤ soglia={settings.StrategyCheckThresholdMs}ms)",
Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt); Models.AuctionLogLevel.Info, Models.AuctionLogCategory.BidAttempt);
if (!ShouldBid(auction, state)) if (!ShouldBid(auction, state, settings))
{ {
return; return;
} }
@@ -697,7 +760,7 @@ namespace AutoBidder.Services
auction.AddLog($"[BID] Timer locale={estimatedTimerMs:F0}ms, Offset utente={offsetMs}ms"); auction.AddLog($"[BID] Timer locale={estimatedTimerMs:F0}ms, Offset utente={offsetMs}ms");
await ExecuteBid(auction, state, token); await ExecuteBid(auction, state, settings, token);
} }
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
@@ -725,13 +788,13 @@ namespace AutoBidder.Services
? auction.BidBeforeDeadlineMs ? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs; : settings.DefaultBidBeforeDeadlineMs;
double timerMs = state.Timer * 1000; double timerMs = state.Timer * 1000;
await TryPlaceBidTicker(auction, timerMs, offsetMs, token); await TryPlaceBidTicker(auction, timerMs, offsetMs, settings, token);
} }
/// <summary> /// <summary>
/// Esegue la puntata e registra metriche /// Esegue la puntata e registra metriche
/// </summary> /// </summary>
private async Task ExecuteBid(AuctionInfo auction, AuctionState state, CancellationToken token) private async Task ExecuteBid(AuctionInfo auction, AuctionState state, AppSettings settings, CancellationToken token)
{ {
try try
{ {
@@ -780,7 +843,6 @@ namespace AutoBidder.Services
else else
{ {
var pollingPing = auction.PollingLatencyMs; var pollingPing = auction.PollingLatencyMs;
var settings = SettingsManager.Load();
// Rileva errore "timer scaduto" per feedback utente // Rileva errore "timer scaduto" per feedback utente
bool isLateBid = result.Error?.Contains("timer") == true || bool isLateBid = result.Error?.Contains("timer") == true ||
@@ -826,9 +888,9 @@ namespace AutoBidder.Services
} }
} }
private bool ShouldBid(AuctionInfo auction, AuctionState state) private bool ShouldBid(AuctionInfo auction, AuctionState state, AppSettings? settings = null)
{ {
var settings = Utilities.SettingsManager.Load(); settings ??= Utilities.SettingsManager.Load();
// CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili) // CONTROLLO 0: Verifica convenienza (se abilitato e dati disponibili)
if (settings.ValueCheckEnabled && if (settings.ValueCheckEnabled &&
@@ -917,7 +979,28 @@ namespace AutoBidder.Services
return false; return false;
} }
// CONTROLLO 3: MinPrice/MaxPrice // CONTROLLO 3: Limite puntate per questa asta
// Controlla MaxClicks (impostato dall'utente nella griglia o da product stats)
{
int maxBids = auction.MaxClicks; // 0 = illimitato
int usedBids = auction.BidsUsedOnThisAuction ?? 0;
if (maxBids > 0 && usedBids >= maxBids)
{
auction.AddLog($"⛔ Limite puntate raggiunto ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Warning, Models.AuctionLogCategory.Limit);
LogBlockThrottled(auction, "MAXBIDS", $"[LIMIT] {auction.Name}: puntate {usedBids}/{maxBids} - STOP");
return false;
}
if (maxBids > 0)
{
auction.AddLog($"✓ Puntate OK ({usedBids}/{maxBids})",
Models.AuctionLogLevel.Debug, Models.AuctionLogCategory.Limit);
}
}
// CONTROLLO 4: MinPrice/MaxPrice
if (auction.MinPrice > 0 && state.Price < auction.MinPrice) if (auction.MinPrice > 0 && state.Price < auction.MinPrice)
{ {
auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}", auction.AddLog($"⛔ Prezzo €{state.Price:F2} < Min €{auction.MinPrice:F2}",
@@ -1010,7 +1093,7 @@ namespace AutoBidder.Services
{ {
Timestamp = DateTime.UtcNow, Timestamp = DateTime.UtcNow,
EventType = BidEventType.Reset, EventType = BidEventType.Reset,
Bidder = state.LastBidder, Bidder = state.LastBidder ?? "",
Price = state.Price, Price = state.Price,
Timer = state.Timer, Timer = state.Timer,
Notes = $"Puntata: EUR{state.Price:F2}" Notes = $"Puntata: EUR{state.Price:F2}"

View File

@@ -436,17 +436,40 @@ namespace AutoBidder.Utilities
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder"); private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
private static readonly string _file = Path.Combine(_folder, "settings.json"); private static readonly string _file = Path.Combine(_folder, "settings.json");
// Cache per evitare letture disco ripetute nel hot path (ticker loop 50ms)
private static readonly object _cacheLock = new();
private static AppSettings? _cached;
private static DateTime _cacheExpiry = DateTime.MinValue;
private const int CACHE_TTL_MS = 2000; // Ricarica da disco al massimo ogni 2s
public static AppSettings Load() public static AppSettings Load()
{ {
try lock (_cacheLock)
{ {
if (!File.Exists(_file)) return new AppSettings(); var now = DateTime.UtcNow;
var txt = File.ReadAllText(_file); if (_cached != null && now < _cacheExpiry)
var s = JsonSerializer.Deserialize<AppSettings>(txt); return _cached;
if (s == null) return new AppSettings();
return s; try
{
if (!File.Exists(_file))
{
_cached = new AppSettings();
}
else
{
var txt = File.ReadAllText(_file);
_cached = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
}
}
catch
{
_cached ??= new AppSettings();
}
_cacheExpiry = now.AddMilliseconds(CACHE_TTL_MS);
return _cached;
} }
catch { return new AppSettings(); }
} }
public static void Save(AppSettings settings) public static void Save(AppSettings settings)
@@ -456,6 +479,13 @@ namespace AutoBidder.Utilities
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder); if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_file, txt); File.WriteAllText(_file, txt);
// Invalida cache così il prossimo Load() legge i nuovi valori
lock (_cacheLock)
{
_cached = settings;
_cacheExpiry = DateTime.UtcNow.AddMilliseconds(CACHE_TTL_MS);
}
} }
catch { } catch { }
} }