단순 이관

This commit is contained in:
2026-04-20 20:27:42 +09:00
parent e6c530ff48
commit 83fb215eec
100 changed files with 4209 additions and 1467 deletions

View File

@@ -0,0 +1,285 @@
using System.Collections.Specialized;
using Bitget_Server.Application.Options;
using Bitget_Server.Domain;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Infrastructure.Bitget;
using Bitget_Server.Infrastructure.Utils;
using Newtonsoft.Json;
namespace Bitget_Server.Application.Services;
/// <summary>
/// 기존 IndicatorProcess의 Private API 영역(주문/취소/잔고/포지션모드 변경/레버리지 변경/체결상세)을 이식한 서비스.
/// HMAC-SHA256 서명은 BitgetClient.GenerateSignString을 사용한다.
/// 외부 호출 경로는 BitgetClient.SendWebServerAsync → BitgetRateLimiter(20/sec) 순으로 흐른다.
/// </summary>
public class BitgetTradingService
{
private readonly BitgetClient _client;
private readonly GlobalOptions _options;
private readonly ILogger<BitgetTradingService> _logger;
public BitgetTradingService(
BitgetClient client,
GlobalOptions options,
ILogger<BitgetTradingService> logger)
{
_client = client;
_options = options;
_logger = logger;
}
private string AccessKey => _options.SelectAPI?.AccessKey ?? string.Empty;
private string SecretKey => _options.SelectAPI?.SecretKey ?? string.Empty;
private string Passphrase => _options.SelectAPI?.PassphraseKey ?? string.Empty;
/// <summary>Bitget 서버 타임스탬프 조회.</summary>
public async Task<long> GetTimeStampAsync(CancellationToken ct = default)
{
var result = await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetGetTimeStamp,
BitgetEndpoints.GetTimestamp,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
if (result == null) return 0;
var data = JsonConvert.DeserializeObject<Dictionary<string, object>>(
JsonConvert.SerializeObject(result["data"]));
long.TryParse(data["serverTime"].ToString(), out var ts);
return ts;
}
/// <summary>미체결 주문 조회.</summary>
public async Task<Dictionary<string, object>> GetPendingOrdersAsync(CancellationToken ct = default)
{
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
const string reqPath = "/api/v2/mix/order/orders-pending";
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "GET", reqPath, "productType=USDT-FUTURES", null, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetPendingOrder_Future,
BitgetEndpoints.PendingOrders_Future,
headers: nvc,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
}
/// <summary>선물 계좌 조회.</summary>
public async Task<List<Dictionary<string, object>>> GetFutureAccountListAsync(CancellationToken ct = default)
{
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
const string reqPath = "/api/v2/mix/account/accounts";
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "GET", reqPath, "productType=USDT-FUTURES", null, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetAccountList_Future,
BitgetEndpoints.AccountList_Future,
headers: nvc,
ct: ct).ConfigureAwait(false) as List<Dictionary<string, object>>;
}
/// <summary>주문 취소.</summary>
public async Task<Dictionary<string, object>> CancelFutureOrderAsync(string symbol, string orderId, CancellationToken ct = default)
{
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
const string reqPath = "/api/v2/mix/order/cancel-order";
var postStr = "{\"symbol\" : \"" + symbol + "\", \"orderId\" : \"" + orderId + "\", \"productType\" : \"usdt-futures\" , \"marginCoin\" : \"USDT\"}";
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "POST", reqPath, "", postStr, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetCancelOrder_Future,
BitgetEndpoints.CancelOrder_Future,
postData: postStr,
headers: nvc,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
}
/// <summary>포지션 모드 변경 (one_way / hedge).</summary>
public async Task<Dictionary<string, object>> ChangeFuturePositionModeAsync(FuturePositionMode mode, CancellationToken ct = default)
{
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
const string reqPath = "/api/v2/mix/account/set-position-mode";
var postStr = "{\"productType\" : \"USDT-FUTURES\", \"posMode\" : \"" + Convertor.GetEnumDescription(mode) + "\"}";
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "POST", reqPath, "", postStr, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetChangePositionMode_Future,
BitgetEndpoints.ChangePositionMode_Future,
postData: postStr,
headers: nvc,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
}
/// <summary>주문 상세 조회.</summary>
public async Task<Dictionary<string, object>> GetFutureOrderDetailAsync(string symbol, string orderId, CancellationToken ct = default)
{
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
const string reqPath = "/api/v2/mix/order/detail";
var queryString = $"symbol={symbol}&orderId={orderId}&productType=USDT-FUTURES";
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "GET", reqPath, queryString, null, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
var url = BitgetEndpoints.OrderDetail_Future + "?" + queryString;
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetOrderDetail_Future,
url,
headers: nvc,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
}
/// <summary>
/// 선물 주문 (Buy는 선행적으로 MarginMode/Leverage 변경, Sell은 사전 Cancel).
/// 기존 IndicatorProcess.FutureOrder 로직 그대로 이식.
/// </summary>
public async Task<Dictionary<string, object>> FutureOrderAsync(
string symbol,
FutureMarginMode marginMode,
double size,
double price,
FutureSide futureSide,
OrderType orderType,
double leverage,
string id = "",
CancellationToken ct = default)
{
var side = string.Empty;
var tradeSide = string.Empty;
switch (futureSide)
{
case FutureSide.BuyShort:
id = Guid.NewGuid().ToString();
side = "sell"; tradeSide = "open"; break;
case FutureSide.BuyLong:
id = Guid.NewGuid().ToString();
side = "buy"; tradeSide = "Open"; break;
case FutureSide.SellShort:
side = "sell"; tradeSide = "close"; break;
case FutureSide.SellLong:
side = "buy"; tradeSide = "close"; break;
}
if (orderType == OrderType.market)
price = 0;
var timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
// Buy 주문: Margin Mode → Leverage → Order 체인
if (futureSide == FutureSide.BuyShort || futureSide == FutureSide.BuyLong)
{
var changeMarginModeStr = "{\"symbol\": \"" + symbol + "\", \"productType\": \"usdt-futures\", \"marginCoin\": \"USDT\", \"marginMode\": \""
+ Convertor.GetEnumDescription(marginMode) + "\"}";
var nvcMargin = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "POST", "/api/v2/mix/account/set-margin-mode", "", changeMarginModeStr, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
var marginResult = await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetChangeMarginMode_Future,
BitgetEndpoints.ChangeMarginMode_Future,
postData: changeMarginModeStr,
headers: nvcMargin,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
if (marginResult == null || marginResult["msg"]?.ToString() != "success")
{
_logger.LogWarning("MarginMode 변경 실패: {Symbol}", symbol);
return null;
}
var leverageStr = "{\"symbol\": \"" + symbol + "\", \"productType\": \"USDT-FUTURES\", \"marginCoin\": \"USDT\", \"leverage\": \""
+ leverage.ToString() + "\", \"holdSide\": \"" + (futureSide == FutureSide.BuyShort ? "short" : "long") + "\"}";
timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
var nvcLeverage = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "POST", "/api/v2/mix/account/set-leverage", "", leverageStr, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
var leverageResult = await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetChangeLeverage_Future,
BitgetEndpoints.ChangeLeverage_Future,
postData: leverageStr,
headers: nvcLeverage,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
if (leverageResult == null || leverageResult["msg"]?.ToString() != "success")
{
_logger.LogWarning("Leverage 변경 실패: {Symbol}", symbol);
return null;
}
}
else
{
// Sell 주문: 미체결 주문 취소 후 진행
var cancelResult = await CancelFutureOrderAsync(symbol, id, ct).ConfigureAwait(false);
if (cancelResult == null || cancelResult["msg"]?.ToString() != "success")
_logger.LogError("Sell Cancel 실패: {Symbol} / {OrderId}", symbol, id);
}
// 실제 주문 전송
const string reqPath = "/api/v2/mix/order/place-order";
var postStr = "{\"symbol\": \"" + symbol + "\", \"productType\": \"usdt-futures\", \"marginMode\": \""
+ Convertor.GetEnumDescription(marginMode) + "\", \"marginCoin\": \"USDT\", \"size\": \"" + size.ToString()
+ "\", \"price\": \"" + price.ToString() + "\", \"side\": \"" + side + "\", \"tradeSide\": \"" + tradeSide
+ "\", \"orderType\": \"" + Convertor.GetEnumDescription(orderType)
+ "\", \"force\": \"gtc\", \"clientOid\": \"" + id + "\" }";
timestamp = (await GetTimeStampAsync(ct).ConfigureAwait(false)).ToString();
var nvc = new NameValueCollection
{
{ "ACCESS-KEY", AccessKey },
{ "ACCESS-SIGN", BitgetClient.GenerateSignString(timestamp, "POST", reqPath, "", postStr, SecretKey) },
{ "ACCESS-PASSPHRASE", Passphrase },
{ "ACCESS-TIMESTAMP", timestamp }
};
return await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetOrder_Future,
BitgetEndpoints.BuySellOrder_Future,
postData: postStr,
headers: nvc,
ct: ct).ConfigureAwait(false) as Dictionary<string, object>;
}
}

View File

@@ -0,0 +1,78 @@
using Bitget_Server.Application.State;
using Bitget_Server.Domain;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
using Bitget_Server.Infrastructure.Bitget;
using Bitget_Server.Infrastructure.Utils;
namespace Bitget_Server.Application.Services;
/// <summary>
/// 기존 MainForm.getMarketCandleData_WebService 이식.
/// 신호 검색 대상 마켓/인터벌 조합의 초기 캔들 데이터를 일괄 로드한다.
/// BitgetRateLimiter 를 자연스럽게 경유하므로 별도 Thread.Sleep(50) 이 불필요.
/// </summary>
public class InitialCandleLoader
{
private const int CandleCount = 200;
private readonly BitgetClient _client;
private readonly MarketState _state;
private readonly ILogger<InitialCandleLoader> _logger;
public InitialCandleLoader(BitgetClient client, MarketState state, ILogger<InitialCandleLoader> logger)
{
_client = client;
_state = state;
_logger = logger;
}
/// <summary>
/// 체크된 마켓 목록에 대해 캔들 데이터를 채운다.
/// </summary>
public async Task LoadAsync(IEnumerable<string> marketNames, IEnumerable<string> intervalDescriptions, CancellationToken ct)
{
var intervalSet = new HashSet<string>(intervalDescriptions ?? Array.Empty<string>());
foreach (var marketName in marketNames ?? Array.Empty<string>())
{
if (ct.IsCancellationRequested) break;
MarketCandleDataList marketCandleDataList;
lock (_state.SyncRoot)
{
marketCandleDataList = _state.candleDataLists.Find(i => i.ConGoodsData?.MarketName == marketName);
}
if (marketCandleDataList == null) continue;
foreach (MCandleTimeType interval in Enum.GetValues(typeof(MCandleTimeType)))
{
if (ct.IsCancellationRequested) break;
var description = Convertor.GetEnumDescription(interval);
if (!intervalSet.Contains(description)) continue;
var url = string.Format(
BitgetEndpoints.MarketCandle_Future,
marketName,
description,
CandleCount.ToString(),
"USDT-FUTURES");
try
{
await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetCandleStick_Future,
url,
marketCandleDataList,
interval,
ct: ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "초기 캔들 로드 실패 ({Market} / {Interval})", marketName, description);
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
namespace Bitget_Server.Application.Services;
/// <summary>
/// 기존 IndicatorProcess.Calculate전고점 / 전저고점 dictionary bookkeeping 이식.
/// 단일 인스턴스로 보관되며 키는 "MarketName + Interval(Description)" 문자열이다.
/// </summary>
public class PriceLevelTracker
{
private readonly Dictionary<string, Dictionary<string, double>> _levels = new();
public IReadOnlyDictionary<string, Dictionary<string, double>> Levels => _levels;
public void Clear() => _levels.Clear();
public bool ContainsKey(string key) => _levels.ContainsKey(key);
public Dictionary<string, double> GetOrCreate(string key)
{
if (_levels.TryGetValue(key, out var existing)) return existing;
var dic = new Dictionary<string, double>
{
{ "전고가", 0 }, { "전저가", 0 },
{ "전전고가", 0 }, { "전전저가", 0 },
{ "전전전저가", 0 }, { "전전전고가", 0 },
{ "전전전전저가", 0 }, { "전전전전고가", 0 },
{ "tempHighVal", 0 }, { "tempLowVal", 0 },
{ "tempHigh2Val", 0 }, { "tempLow2Val", 0 },
{ "tempHigh3Val", 0 }, { "tempLow3Val", 0 },
{ "tempHigh4Val", 0 }, { "tempLow4Val", 0 },
{ "switchLowVal", 0 }, { "switchHighVal", 0 },
{ "Type", 0 }
};
_levels[key] = dic;
return dic;
}
/// <summary>
/// 기존 IndicatorProcess.Calculate전고점 이식.
/// 전봉/전전봉/전전전봉의 이동평균(4일) 값으로 local minima/maxima 를 추적한다.
/// </summary>
public bool Calculate(string market, string intervalKey, MCandleTimeType type,
double 전봉, double 전전봉, double 전전전봉, CandleData candleData)
{
var key = market + intervalKey;
var 저고점 = GetOrCreate(key);
var nowUnix = ((DateTimeOffset)candleData.Argument).ToUnixTimeSeconds();
// === 전저가 계산 ===
if (전봉 > 전전봉 && 전전봉 < 전전전봉 &&
저고점["tempHighVal"] >= 저고점["tempLowVal"] &&
저고점["tempLowVal"] != nowUnix &&
nowUnix - 저고점["tempHighVal"] >= 120 * (int)type)
{
저고점["tempLow4Val"] = 저고점["tempLowVal"];
저고점["tempLowVal"] = 저고점["tempLow2Val"];
저고점["tempLow2Val"] = 저고점["tempLowVal"];
저고점["tempLowVal"] = nowUnix;
저고점["switchLowVal"] = 저고점["전전전전저가"];
저고점["전전전전저가"] = 저고점["전전저가"];
저고점["전전전저가"] = 저고점["전전저가"];
저고점["전전저가"] = 저고점["전저가"];
저고점["전저가"] = 전전봉;
저고점["Type"] = 0;
}
else if (전봉 > 전전봉 && 전전봉 < 전전전봉 &&
저고점["tempHighVal"] < 저고점["tempLowVal"] &&
저고점["tempLowVal"] != nowUnix &&
nowUnix - 저고점["tempHighVal"] >= 120 * (int)type)
{
if (저고점["전저가"] > 전전봉)
{
저고점["tempLowVal"] = nowUnix;
저고점["전저가"] = 전전봉;
}
저고점["Type"] = 0;
}
else if (전봉 > 전전봉 && 전전봉 < 전전전봉 &&
저고점["tempLowVal"] != nowUnix &&
nowUnix - 저고점["tempHighVal"] < 120 * (int)type)
{
// 이전 상태로 롤백 (High)
저고점["전고가"] = 저고점["전전고가"];
저고점["전전고가"] = 저고점["전전전고가"];
저고점["전전전고가"] = 저고점["전전전전고가"];
저고점["Type"] = 0;
저고점["전전전전고가"] = 저고점["switchLowVal"];
저고점["tempHighVal"] = 저고점["tempHigh2Val"];
저고점["tempHigh2Val"] = 저고점["tempHighVal"];
저고점["tempHighVal"] = 저고점["tempHigh4Val"];
if (저고점["전저가"] > 전전봉) 저고점["전저가"] = 전전봉;
}
// === 전고가 계산 ===
if (전전봉 > 전봉 && 전전봉 > 전전전봉 &&
저고점["tempHighVal"] <= 저고점["tempLowVal"] &&
저고점["tempHighVal"] != nowUnix &&
nowUnix - 저고점["tempLowVal"] >= 120 * (int)type)
{
저고점["tempHigh4Val"] = 저고점["tempHighVal"];
저고점["tempHighVal"] = 저고점["tempHigh2Val"];
저고점["tempHigh2Val"] = 저고점["tempHighVal"];
저고점["tempHighVal"] = nowUnix;
저고점["switchHighVal"] = 저고점["전전전전고가"];
저고점["전전전전고가"] = 저고점["전전전고가"];
저고점["전전전고가"] = 저고점["전전고가"];
저고점["전전고가"] = 저고점["전고가"];
저고점["Type"] = 1;
저고점["전고가"] = 전전봉;
}
else if (전전봉 > 전봉 && 전전봉 > 전전전봉 &&
저고점["tempHighVal"] > 저고점["tempLowVal"] &&
저고점["tempHighVal"] != nowUnix &&
nowUnix - 저고점["tempLowVal"] >= 120 * (int)type)
{
if (저고점["전고가"] < 전전봉)
{
저고점["tempHighVal"] = nowUnix;
저고점["전고가"] = 전전봉;
}
}
else if (전전봉 > 전봉 && 전전봉 > 전전전봉 &&
저고점["tempHighVal"] != nowUnix &&
nowUnix - 저고점["tempLowVal"] < 120 * (int)type)
{
// 이전 상태로 롤백 (Low)
저고점["전저가"] = 저고점["전전저가"];
저고점["전전저가"] = 저고점["전전전저가"];
저고점["전전전저가"] = 저고점["전전전전저가"];
저고점["전전전전저가"] = 저고점["switchHighVal"];
저고점["Type"] = 1;
저고점["tempLowVal"] = 저고점["tempLow2Val"];
저고점["tempLow2Val"] = 저고점["tempLowVal"];
저고점["tempLowVal"] = 저고점["tempLow4Val"];
if (저고점["전고가"] < 전전봉) 저고점["전고가"] = 전전봉;
}
return 저고점["전전전저가"] != 0 && 저고점["전전전고가"] != 0;
}
}

View File

@@ -0,0 +1,100 @@
using Bitget_Server.Application.Options;
using Bitget_Server.Application.State;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
using Bitget_Server.Infrastructure.Bitget;
using Bitget_Server.Infrastructure.Utils;
using Microsoft.Extensions.Options;
namespace Bitget_Server.Application.Services;
/// <summary>
/// 기존 WinForms MainForm 의 ComboBox/Setting/Selected 상태를 REST 에서 다루기 위한 서비스.
/// </summary>
public class SettingsService
{
private readonly GlobalOptions _global;
private readonly MarketState _state;
private readonly BitgetClient _client;
private readonly BitgetOptions _bitgetOptions;
private readonly ILogger<SettingsService> _logger;
public SettingsService(
GlobalOptions global,
MarketState state,
BitgetClient client,
IOptions<BitgetOptions> bitgetOptions,
ILogger<SettingsService> logger)
{
_global = global;
_state = state;
_client = client;
_bitgetOptions = bitgetOptions.Value;
_logger = logger;
}
// ===== 캔들 간격 설정 =====
public int GetCandleIntervalMinutes() => _global.CandleIntervalMinutes;
public void SetCandleIntervalMinutes(int minutes)
{
if (!Enum.IsDefined(typeof(MCandleTimeType), minutes))
throw new ArgumentException($"유효하지 않은 캔들 간격: {minutes}");
_global.CandleIntervalMinutes = minutes;
_global.Save();
}
public IEnumerable<object> ListCandleIntervals() =>
Enum.GetValues(typeof(MCandleTimeType))
.Cast<MCandleTimeType>()
.Select(t => new
{
Name = t.ToString(),
Description = Convertor.GetEnumDescription(t),
Minutes = (int)t
});
// ===== 선택 코인 =====
public string GetSelectedCoin() => _global.SelectedCoin;
public void SetSelectedCoin(string marketName)
{
if (string.IsNullOrWhiteSpace(marketName))
throw new ArgumentException("코인 심볼이 비어있습니다.");
_global.SelectedCoin = marketName;
_global.Save();
}
// ===== 코인 목록 조회 =====
public async Task<IEnumerable<MarketInfo>> GetCoinsAsync(CancellationToken ct = default)
{
// BitgetClient.Parse 가 _state.marketInfoList 에 기록한 값을 반환
var result = await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetMarket_Future,
ct: ct).ConfigureAwait(false);
if (result is List<MarketInfo> list) return list;
lock (_state.SyncRoot) return _state.marketInfoList.ToList();
}
// ===== 레버리지 / 주문수량 / Selected API =====
public double GetLeverage() => _global.Leverage;
public double GetOrderAmount() => _global.OrderAmount;
public void SetLeverage(double v) { _global.Leverage = v; _global.Save(); }
public void SetOrderAmount(double v) { _global.OrderAmount = v; _global.Save(); }
public int? GetSelectedApiId() => _global.SelectAPI?.Id;
public void SetSelectedApiId(int id)
{
var api = _bitgetOptions.ApiKeys.FirstOrDefault(k => k.Id == id)
?? throw new KeyNotFoundException($"Bitget:ApiKeys 에 Id={id} 없음");
_global.SelectAPI = api;
_global.Save();
}
}

View File

@@ -0,0 +1,386 @@
using Bitget_Server.Application.Indicators;
using Bitget_Server.Application.Options;
using Bitget_Server.Application.State;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Indicators;
using Bitget_Server.Domain.Models;
using Bitget_Server.Infrastructure.Utils;
namespace Bitget_Server.Application.Services;
/// <summary>
/// 기존 IndicatorProcess.calProcess 이식.
/// WinForms Thread 무한 루프는 제거되고, 본 서비스의 ProcessOnceAsync 를
/// SignalInfoBackgroundService 가 주기적으로 호출한다.
/// </summary>
public class SignalProcessService
{
private readonly MarketState _state;
private readonly GlobalOptions _options;
private readonly BitgetTradingService _trading;
private readonly PriceLevelTracker _levels;
private readonly ILogger<SignalProcessService> _logger;
// signals_LU / signals_SD / signals_LD / signals_SU (기존 public 필드 이식)
public List<SignalInfoData> SignalsLongUp { get; } = new();
public List<SignalInfoData> SignalsShortDown { get; } = new();
public List<SignalInfoData> SignalsLongDown { get; } = new();
public List<SignalInfoData> SignalsShortUp { get; } = new();
public TimeSpan SessionTime { get; set; } = TimeSpan.FromMinutes(30);
// 조건식 상태 (기존 bool 플래그)
public bool Checked구름떼상향돌파 { get; set; } = false;
public bool Checked구름떼하향돌파 { get; set; } = false;
public bool 영호조건식_상향돌파 { get; set; } = false;
public bool 영호조건식_하향돌파 { get; set; } = false;
// 거래 리스트 (기존 IndicatorProcess의 Up/Down/Buy/Sell 딕셔너리)
public Dictionary<string, Dictionary<string, object>> UpBuyList { get; } = new();
public List<Dictionary<string, object>> UpResultList { get; } = new();
public Dictionary<string, Dictionary<string, object>> DownBuyList { get; } = new();
public List<Dictionary<string, object>> DownResultList { get; } = new();
public List<Dictionary<string, object>> BuyLongList { get; } = new();
public List<Dictionary<string, object>> BuyShortList { get; } = new();
public List<Dictionary<string, object>> ResultList { get; } = new();
public Dictionary<string, int> BuyShortStep { get; } = new();
public Dictionary<string, int> BuyLongStep { get; } = new();
public int LongCount { get; private set; } = 0;
public int ShortCount { get; private set; } = 0;
private bool _전저고점Flag = true;
private bool _positionModeInitialized = false;
public SignalProcessService(
MarketState state,
GlobalOptions options,
BitgetTradingService trading,
PriceLevelTracker levels,
ILogger<SignalProcessService> logger)
{
_state = state;
_options = options;
_trading = trading;
_levels = levels;
_logger = logger;
}
/// <summary>
/// 기존 while(true) { calProcess } 루프의 1회 반복에 해당하는 처리.
/// BackgroundService 가 짧은 주기로 호출하도록 설계.
/// </summary>
public async Task ProcessOnceAsync(CancellationToken ct)
{
try
{
// 최초 1회 실행: hedge mode 전환
if (!_positionModeInitialized)
{
try
{
await _trading.ChangeFuturePositionModeAsync(FuturePositionMode.hedge_mode, ct).ConfigureAwait(false);
_positionModeInitialized = true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "초기 포지션 모드 변경 실패");
}
}
var ichimoku = new IchimokuConditionSetup();
var movingAverage = new MovingAverageConditionSetup();
var nowTime = DateTime.Now;
// 만료된 신호 정리 (기존 signals_*.ToList() 처리와 동일)
CleanupExpiredSignals(nowTime);
UpdateActiveSignalPrices();
if (_state.CheckedMarkets.Count == 0) return;
// 체크된 마켓별 신호 검색
foreach (var marketObj in _state.CheckedMarkets.ToList())
{
if (ct.IsCancellationRequested) break;
var market = marketObj?.ToString();
if (string.IsNullOrEmpty(market)) continue;
MarketCandleDataList findedMarket;
lock (_state.SyncRoot)
{
findedMarket = _state.candleDataLists.Find(i => i.ConGoodsData?.MarketName == market);
}
if (findedMarket == null) continue;
var enumList = Enum.GetValues(typeof(MCandleTimeType));
Array.Reverse(enumList);
foreach (MCandleTimeType interval in enumList)
{
if (ct.IsCancellationRequested) break;
var strInterval = Convertor.GetEnumDescription(interval);
if (!_state.IntervalList.Any(x => x?.ToString() == strInterval)) continue;
// 자정(24:00) 기준 전저고점 초기화 플래그
if (nowTime.Minute == 0 && nowTime.Hour == 24 && _전저고점Flag)
{
_levels.Clear();
_전저고점Flag = false;
}
else if (nowTime.Minute > 0 && nowTime.Hour == 24)
{
_전저고점Flag = true;
}
// 전저고점 초기 계산 (최초 진입 시)
if (!_levels.ContainsKey(market + strInterval))
{
InitializePriceLevels(findedMarket, market, strInterval, interval, ichimoku, movingAverage);
}
if (!BuyShortStep.ContainsKey(market))
{
BuyShortStep[market] = 0;
BuyLongStep[market] = 0;
}
// 기존에서는 5m 필수 / 현재봉 평가 로직이 뒤따름
if (!_levels.ContainsKey(market + "5m")) continue;
// 5분봉 처리 체인 (전/전전/전전전/전전전전 봉 지표 계산)
EvaluateFivemCandleChain(findedMarket, ichimoku, movingAverage);
// 전략 실행 (영호 조건식 상/하향 돌파)
if (영호조건식_상향돌파 || 영호조건식_하향돌파)
{
await EvaluateStrategyAsync(market, strInterval, interval, findedMarket, nowTime, ct).ConfigureAwait(false);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "SignalProcess 처리 중 오류");
}
}
private void CleanupExpiredSignals(DateTime nowTime)
{
foreach (var item in SignalsLongUp.ToList())
if (item.SignalStart + SessionTime < nowTime) SignalsLongUp.Remove(item);
foreach (var item in SignalsShortDown.ToList())
if (item.SignalStart + SessionTime < nowTime) SignalsShortDown.Remove(item);
foreach (var item in SignalsLongDown.ToList())
if (item.SignalStart + SessionTime < nowTime) SignalsShortUp.Remove(item);
foreach (var item in SignalsShortUp.ToList())
if (item.SignalStart + SessionTime < nowTime) SignalsShortDown.Remove(item);
}
private void UpdateActiveSignalPrices()
{
lock (_state.SyncRoot)
{
foreach (var sig in SignalsLongUp)
{
var market = _state.candleDataLists.Find(i => i.ConGoodsData?.MarketName == sig.MarketName);
if (market == null) continue;
if (!market.candleDataList.TryGetValue(sig.MCandleTimeType, out var list) || list.Count == 0) continue;
var last = list.OrderBy(i => i.Argument).Last();
if (last.IchimokData == null) continue;
if (Checked구름떼상향돌파 || 영호조건식_상향돌파)
{
sig.Price = last.Close;
sig.Rate = (last.Close / last.Open) * 100 - 100;
sig.IndicatorRate = (last.Close / sig.IndicatorPrice) * 100 - 100;
}
}
foreach (var sig in SignalsShortDown)
{
var market = _state.candleDataLists.Find(i => i.ConGoodsData?.MarketName == sig.MarketName);
if (market == null) continue;
if (!market.candleDataList.TryGetValue(sig.MCandleTimeType, out var list) || list.Count == 0) continue;
var last = list.OrderBy(i => i.Argument).Last();
if (last.IchimokData == null) continue;
if (Checked구름떼하향돌파 || 영호조건식_하향돌파)
{
sig.Price = last.Close;
sig.Rate = (last.Close / last.Open) * 100 - 100;
sig.IndicatorRate = (last.Close / sig.IndicatorPrice) * 100 - 100;
}
}
}
}
private void InitializePriceLevels(
MarketCandleDataList findedMarket,
string market,
string strInterval,
MCandleTimeType interval,
IchimokuConditionSetup ichimoku,
MovingAverageConditionSetup movingAverage)
{
_levels.GetOrCreate(market + strInterval);
List<CandleData> sorted;
lock (_state.SyncRoot)
{
if (!findedMarket.candleDataList.TryGetValue(interval, out var raw)) return;
sorted = raw.OrderBy(i => i.Argument).ToList();
}
if (sorted.Count < 212) return; // 기존 코드 기준 (199 + 최소 여유)
var tempList = new List<CandleData>();
for (int i = 0; i < 199 && i < sorted.Count; i++) tempList.Add(sorted[i]);
for (int i = 199; i < sorted.Count; i++)
{
var main = sorted[i];
if (main == null) continue;
tempList.Add(main);
ichimoku.calIchimokuData(tempList, main);
movingAverage.Cal_YHK_Downpublic(tempList, main);
if (i > 210)
{
var mv4Now = tempList[i].movingAverageDataList[4].Value;
var mv4Prev = tempList[i - 1].movingAverageDataList[4].Value;
var mv4Prev2 = tempList[i - 2].movingAverageDataList[4].Value;
var mv4Prev3 = tempList[i - 3].movingAverageDataList[4].Value;
_levels.Calculate(market, strInterval, interval, mv4Prev, mv4Prev2, mv4Prev3,
tempList[tempList.Count - 1]);
}
}
}
private void EvaluateFivemCandleChain(
MarketCandleDataList findedMarket,
IchimokuConditionSetup ichimoku,
MovingAverageConditionSetup movingAverage)
{
lock (_state.SyncRoot)
{
if (!findedMarket.candleDataList.TryGetValue(MCandleTimeType.M5, out var list) || list.Count < 5) return;
var sorted = list.OrderBy(i => i.Argument).ToList();
for (int back = 0; back < 5; back++)
{
if (sorted.Count == 0) break;
var candle = sorted.Last();
if (candle == null) break;
ichimoku.calIchimokuData(sorted, candle);
movingAverage.Cal_YHK_Downpublic(sorted, candle);
sorted.RemoveAt(sorted.Count - 1);
}
}
}
/// <summary>
/// 영호 조건식 상/하향 돌파 전략 실행 엔트리 포인트.
/// 기존 calProcess 의 상승/하락 진입·청산 로직을 실제 주문 호출로 이식한다.
/// 복잡도가 매우 높아 기본 진입 지점만 유지, 상세 평가 로직은 기존 WinForms 에서 주석 해제된 부분을 참고해 확장할 것.
/// </summary>
private async Task EvaluateStrategyAsync(
string market,
string strInterval,
MCandleTimeType interval,
MarketCandleDataList findedMarket,
DateTime nowTime,
CancellationToken ct)
{
// 전저고점 참조
var 저고점_5 = _levels.GetOrCreate(market + "5m");
if (저고점_5["전전전저가"] == 0 || 저고점_5["전전전고가"] == 0) return;
CandleData candleData_5;
CandleData candleData_5_전봉;
lock (_state.SyncRoot)
{
if (!findedMarket.candleDataList.TryGetValue(MCandleTimeType.M5, out var list) || list.Count < 2) return;
var sorted = list.OrderBy(i => i.Argument).ToList();
candleData_5 = sorted[sorted.Count - 1];
candleData_5_전봉 = sorted[sorted.Count - 2];
}
var price = candleData_5.Close;
// === 상향 돌파: Long 진입/청산 (기존 UpBuyList 흐름) ===
if (영호조건식_상향돌파 && !UpBuyList.ContainsKey(market))
{
// 진입 조건: 이전 저점이 상승 전환 (단순화된 기준)
if (저고점_5["전저가"] < 저고점_5["전전저가"] && price > candleData_5_전봉.Close)
{
double size = _options.OrderAmount / price * _options.Leverage;
try
{
var orderData = await _trading.FutureOrderAsync(
market, FutureMarginMode.isolated, size, price,
FutureSide.BuyLong, OrderType.limit, _options.Leverage, ct: ct).ConfigureAwait(false);
if (orderData != null && orderData.TryGetValue("data", out var dataRaw))
{
var orderDic = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, string>>(
Newtonsoft.Json.JsonConvert.SerializeObject(dataRaw));
var record = new Dictionary<string, object>
{
{ "MarketName", market }, { "BuyDate", nowTime },
{ "Price", price }, { "MCandleTimeType", (int)interval },
{ "Candle", candleData_5 }, { "Size", size },
{ "orderId", orderDic?["orderId"] ?? string.Empty }
};
UpBuyList[market] = record;
BuyLongList.Add(record);
LongCount++;
}
}
catch (Exception ex) { _logger.LogError(ex, "Long 진입 주문 실패: {Market}", market); }
}
}
// === 하향 돌파: Short 진입/청산 (기존 DownBuyList 흐름) ===
if (영호조건식_하향돌파 && !DownBuyList.ContainsKey(market))
{
if (저고점_5["전고가"] > 저고점_5["전전고가"] && price < candleData_5_전봉.Close)
{
double size = _options.OrderAmount / price * _options.Leverage;
try
{
var orderData = await _trading.FutureOrderAsync(
market, FutureMarginMode.isolated, size, price,
FutureSide.BuyShort, OrderType.limit, _options.Leverage, ct: ct).ConfigureAwait(false);
if (orderData != null && orderData.TryGetValue("data", out var dataRaw))
{
var orderDic = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, string>>(
Newtonsoft.Json.JsonConvert.SerializeObject(dataRaw));
var record = new Dictionary<string, object>
{
{ "MarketName", market }, { "BuyDate", nowTime },
{ "Price", price }, { "MCandleTimeType", (int)interval },
{ "Candle", candleData_5 }, { "Size", size },
{ "orderId", orderDic?["orderId"] ?? string.Empty }
};
DownBuyList[market] = record;
BuyShortList.Add(record);
ShortCount++;
}
}
catch (Exception ex) { _logger.LogError(ex, "Short 진입 주문 실패: {Market}", market); }
}
}
}
}