업데이트
This commit is contained in:
75
Buyer/BitgetConfig.cs
Normal file
75
Buyer/BitgetConfig.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
28
Buyer/BitgetGranularity.cs
Normal file
28
Buyer/BitgetGranularity.cs
Normal 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
126
Buyer/BitgetRestClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
239
Buyer/BitgetWebSocketClient.cs
Normal file
239
Buyer/BitgetWebSocketClient.cs
Normal 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
34
Buyer/Buyer.csproj
Normal 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
148
Buyer/CandlePoller.cs
Normal 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
28
Buyer/Dockerfile
Normal 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
85
Buyer/Program.cs
Normal 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();
|
||||
}
|
||||
10
Buyer/Properties/launchSettings.json
Normal file
10
Buyer/Properties/launchSettings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Buyer": {
|
||||
"commandName": "Project"
|
||||
},
|
||||
"Container (Dockerfile)": {
|
||||
"commandName": "Docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
102
Buyer/RealtimeTrader.cs
Normal file
102
Buyer/RealtimeTrader.cs
Normal 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
211
Buyer/example.pine
Normal 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")
|
||||
Reference in New Issue
Block a user