Files
PineSript/Buyer/CandlePoller.cs

162 lines
7.3 KiB
C#

using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// REST API 1초 폴링 방식 캔들 감지기.
///
/// 동작 원리:
/// - 1초마다 최신 N봉을 REST API로 조회한다.
/// - 가장 최신 봉(현재 진행 중인 봉)의 timestamp를 추적한다.
/// - timestamp가 이전과 달라지면 → 이전 봉이 완성된 것.
/// - 완성된 봉 중 아직 처리하지 않은 봉을 OnCandle로 발행한다.
///
/// 시간 계산에 의존하지 않고 서버 응답 데이터 기준으로 판단하므로
/// 서버 클럭 오차, API 지연, 네트워크 지연에 영향받지 않는다.
/// </summary>
public class CandlePoller
{
private readonly BitgetConfig _cfg;
private readonly BitgetRestClient _rest;
/// <summary>새로운 완성 봉이 감지됐을 때 발생합니다.</summary>
public event Action<CandleData>? OnCandle;
/// <summary>WarmUp 마지막 봉 시각 — 이 시각 이전 봉은 처리 건너뜀</summary>
private DateTimeOffset _lastProcessedTime = DateTimeOffset.MinValue;
/// <summary>현재 진행 중인 봉의 시작 시각 (직전 폴링 기준)</summary>
private DateTimeOffset _currentBarTime = DateTimeOffset.MinValue;
public CandlePoller(BitgetConfig cfg, BitgetRestClient rest)
{
_cfg = cfg;
_rest = rest;
}
/// <summary>WarmUp 완료 후 호출 — 이미 처리된 봉 시각을 설정합니다.</summary>
public void SetLastProcessedTime(DateTimeOffset time)
{
_lastProcessedTime = time;
_currentBarTime = time; // 현재 진행 봉도 초기화
}
// ════════════════════════════════════════════════════════════════
// 폴링 루프
// ════════════════════════════════════════════════════════════════
public async Task RunAsync(CancellationToken ct)
{
Log.Information("[Poller] 시작 — {Interval}ms 간격 조회봉수={N}",
_cfg.PollIntervalMs, _cfg.PollCandleCount);
while (!ct.IsCancellationRequested)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
await PollOnceAsync();
// 정확히 PollIntervalMs 간격 유지 (API 호출 시간 보정)
int elapsed = (int)sw.ElapsedMilliseconds;
int wait = _cfg.PollIntervalMs - elapsed;
if (wait > 0)
{
try { await Task.Delay(wait, ct); }
catch (OperationCanceledException) { break; }
}
else if (elapsed > _cfg.PollIntervalMs + 1000)
{
Log.Warning("[Poller] 폴링 주기 1초 이상 초과: {Elapsed}ms (한도={Limit}ms)",
elapsed, _cfg.PollIntervalMs);
}
}
Log.Information("[Poller] 종료");
}
// ════════════════════════════════════════════════════════════════
// 단일 폴링
// ════════════════════════════════════════════════════════════════
private async Task PollOnceAsync()
{
List<CandleData> candles;
var callStart = DateTimeOffset.Now;
try
{
candles = await _rest.GetCandlesAsync(_cfg.PollCandleCount);
}
catch (Exception ex)
{
Log.Warning("[Poller] REST 조회 실패 — 다음 주기에 재시도: {Err}", ex.Message);
return;
}
var callEnd = DateTimeOffset.Now;
var callMs = (int)(callEnd - callStart).TotalMilliseconds;
if (candles.Count == 0) return;
// candles는 오름차순 (과거→현재) 정렬됨
// candles[^1] = 현재 진행 중인 봉 (아직 완성되지 않음)
// candles[^2] 이하 = 완성된 봉들
var latestBar = candles[^1];
// ── 최초 초기화 ────────────────────────────────────────────
if (_currentBarTime == DateTimeOffset.MinValue)
{
_currentBarTime = latestBar.Time;
Log.Information("[Poller] 현재 진행 봉 감지: {Time}",
latestBar.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm"));
return;
}
// ── 봉 바뀜 감지 ─────────────────────────────────────────
// 과거 데이터(서버 불일치)는 무시, 더 최신 봉이 왔을 때만 처리
if (latestBar.Time <= _currentBarTime)
{
// 진행 중인 봉 유지 중 — 매 분마다 한 번씩 상태 로그 (디버그용)
if (DateTimeOffset.UtcNow.Second == 0)
{
Log.Debug("[Poller] 진행 중: 현재봉={Current} 마지막처리={Last} 서버최신={Latest}",
_currentBarTime.ToLocalTime().ToString("HH:mm"),
_lastProcessedTime.ToLocalTime().ToString("HH:mm"),
latestBar.Time.ToLocalTime().ToString("HH:mm"));
}
return;
}
Log.Information(
"[Poller] 봉 전환 감지: {Prev} → {Next} │ 호출전={CallStart} 호출후={CallEnd} ({Ms}ms)",
_currentBarTime.ToLocalTime().ToString("HH:mm"),
latestBar.Time.ToLocalTime().ToString("HH:mm"),
callStart.ToString("HH:mm:ss.fff"),
callEnd.ToString("HH:mm:ss.fff"),
callMs);
_currentBarTime = latestBar.Time;
// ── 완성 봉 처리 ─────────────────────────────────────────
// 현재 진행 중인 봉(latestBar) 제외, 나머지 중 처리 안 된 봉 발행
foreach (var candle in candles)
{
// 현재 진행 중인 봉 스킵
if (candle.Time >= _currentBarTime) continue;
// 이미 처리된 봉 스킵
if (candle.Time <= _lastProcessedTime) continue;
Log.Information("[Poller] ✅ 완성 봉: {Time} O={O} H={H} L={L} C={C}",
candle.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
candle.Open, candle.High, candle.Low, candle.Close);
_lastProcessedTime = candle.Time;
try { OnCandle?.Invoke(candle); }
catch (Exception ex) { Log.Error(ex, "[Poller] OnCandle 핸들러 오류"); }
}
}
}
}