feat(fundamentals): add KeyedLockPool for concurrent scraping synchronization and update MQTT RPC handlers

This commit is contained in:
2026-08-24 21:35:43 +02:00
parent 8112598602
commit 600ccf299e
6 changed files with 136 additions and 69 deletions
@@ -35,7 +35,7 @@ public interface IFundamentalsDbService
public class FundamentalsDbService : IFundamentalsDbService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> IsinLocks = new();
private static readonly KeyedLockPool LockPool = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly IYahooFinanceScraper _scraper;
@@ -65,15 +65,13 @@ public class FundamentalsDbService : IFundamentalsDbService
var cleanIsin = isin.Trim().ToUpperInvariant();
var requestedTicker = ticker?.Trim().ToUpperInvariant();
var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1));
await isinLock.WaitAsync(cancellationToken);
try
using (await LockPool.LockAsync(cleanIsin, cancellationToken))
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
// 1. Dynamic Settings lesen
bool allowForceRefresh =
await settingsService.GetSettingAsync(SettingKeys.AllowForceRefresh, cancellationToken);
@@ -380,6 +378,46 @@ public class FundamentalsDbService : IFundamentalsDbService
assetData.AssetEvents.Add(newEvent);
}
}
// Structured Trade Republic dividend data (ExpectedDividend + historical Dividends) carries
// a real ExDate per entry - a far more reliable "this is a dividend" signal than matching
// the generic Events/PastEvents feed's free-text Type/Title strings above, whose exact
// wording for dividend entries is not guaranteed. The canonical "Dividend" Type here is
// fully controlled by this codebase (not guessed from TR's free text), so
// AssetFundamentalsDto.DaysToNextExDividend can match on it reliably (Rules.md §4).
var trDividendList = new List<TradeRepublicDividendDto>();
if (trDetails.ExpectedDividend != null) trDividendList.Add(trDetails.ExpectedDividend);
if (trDetails.Dividends != null) trDividendList.AddRange(trDetails.Dividends);
foreach (var div in trDividendList)
{
if (string.IsNullOrWhiteSpace(div.ExDate) ||
!DateTime.TryParse(div.ExDate, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var exDate))
{
continue;
}
bool isDuplicateDividend = assetData.AssetEvents.Any(e =>
e.Date.Date == exDate.Date && string.Equals(e.Type, "Dividend", StringComparison.OrdinalIgnoreCase));
if (!isDuplicateDividend)
{
var newDividendEvent = new AssetEventEntity
{
AssetDataIsin = cleanIsin,
Ticker = new TickerEntity
{
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
},
Type = "Dividend",
Date = exDate.Date
};
context.AssetEvents.Add(newDividendEvent);
assetData.AssetEvents.Add(newDividendEvent);
}
}
}
// --- Process Modules DTO (Executives & Fundamental Data) ---
@@ -579,12 +617,9 @@ public class FundamentalsDbService : IFundamentalsDbService
return MapToDto(assetData, fundamentalData, executivesList, eventsList);
}
finally
{
isinLock.Release();
}
}
/// <inheritdoc />
public async Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default)
{