213 lines
8.8 KiB
C#
213 lines
8.8 KiB
C#
|
|
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);
|
||
|
|
}
|
||
|
|
}
|