|
|
@@ -10,9 +10,10 @@ using SharedKernel;
|
|
|
namespace Infrastructure.StockData;
|
|
|
|
|
|
/// <summary>
|
|
|
-/// 주요 종목·상품 시세 스냅샷 수집 — Stooq 배치 라이트 시세(/q/l/?f=sd2t2ohlcv, 거래량 포함)를 하루 1회 조회해 심볼당 최신 종가를 upsert.
|
|
|
-/// config(MarketQuote:Items) 심볼을 한 요청으로 모아 조회하고, 응답 CSV 를 파싱해 MarketQuoteSnapshot 에 반영한다(전일 대비는 직전 저장 종가로 산출).
|
|
|
-/// WorldIndexSyncService 와 동일 패턴 + Volume·Category·GroupCode. 기본 07:10 KST(세계지수 07:00 직후). Items 비었거나 전량 실패 시 로그만 남기고 skip/재시도.
|
|
|
+/// 주요 종목·상품 시세 스냅샷 수집 — 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)
|
|
|
/// </summary>
|
|
|
internal sealed class MarketQuoteSyncService(
|
|
|
IServiceScopeFactory scopeFactory,
|
|
|
@@ -42,48 +43,43 @@ internal sealed class MarketQuoteSyncService(
|
|
|
|
|
|
using var scope = scopeFactory.CreateScope();
|
|
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
|
|
- var client = httpClientFactory.CreateClient(StooqHttp.ClientName);
|
|
|
-
|
|
|
- var bySymbol = items
|
|
|
- .GroupBy(c => c.Symbol.Trim().ToLowerInvariant())
|
|
|
- .ToDictionary(g => g.Key, g => g.First());
|
|
|
-
|
|
|
- var symbolList = string.Join(",", bySymbol.Keys);
|
|
|
- var url = $"{cfg.BaseUrl.TrimEnd('/')}/q/l/?s={symbolList}&f=sd2t2ohlcv&h&e=csv";
|
|
|
-
|
|
|
- string csv;
|
|
|
- try
|
|
|
- {
|
|
|
- csv = await StooqHttp.GetStringWithRetryAsync(client, url, Logger, ct);
|
|
|
- }
|
|
|
- catch (Exception ex) when (ex is not OperationCanceledException)
|
|
|
- {
|
|
|
- Logger.LogError(ex, "[{Job}] Stooq 호출 실패 — 재시도 대상", JobName);
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- var quotes = MarketQuoteCsvParser.Parse(csv);
|
|
|
- Logger.LogInformation("[{Job}] 응답 파싱 rows={Rows} (요청 {Count} 심볼)", JobName, quotes.Count, bySymbol.Count);
|
|
|
-
|
|
|
- if (quotes.Count == 0)
|
|
|
- {
|
|
|
- Logger.LogWarning("[{Job}] 파싱 0건 — 재시도 대상", JobName);
|
|
|
- return false;
|
|
|
- }
|
|
|
+ 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 q in quotes)
|
|
|
+ foreach (var meta in items)
|
|
|
{
|
|
|
- if (!bySymbol.TryGetValue(q.Symbol, out var meta))
|
|
|
+ ct.ThrowIfCancellationRequested();
|
|
|
+
|
|
|
+ var symbol = meta.Symbol.Trim();
|
|
|
+ var category = Enum.TryParse<QuoteCategory>(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
|
|
|
{
|
|
|
- continue; // 요청하지 않은 심볼 방어
|
|
|
+ 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;
|
|
|
}
|
|
|
|
|
|
- var category = Enum.TryParse<QuoteCategory>(meta.Category, ignoreCase: true, out var parsed) ? parsed : QuoteCategory.Stock;
|
|
|
+ if (q is null)
|
|
|
+ {
|
|
|
+ Logger.LogWarning("[{Job}] {Symbol} 데이터 없음 — skip", JobName, symbol);
|
|
|
+ failed++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
|
|
|
if (existingBySymbol.TryGetValue(q.Symbol, out var snap))
|
|
|
{
|
|
|
@@ -100,8 +96,14 @@ internal sealed class MarketQuoteSyncService(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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}", JobName, inserted, updated);
|
|
|
+ Logger.LogInformation("[{Job}] 완료 — inserted={Inserted}, updated={Updated}, failed={Failed}", JobName, inserted, updated, failed);
|
|
|
return true;
|
|
|
}
|
|
|
}
|