Jelajahi Sumber

feat(market): D1 KRX 주식(종목) 데이터 수집 — 유가증권·코스닥 일별매매+종목기본정보

KRX OpenAPI 승인 완료(라이브 검증) → data.go.kr(키 없음) 대신 KRX 로
Stock/StockDailyPrice 를 실제 채운다. /stock·quotes·모의투자 체결 실동작.

- KRXCoKrSection: StockSync(기본 false)·MasterSyncTime(07:40)·StockSyncTime(18:10).
- KrxStockParser(순수·테스트가능): 일별(stk/ksq_bydd_trd)·마스터(stk/ksq_isu_base_info)
  파싱. 콤마제거·""/"-"→null·단축코드 정규화. FLUC_RT 는 decimal %(DataGoKr 규약 일치).
- KrxStockMasterSyncService(07:40): 종목기본정보 → Stock upsert(신규 Create/기존
  UpdateMaster, 스냅샷 이탈 종목 soft-off). ISU_SRT_CD→Code, ISU_CD→ISIN,
  ISU_ABBRV→Name, ISU_ENG_NM→EnglishName, SECT_TP_NM→SectorName, LIST_DD→ListedDate.
- KrxDailyPriceSyncService(18:10): 전 영업일 일별매매 → StockDailyPrice upsert +
  Stock.UpdateLastPrice denorm(DailyPriceSyncService 와 동일 패턴).
- DI: KRXCoKr:StockSync 게이트로 등록(Web.Api 전용, 기존 KRX HttpClient 재사용).
- Stock.Create/UpdateMaster 에 englishName/sectorName 선택 파라미터(하위호환).
- 테스트 KrxStockParserTests 5종. 빌드0·114/114. 마이그레이션 불필요(기존 컬럼).

배포: KRXCoKr:StockSync=true + Jenkins Secret 키. data.go.kr 와 동시 활성 금지(이중기록).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 minggu lalu
induk
melakukan
828df38c2e

+ 20 - 4
Domain/Entities/Stocks/Stock.cs

@@ -64,7 +64,7 @@ public class Stock
 
 
     private Stock() { }
     private Stock() { }
 
 
-    public static Stock Create(string code, string name, StockMarket market, DateOnly listedDate, string? isin = null, string? corpCode = null)
+    public static Stock Create(string code, string name, StockMarket market, DateOnly listedDate, string? isin = null, string? corpCode = null, string? englishName = null, string? sectorName = null)
     {
     {
         if (code is not { Length: 6 } || code.Any(c => !char.IsAsciiDigit(c)))
         if (code is not { Length: 6 } || code.Any(c => !char.IsAsciiDigit(c)))
         {
         {
@@ -89,12 +89,14 @@ public class Stock
             TradingStatus = TradingStatus.Normal,
             TradingStatus = TradingStatus.Normal,
             ListedDate = listedDate,
             ListedDate = listedDate,
             ISIN = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim(),
             ISIN = string.IsNullOrWhiteSpace(isin) ? null : isin.Trim(),
-            CorpCode = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim()
+            CorpCode = string.IsNullOrWhiteSpace(corpCode) ? null : corpCode.Trim(),
+            EnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim(),
+            SectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim()
         };
         };
     }
     }
 
 
-    /// <summary>마스터 동기화 — 이름/시장/ISIN 반영. 상폐 상태였다면 재상장으로 복구.</summary>
-    public void UpdateMaster(string name, StockMarket market, string? isin)
+    /// <summary>마스터 동기화 — 이름/시장/ISIN(+영문명/업종) 반영. 상폐 상태였다면 재상장으로 복구. englishName/sectorName 은 null 전달 시 미변경.</summary>
+    public void UpdateMaster(string name, StockMarket market, string? isin, string? englishName = null, string? sectorName = null)
     {
     {
         var changed = false;
         var changed = false;
 
 
@@ -117,6 +119,20 @@ public class Stock
             changed = true;
             changed = true;
         }
         }
 
 
+        var normalizedEnglishName = string.IsNullOrWhiteSpace(englishName) ? null : englishName.Trim();
+        if (normalizedEnglishName is not null && EnglishName != normalizedEnglishName)
+        {
+            EnglishName = normalizedEnglishName;
+            changed = true;
+        }
+
+        var normalizedSectorName = string.IsNullOrWhiteSpace(sectorName) ? null : sectorName.Trim();
+        if (normalizedSectorName is not null && SectorName != normalizedSectorName)
+        {
+            SectorName = normalizedSectorName;
+            changed = true;
+        }
+
         if (!IsActive)
         if (!IsActive)
         {
         {
             IsActive = true;
             IsActive = true;

+ 7 - 0
Infrastructure/DependencyInjection.cs

@@ -277,6 +277,13 @@ public static class DependencyInjection
             services.AddHostedService<IndexPriceSyncService>();
             services.AddHostedService<IndexPriceSyncService>();
         }
         }
 
 
+        // 주식(종목) 마스터 + 일별매매 수집 (KRX OpenAPI) — KRXCoKr:StockSync 플래그 (기본 false). KRXCoKr HttpClient 재사용
+        if (krx.StockSync)
+        {
+            services.AddHostedService<KrxStockMasterSyncService>();
+            services.AddHostedService<KrxDailyPriceSyncService>();
+        }
+
         // 모의투자 체결·스냅샷 배치 (d4 M2) — BackgroundJobs:PaperFill 플래그 (기본 false). Web.Api 전용.
         // 모의투자 체결·스냅샷 배치 (d4 M2) — BackgroundJobs:PaperFill 플래그 (기본 false). Web.Api 전용.
         var bg = configuration.GetSection("BackgroundJobs").Get<AppSettings.BackgroundJobsSection>() ?? new AppSettings.BackgroundJobsSection();
         var bg = configuration.GetSection("BackgroundJobs").Get<AppSettings.BackgroundJobsSection>() ?? new AppSettings.BackgroundJobsSection();
 
 

+ 111 - 0
Infrastructure/StockData/KrxDailyPriceSyncService.cs

@@ -0,0 +1,111 @@
+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;
+
+/// <summary>
+/// 일별 마감 시세 수집 (KRX OpenAPI) — stk_bydd_trd(KOSPI) + ksq_bydd_trd(KOSDAQ) 일별매매정보.
+/// 기본 18:10 KST 실행(장 마감 15:30 이후 확정), basDd=직전 영업일 전량 수집 →
+/// StockDailyPrice upsert (StockID+TradingDate) + Stock.UpdateLastPrice denorm(최근 종가/등락률/시총) 갱신.
+/// 마스터 미동기화 코드는 skip (다음 마스터 동기화 후 자연 반영). 미반영(0건)이면 2시간 간격 2회 재시도.
+/// ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
+/// </summary>
+internal sealed class KrxDailyPriceSyncService(
+    IServiceScopeFactory scopeFactory,
+    IHttpClientFactory httpClientFactory,
+    IOptions<AppSettings> settings,
+    ILogger<KrxDailyPriceSyncService> logger
+) : DailyScheduledService(logger)
+{
+    // (시장, 엔드포인트 경로) — KOSPI/KOSDAQ. KONEX(knx_bydd_trd)·ETF/채권/파생 등은 D1 범위 밖.
+    private static readonly (StockMarket Market, string Path)[] Endpoints =
+    [
+        (StockMarket.KOSPI, "/svc/apis/sto/stk_bydd_trd"),
+        (StockMarket.KOSDAQ, "/svc/apis/sto/ksq_bydd_trd")
+    ];
+
+    protected override string JobName => "KrxDailyPriceSync";
+
+    protected override TimeOnly TargetTime => ParseTime(settings.Value.KRXCoKr.StockSyncTime, new TimeOnly(18, 10));
+
+    protected override int MaxRetryCount => 2;
+
+    protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
+
+    protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
+    {
+        var cfg = settings.Value.KRXCoKr;
+        if (string.IsNullOrWhiteSpace(cfg.ApiKey))
+        {
+            Logger.LogWarning("[{Job}] KRXCoKr:ApiKey 미설정 — 수집 skip", JobName);
+            return true;
+        }
+
+        using var scope = scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+        var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
+
+        var targetDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        var rows = new List<KrxStockParser.DailyPriceItem>();
+
+        foreach (var (market, path) in Endpoints)
+        {
+            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={targetDate:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var parsed = KrxStockParser.ParseDailyPrices(json, market);
+
+            Logger.LogInformation("[{Job}] {Market} basDd={TargetDate} rows={Rows}", JobName, market, targetDate, parsed.Count);
+            rows.AddRange(parsed);
+        }
+
+        if (rows.Count == 0)
+        {
+            Logger.LogInformation("[{Job}] basDd={TargetDate} 시세 미반영 (0건)", JobName, targetDate);
+            return false;
+        }
+
+        var stockByCode = await db.Stock.ToDictionaryAsync(c => c.Code, ct);
+        var existingByStockID = await db.StockDailyPrice.Where(c => c.TradingDate == targetDate).ToDictionaryAsync(c => c.StockID, ct);
+        var inserted = 0;
+        var updated = 0;
+        var unknown = 0;
+
+        foreach (var row in rows)
+        {
+            if (!stockByCode.TryGetValue(row.Code, out var stock))
+            {
+                // 마스터 미동기화 종목 — 다음 마스터 동기화 후 자연 반영 (DailyPriceSyncService 와 동일 정책)
+                unknown++;
+                continue;
+            }
+
+            if (existingByStockID.TryGetValue(stock.ID, out var price))
+            {
+                price.Update(row.Open, row.High, row.Low, row.Close, row.Volume, row.TradingValue, row.ChangeAmount, row.ChangeRate, row.MarketCap, row.ListedShares);
+                updated++;
+            }
+            else
+            {
+                var created = StockDailyPrice.Create(stock.ID, row.TradingDate, row.Open, row.High, row.Low, row.Close, row.Volume, row.TradingValue, row.ChangeAmount, row.ChangeRate, row.MarketCap, row.ListedShares);
+                await db.StockDailyPrice.AddAsync(created, ct);
+                existingByStockID[stock.ID] = created;
+                inserted++;
+            }
+
+            stock.UpdateLastPrice(row.TradingDate, row.Close, row.ChangeRate, row.MarketCap);
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        Logger.LogInformation("[{Job}] 완료 — basDd={TargetDate}, rows={Rows}, inserted={Inserted}, updated={Updated}, unknownCode={Unknown}",
+            JobName, targetDate, rows.Count, inserted, updated, unknown);
+
+        return true;
+    }
+}

+ 134 - 0
Infrastructure/StockData/KrxStockMasterSyncService.cs

@@ -0,0 +1,134 @@
+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;
+
+/// <summary>
+/// 종목 마스터 동기화 (KRX OpenAPI) — stk_isu_base_info(KOSPI) + ksq_isu_base_info(KOSDAQ) 종목기본정보를
+/// 일 1회(기본 07:40 KST) 전량 수집하여 Stock upsert. 신규 상장 = insert, 명칭/시장/영문명/업종 변경 = update,
+/// 스냅샷에서 사라진 활성 종목 = 상폐 soft-off. ApiKey 미설정 시 로그만 남기고 skip (data.go.kr 배치와 동일 정책).
+/// 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다.
+/// </summary>
+internal sealed class KrxStockMasterSyncService(
+    IServiceScopeFactory scopeFactory,
+    IHttpClientFactory httpClientFactory,
+    IOptions<AppSettings> settings,
+    ILogger<KrxStockMasterSyncService> logger
+) : DailyScheduledService(logger)
+{
+    // (시장, 엔드포인트 경로) — KOSPI/KOSDAQ. KONEX(knx_*)·ETF/채권/파생 등은 D1 범위 밖.
+    private static readonly (StockMarket Market, string Path)[] Endpoints =
+    [
+        (StockMarket.KOSPI, "/svc/apis/sto/stk_isu_base_info"),
+        (StockMarket.KOSDAQ, "/svc/apis/sto/ksq_isu_base_info")
+    ];
+
+    private const int MaxBaseDateLookback = 7;
+
+    protected override string JobName => "KrxStockMasterSync";
+
+    protected override TimeOnly TargetTime => ParseTime(settings.Value.KRXCoKr.MasterSyncTime, new TimeOnly(7, 40));
+
+    protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
+    {
+        var cfg = settings.Value.KRXCoKr;
+        if (string.IsNullOrWhiteSpace(cfg.ApiKey))
+        {
+            Logger.LogWarning("[{Job}] KRXCoKr:ApiKey 미설정 — 수집 skip", JobName);
+            return true;
+        }
+
+        using var scope = scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+        var client = httpClientFactory.CreateClient(KrxCoKrHttp.ClientName);
+
+        // 종목기본정보는 basDd 단위 스냅샷 — 직전 영업일부터 최대 7일 소급하며 데이터가 있는 기준일을 찾는다
+        var baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, todayKst.AddDays(1), ct);
+        List<KrxStockParser.MasterItem>? snapshot = null;
+
+        for (var back = 0; back < MaxBaseDateLookback; back++)
+        {
+            snapshot = await FetchSnapshotAsync(client, cfg, baseDate, ct);
+            if (snapshot.Count > 0)
+            {
+                break;
+            }
+
+            baseDate = await MarketCalendar.GetPreviousBusinessDayAsync(db, baseDate, ct);
+        }
+
+        if (snapshot is null || snapshot.Count == 0)
+        {
+            Logger.LogError("[{Job}] 종목기본정보 스냅샷 없음 — 최근 {Days}영업일 조회 실패", JobName, MaxBaseDateLookback);
+            return true;
+        }
+
+        // 동일 코드 중복 행은 마지막 행 우선
+        var byCode = new Dictionary<string, KrxStockParser.MasterItem>();
+        foreach (var item in snapshot)
+        {
+            byCode[item.Code] = item;
+        }
+
+        var stocks = await db.Stock.ToListAsync(ct);
+        var stockByCode = stocks.ToDictionary(c => c.Code);
+        var inserted = 0;
+        var updated = 0;
+        var delisted = 0;
+
+        foreach (var (code, item) in byCode)
+        {
+            if (stockByCode.TryGetValue(code, out var stock))
+            {
+                var wasUpdated = stock.UpdatedAt;
+                stock.UpdateMaster(item.Name, item.Market, item.Isin, item.EnglishName, item.SectorName);
+                if (stock.UpdatedAt != wasUpdated)
+                {
+                    updated++;
+                }
+            }
+            else
+            {
+                await db.Stock.AddAsync(Stock.Create(code, item.Name, item.Market, item.ListedDate, item.Isin, englishName: item.EnglishName, sectorName: item.SectorName), ct);
+                inserted++;
+            }
+        }
+
+        // 스냅샷에서 사라진 활성 종목 = 상폐 soft-off
+        foreach (var stock in stocks.Where(c => c.IsActive && !byCode.ContainsKey(c.Code)))
+        {
+            stock.MarkDelisted(baseDate);
+            delisted++;
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        Logger.LogInformation("[{Job}] 완료 — basDd={BaseDate}, snapshot={Snapshot}, inserted={Inserted}, updated={Updated}, delisted={Delisted}",
+            JobName, baseDate, byCode.Count, inserted, updated, delisted);
+
+        return true;
+    }
+
+    private async Task<List<KrxStockParser.MasterItem>> FetchSnapshotAsync(HttpClient client, AppSettings.KRXCoKrSection cfg, DateOnly baseDate, CancellationToken ct)
+    {
+        var all = new List<KrxStockParser.MasterItem>();
+
+        foreach (var (market, path) in Endpoints)
+        {
+            var url = $"{cfg.BaseUrl.TrimEnd('/')}{path}?basDd={baseDate:yyyyMMdd}";
+            var json = await KrxCoKrHttp.GetStringWithRetryAsync(client, url, cfg.ApiKey, Logger, ct);
+            var rows = KrxStockParser.ParseMasterInfo(json, market);
+
+            Logger.LogInformation("[{Job}] {Market} basDd={BaseDate} rows={Rows}", JobName, market, baseDate, rows.Count);
+            all.AddRange(rows);
+        }
+
+        return all;
+    }
+}

+ 179 - 0
Infrastructure/StockData/KrxStockParser.cs

@@ -0,0 +1,179 @@
+using System.Globalization;
+using System.Text.Json;
+using Domain.Entities.Stocks.ValueObject;
+
+namespace Infrastructure.StockData;
+
+/// <summary>
+/// KRX OpenAPI(data-dbg.krx.co.kr) 주식(종목) 응답 파서 — 순수 C# (System.Text.Json). "D1 KRX 주식(종목) 데이터 수집".
+/// 응답 골격: { "OutBlock_1": [ { ...string values }, ... ] }. 모든 값은 문자열, 천단위 콤마 포함 가능, 결측치는 "" 또는 "-".
+/// 일별매매(stk_bydd_trd/ksq_bydd_trd): ISU_CD 가 6자리 단축코드. FLUC_RT(%)는 소수 % 그대로 저장한다
+/// (StockDailyPrice.ChangeRate / Stock.LastChangeRate 이 decimal % 이므로 DataGoKrStockParser 와 동일 스케일 유지 — Bp 변환 안 함).
+/// 종목기본정보(stk_isu_base_info/ksq_isu_base_info): ISU_SRT_CD 가 6자리 단축코드, ISU_CD 가 표준코드(KR7…) ISIN.
+/// </summary>
+public static class KrxStockParser
+{
+    /// <summary>종목기본정보 1행 (마스터 upsert 원천). ISU_ABBRV → Name, ISU_ENG_NM → EnglishName, LIST_DD → ListedDate.</summary>
+    public sealed record MasterItem(
+        string Code,
+        string? Isin,
+        string Name,
+        string? EnglishName,
+        StockMarket Market,
+        string? SectorName,
+        DateOnly ListedDate
+    );
+
+    /// <summary>일별매매 1행 (등락률은 소수 % 그대로 — Bp 아님)</summary>
+    public sealed record DailyPriceItem(
+        string Code,
+        DateOnly TradingDate,
+        int Open,
+        int High,
+        int Low,
+        int Close,
+        long Volume,
+        long TradingValue,
+        int ChangeAmount,
+        decimal ChangeRate,
+        long? MarketCap,
+        long? ListedShares
+    );
+
+    /// <summary>일별매매(stk_bydd_trd/ksq_bydd_trd) 파싱. market 은 호출 엔드포인트로 결정 (응답 MKT_NM 은 무시하고 인자 신뢰).</summary>
+    public static IReadOnlyList<DailyPriceItem> ParseDailyPrices(string json, StockMarket market)
+    {
+        using var doc = JsonDocument.Parse(json);
+        var rows = new List<DailyPriceItem>();
+
+        if (!doc.RootElement.TryGetProperty("OutBlock_1", out var block) || block.ValueKind != JsonValueKind.Array)
+        {
+            return rows;
+        }
+
+        foreach (var item in block.EnumerateArray())
+        {
+            var code = NormalizeCode(GetString(item, "ISU_CD"));
+            var tradingDate = ParseDate(GetString(item, "BAS_DD"));
+
+            if (code is null || tradingDate is null)
+            {
+                continue;
+            }
+
+            rows.Add(new DailyPriceItem(
+                code,
+                tradingDate.Value,
+                ParseInt(GetString(item, "TDD_OPNPRC")),
+                ParseInt(GetString(item, "TDD_HGPRC")),
+                ParseInt(GetString(item, "TDD_LWPRC")),
+                ParseInt(GetString(item, "TDD_CLSPRC")),
+                ParseLong(GetString(item, "ACC_TRDVOL")),
+                ParseLong(GetString(item, "ACC_TRDVAL")),
+                ParseInt(GetString(item, "CMPPREVDD_PRC")),
+                ParseDecimal(GetString(item, "FLUC_RT")),
+                ParseNullableLong(GetString(item, "MKTCAP")),
+                ParseNullableLong(GetString(item, "LIST_SHRS"))
+            ));
+        }
+
+        return rows;
+    }
+
+    /// <summary>종목기본정보(stk_isu_base_info/ksq_isu_base_info) 파싱. market 은 호출 엔드포인트로 결정.</summary>
+    public static IReadOnlyList<MasterItem> ParseMasterInfo(string json, StockMarket market)
+    {
+        using var doc = JsonDocument.Parse(json);
+        var rows = new List<MasterItem>();
+
+        if (!doc.RootElement.TryGetProperty("OutBlock_1", out var block) || block.ValueKind != JsonValueKind.Array)
+        {
+            return rows;
+        }
+
+        foreach (var item in block.EnumerateArray())
+        {
+            var code = NormalizeCode(GetString(item, "ISU_SRT_CD"));
+            var name = GetString(item, "ISU_ABBRV");
+            var listedDate = ParseDate(GetString(item, "LIST_DD"));
+
+            if (code is null || name.Length == 0 || listedDate is null)
+            {
+                continue;
+            }
+
+            var isin = GetString(item, "ISU_CD");
+            var englishName = GetString(item, "ISU_ENG_NM");
+            var sectorName = GetString(item, "SECT_TP_NM");
+
+            rows.Add(new MasterItem(
+                code,
+                isin.Length == 12 ? isin : null,
+                name,
+                englishName.Length == 0 ? null : englishName,
+                market,
+                sectorName.Length == 0 ? null : sectorName,
+                listedDate.Value
+            ));
+        }
+
+        return rows;
+    }
+
+    private static string GetString(JsonElement element, string name)
+    {
+        if (!element.TryGetProperty(name, out var value))
+        {
+            return string.Empty;
+        }
+
+        return value.ValueKind switch
+        {
+            JsonValueKind.String => value.GetString()?.Trim() ?? string.Empty,
+            JsonValueKind.Number => value.GetRawText(),
+            _ => string.Empty
+        };
+    }
+
+    /// <summary>단축코드 정규화 — 'A' 프리픽스 등 6자리 초과분은 뒤 6자리만 취하고, 6자리 숫자만 유효</summary>
+    private static string? NormalizeCode(string raw)
+    {
+        if (raw.Length > 6)
+        {
+            raw = raw[^6..];
+        }
+
+        if (raw is not { Length: 6 } || raw.Any(c => !char.IsAsciiDigit(c)))
+        {
+            return null;
+        }
+
+        return raw;
+    }
+
+    private static DateOnly? ParseDate(string raw)
+    {
+        return DateOnly.TryParseExact(raw, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null;
+    }
+
+    private static int ParseInt(string raw)
+    {
+        return int.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0;
+    }
+
+    private static long ParseLong(string raw)
+    {
+        return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0L;
+    }
+
+    private static long? ParseNullableLong(string raw)
+    {
+        return long.TryParse(raw, NumberStyles.Integer | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : null;
+    }
+
+    /// <summary>등락률(%) 문자열 → decimal % 그대로. "" 나 "-" 등 파싱 실패는 0 (DataGoKrStockParser.fltRt 와 동일 스케일).</summary>
+    private static decimal ParseDecimal(string raw)
+    {
+        return decimal.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value) ? value : 0m;
+    }
+}

+ 9 - 0
SharedKernel/AppSetting.cs

@@ -199,5 +199,14 @@ public sealed class AppSettings
 
 
         /// <summary>지수 수집 실행 시각 (KST, "HH:mm") — KRX 지수는 장 마감(15:30) 이후 확정, 여유 두고 18:30</summary>
         /// <summary>지수 수집 실행 시각 (KST, "HH:mm") — KRX 지수는 장 마감(15:30) 이후 확정, 여유 두고 18:30</summary>
         public string IndexSyncTime { get; init; } = "18:30";
         public string IndexSyncTime { get; init; } = "18:30";
+
+        /// <summary>주식(종목) 마스터 + 일별매매 수집 배치 (KrxStockMasterSyncService/KrxDailyPriceSyncService) 활성화 — D1 KRX 주식 데이터 수집</summary>
+        public bool StockSync { get; init; } = false;
+
+        /// <summary>종목 마스터 동기화 실행 시각 (KST, "HH:mm") — 장 시작 전 07:40</summary>
+        public string MasterSyncTime { get; init; } = "07:40";
+
+        /// <summary>주식 일별매매 수집 실행 시각 (KST, "HH:mm") — KRX 일별매매는 장 마감(15:30) 이후 확정, 여유 두고 18:10</summary>
+        public string StockSyncTime { get; init; } = "18:10";
     }
     }
 }
 }

+ 127 - 0
Tests/Application.Tests/KrxStockParserTests.cs

@@ -0,0 +1,127 @@
+using Domain.Entities.Stocks.ValueObject;
+using Infrastructure.StockData;
+
+namespace Application.Tests;
+
+[TestClass]
+public sealed class KrxStockParserTests
+{
+    [TestMethod]
+    public void ParseDailyPrices_MapsFields_FromSampleJson()
+    {
+        // KRX OpenAPI(stk_bydd_trd) 표준 응답 샘플 — ISU_CD=6자리 단축코드, FLUC_RT 는 소수 % 그대로 저장(Bp 아님)
+        const string json = """
+        {"OutBlock_1":[
+        {"BAS_DD":"20250102","ISU_CD":"005930","ISU_NM":"삼성전자","MKT_NM":"KOSPI","SECT_TP_NM":"","TDD_CLSPRC":"70,500","CMPPREVDD_PRC":"-500","FLUC_RT":"-0.70","TDD_OPNPRC":"71,000","TDD_HGPRC":"71,200","TDD_LWPRC":"70,300","ACC_TRDVOL":"12,345,678","ACC_TRDVAL":"870,000,000,000","MKTCAP":"420,870,169,275,000","LIST_SHRS":"5,969,782,550"},
+        {"BAS_DD":"20250102","ISU_CD":"000660","ISU_NM":"SK하이닉스","MKT_NM":"KOSPI","SECT_TP_NM":"","TDD_CLSPRC":"210000","CMPPREVDD_PRC":"3000","FLUC_RT":"1.45","TDD_OPNPRC":"208000","TDD_HGPRC":"211500","TDD_LWPRC":"207000","ACC_TRDVOL":"2345678","ACC_TRDVAL":"490000000000","MKTCAP":"-","LIST_SHRS":"-"}
+        ]}
+        """;
+
+        var rows = KrxStockParser.ParseDailyPrices(json, StockMarket.KOSPI);
+
+        Assert.AreEqual(2, rows.Count);
+
+        var samsung = rows[0];
+        Assert.AreEqual("005930", samsung.Code);
+        Assert.AreEqual(new DateOnly(2025, 1, 2), samsung.TradingDate);
+        Assert.AreEqual(71000, samsung.Open, "콤마 천단위를 제거하고 시가 파싱");
+        Assert.AreEqual(71200, samsung.High);
+        Assert.AreEqual(70300, samsung.Low);
+        Assert.AreEqual(70500, samsung.Close);
+        Assert.AreEqual(12345678L, samsung.Volume, "거래량 콤마 제거");
+        Assert.AreEqual(870000000000L, samsung.TradingValue, "거래대금 콤마 제거");
+        Assert.AreEqual(-500, samsung.ChangeAmount);
+        Assert.AreEqual(-0.70m, samsung.ChangeRate, "FLUC_RT 는 소수 % 그대로 (DataGoKr fltRt 와 동일 스케일, Bp 아님)");
+        Assert.AreEqual(420870169275000L, samsung.MarketCap);
+        Assert.AreEqual(5969782550L, samsung.ListedShares);
+
+        var hynix = rows[1];
+        Assert.AreEqual("000660", hynix.Code);
+        Assert.AreEqual(1.45m, hynix.ChangeRate);
+        Assert.IsNull(hynix.MarketCap, "\"-\" 시가총액은 null");
+        Assert.IsNull(hynix.ListedShares, "\"-\" 상장주식수는 null");
+    }
+
+    [TestMethod]
+    public void ParseDailyPrices_NormalizesPrefixedCode()
+    {
+        // 'A' 프리픽스 단축코드는 뒤 6자리로 정규화
+        const string json = """
+        {"OutBlock_1":[
+        {"BAS_DD":"20250102","ISU_CD":"A000660","ISU_NM":"SK하이닉스","MKT_NM":"KOSPI","TDD_CLSPRC":"210000","CMPPREVDD_PRC":"0","FLUC_RT":"0.00","TDD_OPNPRC":"210000","TDD_HGPRC":"210000","TDD_LWPRC":"210000","ACC_TRDVOL":"0","ACC_TRDVAL":"0","MKTCAP":"0","LIST_SHRS":"0"}
+        ]}
+        """;
+
+        var rows = KrxStockParser.ParseDailyPrices(json, StockMarket.KOSPI);
+
+        Assert.AreEqual(1, rows.Count);
+        Assert.AreEqual("000660", rows[0].Code, "'A' 프리픽스 단축코드는 뒤 6자리로 정규화한다");
+    }
+
+    [TestMethod]
+    public void ParseDailyPrices_EmptyOrMissingBlock_ReturnsEmpty()
+    {
+        // basDd 미반영 — OutBlock_1 이 빈 배열
+        Assert.AreEqual(0, KrxStockParser.ParseDailyPrices("""{"OutBlock_1":[]}""", StockMarket.KOSDAQ).Count);
+
+        // 인증 실패 등 OutBlock_1 노드 자체가 없는 응답도 안전하게 0건
+        Assert.AreEqual(0, KrxStockParser.ParseDailyPrices("""{"respMsg":"Unauthorized API Call","respCode":"401"}""", StockMarket.KOSPI).Count);
+    }
+
+    [TestMethod]
+    public void ParseMasterInfo_MapsFields_KospiAndKosdaq()
+    {
+        // KRX OpenAPI(stk_isu_base_info/ksq_isu_base_info) 표준 응답 샘플 — ISU_SRT_CD=단축코드, ISU_CD=표준코드 ISIN
+        const string kospiJson = """
+        {"OutBlock_1":[
+        {"ISU_CD":"KR7005930003","ISU_SRT_CD":"005930","ISU_NM":"삼성전자보통주","ISU_ABBRV":"삼성전자","ISU_ENG_NM":"SAMSUNG ELECTRONICS CO.,LTD.","LIST_DD":"19750611","MKT_TP_NM":"KOSPI","SECUGRP_NM":"주권","SECT_TP_NM":"","KIND_STKCERT_TP_NM":"보통주","PARVAL":"100","LIST_SHRS":"5969782550"}
+        ]}
+        """;
+
+        var kospi = KrxStockParser.ParseMasterInfo(kospiJson, StockMarket.KOSPI);
+
+        Assert.AreEqual(1, kospi.Count);
+        var samsung = kospi[0];
+        Assert.AreEqual("005930", samsung.Code, "ISU_SRT_CD → Code");
+        Assert.AreEqual("KR7005930003", samsung.Isin, "ISU_CD(표준코드) → ISIN");
+        Assert.AreEqual("삼성전자", samsung.Name, "ISU_ABBRV(약칭) → Name");
+        Assert.AreEqual("SAMSUNG ELECTRONICS CO.,LTD.", samsung.EnglishName);
+        Assert.AreEqual(StockMarket.KOSPI, samsung.Market, "market 은 호출 엔드포인트 인자로 결정");
+        Assert.IsNull(samsung.SectorName, "빈 SECT_TP_NM 은 null");
+        Assert.AreEqual(new DateOnly(1975, 6, 11), samsung.ListedDate, "LIST_DD → ListedDate");
+
+        // KOSDAQ 은 SECT_TP_NM(소속부) 이 채워져 온다
+        const string kosdaqJson = """
+        {"OutBlock_1":[
+        {"ISU_CD":"KR7058820002","ISU_SRT_CD":"058820","ISU_NM":"CMG제약","ISU_ABBRV":"CMG제약","ISU_ENG_NM":"CMG Pharmaceutical Co., Ltd.","LIST_DD":"20010831","MKT_TP_NM":"KOSDAQ","SECUGRP_NM":"주권","SECT_TP_NM":"우량기업부","KIND_STKCERT_TP_NM":"보통주","PARVAL":"500","LIST_SHRS":"111111111"}
+        ]}
+        """;
+
+        var kosdaq = KrxStockParser.ParseMasterInfo(kosdaqJson, StockMarket.KOSDAQ);
+
+        Assert.AreEqual(1, kosdaq.Count);
+        Assert.AreEqual(StockMarket.KOSDAQ, kosdaq[0].Market);
+        Assert.AreEqual("우량기업부", kosdaq[0].SectorName, "KOSDAQ SECT_TP_NM(소속부) → SectorName");
+    }
+
+    [TestMethod]
+    public void ParseMasterInfo_InvalidIsin_And_EmptyBlock()
+    {
+        // ISU_CD 가 12자리가 아니면 ISIN=null (방어)
+        const string json = """
+        {"OutBlock_1":[
+        {"ISU_CD":"BADISIN","ISU_SRT_CD":"005930","ISU_NM":"삼성전자보통주","ISU_ABBRV":"삼성전자","ISU_ENG_NM":"","LIST_DD":"19750611","MKT_TP_NM":"KOSPI","SECT_TP_NM":"","LIST_SHRS":"1"}
+        ]}
+        """;
+
+        var rows = KrxStockParser.ParseMasterInfo(json, StockMarket.KOSPI);
+
+        Assert.AreEqual(1, rows.Count);
+        Assert.IsNull(rows[0].Isin, "12자리 아닌 표준코드는 ISIN=null");
+        Assert.IsNull(rows[0].EnglishName, "빈 ISU_ENG_NM 은 null");
+
+        // 빈 스냅샷 / 노드 없음 안전 처리
+        Assert.AreEqual(0, KrxStockParser.ParseMasterInfo("""{"OutBlock_1":[]}""", StockMarket.KOSPI).Count);
+        Assert.AreEqual(0, KrxStockParser.ParseMasterInfo("""{"respCode":"401"}""", StockMarket.KOSDAQ).Count);
+    }
+}