Files
PineSript/Buyer/RealtimeTrader.cs
2026-10-20 17:57:28 +09:00

473 lines
21 KiB
C#

using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// 실시간 트레이더.
///
/// 실행 순서:
/// 1) WarmUp — 과거 봉으로 Pine Script 지표 초기화
/// 2) Reconcile — Bitget 실제 포지션과 Pine 상태 동기화 (API 키 설정 시)
/// 3) TestCycle — BUY→SELL→SHORT→COVER 파이프라인 검증 (TestOnStart=true 시)
/// 4) 실시간 폴링 — 봉 완성마다 Pine Script 실행 → 신호 → 주문
///
/// DryRun = true : 신호 로그만 출력, 실제 주문 미전송 (기본값)
/// DryRun = false : 실제 Bitget 주문 전송
/// </summary>
public class RealtimeTrader : IDisposable
{
private readonly BitgetConfig _cfg;
private readonly PineScriptExecutor _executor;
private readonly BitgetRestClient _rest;
private readonly CandlePoller _poller;
// 인증/주문 클라이언트 (ApiKeyFilePath 설정 시에만 초기화)
private BitgetAuthClient? _auth;
private BitgetPositionClient? _positionClient;
private BitgetOrderClient? _orderClient;
// 현재 보유 포지션 수량 추적
// (진입 시 OrderSize로 설정, 청산 시 0으로 리셋)
private decimal _openedSize = 0m;
public RealtimeTrader(BitgetConfig cfg)
{
if (!cfg.DryRun)
throw new InvalidOperationException("Legacy live trading is disabled during observation validation.");
_cfg = cfg;
_executor = new PineScriptExecutor(cfg.PineScriptPath);
_rest = new BitgetRestClient(cfg);
_poller = new CandlePoller(cfg, _rest);
if (!cfg.DryRun && !string.IsNullOrWhiteSpace(cfg.ApiKeyFilePath))
{
try
{
var key = ApiKeyLoader.Load(cfg.ApiKeyFilePath);
_auth = new BitgetAuthClient(key);
_positionClient = new BitgetPositionClient(_auth, cfg);
_orderClient = new BitgetOrderClient(_auth, cfg);
}
catch (Exception ex)
{
Log.Warning(ex, "[Trader] API 키 로드 실패 — 주문 비활성화");
}
}
else
{
Log.Information("[Trader] ApiKeyFilePath 미설정 — 주문 비활성화");
}
if (_cfg.DryRun)
Log.Warning("[Trader] ★ DryRun 모드 — 실제 주문을 전송하지 않습니다");
}
// ════════════════════════════════════════════════════════════════
// 메인 루프
// ════════════════════════════════════════════════════════════════
public async Task RunAsync(CancellationToken ct = default)
{
// ── Step 1: WarmUp ───────────────────────────────────────
Log.Information("[Trader] ═══ WarmUp 시작 ═══");
Log.Information("[Trader] 심볼={Symbol} 타임프레임={Gran} 봉수={N}",
_cfg.Symbol, _cfg.Granularity, _cfg.WarmUpBars);
var warmupCandles = await _rest.GetCandlesAsync(_cfg.WarmUpBars);
if (warmupCandles.Count == 0)
{
Log.Error("[Trader] WarmUp 데이터 없음 — 종료");
return;
}
_executor.WarmUp(warmupCandles);
_poller.SetLastProcessedTime(warmupCandles[^1].Time);
Log.Information("[Trader] ═══ WarmUp 완료: {Time} ═══",
warmupCandles[^1].Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm"));
// ── Step 2: 포지션 동기화 ────────────────────────────────
if (_positionClient != null && _orderClient != null)
{
var reconciler = new StartupReconciler(_positionClient, _orderClient);
var injected = await reconciler.RunAsync(ct);
// Case 1에서 기존 포지션 수량을 복원
if (injected != null)
{
_openedSize = injected.Total;
Log.Information("[Trader] 포지션 수량 복원: {Size}", _openedSize);
}
}
// ── Step 3: 신호 델리게이트 등록 ────────────────────────
RegisterSignalHandlers();
Log.Information("[Trader] 실시간 폴링 모드 진입 (Ctrl+C로 종료)");
// ── Step 4: 테스트 사이클 ────────────────────────────────
if (_cfg.TestOnStart)
{
await RunTestCycleAsync(ct);
if (_cfg.ExitAfterTest)
{
Log.Information("[Trader] ExitAfterTest=true — 테스트 완료 후 종료");
return;
}
}
// ── Step 5: 폴러 콜백 등록 ──────────────────────────────
_poller.OnCandle += candle =>
{
try
{
var signals = _executor.ProcessCandle(candle);
Log.Information("[Trader] ProcessCandle: {Time} 신호={N}개",
candle.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm"),
signals.Count);
}
catch (Exception ex)
{
Log.Error(ex, "[Trader] ProcessCandle 오류: {Time}",
candle.Time.ToString("yyyy-MM-dd HH:mm"));
}
};
// ── Step 6: 폴링 루프 (블로킹) ──────────────────────────
await _poller.RunAsync(ct);
Log.Information("[Trader] 종료");
}
// ════════════════════════════════════════════════════════════════
// 신호 핸들러 등록
// ════════════════════════════════════════════════════════════════
private void RegisterSignalHandlers()
{
TradingSignal.OnBuy = price =>
{
decimal size = CalculateOrderSize(price);
PrintSignalBanner("BUY 롱 진입 ▲", price, size);
_openedSize = size;
FireOrder(() => _orderClient!.OpenLongAsync(size));
};
TradingSignal.OnSell = price =>
{
PrintSignalBanner("SELL 롱 청산 ▼", price, _openedSize);
_openedSize = 0m;
FireOrder(() => CloseWithActualSizeAsync(isLong: true));
};
TradingSignal.OnShort = price =>
{
decimal size = CalculateOrderSize(price);
PrintSignalBanner("SHORT 숏 진입 ▼", price, size);
_openedSize = size;
FireOrder(() => _orderClient!.OpenShortAsync(size));
};
TradingSignal.OnCover = price =>
{
PrintSignalBanner("COVER 숏 청산 ▲", price, _openedSize);
_openedSize = 0m;
FireOrder(() => CloseWithActualSizeAsync(isLong: false));
};
}
// ════════════════════════════════════════════════════════════════
// 주문 실행
// ════════════════════════════════════════════════════════════════
/// <summary>
/// DryRun 여부에 따라 실제 주문을 전송하거나 로그만 출력합니다.
/// 주문은 비동기 fire-and-forget으로 실행되며, 오류는 로그에 기록됩니다.
/// 실시간 신호 핸들러(OnBuy/OnSell/OnShort/OnCover)에서 사용합니다.
/// </summary>
private void FireOrder(Func<Task<string>> orderFunc)
{
if (_cfg.DryRun)
{
Log.Warning("[Order] ── DryRun: 실제 주문 미전송 ──");
return;
}
if (_orderClient == null)
{
Log.Warning("[Order] API 키 미설정 — 주문 스킵");
return;
}
_ = Task.Run(async () =>
{
try
{
string orderId = await orderFunc();
Log.Information("[Order] ✅ 체결: orderId={OrderId}", orderId);
}
catch (Exception ex)
{
Log.Error(ex, "[Order] ❌ 주문 실패");
}
});
}
/// <summary>
/// DryRun 여부에 따라 실제 주문을 전송하거나 로그만 출력합니다.
/// 완료될 때까지 await합니다. 테스트 사이클의 순차 실행에 사용합니다.
/// </summary>
private async Task FireOrderAsync(Func<Task<string>> orderFunc)
{
if (_cfg.DryRun)
{
Log.Warning("[Order] ── DryRun: 실제 주문 미전송 ──");
return;
}
if (_orderClient == null)
{
Log.Warning("[Order] API 키 미설정 — 주문 스킵");
return;
}
try
{
string orderId = await orderFunc();
Log.Information("[Order] ✅ 체결: orderId={OrderId}", orderId);
}
catch (Exception ex)
{
Log.Error(ex, "[Order] ❌ 주문 실패");
}
}
// ════════════════════════════════════════════════════════════════
// 테스트 사이클 (TestOnStart = true)
// ════════════════════════════════════════════════════════════════
/// <summary>
/// BUY → SELL → SHORT → COVER 순서로 신호를 발생시켜
/// 주문 파이프라인 전체를 검증합니다.
///
/// DryRun = true → 로그만 확인
/// DryRun = false → 실제 소량 주문 발생 주의
/// </summary>
private async Task RunTestCycleAsync(CancellationToken ct)
{
decimal price = GetCurrentPrice();
// TestSignals가 비어있으면 전체 신호 순서대로 실행
// HashSet 대신 List로 순서 보장
var allSignals = new[] { "BUY", "SELL", "SHORT", "COVER" };
var testSignals = _cfg.TestSignals.Count > 0
? allSignals.Where(s => _cfg.TestSignals
.Any(t => t.Equals(s, StringComparison.OrdinalIgnoreCase)))
.ToList()
: allSignals.ToList();
string signalList = string.Join(" → ", testSignals);
Log.Warning("══════════════════════════════════════════");
Log.Warning(" ⚑ 테스트 사이클 시작 (순차 실행)");
Log.Warning(" DryRun={DryRun} OrderSize={Size} Price≈{Price}",
_cfg.DryRun, _cfg.OrderSize, price);
Log.Warning(" 실행 순서: {Signals}", signalList);
Log.Warning("══════════════════════════════════════════");
await Task.Delay(500, ct);
int total = testSignals.Count;
int step = 1;
foreach (string signal in testSignals)
{
Log.Warning("[Test] {Step}/{Total} {Signal} 신호 발생", step++, total, signal);
bool isEntry = signal is "BUY" or "SHORT";
switch (signal)
{
case "BUY":
{
decimal size = CalculateOrderSize(price);
PrintSignalBanner("BUY 롱 진입 ▲", price, size);
_openedSize = size;
await FireOrderAsync(() => _orderClient!.OpenLongAsync(size));
break;
}
case "SELL":
PrintSignalBanner("SELL 롱 청산 ▼", price, _openedSize);
_openedSize = 0m;
await FireOrderAsync(() => CloseWithActualSizeAsync(isLong: true));
break;
case "SHORT":
{
decimal size = CalculateOrderSize(price);
PrintSignalBanner("SHORT 숏 진입 ▼", price, size);
_openedSize = size;
await FireOrderAsync(() => _orderClient!.OpenShortAsync(size));
break;
}
case "COVER":
PrintSignalBanner("COVER 숏 청산 ▲", price, _openedSize);
_openedSize = 0m;
await FireOrderAsync(() => CloseWithActualSizeAsync(isLong: false));
break;
}
if (step <= total)
{
// 진입 주문(BUY/SHORT) 후에는 포지션이 거래소에 실제로 반영될 때까지
// 충분히 대기합니다. 청산 주문 후에는 짧게 대기합니다.
int delayMs = isEntry ? 3000 : 1000;
Log.Information("[Test] 다음 신호까지 {Delay}ms 대기...", delayMs);
await Task.Delay(delayMs, ct);
}
}
Log.Warning("══════════════════════════════════════════");
Log.Warning(" ⚑ 테스트 사이클 완료");
Log.Warning("══════════════════════════════════════════");
}
// ════════════════════════════════════════════════════════════════
// 헬퍼
// ════════════════════════════════════════════════════════════════
/// <summary>
/// 청산 주문 공통 흐름:
/// 1) 미체결 진입 주문 일괄 취소 (부분 체결 잔량 정리)
/// 2) 거래소 실제 포지션 수량 조회
/// 3) 실제 수량으로 청산 주문 전송
///
/// isLong=true → 롱 청산 (SELL)
/// isLong=false → 숏 청산 (COVER)
/// </summary>
private async Task<string> CloseWithActualSizeAsync(
bool isLong, CancellationToken ct = default)
{
if (_orderClient == null || _positionClient == null)
throw new InvalidOperationException("OrderClient / PositionClient 미초기화");
// ── 1. 미체결 주문 일괄 취소 ─────────────────────────────
await _orderClient.CancelAllPendingOrdersAsync(ct);
// 취소 처리가 거래소에 반영되도록 잠시 대기
await Task.Delay(500, ct);
// ── 2. 실제 포지션 수량 조회 ─────────────────────────────
List<PositionInfo> positions;
try
{
positions = await _positionClient.GetPositionsAsync(ct);
}
catch (Exception ex)
{
Log.Error(ex, "[Order] 포지션 조회 실패 — 청산 중단");
throw;
}
var pos = isLong
? positions.FirstOrDefault(p => p.IsLong)
: positions.FirstOrDefault(p => p.IsShort);
if (pos == null || pos.Total <= 0)
{
Log.Warning("[Order] 청산할 {Side} 포지션 없음 — 스킵",
isLong ? "롱" : "숏");
return "";
}
Log.Information("[Order] 실제 포지션 수량 확인: {Total}계약 (요청했던 수량: {Requested})",
pos.Total, _cfg.OrderSize);
string holdSide = isLong ? "long" : "short";
// ── 3a. FlashClose 시도 (close-positions 전용 엔드포인트) ──
// place-order의 sell/close가 계정 모드 문제로 거부될 때 대안
try
{
return await _orderClient.FlashCloseAsync(holdSide, ct);
}
catch (Exception ex)
{
Log.Warning(ex, "[Order] FlashClose 실패 — place-order 방식으로 재시도");
}
// ── 3b. 폴백: place-order close ──────────────────────────
return isLong
? await _orderClient.CloseLongAsync(pos.Total, ct)
: await _orderClient.CloseShortAsync(pos.Total, ct);
}
/// <summary>
/// 현재가 기준으로 주문 계약 수를 계산합니다.
///
/// OrderUsdt > 0 : USDT 금액 기준 자동 계산
/// - 실거래 : Floor(OrderUsdt / price) — 예산 초과 방지
/// - 테스트 : Ceiling(Max(OrderUsdt, MinTestUsdt) / price) — 최소 5 USDT 보장
/// OrderUsdt = 0 : OrderSize(계약 수) 고정값 그대로 사용
/// </summary>
private decimal CalculateOrderSize(decimal price)
{
if (price <= 0m)
return _cfg.OrderSize > 0 ? _cfg.OrderSize : 1m;
if (_cfg.OrderUsdt > 0 || _cfg.TestOnStart)
{
const decimal TestUsdt = 5m; // 테스트는 항상 5 USDT 고정
decimal usdt = _cfg.TestOnStart
? TestUsdt // 테스트: 5 USDT 고정
: _cfg.OrderUsdt; // 실거래: 설정값 그대로
decimal size = _cfg.TestOnStart
? Math.Ceiling(usdt / price) // 테스트: 올림 (최소금액 충족)
: Math.Floor(usdt / price); // 실거래: 내림 (예산 초과 방지)
size = Math.Max(size, 1m); // 최소 1계약
Log.Information(
"[Order] 주문 수량 계산: {Usdt} USDT ÷ {Price:F4} = {Size}계약 (모드={Mode})",
usdt, price, size, _cfg.TestOnStart ? "테스트(5 USDT 고정)" : "실거래");
return size;
}
// OrderUsdt = 0 → 계약 수 고정 모드
return _cfg.OrderSize > 0 ? _cfg.OrderSize : 1m;
}
private static decimal GetCurrentPrice()
{
try { return (decimal)(double)PineScriptV6.Pine.close; }
catch { return 0m; }
}
private void PrintSignalBanner(string action, decimal price, decimal orderSize = 0m)
{
string bar = new string('═', 46);
Log.Information(bar);
Log.Information(" ▶ {Action,-16} | {Symbol} {Price:F5}",
action, _cfg.Symbol, price);
Log.Information(" 시각: {Time} 주문수량: {Order}계약 보유수량: {Held}",
DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"),
orderSize > 0 ? orderSize : _openedSize,
_openedSize);
Log.Information(bar);
}
public void Dispose()
{
_rest.Dispose();
_auth?.Dispose();
}
}
}