using Bitget_Server.Api.Dtos; using Bitget_Server.Application.Services; using Bitget_Server.Application.State; using Bitget_Server.Domain.Enums; using Bitget_Server.Domain.Models; using Microsoft.AspNetCore.Mvc; namespace Bitget_Server.Api.Controllers; /// /// 런타임 캐시/지표 계산 결과를 외부에서 점검하기 위한 진단용 API. /// wwwroot/chart.html 화면이 주로 소비한다. /// [ApiController] [Route("api/diag")] [Produces("application/json")] public class DiagnosticsController : ControllerBase { private readonly MarketState _state; private readonly PriceLevelTracker _priceLevels; private readonly SignalProcessService _signals; public DiagnosticsController(MarketState state, PriceLevelTracker priceLevels, SignalProcessService signals) { _state = state; _priceLevels = priceLevels; _signals = signals; } /// /// 현재 캐시에 존재하는 심볼 + 서버가 지원하는 인터벌 목록. /// [HttpGet("symbols")] [ProducesResponseType(typeof(DiagSymbolsResponse), StatusCodes.Status200OK)] public ActionResult GetSymbols() { lock (_state.SyncRoot) { var symbols = _state.candleDataLists .Select(x => x.ConGoodsData?.MarketName) .Where(n => !string.IsNullOrEmpty(n)) .Distinct() .OrderBy(n => n) .ToList(); var intervals = Enum.GetNames(typeof(MCandleTimeType)); return Ok(new DiagSymbolsResponse(symbols, intervals)); } } /// /// 특정 심볼/인터벌의 캔들 데이터 스냅샷 + 인디케이터 동봉. /// Chart.js financial 플러그인 호환 포맷 (t,o,h,l,c). /// [HttpGet("candles")] [ProducesResponseType(typeof(DiagCandleResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public ActionResult GetCandles( [FromQuery] string symbol, [FromQuery] string interval = "M1", [FromQuery] int limit = 200) { if (string.IsNullOrWhiteSpace(symbol)) return Problem("symbol 파라미터 필수", statusCode: StatusCodes.Status400BadRequest); if (!Enum.TryParse(interval, ignoreCase: true, out var t)) return Problem($"지원하지 않는 interval: {interval}", statusCode: StatusCodes.Status400BadRequest); List snapshot; lock (_state.SyncRoot) { var entry = _state.candleDataLists .FirstOrDefault(x => string.Equals(x.ConGoodsData?.MarketName, symbol, StringComparison.OrdinalIgnoreCase)); if (entry == null || entry.candleDataList == null || !entry.candleDataList.TryGetValue(t, out var list) || list == null) return Problem( detail: $"캐시에 {symbol}/{interval} 데이터가 없습니다. WebSocketCalcBackgroundService 가 동작 중이며 대상 마켓이 checked 되었는지 확인.", statusCode: StatusCodes.Status404NotFound, title: "Candle data not cached"); // 얕은 복사 (원본 컬렉션 수정과 응답 직렬화 동시 진행 방지) snapshot = list.ToList(); } if (limit > 0 && snapshot.Count > limit) snapshot = snapshot.Skip(snapshot.Count - limit).ToList(); var dtos = snapshot.Select(ToDto).OrderBy(d => d.t).ToList(); return Ok(new DiagCandleResponse(symbol.ToUpperInvariant(), t.ToString(), dtos.Count, dtos)); } /// /// 특정 심볼/인터벌에 대한 PriceLevelTracker 의 전체 내부 상태. /// (전고가/전저가/전전고가/전전저가/tempHighVal/tempLowVal 등) /// [HttpGet("price-levels")] [ProducesResponseType(typeof(DiagPriceLevelsDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public ActionResult GetPriceLevels( [FromQuery] string symbol, [FromQuery] string interval = "M1") { if (string.IsNullOrWhiteSpace(symbol)) return Problem("symbol 파라미터 필수", statusCode: StatusCodes.Status400BadRequest); var key = symbol + interval; if (!_priceLevels.ContainsKey(key)) return Problem( detail: $"PriceLevelTracker 에 키가 없습니다 (key={key}). SignalProcessBackgroundService 가 해당 마켓을 처리한 적이 있는지 확인.", statusCode: StatusCodes.Status404NotFound, title: "Price levels not tracked"); var levels = _priceLevels.GetOrCreate(key); // 값 복사 var copy = new Dictionary(levels); return Ok(new DiagPriceLevelsDto(symbol.ToUpperInvariant(), interval, key, copy)); } /// /// 현재 활성 신호를 심볼/인터벌로 필터링해 차트 마커용 포맷으로 반환. /// [HttpGet("signal-markers")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public ActionResult> GetSignalMarkers( [FromQuery] string symbol, [FromQuery] string interval = null) { IEnumerable Map(IEnumerable src, string dir) => src.Select(s => new DiagSignalMarkerDto( direction: dir, symbol: s.MarketName, interval: s.MCandleTimeType.ToString(), t: new DateTimeOffset(s.SignalStart == default ? DateTime.UtcNow : s.SignalStart.ToUniversalTime()) .ToUnixTimeMilliseconds(), price: s.Price, rate: s.Rate, indicatorPrice: s.IndicatorPrice, indicatorRate: s.IndicatorRate, description: s.Description ?? string.Empty)); bool SymbolMatch(SignalInfoData s) => string.IsNullOrWhiteSpace(symbol) || string.Equals(s.MarketName, symbol, StringComparison.OrdinalIgnoreCase); bool IntervalMatch(SignalInfoData s) => string.IsNullOrWhiteSpace(interval) || string.Equals(s.MCandleTimeType.ToString(), interval, StringComparison.OrdinalIgnoreCase); var all = new List(); all.AddRange(Map(_signals.SignalsLongUp.Where(s => SymbolMatch(s) && IntervalMatch(s)), "LongUp")); all.AddRange(Map(_signals.SignalsShortDown.Where(s => SymbolMatch(s) && IntervalMatch(s)), "ShortDown")); all.AddRange(Map(_signals.SignalsLongDown.Where(s => SymbolMatch(s) && IntervalMatch(s)), "LongDown")); all.AddRange(Map(_signals.SignalsShortUp.Where(s => SymbolMatch(s) && IntervalMatch(s)), "ShortUp")); return Ok(all); } // ===================================================================== // private helpers // ===================================================================== private static DiagCandleDto ToDto(CandleData c) { long t = c.Argument == default ? 0L : new DateTimeOffset(DateTime.SpecifyKind(c.Argument, DateTimeKind.Utc)).ToUnixTimeMilliseconds(); DiagIchimokuDto ichi = null; if (c.IchimokData != null) { ichi = new DiagIchimokuDto( c.IchimokData.기준선, c.IchimokData.전환선, c.IchimokData.후행스팬, c.IchimokData.선행스팬1, c.IchimokData.선행스팬2, c.IchimokData.상한선, c.IchimokData.하한선, c.IchimokData.IsLongUp, c.IchimokData.IsLongDown, c.IchimokData.IsShortUp, c.IchimokData.IsShortDown); } Dictionary ma = null; if (c.movingAverageDataList != null) { ma = c.movingAverageDataList.ToDictionary( kv => kv.Key, kv => new DiagMovingAverageDto( kv.Value.Value, kv.Value.Average, kv.Value.BolangerValue_Up, kv.Value.BolangerValue_Down)); } Dictionary macd = null; if (c.impulseMacdDataList != null) { macd = c.impulseMacdDataList.ToDictionary( kv => kv.Key, kv => new DiagImpulseMacdDto(kv.Value.MD, kv.Value.SH, kv.Value.SB)); } return new DiagCandleDto( t, c.Open, c.High, c.Low, c.Close, c.Vol_C, c.Vol_USDT, c.WebSocketUpdate, c.WebServiceUpdate, ichi, ma, macd); } }