using Application.Abstractions.Data;
using Domain.Entities.Stocks;
using Domain.Entities.Stocks.ValueObject;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SharedKernel;
namespace Infrastructure.StockData;
///
/// 주요 종목·상품 시세 스냅샷 수집 — Yahoo Finance v8 chart(무키)를 심볼별로 하루 1회 조회해 최신 종가·거래량을 upsert.
/// config(MarketQuote:Items) 심볼(Yahoo 표기, 예: AAPL/MSFT/CL=F)을 하나씩 GET 하고 meta 를 파싱해 MarketQuoteSnapshot 에 반영한다.
/// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST. 심볼 실패는 skip(격리), 전량 실패 시에만 재시도.
/// (Stooq 봇차단으로 소스 전환 2026-07-09)
///
internal sealed class MarketQuoteSyncService(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
IOptions settings,
ILogger logger
) : DailyScheduledService(logger)
{
protected override string JobName => "MarketQuoteSync";
protected override TimeOnly TargetTime => ParseTime(settings.Value.MarketQuote.SyncTime, new TimeOnly(7, 10));
protected override int MaxRetryCount => 2;
protected override TimeSpan RetryDelay => TimeSpan.FromMinutes(settings.Value.MarketQuote.RetryDelayMinutes > 0 ? settings.Value.MarketQuote.RetryDelayMinutes : 60);
protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct)
{
var cfg = settings.Value.MarketQuote;
using var scope = scopeFactory.CreateScope();
var collectorSettings = scope.ServiceProvider.GetRequiredService();
if (!await collectorSettings.IsEnabledAsync(CollectorFlag.MarketQuote, ct))
{
return true;
}
var db = scope.ServiceProvider.GetRequiredService();
var items = cfg.Items.Where(c => !string.IsNullOrWhiteSpace(c.Symbol)).ToList();
if (items.Count == 0)
{
Logger.LogWarning("[{Job}] MarketQuote:Items 미설정 — 수집 skip", JobName);
return true;
}
var client = httpClientFactory.CreateClient(YahooFinanceHttp.ClientName);
var existing = await db.MarketQuoteSnapshot.ToListAsync(ct);
var existingBySymbol = existing.ToDictionary(c => c.Symbol, StringComparer.OrdinalIgnoreCase);
var baseUrl = cfg.BaseUrl.TrimEnd('/');
var inserted = 0;
var updated = 0;
var failed = 0;
foreach (var meta in items)
{
ct.ThrowIfCancellationRequested();
var symbol = meta.Symbol.Trim();
var category = Enum.TryParse(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock;
var url = $"{baseUrl}/v8/finance/chart/{Uri.EscapeDataString(symbol)}?interval=1d&range=5d";
YahooChartParser.YahooQuote? q;
try
{
var json = await YahooFinanceHttp.GetStringWithRetryAsync(client, url, Logger, ct);
q = YahooChartParser.Parse(json, symbol);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogWarning(ex, "[{Job}] {Symbol} 조회 실패 — skip", JobName, symbol);
failed++;
continue;
}
if (q is null)
{
Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol);
failed++;
continue;
}
if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
{
snap.UpdateMeta(meta.Name, category, meta.GroupCode, meta.CountryCode);
snap.Apply(q.TradeDate, q.Close, q.Open, q.High, q.Low, q.Volume);
updated++;
}
else
{
var created = MarketQuoteSnapshot.Create(q.Symbol, meta.Name, category, meta.GroupCode, meta.CountryCode, q.TradeDate, q.Close, q.Open, q.High, q.Low, q.Volume);
await db.MarketQuoteSnapshot.AddAsync(created, ct);
existingBySymbol[q.Symbol] = created;
inserted++;
}
}
if (inserted == 0 && updated == 0)
{
Logger.LogWarning("[{Job}] 전량 실패 (failed={Failed}) — 재시도 대상", JobName, failed);
return false;
}
await db.SaveChangesAsync(ct);
Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed);
return true;
}
}