473 lines
21 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|