Files
PineSript/Buyer/StartupReconciler.cs
2026-04-26 19:00:54 +09:00

193 lines
9.3 KiB
C#

using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// 프로그램 재시작 시 거래소 실제 포지션과 Pine Script 내부 상태를 동기화합니다.
///
/// ┌──────────────────────────────────────────────────────────────┐
/// │ Case 1: Short 보유 중 + WarmUp 구간에 Cover 신호 없음 │
/// │ → Pine Script inShort=true 주입 후 실시간 폴링 계속 │
/// │ │
/// │ Case 2: Short 보유 중 + WarmUp 구간에 Cover 신호 감지됨 │
/// │ → 즉시 시장가 청산 주문 → 신규 상태로 폴링 시작 │
/// └──────────────────────────────────────────────────────────────┘
///
/// Long 포지션도 동일 로직 (Sell 신호 기준).
/// </summary>
public class StartupReconciler
{
private readonly BitgetPositionClient _positionClient;
private readonly BitgetOrderClient _orderClient;
public StartupReconciler(
BitgetPositionClient positionClient,
BitgetOrderClient orderClient)
{
_positionClient = positionClient;
_orderClient = orderClient;
}
// ════════════════════════════════════════════════════════════════
// 진입점
// ════════════════════════════════════════════════════════════════
/// <summary>
/// WarmUp 완료 직후 호출합니다.
/// TradingSignal.Results에 WarmUp 중 발생한 신호가 이미 쌓여있어야 합니다.
/// 반환값: 주입된 포지션 정보 (없으면 null)
/// </summary>
public async Task<PositionInfo?> RunAsync(CancellationToken ct = default)
{
Log.Information("[Reconciler] ═══ 포지션 동기화 시작 ═══");
// ── 1. 거래소 포지션 조회 ────────────────────────────────
List<PositionInfo> positions;
try
{
positions = await _positionClient.GetPositionsAsync(ct);
}
catch (Exception ex)
{
Log.Error(ex, "[Reconciler] 포지션 조회 실패 — 동기화 건너뜀");
return null;
}
if (positions.Count == 0)
{
Log.Information("[Reconciler] 보유 포지션 없음 → 신규 시작");
return null;
}
// ── 2. 각 포지션 처리 (첫 번째 포지션 반환) ─────────────
PositionInfo? injected = null;
foreach (var pos in positions)
{
bool stateInjected = await ReconcileOneAsync(pos, ct);
if (stateInjected) injected = pos;
}
Log.Information("[Reconciler] ═══ 포지션 동기화 완료 ═══");
return injected;
}
// ════════════════════════════════════════════════════════════════
// 단일 포지션 처리
// ════════════════════════════════════════════════════════════════
/// <returns>true = Pine 상태 주입됨 (Case 1), false = 강제 청산됨 (Case 2)</returns>
private async Task<bool> ReconcileOneAsync(PositionInfo pos, CancellationToken ct)
{
Log.Information("[Reconciler] 포지션 분석: {Pos}", pos);
// ── 3. WarmUp 중 청산 신호 발생 여부 확인 ────────────────
bool closeSignalFound = HasCloseSignalAfterEntry(pos);
if (closeSignalFound)
{
// ── Case 2: 청산 신호가 WarmUp 구간에 이미 발생 ──────
Log.Warning(
"[Reconciler] ★ Case 2: WarmUp 중 청산 신호 감지 " +
"({Side}) → 즉시 시장가 청산", pos.HoldSide);
try
{
await _orderClient.ClosePositionAsync(pos, ct);
Log.Information("[Reconciler] 강제 청산 완료 → 신규 상태로 시작");
}
catch (Exception ex)
{
Log.Error(ex, "[Reconciler] 강제 청산 실패 — 수동 확인 필요!");
}
// Pine Script 상태는 건드리지 않음 (inShort/inLong 모두 false 유지)
return false;
}
else
{
// ── Case 1: WarmUp 구간에 청산 신호 없음 ─────────────
Log.Information(
"[Reconciler] ★ Case 1: 청산 신호 없음 " +
"({Side}) → Pine Script 상태 주입", pos.HoldSide);
InjectPineState(pos);
return true;
}
}
// ════════════════════════════════════════════════════════════════
// WarmUp 신호 분석
// ════════════════════════════════════════════════════════════════
/// <summary>
/// WarmUp 중 생성된 신호 중 포지션 진입 시각 이후의 청산 신호가 있는지 확인합니다.
/// </summary>
private static bool HasCloseSignalAfterEntry(PositionInfo pos)
{
SignalType closeType = pos.IsShort ? SignalType.Cover : SignalType.Sell;
bool found = TradingSignal.Results.Any(r =>
r.Signal == closeType &&
r.Time > pos.CTime);
if (found)
{
var lastClose = TradingSignal.Results
.Where(r => r.Signal == closeType && r.Time > pos.CTime)
.OrderByDescending(r => r.Time)
.First();
Log.Information(
"[Reconciler] 청산 신호 확인: {Signal} @ {Time:yyyy-MM-dd HH:mm} price={Price}",
lastClose.Signal, lastClose.Time.ToLocalTime(), lastClose.Price);
}
else
{
Log.Information(
"[Reconciler] 진입 시각({Entry:yyyy-MM-dd HH:mm}) 이후 {CloseType} 신호 없음",
pos.CTime.ToLocalTime(), closeType);
}
return found;
}
// ════════════════════════════════════════════════════════════════
// Pine Script 상태 주입
// ════════════════════════════════════════════════════════════════
/// <summary>
/// WarmUp 이후 Pine Script 내부 상태 변수를 실제 포지션에 맞게 덮어씁니다.
///
/// ※ Pine Script의 var bool 변수는 WarmUp 완료 시점에 이미 선언된 상태입니다.
/// Set()으로 값을 강제 주입합니다.
/// </summary>
private static void InjectPineState(PositionInfo pos)
{
if (pos.IsShort)
{
PineStateVars.Set("inShort", (object?)true);
PineStateVars.Set("inLong", (object?)false);
Log.Information("[Reconciler] Pine 상태 주입 → inShort=true, inLong=false");
}
else if (pos.IsLong)
{
PineStateVars.Set("inLong", (object?)true);
PineStateVars.Set("inShort", (object?)false);
Log.Information("[Reconciler] Pine 상태 주입 → inLong=true, inShort=false");
}
// ── 진입가 주입 (Pine Script에서 openLongPrice / openShortPrice 변수 사용 시) ──
if (pos.IsShort && PineStateVars.Has("openShortPrice"))
{
PineStateVars.Set("openShortPrice", (object?)(double)pos.OpenPriceAvg);
Log.Information("[Reconciler] openShortPrice = {Price}", pos.OpenPriceAvg);
}
if (pos.IsLong && PineStateVars.Has("openLongPrice"))
{
PineStateVars.Set("openLongPrice", (object?)(double)pos.OpenPriceAvg);
Log.Information("[Reconciler] openLongPrice = {Price}", pos.OpenPriceAvg);
}
}
}
}