193 lines
9.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|