|
@@ -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; }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|