Răsfoiți Sursa

feat(market): 국내 증시 요약 API (코스피/코스닥/KOSPI200) — Phase 1

- GetDomesticSummary 쿼리 + GET api/market/domestic-summary (AllowAnonymous)
- 최신 거래일 3지수(값/전일비/등락률) + 시장별 등락종목수(StockDailyPrice×Stock GroupBy)
  · 상승/보합/하락 정확, 상한/하한은 가격제한 플래그 부재로 ±29.5% 근사
- 투자자별 순매수·베이시스(KOSPI200)는 Phase 2 (신규 수집/선물계산) — 응답 null
- Release 빌드 0에러 (Debug는 서버 실행 중 잠금)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 săptămâni în urmă
părinte
comite
4982c6ede2

+ 151 - 0
Application/Features/Api/Stocks/GetDomesticSummary/Handler.cs

@@ -0,0 +1,151 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Stocks.ValueObject;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.Stocks.GetDomesticSummary;
+
+/// <summary>
+/// 국내 증시 요약 — 최신 거래일의 코스피·코스닥·KOSPI200 지수(IndexDailyPrice) + 시장별 등락종목수(StockDailyPrice×Stock).
+/// 상한/하한은 가격제한 플래그가 없어 등락률 ±29.5% 근사. 투자자별 순매수·베이시스(KOSPI200)는 Phase 2(신규 수집·선물계산) — 현재 null.
+/// </summary>
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    private const decimal LimitThreshold = 29.5m; // 가격제한(±30%) 근사
+
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var rows = new List<Response.Row>();
+
+        // ── 지수 (최신 거래일) ──
+        var latestIndexDate = await db.IndexDailyPrice.AsNoTracking()
+            .OrderByDescending(c => c.TradeDate)
+            .Select(c => (DateOnly?)c.TradeDate)
+            .FirstOrDefaultAsync(ct);
+
+        if (latestIndexDate is not DateOnly indexDate)
+        {
+            return new Response { List = rows };
+        }
+
+        // 시장별 등락종목수 (최신 종목 거래일 — 지수일과 다를 수 있어 별도 산출)
+        var stockDate = await db.StockDailyPrice.AsNoTracking()
+            .OrderByDescending(c => c.TradingDate)
+            .Select(c => (DateOnly?)c.TradingDate)
+            .FirstOrDefaultAsync(ct);
+
+        var breadthByMarket = new Dictionary<StockMarket, BreadthRow>();
+
+        if (stockDate is DateOnly sd)
+        {
+            // 익명 타입으로 집계(EF GroupBy 변환 안전) 후 메모리에서 매핑
+            var breadth = await db.StockDailyPrice.AsNoTracking()
+                .Where(c => c.TradingDate == sd)
+                .Join(db.Stock.AsNoTracking().Where(s => s.IsActive), p => p.StockID, s => s.ID, (p, s) => new { s.Market, p.ChangeRate })
+                .GroupBy(x => x.Market)
+                .Select(g => new
+                {
+                    Market = g.Key,
+                    Advances = g.Count(x => x.ChangeRate > 0m),
+                    Declines = g.Count(x => x.ChangeRate < 0m),
+                    Unchanged = g.Count(x => x.ChangeRate == 0m),
+                    LimitUp = g.Count(x => x.ChangeRate >= LimitThreshold),
+                    LimitDown = g.Count(x => x.ChangeRate <= -LimitThreshold)
+                })
+                .ToListAsync(ct);
+
+            breadthByMarket = breadth.ToDictionary(
+                b => b.Market,
+                b => new BreadthRow
+                {
+                    Market = b.Market,
+                    Advances = b.Advances,
+                    Declines = b.Declines,
+                    Unchanged = b.Unchanged,
+                    LimitUp = b.LimitUp,
+                    LimitDown = b.LimitDown
+                });
+        }
+
+        // 코스피
+        await AddIndexAsync(rows, "kospi", MarketIndexSeries.KOSPI, "코스피", indexDate, breadthByMarket, StockMarket.KOSPI, ct);
+        // 코스닥
+        await AddIndexAsync(rows, "kosdaq", MarketIndexSeries.KOSDAQ, "코스닥", indexDate, breadthByMarket, StockMarket.KOSDAQ, ct);
+        // KOSPI200 (KOSPI 계열 내 "200" 포함 지수) — 등락종목수 없음, 베이시스는 Phase 2
+        await AddKospi200Async(rows, indexDate, ct);
+
+        return new Response { List = rows };
+    }
+
+    private async Task AddIndexAsync(
+        List<Response.Row> rows,
+        string key,
+        MarketIndexSeries series,
+        string indexName,
+        DateOnly indexDate,
+        Dictionary<StockMarket, BreadthRow> breadthByMarket,
+        StockMarket market,
+        CancellationToken ct
+    ) {
+        var idx = await db.IndexDailyPrice.AsNoTracking()
+            .Where(c => c.TradeDate == indexDate && c.Series == series && c.IndexName == indexName)
+            .Select(c => new { c.Close, c.ChangeVal, c.FlucRateBp })
+            .FirstOrDefaultAsync(ct);
+
+        if (idx is null)
+        {
+            return;
+        }
+
+        breadthByMarket.TryGetValue(market, out var b);
+
+        rows.Add(new Response.Row
+        {
+            Key = key,
+            Name = indexName,
+            Close = idx.Close,
+            ChangeVal = idx.ChangeVal,
+            FlucRateBp = idx.FlucRateBp,
+            TradeDate = indexDate.ToString("yyyy-MM-dd"),
+            Advances = b?.Advances,
+            Declines = b?.Declines,
+            Unchanged = b?.Unchanged,
+            LimitUp = b?.LimitUp,
+            LimitDown = b?.LimitDown
+        });
+    }
+
+    private async Task AddKospi200Async(List<Response.Row> rows, DateOnly indexDate, CancellationToken ct)
+    {
+        var idx = await db.IndexDailyPrice.AsNoTracking()
+            .Where(c => c.TradeDate == indexDate && c.Series == MarketIndexSeries.KOSPI && c.IndexName.Contains("200"))
+            .OrderBy(c => c.IndexName)
+            .Select(c => new { c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp })
+            .FirstOrDefaultAsync(ct);
+
+        if (idx is null)
+        {
+            return;
+        }
+
+        rows.Add(new Response.Row
+        {
+            Key = "kospi200",
+            Name = idx.IndexName,
+            Close = idx.Close,
+            ChangeVal = idx.ChangeVal,
+            FlucRateBp = idx.FlucRateBp,
+            TradeDate = indexDate.ToString("yyyy-MM-dd")
+        });
+    }
+
+    private sealed class BreadthRow
+    {
+        public StockMarket Market { get; init; }
+        public int Advances { get; init; }
+        public int Declines { get; init; }
+        public int Unchanged { get; init; }
+        public int LimitUp { get; init; }
+        public int LimitDown { get; init; }
+    }
+}

+ 6 - 0
Application/Features/Api/Stocks/GetDomesticSummary/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Api.Stocks.GetDomesticSummary;
+
+/// <summary>국내 증시 요약(메인) — 코스피·코스닥·KOSPI200 지수 + 등락종목수. 익명 열람. (투자자별 순매수·베이시스는 Phase 2)</summary>
+public sealed record Query() : IQuery<Response>;

+ 43 - 0
Application/Features/Api/Stocks/GetDomesticSummary/Response.cs

@@ -0,0 +1,43 @@
+namespace Application.Features.Api.Stocks.GetDomesticSummary;
+
+public sealed class Response
+{
+    public required IReadOnlyList<Row> List { get; init; }
+
+    public sealed class Row
+    {
+        /// <summary>키 — "kospi" | "kosdaq" | "kospi200"</summary>
+        public required string Key { get; init; }
+
+        /// <summary>지수명 — 코스피 / 코스닥 / KOSPI 200</summary>
+        public required string Name { get; init; }
+
+        public required decimal Close { get; init; }
+        public required decimal ChangeVal { get; init; }
+
+        /// <summary>등락률 basis point (%×100)</summary>
+        public required int FlucRateBp { get; init; }
+
+        public required string TradeDate { get; init; }
+
+        // ── 등락 종목 수 (코스피/코스닥만; KOSPI200 은 null) ──
+        public int? Advances { get; init; }
+        public int? Declines { get; init; }
+        public int? Unchanged { get; init; }
+
+        /// <summary>상한(가격제한 근사 — 등락률 +29.5%↑)</summary>
+        public int? LimitUp { get; init; }
+
+        /// <summary>하한(가격제한 근사 — 등락률 -29.5%↓)</summary>
+        public int? LimitDown { get; init; }
+
+        // ── 투자자별 순매수(억원) — Phase 2, 현재 null ──
+        public long? IndividualNet { get; init; }
+        public long? ForeignNet { get; init; }
+        public long? InstitutionNet { get; init; }
+        public long? ProgramNet { get; init; }
+
+        // ── 베이시스 (KOSPI200) — Phase 2, 현재 null ──
+        public decimal? Basis { get; init; }
+    }
+}

+ 22 - 0
Web.Api/Endpoints/Market/DomesticSummary.cs

@@ -0,0 +1,22 @@
+using Application.Abstractions.Messaging;
+using Web.Api.Common;
+
+namespace Web.Api.Endpoints.Market;
+
+/// <summary>국내 증시 요약(메인) — 코스피·코스닥·KOSPI200 지수 + 등락종목수. 익명 열람.</summary>
+internal sealed class DomesticSummary : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/market/domestic-summary", async (
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var result = await sender.Send(new Application.Features.Api.Stocks.GetDomesticSummary.Query(), ct);
+
+            return ApiResponse.Ok(result);
+        })
+        .WithTags("Market")
+        .AllowAnonymous();
+    }
+}