업데이트

This commit is contained in:
2026-04-25 17:32:49 +09:00
commit c88721e7d0
32 changed files with 28909 additions and 0 deletions

75
Buyer/BitgetConfig.cs Normal file
View File

@@ -0,0 +1,75 @@
namespace Buyer
{
/// <summary>
/// Bitget 실시간 트레이더 설정.
/// 모든 외부 의존 값은 이 클래스에서 변수화합니다.
/// </summary>
public class BitgetConfig
{
// ── 종목 설정 ─────────────────────────────────────────────────
/// <summary>거래 심볼 (예: XRPUSDT)</summary>
public string Symbol { get; set; } = "XRPUSDT";
/// <summary>상품 타입 (USDT-FUTURES | COIN-FUTURES)</summary>
public string ProductType { get; set; } = "USDT-FUTURES";
/// <summary>
/// 캔들 시간 단위 — Bitget API 형식 그대로 입력합니다.
/// 지원 값: 1m 3m 5m 15m 30m 1H 2H 4H 6H 12H 1D 1W 1M
/// </summary>
public string Granularity { get; set; } = BitgetGranularity.Min30;
// ── WarmUp 설정 ───────────────────────────────────────────────
/// <summary>
/// 시작 전 로드할 과거 봉 수.
/// Pine Script 지표의 최대 윈도우(sma72 등)보다 충분히 커야 합니다.
/// </summary>
public int WarmUpBars { get; set; } = 500;
// ── REST API ─────────────────────────────────────────────────
/// <summary>Bitget REST API 베이스 URL</summary>
public string RestBaseUrl { get; set; } = "https://api.bitget.com";
/// <summary>REST 요청 타임아웃 (초)</summary>
public int RestTimeoutSec { get; set; } = 10;
// ── 폴링 설정 ─────────────────────────────────────────────────
/// <summary>
/// REST API 호출 간격 (밀리초).
/// 1000 = 1초마다 호출. Bitget 레이트 리밋 여유 있음.
/// </summary>
public int PollIntervalMs { get; set; } = 1000;
/// <summary>
/// 폴링 1회에 조회할 최근 봉 수.
/// 현재 진행 중인 봉 1개 + 완성 봉 확인용 2개 = 3 권장.
/// </summary>
public int PollCandleCount { get; set; } = 3;
// ── Pine Script ───────────────────────────────────────────────
/// <summary>실행할 .pine 스크립트 파일 경로</summary>
public string PineScriptPath { get; set; } = "./example.pine";
// ── 파생 속성 ─────────────────────────────────────────────────
/// <summary>REST API에 전달할 granularity — Granularity 그대로 사용</summary>
public string RestGranularity => Granularity;
/// <summary>Granularity 문자열 → 분(minutes)</summary>
public int IntervalMinutes => Granularity switch
{
"1m" => 1,
"3m" => 3,
"5m" => 5,
"15m" => 15,
"30m" => 30,
"1H" => 60,
"2H" => 120,
"4H" => 240,
"6H" => 360,
"12H" => 720,
"1D" => 1440,
"1W" => 10080,
_ => 30
};
}
}

View File

@@ -0,0 +1,28 @@
namespace Buyer
{
/// <summary>
/// Bitget API 캔들 시간 단위 상수.
/// BitgetConfig.Granularity에 이 값을 사용합니다.
/// </summary>
public static class BitgetGranularity
{
// ── 분봉 ──────────────────────────────────────────────────────
public const string Min1 = "1m";
public const string Min3 = "3m";
public const string Min5 = "5m";
public const string Min15 = "15m";
public const string Min30 = "30m";
// ── 시간봉 ────────────────────────────────────────────────────
public const string Hour1 = "1H";
public const string Hour2 = "2H";
public const string Hour4 = "4H";
public const string Hour6 = "6H";
public const string Hour12 = "12H";
// ── 일/주/월봉 ────────────────────────────────────────────────
public const string Day1 = "1D";
public const string Week1 = "1W";
public const string Month1 = "1M";
}
}

126
Buyer/BitgetRestClient.cs Normal file
View File

@@ -0,0 +1,126 @@
using System.Text.Json;
using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// Bitget REST API — 캔들 데이터 조회.
/// GET /api/v2/mix/market/candles
/// </summary>
public class BitgetRestClient : IDisposable
{
private readonly HttpClient _http;
private readonly BitgetConfig _cfg;
public BitgetRestClient(BitgetConfig cfg)
{
_cfg = cfg;
_http = new HttpClient
{
BaseAddress = new Uri(cfg.RestBaseUrl),
Timeout = TimeSpan.FromSeconds(cfg.RestTimeoutSec)
};
_http.DefaultRequestHeaders.Add("User-Agent", "Buyer/1.0");
}
/// <summary>
/// 최근 <paramref name="limit"/>개 봉을 가져옵니다.
/// 반환값은 과거→현재 오름차순으로 정렬됩니다.
/// </summary>
public async Task<List<CandleData>> GetCandlesAsync(int limit = 500)
{
int safeLimit = Math.Min(limit, 1000);
// 기존 동작 확인된 파라미터 순서: symbol, granularity, limit, productType
string url = "/api/v2/mix/market/candles"
+ $"?symbol={_cfg.Symbol}"
+ $"&granularity={_cfg.RestGranularity}"
+ $"&limit={safeLimit}"
+ $"&productType={_cfg.ProductType}";
Log.Debug("[REST] 요청: {Url}", url);
// GetStringAsync는 4xx/5xx 시 본문 없이 예외 → GetAsync로 변경하여 본문 확인
HttpResponseMessage response;
string body;
try
{
response = await _http.GetAsync(url);
body = await response.Content.ReadAsStringAsync();
}
catch (Exception ex)
{
Log.Error(ex, "[REST] HTTP 요청 자체 실패 (네트워크 오류)");
throw;
}
// HTTP 오류 시 응답 본문을 로그에 찍고 예외
if (!response.IsSuccessStatusCode)
{
Log.Error("[REST] HTTP {Status} — 응답 본문: {Body}",
(int)response.StatusCode, body);
throw new HttpRequestException(
$"HTTP {(int)response.StatusCode}: {body}");
}
// JSON 파싱
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
string code = root.TryGetProperty("code", out var codeEl)
? codeEl.GetString() ?? "" : "";
if (code != "00000")
{
string msg = root.TryGetProperty("msg", out var msgEl)
? msgEl.GetString() ?? "" : "";
Log.Error("[REST] API 오류 code={Code} msg={Msg} body={Body}",
code, msg, body);
throw new Exception($"Bitget API 오류 code={code} msg={msg}");
}
var candles = new List<CandleData>();
foreach (var item in root.GetProperty("data").EnumerateArray())
{
var arr = item.EnumerateArray().ToList();
if (arr.Count < 6) continue;
long ts = long.Parse(arr[0].GetString()!);
decimal o = decimal.Parse(arr[1].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal h = decimal.Parse(arr[2].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal l = decimal.Parse(arr[3].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal c = decimal.Parse(arr[4].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal vol = decimal.Parse(arr[5].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
candles.Add(new CandleData
{
Time = DateTimeOffset.FromUnixTimeMilliseconds(ts),
Open = o,
High = h,
Low = l,
Close = c,
Volume = vol
});
}
// Bitget은 최신→과거 순으로 반환 → 오름차순 정렬
candles.Sort((a, b) => a.Time.CompareTo(b.Time));
Log.Debug("[REST] 수신 {Count}봉 {From} ~ {To}",
candles.Count,
candles.FirstOrDefault()?.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "-",
candles.LastOrDefault() ?.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "-");
return candles;
}
public void Dispose() => _http.Dispose();
}
}

View File

@@ -0,0 +1,239 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// Bitget Public WebSocket — 실시간 캔들 스트림 수신.
/// confirm="1" 인 완성 봉만 OnCandle 이벤트로 발행합니다.
/// 연결 끊김 시 자동 재연결합니다.
/// </summary>
public class BitgetWebSocketClient : IAsyncDisposable
{
private readonly BitgetConfig _cfg;
/// <summary>완성된 봉(confirm=1)이 도착했을 때 발생합니다.</summary>
public event Action<CandleData>? OnCandle;
/// <summary>WebSocket 연결/재연결 상태 변경 시 발생합니다.</summary>
public event Action<bool>? OnConnectionChanged;
private CancellationTokenSource _cts = new();
public BitgetWebSocketClient(BitgetConfig cfg) => _cfg = cfg;
/// <summary>
/// 연결을 시작하고 수신 루프를 백그라운드로 실행합니다.
/// </summary>
public Task StartAsync(CancellationToken ct)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_ = Task.Run(() => RunLoop(_cts.Token), _cts.Token);
return Task.CompletedTask;
}
// ════════════════════════════════════════════════════════════════
// 내부 — 연결 루프 (재연결 포함)
// ════════════════════════════════════════════════════════════════
private async Task RunLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var ws = new ClientWebSocket();
try
{
await ws.ConnectAsync(new Uri(_cfg.WsUrl), ct);
Log.Information("[WS] 연결됨 → {Url}", _cfg.WsUrl);
OnConnectionChanged?.Invoke(true);
await SubscribeAsync(ws, ct);
await Task.WhenAll(
PingLoopAsync(ws, ct),
ReceiveLoopAsync(ws, ct));
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break; // 정상 종료
}
catch (Exception ex)
{
Log.Warning(ex, "[WS] 연결 오류 — {Delay}초 후 재연결",
_cfg.WsReconnectDelaySec);
}
OnConnectionChanged?.Invoke(false);
if (!ct.IsCancellationRequested)
await Task.Delay(_cfg.WsReconnectDelaySec * 1000, ct)
.ConfigureAwait(false);
}
Log.Information("[WS] 연결 루프 종료");
}
// ── 구독 요청 ─────────────────────────────────────────────────
private async Task SubscribeAsync(ClientWebSocket ws, CancellationToken ct)
{
var payload = new
{
op = "subscribe",
args = new[]
{
new
{
instType = "USDT-FUTURES",
channel = _cfg.WsChannel,
instId = _cfg.Symbol
}
}
};
string json = JsonSerializer.Serialize(payload);
await SendAsync(ws, json, ct);
Log.Information("[WS] 구독 요청: {Channel} {Symbol}",
_cfg.WsChannel, _cfg.Symbol);
}
// ── Ping 루프 ────────────────────────────────────────────────
private async Task PingLoopAsync(ClientWebSocket ws, CancellationToken ct)
{
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
await Task.Delay(_cfg.WsPingInterval * 1000, ct);
if (ws.State == WebSocketState.Open)
{
await SendAsync(ws, "ping", ct);
Log.Debug("[WS] ping 전송");
}
}
}
// ── 수신 루프 ────────────────────────────────────────────────
private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct)
{
var buf = new byte[65536];
var sb = new StringBuilder();
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
WebSocketReceiveResult result;
try
{
result = await ws.ReceiveAsync(new ArraySegment<byte>(buf), ct);
}
catch (OperationCanceledException) { break; }
catch (WebSocketException ex)
{
Log.Warning("[WS] 수신 오류: {Msg}", ex.Message);
break;
}
if (result.MessageType == WebSocketMessageType.Close)
{
Log.Warning("[WS] 서버 Close 프레임 수신");
break;
}
sb.Append(Encoding.UTF8.GetString(buf, 0, result.Count));
if (!result.EndOfMessage) continue;
string msg = sb.ToString();
sb.Clear();
if (msg == "pong") { Log.Debug("[WS] pong 수신"); continue; }
ProcessMessage(msg);
}
}
// ── 메시지 처리 ──────────────────────────────────────────────
private void ProcessMessage(string msg)
{
try
{
using var doc = JsonDocument.Parse(msg);
var root = doc.RootElement;
// 이벤트 응답 (subscribe 확인 등) 무시
if (root.TryGetProperty("event", out _))
{
Log.Debug("[WS] 이벤트: {Msg}", msg);
return;
}
if (!root.TryGetProperty("data", out var dataEl)) return;
foreach (var item in dataEl.EnumerateArray())
{
var arr = item.EnumerateArray().ToList();
// Bitget candle 배열: [ts, o, h, l, c, baseVol, quoteVol, usdtVol, confirm]
if (arr.Count < 6) continue;
// confirm: 마지막 원소 "1" = 완성 봉
string confirm = arr[^1].GetString() ?? "0";
if (confirm != "1")
{
Log.Debug("[WS] 미완성 봉 수신 (무시): {Ts}", arr[0].GetString());
continue;
}
long ts = long.Parse(arr[0].GetString()!);
decimal o = decimal.Parse(arr[1].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal h = decimal.Parse(arr[2].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal l = decimal.Parse(arr[3].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal c = decimal.Parse(arr[4].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
decimal vol = decimal.Parse(arr[5].GetString()!,
System.Globalization.CultureInfo.InvariantCulture);
var candle = new CandleData
{
Time = DateTimeOffset.FromUnixTimeMilliseconds(ts),
Open = o,
High = h,
Low = l,
Close = c,
Volume = vol
};
Log.Information("[WS] ✅ 완성 봉: {Time} O={O} H={H} L={L} C={C} V={V}",
candle.Time.ToString("yyyy-MM-dd HH:mm"),
candle.Open, candle.High, candle.Low, candle.Close, candle.Volume);
OnCandle?.Invoke(candle);
}
}
catch (Exception ex)
{
Log.Debug("[WS] 메시지 파싱 오류: {Err} | Raw={Msg}", ex.Message, msg);
}
}
// ── 전송 헬퍼 ────────────────────────────────────────────────
private static async Task SendAsync(ClientWebSocket ws, string text, CancellationToken ct)
{
if (ws.State != WebSocketState.Open) return;
byte[] data = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(new ArraySegment<byte>(data),
WebSocketMessageType.Text, true, ct);
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
return ValueTask.CompletedTask;
}
}
}

34
Buyer/Buyer.csproj Normal file
View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Buyer</RootNamespace>
</PropertyGroup>
<!-- PineScript 엔진 프로젝트 참조 -->
<ItemGroup>
<ProjectReference Include="..\PineScript\PineScript.csproj" />
</ItemGroup>
<!-- WebSocket 파일 제외 (폴링 방식으로 전환) -->
<ItemGroup>
<Compile Remove="BitgetWebSocketClient.cs" />
</ItemGroup>
<!-- NuGet 패키지 -->
<ItemGroup>
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="example.pine">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

148
Buyer/CandlePoller.cs Normal file
View File

@@ -0,0 +1,148 @@
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; }
}
}
Log.Information("[Poller] 종료");
}
// ════════════════════════════════════════════════════════════════
// 단일 폴링
// ════════════════════════════════════════════════════════════════
private async Task PollOnceAsync()
{
List<CandleData> candles;
try
{
candles = await _rest.GetCandlesAsync(_cfg.PollCandleCount);
}
catch (Exception ex)
{
Log.Warning("[Poller] REST 조회 실패 — 다음 주기에 재시도: {Err}", ex.Message);
return;
}
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)
{
Log.Information("[Poller] 봉 전환 감지: {Prev} → {Next}",
_currentBarTime.ToLocalTime().ToString("HH:mm"),
latestBar.Time.ToLocalTime().ToString("HH:mm"));
_currentBarTime = latestBar.Time;
}
else
{
// 진행 중인 봉 유지 중 — 매 분마다 한 번씩 상태 로그 (디버그용)
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"));
}
}
// ── 완성 봉 처리 ─────────────────────────────────────────
// 현재 진행 중인 봉(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 핸들러 오류"); }
}
}
}
}

28
Buyer/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# 디버그 컨테이너를 사용자 지정하는 방법과 Visual Studio 이 Dockerfile을 사용하여 더 빠른 디버깅을 위해 이미지를 빌드하는 방법을 알아보려면 https://aka.ms/customizecontainer를 참조하세요.
# 이 스테이지는 VS에서 빠른 모드로 실행할 때 사용됩니다(디버그 구성의 기본값).
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
USER $APP_UID
WORKDIR /app
# 이 스테이지는 서비스 프로젝트를 빌드하는 데 사용됩니다.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Buyer/Buyer.csproj", "Buyer/"]
RUN dotnet restore "./Buyer/Buyer.csproj"
COPY . .
WORKDIR "/src/Buyer"
RUN dotnet build "./Buyer.csproj" -c $BUILD_CONFIGURATION -o /app/build
# 이 스테이지는 최종 스테이지에 복사할 서비스 프로젝트를 게시하는 데 사용됩니다.
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Buyer.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# 이 스테이지는 프로덕션에서 사용되거나 VS에서 일반 모드로 실행할 때 사용됩니다(디버그 구성을 사용하지 않는 경우 기본값).
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Buyer.dll"]

85
Buyer/Program.cs Normal file
View File

@@ -0,0 +1,85 @@
using Buyer;
using Serilog;
// ════════════════════════════════════════════════════════════════
// 로거 초기화
// ════════════════════════════════════════════════════════════════
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Async(a => a.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"))
.WriteTo.Async(a => a.File(
path: "logs/buyer-.log",
rollingInterval: RollingInterval.Day,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"))
.CreateLogger();
// ════════════════════════════════════════════════════════════════
// 설정 — 변수를 여기서 관리
// ════════════════════════════════════════════════════════════════
var config = new BitgetConfig
{
// ── 종목 ──────────────────────────────────────────────────────
Symbol = "XRPUSDT",
ProductType = "USDT-FUTURES", // USDT-FUTURES | COIN-FUTURES
Granularity = BitgetGranularity.Min30, // BitgetGranularity 상수 사용
// ── WarmUp ────────────────────────────────────────────────────
// Pine Script 지표 최대 윈도우(sma72 등)보다 충분히 크게 설정
WarmUpBars = 500,
// ── REST API ──────────────────────────────────────────────────
RestBaseUrl = "https://api.bitget.com",
RestTimeoutSec = 30,
// ── 폴링 ──────────────────────────────────────────────────────
// REST API 호출 간격 (밀리초). 1000 = 1초마다 호출.
// 봉 완성 시점을 시간 계산 없이 서버 응답 기준으로 감지합니다.
PollIntervalMs = 1000,
// 폴링 1회에 조회할 봉 수 (3이면 충분, 누락 방지)
PollCandleCount = 3,
// ── Pine Script 경로 ──────────────────────────────────────────
// 실행 환경에 맞게 절대 경로 또는 상대 경로로 수정하세요.
PineScriptPath = @"example.pine"
};
// ════════════════════════════════════════════════════════════════
// Ctrl+C 핸들러
// ════════════════════════════════════════════════════════════════
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Log.Information("종료 요청 (Ctrl+C)...");
cts.Cancel();
};
// ════════════════════════════════════════════════════════════════
// 실행
// ════════════════════════════════════════════════════════════════
Log.Information("══════════════════════════════════════════");
Log.Information(" Buyer 실시간 트레이더 시작 (REST 폴링)");
Log.Information(" 심볼: {Symbol}", config.Symbol);
Log.Information(" 타임프레임: {Gran}", config.Granularity);
Log.Information(" 상품: {Product}", config.ProductType);
Log.Information(" WarmUp 봉수: {N}", config.WarmUpBars);
Log.Information(" 폴링 간격: {D}ms", config.PollIntervalMs);
Log.Information(" 스크립트: {Path}", config.PineScriptPath);
Log.Information("══════════════════════════════════════════");
try
{
using var trader = new RealtimeTrader(config);
await trader.RunAsync(cts.Token);
}
catch (Exception ex)
{
Log.Fatal(ex, "치명적 오류 발생");
}
finally
{
Log.Information("프로그램 종료");
await Log.CloseAndFlushAsync();
}

View File

@@ -0,0 +1,10 @@
{
"profiles": {
"Buyer": {
"commandName": "Project"
},
"Container (Dockerfile)": {
"commandName": "Docker"
}
}
}

102
Buyer/RealtimeTrader.cs Normal file
View File

@@ -0,0 +1,102 @@
using PineScriptV6;
using Serilog;
namespace Buyer
{
/// <summary>
/// 실시간 트레이더.
/// 1) REST API로 과거 봉을 받아 WarmUp (지표 초기화)
/// 2) CandlePoller로 봉 완성 시각마다 REST 폴링
/// 3) 새 완성 봉 → ProcessCandle → 신호 발생 시 로그 출력
/// </summary>
public class RealtimeTrader : IDisposable
{
private readonly BitgetConfig _cfg;
private readonly PineScriptExecutor _executor;
private readonly BitgetRestClient _rest;
private readonly CandlePoller _poller;
public RealtimeTrader(BitgetConfig cfg)
{
_cfg = cfg;
_executor = new PineScriptExecutor(cfg.PineScriptPath);
_rest = new BitgetRestClient(cfg);
_poller = new CandlePoller(cfg, _rest);
}
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);
// WarmUp 마지막 봉 시각을 폴러에 전달 → 중복 처리 방지
var lastWarmupTime = warmupCandles[^1].Time;
_poller.SetLastProcessedTime(lastWarmupTime);
Log.Information("[Trader] ═══ WarmUp 완료 ═══");
Log.Information("[Trader] 마지막 WarmUp 봉: {Time}",
lastWarmupTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm"));
Log.Information("[Trader] 실시간 폴링 모드 진입 (Ctrl+C로 종료)");
// ── Step 2: 신호 델리게이트 등록 ────────────────────────
TradingSignal.OnBuy = price => LogOrder("BUY 롱 진입", price);
TradingSignal.OnSell = price => LogOrder("SELL 롱 청산", price);
TradingSignal.OnShort = price => LogOrder("SHORT 숏 진입", price);
TradingSignal.OnCover = price => LogOrder("COVER 숏 청산", price);
// ── Step 3: 폴러 콜백 등록 ──────────────────────────────
_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);
// 신호는 TradingSignal 델리게이트를 통해 자동으로 LogOrder 호출됨
}
catch (Exception ex)
{
Log.Error(ex, "[Trader] ProcessCandle 오류: {Time}",
candle.Time.ToString("yyyy-MM-dd HH:mm"));
}
};
// ── Step 4: 폴링 루프 실행 (블로킹) ─────────────────────
await _poller.RunAsync(ct);
Log.Information("[Trader] 종료");
}
// ── 주문 로그 ────────────────────────────────────────────────
private void LogOrder(string action, decimal price)
{
string bar = new string('═', 42);
Log.Information(bar);
Log.Information(" ▶ {Action} | {Symbol} | {Price:F5}",
action, _cfg.Symbol, price);
Log.Information(" 시각: {Time}",
DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"));
Log.Information(bar);
// TODO: 실제 주문 코드는 여기에 추가
// await _orderClient.PlaceMarketOrderAsync(_cfg.Symbol, side, qty);
}
public void Dispose() => _rest.Dispose();
}
}

211
Buyer/example.pine Normal file
View File

@@ -0,0 +1,211 @@
//@version=5
indicator("LJH - 테스트 (Table 제거 버전)", overlay=true)
// === high close, low close ===
highClose = ta.highest(high, 12)
lowClose = ta.lowest(low, 4)
// === SMA4 Bands ===
sma4 = (close + close[1] + close[2] + close[3]) / 4
prevSma4 = (close[4] + close[1] + close[2] + close[3]) / 4
prev2Sma4 = (close[4] + close[5] + close[2] + close[3]) / 4
prev3Sma4 = (close[4] + close[5] + close[6] + close[3]) / 4
sma72 = ta.sma(close, 72)
sma24 = ta.sma(close, 24)
prevSma72 = ta.sma(close, 72)
prev2Sma72 = ta.sma(close, 72)
prevSma24 = ta.sma(close, 24)
prev2Sma24 = ta.sma(close, 24)
sma4High = ta.highest(sma4, 12)
sma4Middle = ta.sma(sma4, 12)
sma4Low = ta.lowest(sma4, 12)
prevSma4High = ta.highest(prevSma4, 12)
prevSma4Middle = ta.sma(prevSma4, 12)
prevSma4Low = ta.lowest(prevSma4, 12)
prev2Sma4High = ta.highest(prev2Sma4, 12)
prev2Sma4Middle = ta.sma(prev2Sma4, 12)
prev2Sma4Low = ta.lowest(prev2Sma4, 12)
prev3Sma4High = ta.highest(prev3Sma4, 12)
prev3Sma4Middle = ta.sma(prev3Sma4, 12)
prev3Sma4Low = ta.lowest(prev3Sma4, 12)
// === 상태 추적 ===
var bool inLong = false
var bool inShort = false
// === 새 조건을 위한 상태 변수 ===
var int sma4LongTouchBar = na
var int sma4ShortTouchBar = na
// sma4가 sma4Low에 닿은 시점 저장
if (sma4 == sma4Low and prevSma4 >= prevSma4Low)
sma4LongTouchBar := bar_index
// sma4가 sma4High에 닿은 시점 저장
if (sma4 == sma4High and prevSma4 <= prevSma4High)
sma4ShortTouchBar := bar_index
//전저가 전고가 처리
maLength = input.int(4, title="이동평균 기간")
ma = ta.sma(close, maLength)
// 이동평균의 방향 비교
maPrev = ma
maPrev2 = ma
// 꼭지점 조건: 상승에서 하락으로 전환 (고점)
isTop = maPrev2 < maPrev and maPrev > ma
// 꼭지점 조건: 하락에서 상승으로 전환 (저점)
isBottom = maPrev2 > maPrev and maPrev < ma
var float[] topCloses = array.new_float()
var float[] bottomCloses = array.new_float()
if isTop
array.push(topCloses, maPrev)
if isBottom
array.push(bottomCloses, maPrev)
// === 구름떼 (Ichimoku Cloud) 추가 ===
conversionPeriods = 9
basePeriods = 26
laggingSpan2Periods = 52
displacement = 26
conversionLine = (ta.highest(high, conversionPeriods) + ta.lowest(low, conversionPeriods)) / 2
baseLine = (ta.highest(high, basePeriods) + ta.lowest(low, basePeriods)) / 2
leadingSpan1 = (conversionLine + baseLine) / 2
leadingSpan2 = (ta.highest(high, laggingSpan2Periods) + ta.lowest(low, laggingSpan2Periods)) / 2
cloudTopNow = math.max(leadingSpan1[displacement], leadingSpan2[displacement])
cloudBottomNow = math.min(leadingSpan1[displacement], leadingSpan2[displacement])
cloudTopAfter = math.max(leadingSpan1, leadingSpan2)
cloudBottomAfter = math.min(leadingSpan1, leadingSpan2)
isSma4BelowCloud = low < cloudBottomNow
isSma4AboveCloud = high > cloudTopNow
var bool hasEnteredShortAfterCloudBreak = false
// 매 봉마다 상태 업데이트
if not isSma4BelowCloud
hasEnteredShortAfterCloudBreak := false
// 상태 변수
var bool allowNewShortEntry = true
var bool wasBelowCloud = false
// 구름 위/아래 상태 추적
if sma4 < cloudTopNow and not wasBelowCloud
allowNewShortEntry := true
wasBelowCloud := true
if not (sma4 < cloudTopNow)
allowNewShortEntry := false
wasBelowCloud := false
// === 횡보 구간 감지 ===
lengthWindow = 12
rangePeriod = 240
shortLengthWindow = 4
shortRangePeriod = 40
// Long 기준 비율 계산식
longRatio = (high - ta.lowest(low, lengthWindow)) / ta.lowest(low, lengthWindow) * 100
// Short 기준 비율 계산식
shortRatio = (ta.highest(high, shortLengthWindow) - low) / ta.highest(high, shortLengthWindow) * 100
// 평균 계산
rangeThresholdLongPercent = ta.sma(longRatio, rangePeriod)
rangeThresholdShortPercent = ta.sma(shortRatio, shortRangePeriod)
rangeLookback = 12
highestHigh = ta.highest(high, rangeLookback)
lowestLow = ta.lowest(low, rangeLookback)
priceRangeLong = high - lowestLow
priceRangeShort = highestHigh - low
isSidewaysLong = priceRangeLong > (lowestLow * rangeThresholdLongPercent * 1 / 100) and sma4 > cloudTopNow
isSidewaysShort = priceRangeShort > (highestHigh * (rangeThresholdShortPercent) / 100) and sma4 < cloudTopNow
isLongRecentTouch = not na(sma4LongTouchBar) and (bar_index - sma4LongTouchBar <= 10)
isShortRecentTouch = not na(sma4ShortTouchBar) and (bar_index - sma4ShortTouchBar <= 5)
// === 진입/청산 조건 ===
rawNewLong = highClose == high and open < close and isLongRecentTouch and isSidewaysLong and prevSma4 <= sma4 and sma4High == sma4 and (prev2Sma4Middle - prevSma4Middle) > (prevSma4Middle - sma4Middle) and sma4Middle > sma72 and sma4 > cloudTopNow
rawLongSell = (sma4Middle < sma72 and prevSma4Middle > prevSma72) or (sma4 == sma4Low)
rawNewShort = (sma72 < cloudTopNow and prevSma4Middle > prevSma24 and sma4Middle > cloudBottomNow and sma4 < sma72 and sma4High > sma72 and sma24 > sma4Middle and sma4 == sma4Low and isSidewaysShort and prevSma4 > sma4 and sma4 < cloudBottomNow) or (sma4 < sma72 and sma4High > sma72 and sma24 < sma4Middle and sma4 == sma4Low and isSidewaysShort and open > close and sma4 < cloudBottomNow and sma4Middle < cloudTopNow)
rawShortSell = (sma4High <= sma4 and sma4High < sma72) or sma4 > sma72 or (prevSma4Middle > prevSma4 and sma4Middle < sma4) or (isSidewaysShort and prevSma4Low == prevSma4 and sma4Low < sma4)
// === 시그널 변수 ===
newLongSignal = false
longSellSignal = false
newShortSignal = false
shortSellSignal = false
// === 롱 진입/청산 로직 ===
if not inLong and rawNewLong
newLongSignal := true
inLong := true
if inLong and rawLongSell
longSellSignal := true
inLong := false
// === 쇼트 진입/청산 로직 ===
if not inShort and rawNewShort
newShortSignal := true
inShort := true
if inShort and rawShortSell
shortSellSignal := true
inShort := false
// === 수익률 계산 및 라벨 표시용 변수 ===
var float longEntryPrice = na
var float shortEntryPrice = na
if newLongSignal
longEntryPrice := close
if longSellSignal and not na(longEntryPrice)
lastLongProfit = (close - longEntryPrice) / longEntryPrice * 100
label.new(bar_index, high, text="롱: " + str.tostring(lastLongProfit, "#.##") + "%", color=color.green, textcolor=color.white, size=size.small)
longEntryPrice := na
if newShortSignal
shortEntryPrice := close
if shortSellSignal and not na(shortEntryPrice)
lastShortProfit = (shortEntryPrice - close) / shortEntryPrice * 100
label.new(bar_index, low, text="숏: " + str.tostring(lastShortProfit, "#.##") + "%", color=color.red, textcolor=color.white, size=size.small, style=label.style_label_up)
shortEntryPrice := na
// === 시각화 (Plot) ===
bgcolor(isSidewaysLong ? color.new(#458558, 85) : na, title="Sideways Long Background")
bgcolor(isSidewaysShort ? color.new(#964c4c, 85) : na, title="Sideways Short Background")
plot(sma4High, color=color.green, linewidth=2, title="SMA(4) High")
plot(sma4Low, color=color.red, linewidth=2, title="SMA(4) Low")
plot(sma4Middle, color=color.rgb(130, 98, 247), linewidth=2, title="SMA(4) Middle")
plot(sma72, color=color.rgb(0, 0, 0), linewidth=2, title="SMA(72)")
plot(sma24, color=color.rgb(0, 4, 255), linewidth=2, title="SMA(24)")
plotshape(newLongSignal, title="Long Entry", location=location.belowbar, color=color.green, style=shape.labelup, text="Long")
plotshape(longSellSignal, title="Long Exit", location=location.abovebar, color=color.red, style=shape.labeldown, text="Long Sell")
plotshape(newShortSignal, title="Short Entry", location=location.abovebar, color=color.rgb(153, 180, 91), style=shape.labeldown, text="Short")
plotshape(shortSellSignal, title="Short Exit", location=location.belowbar, color=color.rgb(165, 62, 122), style=shape.labelup, text="Short Sell")
// 일목균형표 구름대 시각화
p1 = plot(leadingSpan1, offset = displacement, title="Leading Span 1", color=color.new(color.green, 30))
p2 = plot(leadingSpan2, offset = displacement, title="Leading Span 2", color=color.new(color.red, 30))
fill(p1, p2, color = leadingSpan1 > leadingSpan2 ? color.new(color.green, 85) : color.new(color.red, 85), title="Ichimoku Cloud")