Files
BitGet_API/Bitget_Server/Api/Controllers/DiagnosticsController.cs

213 lines
8.8 KiB
C#
Raw Normal View History

2026-04-21 01:19:08 +09:00
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;
/// <summary>
/// 런타임 캐시/지표 계산 결과를 외부에서 점검하기 위한 진단용 API.
/// wwwroot/chart.html 화면이 주로 소비한다.
/// </summary>
[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;
}
/// <summary>
/// 현재 캐시에 존재하는 심볼 + 서버가 지원하는 인터벌 목록.
/// </summary>
[HttpGet("symbols")]
[ProducesResponseType(typeof(DiagSymbolsResponse), StatusCodes.Status200OK)]
public ActionResult<DiagSymbolsResponse> 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));
}
}
/// <summary>
/// 특정 심볼/인터벌의 캔들 데이터 스냅샷 + 인디케이터 동봉.
/// Chart.js financial 플러그인 호환 포맷 (t,o,h,l,c).
/// </summary>
[HttpGet("candles")]
[ProducesResponseType(typeof(DiagCandleResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<DiagCandleResponse> 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<MCandleTimeType>(interval, ignoreCase: true, out var t))
return Problem($"지원하지 않는 interval: {interval}", statusCode: StatusCodes.Status400BadRequest);
List<CandleData> 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));
}
/// <summary>
/// 특정 심볼/인터벌에 대한 PriceLevelTracker 의 전체 내부 상태.
/// (전고가/전저가/전전고가/전전저가/tempHighVal/tempLowVal 등)
/// </summary>
[HttpGet("price-levels")]
[ProducesResponseType(typeof(DiagPriceLevelsDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<DiagPriceLevelsDto> 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<string, double>(levels);
return Ok(new DiagPriceLevelsDto(symbol.ToUpperInvariant(), interval, key, copy));
}
/// <summary>
/// 현재 활성 신호를 심볼/인터벌로 필터링해 차트 마커용 포맷으로 반환.
/// </summary>
[HttpGet("signal-markers")]
[ProducesResponseType(typeof(IEnumerable<DiagSignalMarkerDto>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<DiagSignalMarkerDto>> GetSignalMarkers(
[FromQuery] string symbol,
[FromQuery] string interval = null)
{
IEnumerable<DiagSignalMarkerDto> Map(IEnumerable<SignalInfoData> 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<DiagSignalMarkerDto>();
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<int, DiagMovingAverageDto> 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<int, DiagImpulseMacdDto> 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);
}
}