단순 이관

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,51 @@
# =============================================================================
# .dockerignore — Bitget_Server
# Build context 가 솔루션 루트라도 동일하게 동작하도록 작성
# =============================================================================
# 빌드 산출물
**/bin/
**/obj/
**/out/
**/publish/
# Visual Studio / IDE
**/.vs/
**/.idea/
**/.vscode/
**/*.user
**/*.suo
**/*.userprefs
# 버전 관리
.git/
.gitignore
.gitattributes
# 패키지 / 캐시
**/packages/
**/node_modules/
**/.nuget/
# 로그/데이터 (호스트 마운트로 주입)
**/data/
**/logs/
**/*.log
# 문서/메타
*.md
LICENSE
**/.editorconfig
# 개발 전용 설정 / 시크릿
**/appsettings.Development.json
**/launchSettings.json
**/secrets.json
# 기존 WinForms 프로젝트 (이식 대상 아님 — 컨텍스트만 가벼워지면 됨)
BitgetApi/
# Docker 자체 메타
Dockerfile
.dockerignore
docker-compose*.yml

View File

@@ -0,0 +1,47 @@
using Bitget_Server.Application.Services;
using Bitget_Server.Domain.Models;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 기존 WinForms 코인 선택 그리드 데이터를 REST 화.
/// </summary>
[ApiController]
[Route("api/coins")]
[Produces("application/json")]
public class CoinsController : ControllerBase
{
private readonly SettingsService _settings;
private readonly ILogger<CoinsController> _logger;
public CoinsController(SettingsService settings, ILogger<CoinsController> logger)
{
_settings = settings;
_logger = logger;
}
/// <summary>
/// USDT 기준 Bitget 선물 마켓 정보 목록을 반환한다.
/// 내부적으로 BitgetClient 캐시(_state.marketInfoList)를 우선 사용하고 비어 있으면 호출.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<MarketInfo>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<ActionResult<IEnumerable<MarketInfo>>> GetCoins(CancellationToken ct)
{
try
{
var coins = await _settings.GetCoinsAsync(ct);
return Ok(coins);
}
catch (Exception ex)
{
_logger.LogError(ex, "코인 조회 실패");
return Problem(
detail: ex.Message,
statusCode: StatusCodes.Status502BadGateway,
title: "Failed to fetch coins from Bitget");
}
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 단순 헬스체크. Docker / 로드밸런서에서 사용.
/// </summary>
[ApiController]
[Route("api/health")]
[Produces("application/json")]
public class HealthController : ControllerBase
{
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Get() => Ok(new
{
status = "ok",
timestampUtc = DateTime.UtcNow,
version = typeof(HealthController).Assembly.GetName().Version?.ToString() ?? "unknown"
});
}

View File

@@ -0,0 +1,148 @@
using Bitget_Server.Api.Dtos;
using Bitget_Server.Application.Services;
using Bitget_Server.Infrastructure.Utils;
using Bitget_Server.Domain.Enums;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 기존 WinForms MainForm 의 ComboBox / 텍스트박스 / API 선택 라디오버튼을 REST 화.
/// </summary>
[ApiController]
[Route("api/settings")]
[Produces("application/json")]
public class SettingsController : ControllerBase
{
private readonly SettingsService _settings;
public SettingsController(SettingsService settings)
{
_settings = settings;
}
// ===== 캔들 간격 =====
/// <summary>현재 선택된 캔들 간격(분)을 반환.</summary>
[HttpGet("candle-interval")]
[ProducesResponseType(typeof(CandleIntervalDto), StatusCodes.Status200OK)]
public ActionResult<CandleIntervalDto> GetCandleInterval()
{
var minutes = _settings.GetCandleIntervalMinutes();
var interval = (MCandleTimeType)minutes;
return Ok(new CandleIntervalDto(minutes, Convertor.GetEnumDescription(interval)));
}
/// <summary>캔들 간격을 변경.</summary>
[HttpPut("candle-interval")]
[ProducesResponseType(typeof(CandleIntervalDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<CandleIntervalDto> UpdateCandleInterval([FromBody] UpdateCandleIntervalRequest req)
{
try
{
_settings.SetCandleIntervalMinutes(req.Minutes);
var interval = (MCandleTimeType)req.Minutes;
return Ok(new CandleIntervalDto(req.Minutes, Convertor.GetEnumDescription(interval)));
}
catch (ArgumentException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest, title: "Invalid candle interval");
}
}
/// <summary>지원되는 캔들 간격 목록.</summary>
[HttpGet("candle-interval/options")]
[ProducesResponseType(typeof(IEnumerable<object>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<object>> ListCandleIntervals()
=> Ok(_settings.ListCandleIntervals());
// ===== 선택 코인 =====
[HttpGet("selected-coin")]
[ProducesResponseType(typeof(SelectedCoinDto), StatusCodes.Status200OK)]
public ActionResult<SelectedCoinDto> GetSelectedCoin()
=> Ok(new SelectedCoinDto(_settings.GetSelectedCoin()));
[HttpPut("selected-coin")]
[ProducesResponseType(typeof(SelectedCoinDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<SelectedCoinDto> UpdateSelectedCoin([FromBody] UpdateSelectedCoinRequest req)
{
try
{
_settings.SetSelectedCoin(req.MarketName);
return Ok(new SelectedCoinDto(req.MarketName));
}
catch (ArgumentException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status400BadRequest, title: "Invalid coin");
}
}
// ===== 레버리지 =====
[HttpGet("leverage")]
[ProducesResponseType(typeof(LeverageDto), StatusCodes.Status200OK)]
public ActionResult<LeverageDto> GetLeverage()
=> Ok(new LeverageDto(_settings.GetLeverage()));
[HttpPut("leverage")]
[ProducesResponseType(typeof(LeverageDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<LeverageDto> UpdateLeverage([FromBody] UpdateLeverageRequest req)
{
if (req.Leverage <= 0)
return Problem(detail: "Leverage must be > 0", statusCode: StatusCodes.Status400BadRequest, title: "Invalid leverage");
_settings.SetLeverage(req.Leverage);
return Ok(new LeverageDto(req.Leverage));
}
// ===== 주문 수량 =====
[HttpGet("order-amount")]
[ProducesResponseType(typeof(OrderAmountDto), StatusCodes.Status200OK)]
public ActionResult<OrderAmountDto> GetOrderAmount()
=> Ok(new OrderAmountDto(_settings.GetOrderAmount()));
[HttpPut("order-amount")]
[ProducesResponseType(typeof(OrderAmountDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<OrderAmountDto> UpdateOrderAmount([FromBody] UpdateOrderAmountRequest req)
{
if (req.OrderAmount <= 0)
return Problem(detail: "OrderAmount must be > 0", statusCode: StatusCodes.Status400BadRequest, title: "Invalid order amount");
_settings.SetOrderAmount(req.OrderAmount);
return Ok(new OrderAmountDto(req.OrderAmount));
}
// ===== Selected API 키 =====
[HttpGet("api-key")]
[ProducesResponseType(typeof(SelectedApiDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult<SelectedApiDto> GetSelectedApi()
{
var id = _settings.GetSelectedApiId();
if (id == null) return NoContent();
return Ok(new SelectedApiDto(id.Value, string.Empty));
}
[HttpPut("api-key")]
[ProducesResponseType(typeof(SelectedApiDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public ActionResult<SelectedApiDto> UpdateSelectedApi([FromBody] UpdateSelectedApiRequest req)
{
try
{
_settings.SetSelectedApiId(req.Id);
return Ok(new SelectedApiDto(req.Id, string.Empty));
}
catch (KeyNotFoundException ex)
{
return Problem(detail: ex.Message, statusCode: StatusCodes.Status404NotFound, title: "API key not found");
}
}
}

View File

@@ -0,0 +1,97 @@
using Bitget_Server.Api.Dtos;
using Bitget_Server.Application.Services;
using Bitget_Server.Application.State;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 기존 WinForms 의 구름떼/영호 조건식 체크박스 및 신호검색 그리드를 REST 화.
/// </summary>
[ApiController]
[Route("api/signals")]
[Produces("application/json")]
public class SignalsController : ControllerBase
{
private readonly SignalProcessService _signalProcess;
private readonly MarketState _state;
public SignalsController(SignalProcessService signalProcess, MarketState state)
{
_signalProcess = signalProcess;
_state = state;
}
/// <summary>현재 활성화된 시그널 목록 + 카운트.</summary>
[HttpGet]
[ProducesResponseType(typeof(SignalListResponse), StatusCodes.Status200OK)]
public ActionResult<SignalListResponse> GetSignals()
{
var result = new SignalListResponse(
_signalProcess.SignalsLongUp.Select(Map),
_signalProcess.SignalsShortDown.Select(Map),
_signalProcess.SignalsLongDown.Select(Map),
_signalProcess.SignalsShortUp.Select(Map),
_signalProcess.LongCount,
_signalProcess.ShortCount);
return Ok(result);
static SignalSummaryDto Map(Domain.Models.SignalInfoData s)
=> new(s.MarketName, s.Description, s.SignalStart, s.Price, s.Rate,
s.IndicatorPrice, s.IndicatorRate, s.MCandleTimeType);
}
/// <summary>조건식 상태 조회.</summary>
[HttpGet("conditions")]
[ProducesResponseType(typeof(SignalConditionsDto), StatusCodes.Status200OK)]
public ActionResult<SignalConditionsDto> GetConditions()
=> Ok(new SignalConditionsDto(
_signalProcess.Checked구름떼상향돌파,
_signalProcess.Checked구름떼하향돌파,
_signalProcess.영호조건식_상향돌파,
_signalProcess.영호조건식_하향돌파));
/// <summary>조건식 상태 변경.</summary>
[HttpPut("conditions")]
[ProducesResponseType(typeof(SignalConditionsDto), StatusCodes.Status200OK)]
public ActionResult<SignalConditionsDto> UpdateConditions([FromBody] UpdateSignalConditionsRequest req)
{
_signalProcess.Checked구름떼상향돌파 = req.구름떼상향돌파;
_signalProcess.Checked구름떼하향돌파 = req.구름떼하향돌파;
_signalProcess.영호조건식_상향돌파 = req.영호조건식_상향돌파;
_signalProcess.영호조건식_하향돌파 = req.영호조건식_하향돌파;
return Ok(new SignalConditionsDto(
_signalProcess.Checked구름떼상향돌파,
_signalProcess.Checked구름떼하향돌파,
_signalProcess.영호조건식_상향돌파,
_signalProcess.영호조건식_하향돌파));
}
/// <summary>체크된 마켓/인터벌 목록 조회 (신호검색 대상).</summary>
[HttpGet("checked-markets")]
[ProducesResponseType(typeof(CheckedMarketsDto), StatusCodes.Status200OK)]
public ActionResult<CheckedMarketsDto> GetCheckedMarkets()
{
lock (_state.SyncRoot)
{
return Ok(new CheckedMarketsDto(
_state.CheckedMarkets.Select(m => m?.ToString() ?? string.Empty).ToList(),
_state.IntervalList.Select(i => i?.ToString() ?? string.Empty).ToList()));
}
}
/// <summary>체크된 마켓/인터벌 일괄 설정.</summary>
[HttpPut("checked-markets")]
[ProducesResponseType(typeof(CheckedMarketsDto), StatusCodes.Status200OK)]
public ActionResult<CheckedMarketsDto> UpdateCheckedMarkets([FromBody] UpdateCheckedMarketsRequest req)
{
lock (_state.SyncRoot)
{
_state.CheckedMarkets = req.Markets.Cast<object>().ToList();
_state.IntervalList = req.Intervals.Cast<object>().ToList();
}
return Ok(new CheckedMarketsDto(req.Markets, req.Intervals));
}
}

View File

@@ -0,0 +1,122 @@
using Bitget_Server.Api.Dtos;
using Bitget_Server.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 기존 WinForms 의 주문/취소/잔고 버튼을 REST 화.
/// Bitget Private API 를 호출하므로 반드시 선택된 API 키가 설정되어 있어야 한다.
/// </summary>
[ApiController]
[Route("api/trading")]
[Produces("application/json")]
public class TradingController : ControllerBase
{
private readonly BitgetTradingService _trading;
private readonly ILogger<TradingController> _logger;
public TradingController(BitgetTradingService trading, ILogger<TradingController> logger)
{
_trading = trading;
_logger = logger;
}
/// <summary>Bitget 서버 타임스탬프 조회 (헬스체크 용도로도 활용).</summary>
[HttpGet("timestamp")]
[ProducesResponseType(typeof(long), StatusCodes.Status200OK)]
public async Task<ActionResult<long>> GetTimestamp(CancellationToken ct)
=> Ok(await _trading.GetTimeStampAsync(ct));
/// <summary>선물 계좌 잔고/포지션 조회.</summary>
[HttpGet("account")]
[ProducesResponseType(typeof(List<Dictionary<string, object>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<IActionResult> GetAccount(CancellationToken ct)
{
var result = await _trading.GetFutureAccountListAsync(ct);
if (result == null)
return Problem(detail: "Bitget API 응답 없음", statusCode: StatusCodes.Status502BadGateway, title: "Account fetch failed");
return Ok(result);
}
/// <summary>미체결 주문 조회.</summary>
[HttpGet("orders/pending")]
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<IActionResult> GetPendingOrders(CancellationToken ct)
{
var result = await _trading.GetPendingOrdersAsync(ct);
if (result == null)
return Problem(detail: "Bitget API 응답 없음", statusCode: StatusCodes.Status502BadGateway, title: "Pending orders fetch failed");
return Ok(result);
}
/// <summary>주문 상세 조회.</summary>
[HttpGet("orders/{symbol}/{orderId}")]
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrderDetail(string symbol, string orderId, CancellationToken ct)
{
var result = await _trading.GetFutureOrderDetailAsync(symbol, orderId, ct);
if (result == null)
return Problem(detail: "주문 상세 응답 없음", statusCode: StatusCodes.Status404NotFound, title: "Order detail not found");
return Ok(result);
}
/// <summary>신규 주문.</summary>
[HttpPost("orders")]
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderRequest req, CancellationToken ct)
{
try
{
var result = await _trading.FutureOrderAsync(
req.Symbol,
req.MarginMode,
req.Size,
req.Price,
req.Side,
req.OrderType,
req.Leverage,
req.ClientOrderId ?? string.Empty,
ct);
if (result == null)
return Problem(detail: "주문 실패 (MarginMode/Leverage/Order 체인 중 단계 실패)",
statusCode: StatusCodes.Status502BadGateway, title: "Order failed");
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "주문 처리 실패: {Symbol}", req.Symbol);
return Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "Order processing error");
}
}
/// <summary>주문 취소.</summary>
[HttpDelete("orders")]
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<IActionResult> CancelOrder([FromBody] CancelOrderRequest req, CancellationToken ct)
{
var result = await _trading.CancelFutureOrderAsync(req.Symbol, req.OrderId, ct);
if (result == null)
return Problem(detail: "취소 응답 없음", statusCode: StatusCodes.Status502BadGateway, title: "Cancel failed");
return Ok(result);
}
/// <summary>포지션 모드 변경(hedge / one_way).</summary>
[HttpPut("position-mode")]
[ProducesResponseType(typeof(Dictionary<string, object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status502BadGateway)]
public async Task<IActionResult> ChangePositionMode([FromBody] ChangePositionModeRequest req, CancellationToken ct)
{
var result = await _trading.ChangeFuturePositionModeAsync(req.Mode, ct);
if (result == null)
return Problem(detail: "포지션 모드 변경 실패", statusCode: StatusCodes.Status502BadGateway, title: "Position mode change failed");
return Ok(result);
}
}

View File

@@ -0,0 +1,66 @@
using Bitget_Server.Domain.Enums;
using Bitget_Server.Infrastructure.Bitget;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Api.Controllers;
/// <summary>
/// 기존 WinForms 의 BitGetWebSocektStart / closeWebSocket 버튼 REST 화.
/// </summary>
[ApiController]
[Route("api/websocket")]
[Produces("application/json")]
public class WebSocketController : ControllerBase
{
private readonly BitgetWebSocketClient _ws;
private readonly ILogger<WebSocketController> _logger;
public WebSocketController(BitgetWebSocketClient ws, ILogger<WebSocketController> logger)
{
_ws = ws;
_logger = logger;
}
/// <summary>현재 WebSocket 연결 상태.</summary>
[HttpGet("status")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
public IActionResult GetStatus() => Ok(new { connected = _ws.IsConnected });
/// <summary>
/// WebSocket 연결 시작. subscribeArgs 는 Bitget 공식 포맷의 args 리스트.
/// 예: [{"instType":"USDT-FUTURES","channel":"ticker","instId":"BTCUSDT"}]
/// </summary>
[HttpPost("start")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Start(
[FromQuery] BitgetWebSocketType type,
[FromBody] IEnumerable<object> subscribeArgs,
CancellationToken ct)
{
try
{
var dic = new Dictionary<string, object>
{
{ "op", "subscribe" },
{ "args", subscribeArgs ?? Array.Empty<object>() }
};
await _ws.StartAsync(type, dic, ct);
return Accepted();
}
catch (Exception ex)
{
_logger.LogError(ex, "WebSocket Start 실패");
return Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError, title: "WebSocket start failed");
}
}
/// <summary>WebSocket 연결 종료.</summary>
[HttpPost("stop")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Stop(CancellationToken ct)
{
await _ws.StopAsync(ct);
return NoContent();
}
}

View File

@@ -0,0 +1,18 @@
namespace Bitget_Server.Api.Dtos;
public record CandleIntervalDto(int Minutes, string Description);
public record UpdateCandleIntervalRequest(int Minutes);
public record SelectedCoinDto(string MarketName);
public record UpdateSelectedCoinRequest(string MarketName);
public record LeverageDto(double Leverage);
public record UpdateLeverageRequest(double Leverage);
public record OrderAmountDto(double OrderAmount);
public record UpdateOrderAmountRequest(double OrderAmount);
public record SelectedApiDto(int Id, string Name);
public record UpdateSelectedApiRequest(int Id);

View File

@@ -0,0 +1,37 @@
using Bitget_Server.Domain.Enums;
namespace Bitget_Server.Api.Dtos;
public record SignalConditionsDto(
bool 구름떼상향돌파,
bool 구름떼하향돌파,
bool 영호조건식_상향돌파,
bool 영호조건식_하향돌파);
public record UpdateSignalConditionsRequest(
bool 구름떼상향돌파,
bool 구름떼하향돌파,
bool 영호조건식_상향돌파,
bool 영호조건식_하향돌파);
public record CheckedMarketsDto(IEnumerable<string> Markets, IEnumerable<string> Intervals);
public record UpdateCheckedMarketsRequest(IEnumerable<string> Markets, IEnumerable<string> Intervals);
public record SignalListResponse(
IEnumerable<SignalSummaryDto> LongUp,
IEnumerable<SignalSummaryDto> ShortDown,
IEnumerable<SignalSummaryDto> LongDown,
IEnumerable<SignalSummaryDto> ShortUp,
int LongCount,
int ShortCount);
public record SignalSummaryDto(
string MarketName,
string Description,
DateTime SignalStart,
double Price,
double Rate,
double IndicatorPrice,
double IndicatorRate,
MCandleTimeType MCandleTimeType);

View File

@@ -0,0 +1,19 @@
using Bitget_Server.Domain.Enums;
namespace Bitget_Server.Api.Dtos;
public record PlaceOrderRequest(
string Symbol,
FutureMarginMode MarginMode,
double Size,
double Price,
FutureSide Side,
OrderType OrderType,
double Leverage,
string ClientOrderId = "");
public record CancelOrderRequest(string Symbol, string OrderId);
public record ChangePositionModeRequest(FuturePositionMode Mode);
public record OrderDetailRequest(string Symbol, string OrderId);

View File

@@ -0,0 +1,59 @@
using Bitget_Server.Application.Options;
using Bitget_Server.Application.State;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Infrastructure.Bitget;
using Microsoft.Extensions.Options;
namespace Bitget_Server.Application.BackgroundServices;
/// <summary>
/// 기존 MainForm.getMarketInfoList_WebService (300ms loop) 이식.
/// USDT-FUTURES 마켓 Ticker 정보를 주기적으로 새로고침하여 MarketState.marketInfoList 를 갱신한다.
/// </summary>
public class MarketInfoBackgroundService : BackgroundService
{
private readonly BitgetClient _client;
private readonly BitgetOptions _options;
private readonly MarketState _state;
private readonly ILogger<MarketInfoBackgroundService> _logger;
public MarketInfoBackgroundService(
BitgetClient client,
IOptions<BitgetOptions> options,
MarketState state,
ILogger<MarketInfoBackgroundService> logger)
{
_client = client;
_options = options.Value;
_state = state;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("MarketInfoBackgroundService 시작 (Interval={Ms}ms)", _options.MarketInfoUpdateIntervalMs);
var delay = TimeSpan.FromMilliseconds(Math.Max(100, _options.MarketInfoUpdateIntervalMs));
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
await _client.SendWebServerAsync(
ExchangeWebReqType.BitgetMarket_Future,
ct: stoppingToken).ConfigureAwait(false);
// 결과는 BitgetClient.Parse 가 _state.marketInfoList 에 반영함
}
catch (OperationCanceledException) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "MarketInfo 업데이트 오류");
}
}
_logger.LogInformation("MarketInfoBackgroundService 종료");
}
}

View File

@@ -0,0 +1,50 @@
using Bitget_Server.Application.Services;
namespace Bitget_Server.Application.BackgroundServices;
/// <summary>
/// 기존 MainForm.Signalnfo → IndicatorProcess.calProcess 무한 루프 이식.
/// SignalProcessService.ProcessOnceAsync 를 짧은 주기로 반복 호출한다.
/// </summary>
public class SignalProcessBackgroundService : BackgroundService
{
private readonly SignalProcessService _signalProcess;
private readonly ILogger<SignalProcessBackgroundService> _logger;
/// <summary>신호 검색 루프 1회 처리 후 대기 시간 (기존 while 루프는 Thread.Sleep 없이 반복되었으나 CPU 과점유 방지를 위해 200ms 부여).</summary>
private static readonly TimeSpan LoopInterval = TimeSpan.FromMilliseconds(200);
public SignalProcessBackgroundService(
SignalProcessService signalProcess,
ILogger<SignalProcessBackgroundService> logger)
{
_signalProcess = signalProcess;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SignalProcessBackgroundService 시작 (Interval={Ms}ms)", LoopInterval.TotalMilliseconds);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _signalProcess.ProcessOnceAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "SignalProcess 1회 처리 오류");
}
try
{
await Task.Delay(LoopInterval, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { /* shutdown */ }
}
_logger.LogInformation("SignalProcessBackgroundService 종료");
}
}

View File

@@ -0,0 +1,62 @@
using Bitget_Server.Application.Options;
using Bitget_Server.Application.State;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Infrastructure.Bitget;
using Microsoft.Extensions.Options;
namespace Bitget_Server.Application.BackgroundServices;
/// <summary>
/// 기존 MainForm.DataCalculateThread (1000ms loop) 이식.
/// BitgetWebSocketClient 가 수집한 메시지를 주기적으로 꺼내어 candleDataLists 로 병합한다.
/// </summary>
public class WebSocketCalcBackgroundService : BackgroundService
{
private readonly BitgetWebSocketClient _ws;
private readonly MarketState _state;
private readonly BitgetOptions _options;
private readonly ILogger<WebSocketCalcBackgroundService> _logger;
public WebSocketCalcBackgroundService(
BitgetWebSocketClient ws,
MarketState state,
IOptions<BitgetOptions> options,
ILogger<WebSocketCalcBackgroundService> logger)
{
_ws = ws;
_state = state;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("WebSocketCalcBackgroundService 시작 (Interval={Ms}ms)", _options.WebSocketCalcIntervalMs);
var delay = TimeSpan.FromMilliseconds(Math.Max(200, _options.WebSocketCalcIntervalMs));
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
// WebSocket 이 꺼져 있으면 처리할 데이터도 없으므로 skip
if (!_ws.IsConnected) continue;
List<object> intervalsSnapshot;
lock (_state.SyncRoot)
intervalsSnapshot = _state.IntervalList.ToList();
_ws.CalculateDataList(BitgetWebSocketType.캔들정보, intervalsSnapshot);
}
catch (OperationCanceledException) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "WebSocket 집계 루프 오류");
}
}
_logger.LogInformation("WebSocketCalcBackgroundService 종료");
}
}

View File

@@ -0,0 +1,91 @@
using Bitget_Server.Domain.Indicators;
using Bitget_Server.Domain.Models;
namespace Bitget_Server.Application.Indicators;
[Serializable]
public class IchimokuConditionSetup
{
/// <summary>기준선 기간</summary>
public int BaselinePeriod { get; set; } = 26;
/// <summary>전환선 기간</summary>
public int ConversionPeriod { get; set; } = 9;
/// <summary>선행스팬2 기간</summary>
public int LaggingSpan2Period { get; set; } = 52;
/// <summary>Shift</summary>
public int Displacement { get; set; } = 26;
// 캔들당 일목균형표 계산하기
public void calIchimokuData(List<CandleData> candleDatas, CandleData candleData)
{
// 현재 종가 인덱스
var endIndex = candleDatas.Count() - 1;
if (candleData == null) return;
if (candleData.IchimokData == null) candleData.IchimokData = new IchimokData();
var endValue = candleData.Close;
// 전봉까지 만듬
var datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - ConversionPeriod &&
candleDatas.IndexOf(i) <= endIndex);
if (datas == null || datas.Count == 0) return;
candleData.IchimokData.전환선 = (datas.Min(i => i.Low) + datas.Max(i => i.High)) / 2;
datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - BaselinePeriod &&
candleDatas.IndexOf(i) <= endIndex);
candleData.IchimokData.기준선 = (datas.Min(i => i.Low) + datas.Max(i => i.High)) / 2;
// 후행스팬
if (endIndex - Displacement >= 0)
{
if (candleDatas[candleDatas.Count - Displacement].IchimokData == null)
{
candleDatas[candleDatas.Count - Displacement].IchimokData = new IchimokData();
}
candleDatas[candleDatas.Count - Displacement].IchimokData.후행스팬 = endValue;
}
// Displacement 일전봉 데이터
endIndex = candleDatas.Count - 1;
endIndex = endIndex - Displacement + 1;
datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - ConversionPeriod &&
candleDatas.IndexOf(i) <= endIndex);
if (datas == null || datas.Count == 0) return;
var displacement_전환선 = (datas.Min(i => i.Low) + datas.Max(i => i.High)) / 2;
datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - BaselinePeriod &&
candleDatas.IndexOf(i) <= endIndex);
if (datas == null || datas.Count == 0) return;
var displacement_기준선 = (datas.Min(i => i.Low) + datas.Max(i => i.High)) / 2;
candleData.IchimokData.선행스팬1 = (displacement_전환선 + displacement_기준선) / 2;
datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - LaggingSpan2Period &&
candleDatas.IndexOf(i) <= endIndex);
candleData.IchimokData.선행스팬2 = (datas.Min(i => i.Low) + datas.Max(i => i.High)) / 2;
candleData.IchimokData.상한선 = Math.Max(candleData.IchimokData.선행스팬1, candleData.IchimokData.선행스팬2);
candleData.IchimokData.하한선 = Math.Min(candleData.IchimokData.선행스팬1, candleData.IchimokData.선행스팬2);
candleData.IchimokData.IsLongUp = candleData.IchimokData.상한선 < candleData.Close;
candleData.IchimokData.IsShortDown = candleData.IchimokData.하한선 > candleData.Close;
candleData.IchimokData.IsLongDown = candleData.IchimokData.상한선 > candleData.Close;
candleData.IchimokData.IsShortUp = candleData.IchimokData.하한선 < candleData.Close;
}
}

View File

@@ -0,0 +1,116 @@
using Bitget_Server.Domain.Indicators;
namespace Bitget_Server.Application.Indicators;
[Serializable]
public class ImpulseMacdConditionSetup
{
private int lengthMA;
private int lengthSignal;
private List<double> srcList = new();
private List<double> highList = new();
private List<double> lowList = new();
private List<double> smmaHighList = new();
private List<double> smmaLowList = new();
private List<double> zlemaList = new();
private List<double> mdList = new();
private List<double> sbList = new();
private List<double> shList = new();
public ImpulseMacdConditionSetup(int lengthMA = 34, int lengthSignal = 12)
{
this.lengthMA = lengthMA;
this.lengthSignal = lengthSignal;
}
public ImpulseMacdData AddDataPoint(double high, double low, double close, bool flag)
{
double src = (high + low + close) / 3.0;
if (flag)
{
srcList.Add(src);
highList.Add(high);
lowList.Add(low);
}
else
{
srcList[srcList.Count - 1] = src;
highList[highList.Count - 1] = high;
lowList[lowList.Count - 1] = low;
}
double smmaHigh = CalculateSMMA(highList, smmaHighList, lengthMA);
double smmaLow = CalculateSMMA(lowList, smmaLowList, lengthMA);
double zlema = CalculateZLEMA(srcList, zlemaList, lengthMA);
double md = 0.0;
if (zlema > smmaHigh) md = zlema - smmaHigh;
else if (zlema < smmaLow) md = zlema - smmaLow;
if (flag) mdList.Add(md);
else mdList[mdList.Count - 1] = md;
double sb = mdList.Skip(mdList.Count - lengthSignal).Average();
double sh = md - sb;
if (flag)
{
sbList.Add(sb);
shList.Add(sh);
}
else
{
sbList[sbList.Count - 1] = sb;
shList[shList.Count - 1] = sh;
}
if (mdList.Count < lengthSignal) return null;
if (mdList.Count > 100) mdList.RemoveRange(0, 50);
if (sbList.Count > 100) sbList.RemoveRange(0, 50);
if (shList.Count > 100) shList.RemoveRange(0, 50);
return new ImpulseMacdData { MD = md, SH = sh, SB = sb };
}
private double CalculateSMMA(List<double> src, List<double> smmaList, int len)
{
if (src.Count < len) return src.Last();
double smma;
if (smmaList.Count == 0)
smma = src.Skip(src.Count - len).Average();
else
smma = (smmaList.Last() * (len - 1) + src.Last()) / len;
smmaList.Add(smma);
return smma;
}
private double CalculateZLEMA(List<double> src, List<double> zlemaList, int length)
{
if (src.Count < length * 2) return src.Last();
var ema1 = CalculateEMA(src, length);
var ema2 = CalculateEMA(ema1, length);
double d = ema1.Last() - ema2.Last();
double zlema = ema1.Last() + d;
zlemaList.Add(zlema);
return zlema;
}
private List<double> CalculateEMA(List<double> src, int length)
{
List<double> ema = new List<double>();
double k = 2.0 / (length + 1);
ema.Add(src.Take(length).Average());
for (int i = length; i < src.Count; i++)
{
double value = src[i] * k + ema.Last() * (1 - k);
ema.Add(value);
}
return ema;
}
}

View File

@@ -0,0 +1,105 @@
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Indicators;
using Bitget_Server.Domain.Models;
namespace Bitget_Server.Application.Indicators;
[Serializable]
public class MovingAverageConditionSetup
{
public MovingAverageType MovingAverageType { get; set; }
// 기간 파라미터 (기존 WinForms와 동일)
public int CandleLength24 { get; set; } = 24;
public int CandleLength48 { get; set; } = 48;
public int CandleLength4 { get; set; } = 4;
public int CandleLength5 { get; set; } = 5;
public int CandleLength2 { get; set; } = 2;
public int CandleLength3 { get; set; } = 3;
public int CandleLength36 { get; set; } = 36;
public int CandleLength72 { get; set; } = 72;
public int CandleLength12 { get; set; } = 12;
public int CandleLength8 { get; set; } = 8;
public int CandleLength144 { get; set; } = 144;
public int CandleLength40 { get; set; } = 40;
public int CandleLength20 { get; set; } = 20;
public int CandleLength120 { get; set; } = 120;
public int CandleLength240 { get; set; } = 240;
public int CandleLength480 { get; set; } = 480;
public int CandleLength80 { get; set; } = 80;
public int CandleLength160 { get; set; } = 160;
public CandlePriceType MACandlePriceType { get; set; }
public void Cal_YHK_Downpublic(List<CandleData> candleDatas, CandleData candleData)
{
var endIndex = candleDatas.Count() - 1;
if (candleData == null) return;
if (candleData.movingAverageDataList == null)
{
candleData.movingAverageDataList = new Dictionary<int, MovingAverageData>
{
{ CandleLength2, new MovingAverageData() },
{ CandleLength4, new MovingAverageData() },
{ CandleLength5, new MovingAverageData() },
{ CandleLength12, new MovingAverageData() },
{ CandleLength24, new MovingAverageData() },
{ CandleLength36, new MovingAverageData() },
{ CandleLength144, new MovingAverageData() },
{ CandleLength40, new MovingAverageData() },
{ CandleLength48, new MovingAverageData() },
{ CandleLength72, new MovingAverageData() },
{ CandleLength3, new MovingAverageData() },
{ CandleLength8, new MovingAverageData() },
{ CandleLength20, new MovingAverageData() },
{ CandleLength80, new MovingAverageData() },
{ CandleLength160, new MovingAverageData() },
{ CandleLength120, new MovingAverageData() },
{ CandleLength240, new MovingAverageData() },
{ CandleLength480, new MovingAverageData() }
};
}
foreach (var item in candleData.movingAverageDataList.ToList())
{
var datas = candleDatas.FindAll(i =>
candleDatas.IndexOf(i) > endIndex - item.Key &&
candleDatas.IndexOf(i) <= endIndex);
candleData.movingAverageDataList[item.Key] = new MovingAverageData();
candleData.movingAverageDataList[item.Key].Value = datas.Sum(i => i.Close) / item.Key;
CalBolangerValue(datas, candleData.movingAverageDataList[item.Key], datas.Count - 1, item.Key);
CalBolangerValue(datas, candleData.movingAverageDataList[item.Key], datas.Count - 1, item.Key);
}
}
// 볼린저 밴드 계산
public void CalBolangerValue(List<CandleData> data, MovingAverageData mvData, int i, int period)
{
double total_average = 0;
if (i < period - 1) return;
for (int t = i - period + 1; t < i; t++)
total_average += data[t].Close;
total_average += data[i].Close;
double average = total_average / period;
double t_Close = 0;
double sum = 0;
double powSum = 0;
int j;
for (j = (i - period + 1); j < i; j++)
{
t_Close += Math.Pow(data[j].Close - average, 2);
sum += data[j].Close;
powSum += Math.Pow(data[j].Close, 2);
}
t_Close += Math.Pow(data[j].Close - average, 2);
mvData.BolangerValue_Down = average - mvData.BolangerSquared * Math.Sqrt(t_Close / period);
mvData.BolangerValue_Up = average + mvData.BolangerSquared * Math.Sqrt(t_Close / period);
mvData.Average = average;
}
}

View File

@@ -0,0 +1,209 @@
namespace Bitget_Server.Application.Indicators;
/// <summary>
/// TradingView 스타일 복합 시그널 (Impulse MACD + MACD + SMA + ADX).
/// 기존 WinForms 구현을 그대로 이식하였으며 WinForms using 만 제거.
/// </summary>
public class TradingViewConditionSetup
{
public int LengthMA = 34;
public int LengthSignal = 12;
public int AdxLength = 12;
public double AdxThreshold = 24;
public double RangeThreshold = 1;
public double RangeThresholdSell = 1;
public List<double> High;
public List<double> Low;
public List<double> Close;
private double[] Sma4;
public void SetData(List<double> high, List<double> low, List<double> close)
{
High = high;
Low = low;
Close = close;
Sma4 = ComputeSma4();
}
private double CalcSmma(List<double> src, int len)
{
double smma = src.Take(len).Average();
for (int i = len; i < src.Count; i++)
smma = (smma * (len - 1) + src[i]) / len;
return smma;
}
private double CalcZlema(List<double> src, int length)
{
double ema1 = Ema(src, length);
double ema2 = Ema(Enumerable.Repeat(ema1, src.Count).ToList(), length);
double d = ema1 - ema2;
return ema1 + d;
}
private double Ema(List<double> src, int length)
{
double alpha = 2.0 / (length + 1);
double ema = src[0];
for (int i = 1; i < src.Count; i++)
ema = alpha * src[i] + (1 - alpha) * ema;
return ema;
}
private double[] ComputeSma4()
{
double[] sma4 = new double[Close.Count];
for (int i = 3; i < Close.Count; i++)
sma4[i] = (Close[i] + Close[i - 1] + Close[i - 2] + Close[i - 3]) / 4.0;
return sma4;
}
private double ComputeAdx()
{
// 원본에서도 더미값 사용. 필요 시 실제 ADX 구현 추가 가능.
return 25.0;
}
public double ComputeMd(bool flag = true)
{
if (flag)
{
double hi = CalcSmma(High, LengthMA);
double lo = CalcSmma(Low, LengthMA);
var hlc3 = High.Zip(Low, (h, l) => (h + l + Close.Last()) / 3).ToList();
double mi = CalcZlema(hlc3, LengthMA);
if (mi > hi) return mi - hi;
if (mi < lo) return mi - lo;
return 0;
}
else
{
var highTemp = new List<double>(High);
var lowTemp = new List<double>(Low);
var closeTemp = new List<double>(Close);
highTemp.RemoveAt(highTemp.Count - 1);
lowTemp.RemoveAt(lowTemp.Count - 1);
closeTemp.RemoveAt(closeTemp.Count - 1);
double hi = CalcSmma(highTemp, LengthMA);
double lo = CalcSmma(lowTemp, LengthMA);
var hlc3 = highTemp.Zip(lowTemp, (h, l) => (h + l + closeTemp.Last()) / 3).ToList();
double mi = CalcZlema(hlc3, LengthMA);
if (mi > hi) return mi - hi;
if (mi < lo) return mi - lo;
return 0;
}
}
public (double macdLine, double signalLine) ComputeMacd()
{
double macdLine = Ema(Close, 34) - Ema(Close, 12);
double signalLine = Ema(Enumerable.Repeat(macdLine, Close.Count).ToList(), 12);
return (macdLine, signalLine);
}
public (double macdLine, double signalLine) PrevComputeMacd()
{
var temp = new List<double>(Close);
temp.RemoveAt(temp.Count - 1);
double macdLine = Ema(temp, 34) - Ema(temp, 12);
double signalLine = Ema(Enumerable.Repeat(macdLine, temp.Count).ToList(), 12);
return (macdLine, signalLine);
}
public bool IsRangeBound()
{
double highestHigh = High.Skip(High.Count - 12).Max();
double lowestLow = Low.Skip(Low.Count - 12).Min();
double range = highestHigh - lowestLow;
return range < (highestHigh * RangeThreshold / 100.0);
}
public bool IsRangeBoundSell()
{
double highestHigh = High.Skip(High.Count - 12).Max();
double lowestLow = Low.Skip(Low.Count - 12).Min();
double range = highestHigh - lowestLow;
return range < (highestHigh * RangeThresholdSell / 100.0);
}
public bool IsTrending() => ComputeAdx() > AdxThreshold;
public double GetAvgData(int range)
{
var sma = 0.0;
for (var i = 0; i < range; i++)
sma += Close[Close.Count - (i + 1)];
return sma / range;
}
public double GetAvgData(int range, int prev)
{
var sma = 0.0;
for (var i = 0; i < range; i++)
sma += Close[Close.Count - (i + 1 + prev)];
return sma / range;
}
public bool IsLongSignal()
{
double md = ComputeMd();
double prevMd = ComputeMd(false);
var (macd, signal) = ComputeMacd();
var (prevMacd, prevSignal) = PrevComputeMacd();
return ((prevMd == 0 && md > 0) || (prevMacd < prevSignal && macd > prevMacd && macd > signal && md > 0)) &&
IsTrending() && IsSmaMiddle(4, 12, true, GetAvgData(72)) && GetAvgData(4) > GetAvgData(4, 1);
}
public bool IsShortSignal()
{
double md = ComputeMd();
double prevMd = ComputeMd(false);
var (macd, signal) = ComputeMacd();
var (prevMacd, prevSignal) = PrevComputeMacd();
return ((prevMd == 0 && md < 0) || (prevMacd > prevSignal && macd < prevMacd && macd < signal && md < 0)) &&
IsTrending() && IsSmaMiddle(4, 12, false, GetAvgData(72)) && GetAvgData(4) < GetAvgData(4, 1);
}
public bool IsSmaHigh(int range, int count)
{
var list = new List<double>();
for (int i = Close.Count - count - 1; i < count; i++)
{
var sum = 0.0;
for (int j = 0; j < range; j++) sum += Close[i - j];
list.Add(sum / range);
}
return list.Max() == list[list.Count - 1];
}
public bool IsSmaLow(int range, int count)
{
var list = new List<double>();
for (int i = Close.Count - count - 1; i < count; i++)
{
var sum = 0.0;
for (int j = 0; j < range; j++) sum += Close[i - j];
list.Add(sum / range);
}
return list.Min() == list[list.Count - 1];
}
public bool IsSmaMiddle(int range, int count, bool longFlag, double value)
{
var list = new List<double>();
for (int i = Close.Count - count - 1; i < count; i++)
{
var sum = 0.0;
for (int j = 0; j < range; j++) sum += Close[i - j];
list.Add(sum / range);
}
if (longFlag) return value < list.Average();
return value > list.Average();
}
}

View File

@@ -0,0 +1,38 @@
using Bitget_Server.Domain.Models;
namespace Bitget_Server.Application.Options;
/// <summary>
/// appsettings.json의 Bitget 섹션 바인딩용.
/// </summary>
public class BitgetOptions
{
public const string SectionName = "Bitget";
/// <summary>기본 API 키 ID (ApiKeys 목록 중 하나).</summary>
public int DefaultApiId { get; set; } = 0;
/// <summary>초당 허용 API 호출 횟수 (Bitget Rate Limit).</summary>
public int RateLimitPerSecond { get; set; } = 20;
/// <summary>JSON 설정 파일 저장 경로. 컨테이너에서는 /data 로 볼륨 마운트.</summary>
public string DataDirectory { get; set; } = "./data";
/// <summary>로그 디렉토리. 컨테이너에서는 /logs 로 볼륨 마운트.</summary>
public string LogDirectory { get; set; } = "./logs";
/// <summary>Debug 레벨 로그 활성화 여부.</summary>
public bool LogDebugWrite { get; set; } = false;
/// <summary>자동 업데이트 주기(ms). BackgroundService에서 사용.</summary>
public int MarketInfoUpdateIntervalMs { get; set; } = 300;
/// <summary>WebSocket 처리 주기(ms).</summary>
public int WebSocketCalcIntervalMs { get; set; } = 1000;
/// <summary>Ping 주기(ms). Bitget는 30초 이내 응답 필요.</summary>
public int WebSocketPingIntervalMs { get; set; } = 25000;
/// <summary>사용자별 Bitget API 키 목록 (기존 하드코딩 3개에 해당).</summary>
public List<ApiKeyInfo_Bitget> ApiKeys { get; set; } = new();
}

View File

@@ -0,0 +1,28 @@
using Bitget_Server.Domain.Models;
using Bitget_Server.Infrastructure.Utils;
namespace Bitget_Server.Application.Options;
/// <summary>
/// 런타임 변경 가능한 전역 옵션. JSON 파일에 영속화됨.
/// (레버리지/주문수량/선택된 API 등 사용자가 런타임에 변경하는 값)
/// </summary>
public class GlobalOptions : BaseSettings<GlobalOptions>
{
public bool LogDebugWrite { get; set; } = true;
/// <summary>
/// 현재 선택된 API 키 정보 (appsettings.json의 ApiKeys 중 하나의 스냅샷).
/// </summary>
public ApiKeyInfo_Bitget SelectAPI { get; set; } = null;
public double Leverage { get; set; } = 10;
public double OrderAmount { get; set; } = 50;
/// <summary>기본 캔들 간격(분).</summary>
public int CandleIntervalMinutes { get; set; } = 5;
/// <summary>선택된 코인 종목 (Bitget symbol, 예: BTCUSDT).</summary>
public string SelectedCoin { get; set; } = string.Empty;
}

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); }
}
}
}
}

View File

@@ -0,0 +1,26 @@
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
namespace Bitget_Server.Application.State;
/// <summary>
/// 기존 WinForms CommonProperty에서 UI 참조를 제외한 순수 런타임 상태.
/// DI 싱글톤으로 등록되어 전 서비스에서 공유.
/// </summary>
public class MarketState
{
public ExchangePlace selectedChangePlace { get; set; } = ExchangePlace.Bitget;
public PaymentType selectedPaymentType { get; set; } = PaymentType.USDT;
public BitgetProductType selectedBitgetProductType { get; set; } = BitgetProductType.FUTURE;
public List<MarketInfo> marketInfoList { get; set; } = new();
public List<MarketCandleDataList> candleDataLists { get; set; } = new();
public List<WebSocketTickerInfo> webSocketCandles { get; set; } = new();
/// <summary>현재 활성화된 신호 검색에 사용될 마켓/인터벌 목록 (기존 MainForm의 체크박스 값).</summary>
public List<object> CheckedMarkets { get; set; } = new();
public List<object> IntervalList { get; set; } = new();
/// <summary>락 오브젝트 (캔들 리스트 동시 접근 보호).</summary>
public readonly object SyncRoot = new();
}

View File

@@ -2,21 +2,21 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>Bitget_Server</RootNamespace>
<AssemblyName>Bitget_Server</AssemblyName>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Map" Version="2.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>Container (Dockerfile)</ActiveDebugProfile>
</PropertyGroup>
</Project>

View File

@@ -1,19 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Bitget_Server.Common
{
public static class CommonProperty
{
public static string DbIp { get; set; } = "localhost";
public static string DbName { get; set; } = "Bitget";
public static string DbId { get; set; } = "million";
public static string DbPw { get; set; } = "million!";
public static string EfCoreConnectionStr { get; set; } = $"Server={DbIp};database={DbName};uid={DbId};pwd={DbPw};TrustServerCertificate=true;";
}
}

View File

@@ -1,44 +0,0 @@
using Bitget_Server.EFCore.Models;
using Bitget_Server.EFCore;
using Microsoft.AspNetCore.Mvc;
namespace Bitget_Server.Controller
{
[ApiController, Route("api")]
public class PushAlarmController : ControllerBase
{
private readonly BitgetContext context;
public PushAlarmController(BitgetContext _context)
{
context = _context;
}
[HttpGet, Route("getalarm")]
public List<PushAlarm> GetAlarm(int index)
{
return context.PushAlarms.Where(x => x.Id > index).ToList();
}
[HttpGet, Route("setalarm")]
public bool SetAlarm(string title,string contents)
{
try
{
context.PushAlarms.Add(new PushAlarm {Title = title,Contents = contents,AlarmTime = DateTime.Now});
context.SaveChanges();
return true;
}
catch (Exception e)
{
return false;
}
}
}
}

65
Bitget_Server/Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
# =============================================================================
# Bitget_Server Dockerfile (Ubuntu 24.04 / noble base, .NET 8.0)
#
# Build context: BitGet_API/ (솔루션 루트)
# 예) docker build -f Bitget_Server/Dockerfile -t bitget-server:latest .
# 또는 docker-compose up --build
# =============================================================================
# ---------------------------------------------------------------------------
# 1) Build stage — SDK 이미지에서 restore/publish 수행
# ---------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:8.0-noble AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# restore 캐시 최적화: csproj 만 먼저 복사 → restore → 그 다음 전체 복사
COPY ["Bitget_Server/Bitget_Server.csproj", "Bitget_Server/"]
RUN dotnet restore "Bitget_Server/Bitget_Server.csproj"
# 전체 소스 복사
COPY . .
WORKDIR "/src/Bitget_Server"
RUN dotnet publish "Bitget_Server.csproj" \
-c $BUILD_CONFIGURATION \
-o /app/publish \
/p:UseAppHost=false
# ---------------------------------------------------------------------------
# 2) Runtime stage — ASP.NET Core 8.0 런타임 (Ubuntu 24.04 noble)
# ---------------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:8.0-noble AS final
# Healthcheck 를 위해 curl 설치 (비-root 전환 전에 수행)
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& rm -rf /var/lib/apt/lists/*
# 타임존 (기본 Asia/Seoul, compose 에서 TZ 환경변수로 덮어쓰기 가능)
ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 영속화 디렉토리 준비 (compose 에서 볼륨 마운트 대상)
RUN mkdir -p /app/data /app/logs && chown -R $APP_UID:$APP_UID /app
WORKDIR /app
COPY --from=build /app/publish .
RUN chown -R $APP_UID:$APP_UID /app
# 비-root 사용자로 전환 (aspnet:8.0-noble 는 UID 1654 의 app 사용자 사전 정의)
USER $APP_UID
# Kestrel 포트 강제 (HTTP 전용 5000)
ENV ASPNETCORE_HTTP_PORTS=5000
ENV ASPNETCORE_URLS=http://+:5000
ENV DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 5000
# 컨테이너 내부 헬스체크 (compose 의 healthcheck 와 이중 안전망)
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:5000/api/health || exit 1
ENTRYPOINT ["dotnet", "Bitget_Server.dll"]

View File

@@ -0,0 +1,46 @@
namespace Bitget_Server.Domain;
/// <summary>
/// Bitget REST API 엔드포인트 URL 상수.
/// </summary>
public static class BitgetEndpoints
{
public const string BaseUrl = "https://api.bitget.com";
public const string MarketInfo_Coin = "https://api.bitget.com/api/v2/spot/public/coins";
public const string MarketInfo_Future = "https://api.bitget.com/api/v2/mix/market/tickers?productType=USDT-FUTURES";
public const string MarketCandle_Coin = "https://api.bitget.com/api/v2/spot/market/candles?symbol={0}&granularity={1}in&startTime={2}&endTime={3}&limit={4}";
public const string MarketCandle_Future = "https://api.bitget.com/api/v2/mix/market/candles?symbol={0}&granularity={1}&limit={2}&productType={3}";
public const string AccountList_Future = "https://api.bitget.com/api/v2/mix/account/accounts?productType=USDT-FUTURES";
public const string BuySellOrder_Future = "https://api.bitget.com/api/v2/mix/order/place-order";
public const string CancelOrder_Future = "https://api.bitget.com/api/v2/mix/order/cancel-order";
public const string PendingOrders_Future = "https://api.bitget.com/api/v2/mix/order/orders-pending?productType=usdt-futures";
public const string ChangeLeverage_Future = "https://api.bitget.com/api/v2/mix/account/set-leverage";
public const string ChangeMarginMode_Future = "https://api.bitget.com/api/v2/mix/account/set-margin-mode";
public const string ChangePositionMode_Future = "https://api.bitget.com/api/v2/mix/account/set-position-mode";
public const string OrderDetail_Future = "https://api.bitget.com/api/v2/mix/order/detail";
public const string GetTimestamp = "https://api.bitget.com/api/v2/public/time";
public const string WebSocketPublic = "wss://ws.bitget.com/v2/ws/public";
}
#region 업비트 (미사용 - 레거시 상수)
public static class UpbitEndpoints
{
public const string BaseUrl = "https://api.upbit.com/";
public const string MarketInfo = "https://api.upbit.com/v1/market/all";
public const string MCandleStick = "https://api.upbit.com/v1/candles/minutes/";
public const string DCandleStick = "https://api.upbit.com/v1/candles/days";
public const string WCandleStick = "https://api.upbit.com/v1/candles/weeks";
public const string MTCandleStick = "https://api.upbit.com/v1/candles/months";
public const string Accounts = "https://api.upbit.com/v1/accounts";
public const string Ticker = "https://api.upbit.com/v1/ticker";
public const string Ticks = "https://api.upbit.com/v1/trades/ticks";
public const string Orders = "https://api.upbit.com/v1/orders";
public const string Order = "https://api.upbit.com/v1/order";
public const string Orderbook = "https://api.upbit.com/v1/orderbook";
public const string OrderChance = "https://api.upbit.com/v1/orders/chance";
}
#endregion

View File

@@ -0,0 +1,175 @@
using System.ComponentModel;
namespace Bitget_Server.Domain.Enums;
public enum WebConnectionType
{
POST = 0,
GET = 1
}
public enum ExchangePlace
{
Upbit = 0,
Bithumb = 1,
Bitget = 2
}
// 코인 Payment 타입
public enum PaymentType
{
KRW = 0,
BTC = 1,
USDT = 2
}
// 최신, 가장오래된, None
public enum CandleType
{
None = 0,
Old = 1,
New = 2
}
// Bitget FUTURE 코인 타입
public enum BitgetProductType
{
COIN = 0,
FUTURE = 1,
MARGIN_보류 = 2
}
// 거래소 Web 요청 타입
public enum ExchangeWebReqType
{
[Description("/Public/CandleStick")]
BithumbCandleStick = 0,
[Description("/public/ticker")]
BithumbCandleTicker = 1,
[Description("/public/Orderbook")]
BithumbMarket = 2,
[Description("UpbitCandle")]
UpbitCandleStick = 10,
[Description("UpbitMarket")]
UpbitMarket = 11,
[Description("BitgetMarketInfo_SPOT")]
BitgetMarket_Spot = 20,
[Description("BitgetMarketInfo_FUTURE")]
BitgetMarket_Future = 21,
[Description("BitgetMCandle_COIN")]
BitgetCandleStick_Coin = 22,
[Description("BitgetMCandle_FUTURE")]
BitgetCandleStick_Future = 23,
[Description("BitgetAccountList_FUTURE")]
BitgetAccountList_Future = 30,
[Description("BitgetOrder_FUTURE")]
BitgetOrder_Future = 31,
[Description("BitgetOrderCancel_FUTURE")]
BitgetCancelOrder_Future = 32,
[Description("BitgetPendingOrder_FUTURE")]
BitgetPendingOrder_Future = 33,
[Description("BitgetChangeLeverage_FUTURE")]
BitgetChangeLeverage_Future = 34,
[Description("BitgetChangeMarginMode_FUTURE")]
BitgetChangeMarginMode_Future = 35,
[Description("BitgetChangePositionMode_FUTURE")]
BitgetChangePositionMode_Future = 36,
[Description("BitgetOrderDetail_FUTURE")]
BitgetOrderDetail_Future = 37,
[Description("BitgetGetTimeStamp")]
BitgetGetTimeStamp = 38
}
// Bitget WebSocketType
public enum BitgetWebSocketType
{
현황정보_전체종목 = 0,
캔들정보 = 1,
보유List = 2,
주문하기 = 3
}
public enum Side
{
[Description("buy")]
buy = 0,
[Description("sell")]
sell = 1
}
public enum FuturePositionMode
{
[Description("one_way_mode")]
one_way_mode = 0,
[Description("hedge_mode")]
hedge_mode = 1
}
public enum OrderType
{
[Description("limit")]
limit = 0,
[Description("market")]
market = 1
}
public enum FutureMarginMode
{
[Description("isolated")]
isolated = 0,
[Description("crossed")]
crossed = 1
}
public enum FutureSide
{
BuyShort = 0,
BuyLong = 1,
SellShort = 2,
SellLong = 3
}
// 시가/고가/종가/저가
public enum CandlePriceType
{
시가 = 1,
고가 = 2,
저가 = 3,
종가 = 4
}
// 캔들 데이터 간격
public enum MCandleTimeType
{
[Description("3m")]
M3 = 3,
[Description("5m")]
M5 = 5,
[Description("1m")]
M1 = 1,
[Description("15m")]
M15 = 15,
[Description("30m")]
M30 = 30,
[Description("1H")]
H1 = 60,
[Description("2H")]
H2 = 120,
[Description("3H")]
H3 = 180,
[Description("4H")]
H4 = 240,
[Description("6H")]
H6 = 360,
[Description("12H")]
H12 = 720
}
// 이동평균선 종류
public enum MovingAverageType
{
캔들 = 1,
거래량 = 2
}

View File

@@ -0,0 +1,21 @@
namespace Bitget_Server.Domain.Indicators;
[Serializable]
public class IchimokData
{
public double 기준선 { get; set; } = 0;
public double 전환선 { get; set; } = 0;
public double 후행스팬 { get; set; } = 0;
public double 선행스팬1 { get; set; } = 0;
public double 선행스팬2 { get; set; } = 0;
public double 상한선 { get; set; }
public double 하한선 { get; set; }
public bool IsLongUp { get; set; } = false;
public bool IsLongDown { get; set; } = false;
public bool IsShortUp { get; set; } = false;
public bool IsShortDown { get; set; } = false;
public bool IsUsedData { get; set; } = true;
}

View File

@@ -0,0 +1,8 @@
namespace Bitget_Server.Domain.Indicators;
public class ImpulseMacdData
{
public double MD;
public double SH;
public double SB;
}

View File

@@ -0,0 +1,14 @@
namespace Bitget_Server.Domain.Indicators;
[Serializable]
public class MovingAverageData
{
// MV Value
public double Value { get; set; }
// Bol Value
public double BolangerValue_Down { get; set; }
public double Average { get; set; }
public double BolangerValue_Up { get; set; }
// Bol 편차
public double BolangerSquared { get; set; } = 2.5;
}

View File

@@ -0,0 +1,20 @@
namespace Bitget_Server.Domain.Models;
/// <summary>
/// 공통 API 키 정보.
/// </summary>
public class ApiKeyInfo
{
public string AccessKey { get; set; }
public string SecretKey { get; set; }
public string Name { get; set; }
public int Id { get; set; }
}
/// <summary>
/// Bitget 전용 API 키 정보 (Passphrase 포함).
/// </summary>
public class ApiKeyInfo_Bitget : ApiKeyInfo
{
public string PassphraseKey { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,42 @@
using Bitget_Server.Domain.Indicators;
namespace Bitget_Server.Domain.Models;
public class CandleData
{
public DateTime Argument { get; set; }
public double Low { get; set; }
public double High { get; set; }
public double Open { get; set; }
public double Close { get; set; }
// 기본 통화 거래량 (BTC)
public double Vol_C { get; set; }
// 견적 통화 거래량 USD
public double Vol_USD { get; set; }
// USDT 거래량
public double Vol_USDT { get; set; }
// 웹소켓 업데이트 기록 여부
public bool WebSocketUpdate { get; set; } = false;
// 웹 서비스 업데이트 기록 여부
public bool WebServiceUpdate { get; set; } = false;
public CandleData() { }
public CandleData(DateTime argument, double low, double high, double open, double close,
double vol_C, double vol_USD, double vol_USDT)
{
this.Argument = argument;
this.Low = low;
this.High = high;
this.Open = open;
this.Close = close;
this.Vol_C = vol_C;
this.Vol_USD = vol_USD;
this.Vol_USDT = vol_USDT;
}
public IchimokData IchimokData { get; set; }
public Dictionary<int, MovingAverageData> movingAverageDataList { get; set; }
public Dictionary<int, ImpulseMacdData> impulseMacdDataList { get; set; }
}

View File

@@ -0,0 +1,16 @@
using Bitget_Server.Domain.Enums;
namespace Bitget_Server.Domain.Models;
[Serializable]
public class MarketCandleDataList
{
// 코인 종목
public MarketInfo ConGoodsData { get; set; } = new MarketInfo();
// 캔들 데이터 (시간 타입별)
public Dictionary<MCandleTimeType, List<CandleData>> candleDataList { get; set; }
= new Dictionary<MCandleTimeType, List<CandleData>>();
public string Uuid { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace Bitget_Server.Domain.Models;
public class MarketInfo
{
public string ExchangePlace { get; set; }
public string MarketName { get; set; }
public double lastPr { get; set; }
public double change24h { get; set; }
public double baseVolume { get; set; }
public double quoteVolume { get; set; }
public double high24h { get; set; }
public double low24h { get; set; }
public double open24h { get; set; }
}

View File

@@ -0,0 +1,21 @@
using Newtonsoft.Json;
namespace Bitget_Server.Domain.Models;
public class ResponseCandleStick
{
public string action { get; set; }
public ResponseCandleStickArg arg { get; set; }
public List<string> data { get; set; }
public double ts { get; set; }
[JsonIgnore]
public DateTime reciveTime { get; set; }
}
public class ResponseCandleStickArg
{
public string instType { get; set; }
public string channel { get; set; }
public string instId { get; set; }
}

View File

@@ -0,0 +1,23 @@
using Bitget_Server.Domain.Enums;
namespace Bitget_Server.Domain.Models;
public class SignalInfoData
{
// 신호검색 시작 시간
public DateTime SignalStart { get; set; }
// 종목 이름
public string MarketName { get; set; }
// 종목 설명
public string Description { get; set; }
// 현재가
public double Price { get; set; }
// 설정된 시간 봉의 수익율
public double Rate { get; set; }
// 구름떼 상한선 가격
public double IndicatorPrice { get; set; }
// 설정된 지표(Indicator)와의 격차 비율
public double IndicatorRate { get; set; }
public MCandleTimeType MCandleTimeType { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace Bitget_Server.Domain.Models;
public class TradeData
{
public string type { get; set; }
public string code { get; set; }
public long timestamp { get; set; }
public string trade_date { get; set; }
public string trade_time { get; set; }
public long trade_timestamp { get; set; }
public double trade_price { get; set; }
public double trade_volume { get; set; }
public string ask_bid { get; set; }
public double prev_closing_price { get; set; }
public string change { get; set; }
public double change_price { get; set; }
public long sequential_id { get; set; }
public string stream_type { get; set; }
}

View File

@@ -0,0 +1,29 @@
namespace Bitget_Server.Domain.Models;
public class WebSocketTickerInfo
{
// 종목
public string instId { get; set; }
// 최종가격
public double lastPr { get; set; }
// 24시간 시가
public double open24h { get; set; }
// 24시간 고가
public double high24h { get; set; }
// 24시간 저가
public double low24h { get; set; }
// 수익률
public double change24h { get; set; }
public double bidPr { get; set; }
public double askPr { get; set; }
public double bidSz { get; set; }
public double askSz { get; set; }
// 코인 거래량
public double baseVolume { get; set; }
// 견적통화 거래량
public double quoteVolume { get; set; }
public double openUtc { get; set; }
public double changeUtc24h { get; set; }
public long ts { get; set; }
public DateTime reciveTime { get; set; }
}

View File

@@ -1,33 +0,0 @@
using Bitget_Server.Common;
using Bitget_Server.EFCore.Models;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Bitget_Server.EFCore
{
public class BitgetContext : DbContext
{
public DbSet<PushAlarm> PushAlarms { get; set; }
public BitgetContext(DbContextOptions<BitgetContext> options)
: base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(CommonProperty.EfCoreConnectionStr);
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
}

View File

@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Bitget_Server.EFCore.Models
{
public class PushAlarm
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public DateTime AlarmTime { get; set; }
public string Title { get; set; } = string.Empty;
public string Contents { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,277 @@
using System.Collections.Specialized;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Bitget_Server.Application.State;
using Bitget_Server.Domain;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
using Bitget_Server.Infrastructure.Utils;
using Newtonsoft.Json;
namespace Bitget_Server.Infrastructure.Bitget;
/// <summary>
/// 기존 WinForms WebConnection.SendWebServer / GenerateSignString 로직을 이식.
/// HttpClient 기반으로 재작성하고, 모든 외부 호출 전에 BitgetRateLimiter를 거친다.
/// </summary>
public class BitgetClient
{
private readonly HttpClient _http;
private readonly BitgetRateLimiter _limiter;
private readonly MarketState _state;
private readonly ILogger<BitgetClient> _logger;
public BitgetClient(
HttpClient http,
BitgetRateLimiter limiter,
MarketState state,
ILogger<BitgetClient> logger)
{
_http = http;
_limiter = limiter;
_state = state;
_logger = logger;
}
/// <summary>
/// 기존 WebConnection.SendWebServer와 동일한 시그니처를 유지한다.
/// 응답을 ExchangeWebReqType에 따라 적절히 역직렬화하여 반환한다.
/// </summary>
public async Task<object> SendWebServerAsync(
ExchangeWebReqType exchangeWebReqType,
string url = "",
MarketCandleDataList marketCandleDataList = null,
MCandleTimeType interval = MCandleTimeType.M1,
string postData = "",
string subUri = "",
NameValueCollection headers = null,
CancellationToken ct = default)
{
var requestUrl = string.IsNullOrEmpty(url) ? GetDefaultUrl(exchangeWebReqType) : url;
if (string.IsNullOrEmpty(requestUrl))
{
_logger.LogWarning("URL 미정 ExchangeWebReqType: {Type}", exchangeWebReqType);
return null;
}
// 1차 호출 → 429 시 1회 재시도
for (int attempt = 0; attempt < 2; attempt++)
{
await _limiter.AcquireAsync(ct).ConfigureAwait(false);
try
{
HttpRequestMessage req;
if (!string.IsNullOrEmpty(postData))
{
req = new HttpRequestMessage(HttpMethod.Post, requestUrl)
{
Content = new StringContent(postData, Encoding.UTF8,
headers != null ? "application/json" : "application/x-www-form-urlencoded")
};
}
else
{
req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
}
if (headers != null)
{
foreach (string key in headers)
req.Headers.TryAddWithoutValidation(key, headers[key]);
if (req.Content != null)
req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
}
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
var resResult = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
// 429 (TooManyRequests) 는 1회에 한해 500ms 후 재시도
if ((int)resp.StatusCode == 429 && attempt == 0)
{
_logger.LogWarning("Bitget 429 TooManyRequests — 500ms 후 1회 재시도 ({Type})", exchangeWebReqType);
await Task.Delay(500, ct).ConfigureAwait(false);
continue;
}
if (!resp.IsSuccessStatusCode)
{
_logger.LogError("Bitget API 에러 {Status}: {Body}", (int)resp.StatusCode, resResult);
return null;
}
return Parse(exchangeWebReqType, resResult, marketCandleDataList, interval);
}
catch (HttpRequestException webEx)
{
_logger.LogError(webEx, "Bitget HTTP 호출 실패: {Type}", exchangeWebReqType);
return null;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Bitget 처리 중 예외");
return null;
}
}
return null;
}
private string GetDefaultUrl(ExchangeWebReqType type) => type switch
{
ExchangeWebReqType.UpbitMarket => UpbitEndpoints.MarketInfo,
ExchangeWebReqType.BitgetMarket_Spot => BitgetEndpoints.MarketInfo_Coin,
ExchangeWebReqType.BitgetMarket_Future => BitgetEndpoints.MarketInfo_Future,
ExchangeWebReqType.BitgetCandleStick_Coin => BitgetEndpoints.MarketCandle_Coin,
ExchangeWebReqType.BitgetCandleStick_Future => BitgetEndpoints.MarketCandle_Future,
_ => string.Empty
};
private object Parse(ExchangeWebReqType type, string resResult, MarketCandleDataList marketCandleDataList, MCandleTimeType interval)
{
switch (type)
{
case ExchangeWebReqType.BitgetMarket_Spot:
{
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
var marketInfo = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
return marketInfo.FindAll(x => x["coin"].ToString().Contains("USDT"));
}
case ExchangeWebReqType.BitgetMarket_Future:
{
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
var marketInfos = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
var newList = new List<MarketInfo>();
foreach (var item in marketInfos)
{
var marketInfo = new MarketInfo
{
MarketName = item["symbol"].ToString(),
ExchangePlace = Enum.GetName(typeof(ExchangePlace), _state.selectedChangePlace),
lastPr = Convert.ToDouble(item["lastPr"]),
change24h = Convert.ToDouble(item["change24h"]),
baseVolume = Convert.ToDouble(item["baseVolume"]),
quoteVolume = Convert.ToDouble(item["quoteVolume"]),
high24h = Convert.ToDouble(item["high24h"]),
low24h = Convert.ToDouble(item["low24h"]),
open24h = Convert.ToDouble(item["open24h"])
};
newList.Add(marketInfo);
}
lock (_state.SyncRoot) { _state.marketInfoList = newList; }
return newList;
}
case ExchangeWebReqType.BitgetCandleStick_Future:
{
if (marketCandleDataList == null) return null;
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
var datas = JsonConvert.DeserializeObject<List<List<object>>>(datalist["data"]?.ToString());
var candleDataList = marketCandleDataList.candleDataList[interval];
lock (_state.SyncRoot)
{
foreach (var item in datas)
{
var candleData = new CandleData
{
Argument = Convertor.GetUnixtimeStampToDateTime(long.Parse(item[0].ToString())),
Open = Convert.ToDouble(item[1]),
High = Convert.ToDouble(item[2]),
Low = Convert.ToDouble(item[3]),
Close = Convert.ToDouble(item[4]),
Vol_C = Convert.ToDouble(item[5]),
Vol_USD = Convert.ToDouble(item[6])
};
if (item == datas.Last())
{
var span = GetIntervalSpan(interval);
var same = candleDataList.Find(i =>
candleData.Argument <= i.Argument &&
i.Argument < (candleData.Argument + span) &&
i.WebSocketUpdate);
if (same != null)
{
same.Open = candleData.Open;
same.Low = Math.Min(same.Low, candleData.Low);
same.High = Math.Max(same.High, candleData.High);
same.WebServiceUpdate = true;
}
else
{
candleDataList.Add(candleData);
candleData.WebServiceUpdate = true;
}
}
else
{
candleDataList.Add(candleData);
candleData.WebServiceUpdate = true;
}
}
}
return null;
}
case ExchangeWebReqType.BitgetAccountList_Future:
{
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
return JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(datalist["data"]?.ToString());
}
case ExchangeWebReqType.BitgetOrder_Future:
case ExchangeWebReqType.BitgetCancelOrder_Future:
case ExchangeWebReqType.BitgetChangeLeverage_Future:
case ExchangeWebReqType.BitgetChangeMarginMode_Future:
case ExchangeWebReqType.BitgetChangePositionMode_Future:
case ExchangeWebReqType.BitgetOrderDetail_Future:
case ExchangeWebReqType.BitgetGetTimeStamp:
case ExchangeWebReqType.BitgetPendingOrder_Future:
return JsonConvert.DeserializeObject<Dictionary<string, object>>(resResult);
default:
return null;
}
}
private static TimeSpan GetIntervalSpan(MCandleTimeType interval) => interval switch
{
MCandleTimeType.M1 => TimeSpan.FromMinutes(1),
MCandleTimeType.M3 => TimeSpan.FromMinutes(3),
MCandleTimeType.M5 => TimeSpan.FromMinutes(5),
MCandleTimeType.M15 => TimeSpan.FromMinutes(15),
MCandleTimeType.M30 => TimeSpan.FromMinutes(30),
MCandleTimeType.H1 => TimeSpan.FromHours(1),
MCandleTimeType.H2 => TimeSpan.FromHours(2),
MCandleTimeType.H3 => TimeSpan.FromHours(3),
MCandleTimeType.H4 => TimeSpan.FromHours(4),
MCandleTimeType.H6 => TimeSpan.FromHours(6),
MCandleTimeType.H12 => TimeSpan.FromHours(12),
_ => TimeSpan.Zero
};
/// <summary>
/// 기존 IndicatorProcess.GenerateSignString을 그대로 이식.
/// </summary>
public static string GenerateSignString(string timestamp, string method, string requestPath, string queryString, string body, string secretKey)
{
method = method.ToUpper();
body = body == null ? "" : body;
queryString = string.IsNullOrWhiteSpace(queryString) ? "" : "?" + queryString;
string preHash = timestamp + method + requestPath + queryString + body;
var encSecretKey_byteData = Encoding.UTF8.GetBytes(secretKey);
var encPreHash_byteData = Encoding.UTF8.GetBytes(preHash);
return Convert.ToBase64String(new HMACSHA256(encSecretKey_byteData).ComputeHash(encPreHash_byteData));
}
}

View File

@@ -0,0 +1,71 @@
namespace Bitget_Server.Infrastructure.Bitget;
/// <summary>
/// Bitget REST API에 대한 클라이언트 사이드 Rate Limiter.
/// Sliding window 방식으로 1초당 N회 호출 제한을 강제한다.
/// 모든 외부 호출은 await AcquireAsync(...) 후에 수행되어야 한다.
/// </summary>
public class BitgetRateLimiter
{
private readonly int _maxRequestsPerSecond;
private readonly Queue<long> _requestTicks = new();
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ILogger<BitgetRateLimiter> _logger;
public BitgetRateLimiter(int maxRequestsPerSecond, ILogger<BitgetRateLimiter> logger)
{
// 0 또는 음수 → 무제한 모드. 양수 → 슬라이딩 윈도우 적용.
_maxRequestsPerSecond = maxRequestsPerSecond <= 0 ? int.MaxValue : maxRequestsPerSecond;
_logger = logger;
if (_maxRequestsPerSecond == int.MaxValue)
_logger.LogWarning("BitgetRateLimiter: RateLimitPerSecond <= 0 — 무제한 모드로 동작합니다.");
}
/// <summary>
/// 호출 슬롯이 확보될 때까지 대기한 뒤 즉시 반환한다.
/// 무제한 모드(_maxRequestsPerSecond == int.MaxValue) 에서는 즉시 반환.
/// </summary>
public async Task AcquireAsync(CancellationToken ct = default)
{
if (_maxRequestsPerSecond == int.MaxValue) return;
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
int totalWaitedMs = 0;
while (true)
{
var now = Environment.TickCount64;
// 1초 이전 요청 기록 제거
while (_requestTicks.Count > 0 && now - _requestTicks.Peek() >= 1000)
_requestTicks.Dequeue();
if (_requestTicks.Count < _maxRequestsPerSecond)
{
_requestTicks.Enqueue(now);
if (totalWaitedMs >= 100)
_logger.LogInformation("Rate limit throttled {WaitMs}ms (queue={Count}/{Max})",
totalWaitedMs, _requestTicks.Count, _maxRequestsPerSecond);
return;
}
// 가장 오래된 요청이 1초 경과할 때까지 대기
var waitMs = 1000 - (int)(now - _requestTicks.Peek()) + 1;
if (waitMs < 1) waitMs = 1;
totalWaitedMs += waitMs;
_logger.LogDebug("Rate limit hit. Waiting {WaitMs}ms (queue={Count}/{Max})",
waitMs, _requestTicks.Count, _maxRequestsPerSecond);
_gate.Release();
try { await Task.Delay(waitMs, ct).ConfigureAwait(false); }
finally { await _gate.WaitAsync(ct).ConfigureAwait(false); }
}
}
finally
{
_gate.Release();
}
}
}

View File

@@ -0,0 +1,237 @@
using System.Net.WebSockets;
using System.Text;
using Bitget_Server.Application.Options;
using Bitget_Server.Application.State;
using Bitget_Server.Domain;
using Bitget_Server.Domain.Enums;
using Bitget_Server.Domain.Models;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Bitget_Server.Infrastructure.Bitget;
/// <summary>
/// 기존 WinForms WebConnection의 WebSocket 로직(BitGetWebSocektStart, closeWebSocket, ContinuePing,
/// ValidateJSON, CalculateDataList)을 ASP.NET Core 환경으로 이식.
/// 싱글톤으로 등록되어 백그라운드 서비스 + Controller에서 공유됨.
/// </summary>
public class BitgetWebSocketClient : IAsyncDisposable
{
private readonly MarketState _state;
private readonly ILogger<BitgetWebSocketClient> _logger;
private readonly BitgetOptions _options;
private ClientWebSocket _webSocket;
private CancellationTokenSource _cts;
private Task _receiveLoopTask;
private Task _pingLoopTask;
private readonly object _lock = new();
private readonly List<Dictionary<string, object>> _webSocketDataList = new();
public bool IsConnected =>
_webSocket != null && _webSocket.State == WebSocketState.Open;
public BitgetWebSocketClient(
MarketState state,
IOptions<BitgetOptions> options,
ILogger<BitgetWebSocketClient> logger)
{
_state = state;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// 기존 BitGetWebSocektStart와 동일 — subscribe 메시지(dic)를 만들어 연결 시작.
/// </summary>
public async Task StartAsync(BitgetWebSocketType wsType, Dictionary<string, object> subscribeDic, CancellationToken ct = default)
{
await StopAsync(ct).ConfigureAwait(false);
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_webSocket = new ClientWebSocket();
try
{
await _webSocket.ConnectAsync(new Uri(BitgetEndpoints.WebSocketPublic), _cts.Token).ConfigureAwait(false);
_logger.LogInformation("WebSocket 연결 성공 ({Type})", wsType);
var jsonData = JsonConvert.SerializeObject(subscribeDic);
await SendMessageAsync(jsonData, _cts.Token).ConfigureAwait(false);
_pingLoopTask = Task.Run(() => PingLoopAsync(_cts.Token));
_receiveLoopTask = Task.Run(() => ReceiveLoopAsync(_cts.Token));
}
catch (Exception ex)
{
_logger.LogError(ex, "WebSocket 연결 실패");
await StopAsync(CancellationToken.None).ConfigureAwait(false);
throw;
}
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var tempStrData = string.Empty;
try
{
while (!ct.IsCancellationRequested && _webSocket.State == WebSocketState.Open)
{
byte[] buffer = new byte[1024];
var result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct).ConfigureAwait(false);
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (message.Contains("pong"))
{
var parts = message.Split(new[] { "pong" }, StringSplitOptions.RemoveEmptyEntries);
message = string.Concat(parts);
}
// JSON 조각 병합
if (tempStrData.Length == 0)
{
if (!ValidateJSON(message)) { tempStrData = message; continue; }
}
else
{
tempStrData += message;
if (!ValidateJSON(tempStrData)) continue;
message = tempStrData;
tempStrData = string.Empty;
}
if (message.Contains("{\"action"))
{
var splitData = message.Split(new[] { "{\"action" }, StringSplitOptions.RemoveEmptyEntries);
foreach (var item in splitData)
{
var packet = "{\"action" + item;
try
{
var datalist = JsonConvert.DeserializeObject<Dictionary<string, object>>(packet);
if (datalist != null && datalist.TryGetValue("action", out var action) && action?.ToString() == "update")
{
lock (_lock) _webSocketDataList.Add(datalist);
}
}
catch (JsonReaderException) { }
}
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "WebSocket 수신 루프 오류");
}
}
private async Task PingLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(_options.WebSocketPingIntervalMs, ct).ConfigureAwait(false);
await SendMessageAsync("ping", ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogWarning(ex, "Ping 루프 종료");
}
}
private async Task SendMessageAsync(string message, CancellationToken ct)
{
try
{
byte[] buffer = Encoding.UTF8.GetBytes(message);
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, ct).ConfigureAwait(false);
_logger.LogDebug("WebSocket 메시지 전송: {Message}", message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "WebSocket 메시지 전송 실패");
}
}
private static bool ValidateJSON(string s)
{
try
{
if (s.Contains("{\"action"))
{
var splitData = s.Split(new[] { "{\"action" }, StringSplitOptions.RemoveEmptyEntries);
foreach (var item in splitData)
{
try { JToken.Parse("{\"action" + item); }
catch (JsonReaderException) { return false; }
}
return true;
}
JToken.Parse(s);
return true;
}
catch (JsonReaderException) { return false; }
}
/// <summary>
/// 기존 CalculateDataList 로직 이식.
/// BackgroundService가 1초 주기로 호출하여 WebSocket 누적 데이터를 candleDataLists로 병합.
/// </summary>
public void CalculateDataList(BitgetWebSocketType bitgetWebSocketType, List<object> intervalList)
{
try
{
List<Dictionary<string, object>> cloneList;
lock (_lock)
{
cloneList = _webSocketDataList.Select(d => new Dictionary<string, object>(d)).ToList();
_webSocketDataList.Clear();
}
// 주의: 복잡한 캔들 병합 로직은 기존 WinForms 버전 그대로 유지 시
// IndicatorProcess와 강하게 결합되므로 별도 서비스에서 처리하도록
// 구조만 유지하고 세부 처리는 IndicatorProcessService로 위임한다.
// (이 메서드 자체는 기존 WebConnection.CalculateDataList 진입점 역할)
foreach (var item in cloneList)
{
// TODO: 필요 시 기존 시간대별 병합 로직 포팅
}
}
catch (Exception ex)
{
_logger.LogError(ex, "WebSocket 데이터 처리 중 오류");
}
}
public async Task StopAsync(CancellationToken ct)
{
try { _cts?.Cancel(); } catch { }
if (_webSocket != null)
{
try
{
if (_webSocket.State == WebSocketState.Open)
await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "WebSocket 연결 종료", ct).ConfigureAwait(false);
}
catch { }
_webSocket.Dispose();
_webSocket = null;
}
_pingLoopTask = null;
_receiveLoopTask = null;
_cts?.Dispose();
_cts = null;
}
public async ValueTask DisposeAsync()
{
await StopAsync(CancellationToken.None).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,79 @@
using Newtonsoft.Json;
namespace Bitget_Server.Infrastructure.Utils;
/// <summary>
/// JSON 파일에 자동 영속화되는 싱글톤 설정 베이스.
/// 기존 WinForms BaseSettings&lt;T&gt; 로직을 유지하되, ASP.NET Core 환경에서는
/// 파일 저장 경로를 /data 볼륨(컨테이너) 또는 AppContext.BaseDirectory로 사용한다.
/// </summary>
public class BaseSettings<T> where T : class, new()
{
private static readonly object lockObject = new object();
private static readonly object fileLockObject = new object();
private static T _instance;
/// <summary>
/// 설정 파일을 저장할 디렉토리. Program.cs에서 시작 시 지정한다 (기본: AppContext.BaseDirectory).
/// </summary>
public static string StorageDirectory { get; set; } = AppContext.BaseDirectory;
public static T Instance
{
get
{
lock (lockObject)
{
if (_instance == null)
_instance = CreateInstance();
}
return _instance;
}
}
protected static T CreateInstance()
{
try
{
var saveFilePath = GetFilePath();
if (File.Exists(saveFilePath))
{
return JsonConvert.DeserializeObject<T>(File.ReadAllText(saveFilePath)) ?? new T();
}
}
catch
{
// 손상 시 기본값으로 복구
}
return new T();
}
private static string GetFilePath()
{
try
{
Directory.CreateDirectory(StorageDirectory);
}
catch { }
return Path.Combine(StorageDirectory, typeof(T).Name + ".json");
}
public void SaveConfig()
{
lock (fileLockObject)
{
try
{
string json = JsonConvert.SerializeObject(this, Formatting.Indented);
File.WriteAllText(GetFilePath(), json);
}
catch
{
// 저장 실패는 무시 (로그는 상위 레이어에서)
}
}
}
/// <summary>SaveConfig 의 짧은 별칭.</summary>
public void Save() => SaveConfig();
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel;
using System.Reflection;
namespace Bitget_Server.Infrastructure.Utils;
public static class Convertor
{
/// <summary>
/// Enum의 Description Attribute 문자열을 가져온다.
/// </summary>
public static string GetEnumDescription(Enum source)
{
FieldInfo fi = source.GetType().GetField(source.ToString());
if (fi == null) return source.ToString();
var att = (DescriptionAttribute)fi.GetCustomAttribute(typeof(DescriptionAttribute));
return att != null ? att.Description : source.ToString();
}
/// <summary>
/// DateTime -> Unix 타임스탬프(초 단위, KST 9시 기준). 기존 WinForms 로직과 동일.
/// </summary>
public static long GetDateTimeToUnixtimeStamp(DateTime datetime)
{
return (long)(datetime - new DateTime(1970, 1, 1, 9, 0, 0)).TotalSeconds;
}
/// <summary>
/// Unix ms 타임스탬프 -> DateTime (KST 9시 기준).
/// </summary>
public static DateTime GetUnixtimeStampToDateTime(long unixStamptime)
{
return new DateTime(1970, 1, 1, 9, 0, 0).AddMilliseconds(unixStamptime);
}
}

View File

@@ -1,54 +0,0 @@
// <auto-generated />
using System;
using Bitget_Server.EFCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Bitget_Server.Migrations
{
[DbContext(typeof(BitgetContext))]
[Migration("20240602102008_init1")]
partial class init1
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Bitget_Server.EFCore.Models.PushAlarm", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AlarmTime")
.HasColumnType("datetime2");
b.Property<string>("Contents")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("PushAlarms");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,37 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bitget_Server.Migrations
{
/// <inheritdoc />
public partial class init1 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PushAlarms",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
AlarmTime = table.Column<DateTime>(type: "datetime2", nullable: false),
Title = table.Column<string>(type: "nvarchar(max)", nullable: false),
Contents = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PushAlarms", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PushAlarms");
}
}
}

View File

@@ -1,51 +0,0 @@
// <auto-generated />
using System;
using Bitget_Server.EFCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Bitget_Server.Migrations
{
[DbContext(typeof(BitgetContext))]
partial class BitgetContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Bitget_Server.EFCore.Models.PushAlarm", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("AlarmTime")
.HasColumnType("datetime2");
b.Property<string>("Contents")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("PushAlarms");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,36 +1,191 @@
using Bitget_Server.EFCore;
using Microsoft.EntityFrameworkCore;
using System.Net;
using Bitget_Server.Application.BackgroundServices;
using Bitget_Server.Application.Options;
using Bitget_Server.Application.Services;
using Bitget_Server.Application.State;
using Bitget_Server.Infrastructure.Bitget;
using Bitget_Server.Infrastructure.Utils;
using Microsoft.OpenApi.Models;
using Serilog;
namespace Bitget_Server
var builder = WebApplication.CreateBuilder(args);
// =========================================================================
// 1. Kestrel: HTTP only / port 5000
// =========================================================================
builder.WebHost.ConfigureKestrel(options =>
{
public class Program
options.ListenAnyIP(5000); // HTTP 전용, HTTPS 사용하지 않음
});
// =========================================================================
// 2. Options 바인딩
// =========================================================================
builder.Services.Configure<BitgetOptions>(builder.Configuration.GetSection(BitgetOptions.SectionName));
var bitgetOptions = builder.Configuration.GetSection(BitgetOptions.SectionName).Get<BitgetOptions>() ?? new BitgetOptions();
// =========================================================================
// 3. 영속화 경로 설정 (BaseSettings<GlobalOptions>.StorageDirectory)
// 컨테이너에서는 /data, 로컬에서는 ./data 로 마운트됨
// =========================================================================
var dataDir = Path.GetFullPath(bitgetOptions.DataDirectory);
var logDir = Path.GetFullPath(bitgetOptions.LogDirectory);
Directory.CreateDirectory(dataDir);
Directory.CreateDirectory(logDir);
BaseSettings<GlobalOptions>.StorageDirectory = dataDir;
// =========================================================================
// 4. Serilog 구성 (Console + 일자별 File Sink)
// =========================================================================
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(bitgetOptions.LogDebugWrite
? Serilog.Events.LogEventLevel.Debug
: Serilog.Events.LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Map(
keyPropertyName: "SourceContext",
defaultKey: "general",
configure: (key, wt) => wt.File(
path: Path.Combine(logDir, "bitget-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}"))
.CreateLogger();
builder.Host.UseSerilog();
// =========================================================================
// 5. DI 등록 - 상태 / 설정 싱글톤
// =========================================================================
builder.Services.AddSingleton<MarketState>();
builder.Services.AddSingleton(sp => GlobalOptions.Instance); // BaseSettings 기반 파일 영속화 싱글톤
builder.Services.AddSingleton<PriceLevelTracker>();
// 기본 API 키 자동 선택 (GlobalOptions.SelectAPI 가 비어 있으면 appsettings.json 의 DefaultApiId 로 초기화)
// 반드시 다른 HostedService 보다 먼저 등록되어야 함 (HostedServices 는 등록 순서대로 StartAsync).
builder.Services.AddHostedService<ApiKeyBootstrapper>();
// =========================================================================
// 6. DI 등록 - Bitget Infrastructure
// =========================================================================
builder.Services.AddSingleton(sp => new BitgetRateLimiter(
bitgetOptions.RateLimitPerSecond,
sp.GetRequiredService<ILogger<BitgetRateLimiter>>()));
// Bitget 단일 엔드포인트만 호출하므로 HttpClient 를 Singleton 으로 공유해도 안전.
// (BackgroundService 가 Singleton 이므로 BitgetClient 도 Singleton 이어야 캡처 문제가 없음)
builder.Services.AddSingleton(sp =>
{
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Add("User-Agent", "Bitget_Server/1.0");
return http;
});
builder.Services.AddSingleton<BitgetClient>();
builder.Services.AddSingleton<BitgetWebSocketClient>();
// =========================================================================
// 7. DI 등록 - Application Services (모두 Singleton: 상태 보관 + BackgroundService 에서 참조)
// =========================================================================
builder.Services.AddSingleton<SettingsService>();
builder.Services.AddSingleton<BitgetTradingService>();
builder.Services.AddSingleton<InitialCandleLoader>();
builder.Services.AddSingleton<SignalProcessService>();
// =========================================================================
// 8. DI 등록 - BackgroundServices (HostedServices)
// =========================================================================
builder.Services.AddHostedService<MarketInfoBackgroundService>();
builder.Services.AddHostedService<WebSocketCalcBackgroundService>();
builder.Services.AddHostedService<SignalProcessBackgroundService>();
// =========================================================================
// 9. MVC / Swagger / ProblemDetails
// =========================================================================
builder.Services.AddControllers()
.AddNewtonsoftJson(opts =>
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<BitgetContext>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllersWithViews();
opts.SerializerSettings.ReferenceLoopHandling =
Newtonsoft.Json.ReferenceLoopHandling.Ignore;
opts.SerializerSettings.NullValueHandling =
Newtonsoft.Json.NullValueHandling.Ignore;
});
var app = builder.Build();
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Bitget Server",
Version = "v1",
Description = "기존 Bitget WinForms 애플리케이션을 ASP.NET Core 8 REST API 로 이식한 서버."
});
});
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
var context = services.GetRequiredService<BitgetContext>();
context.Database.Migrate();
}
// =========================================================================
// 10. 미들웨어 파이프라인 - HTTPS 리디렉션 없음
// =========================================================================
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapControllers();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/", () => "Hello World!");
// Swagger 는 환경 불문 항상 활성화
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "Bitget Server v1");
options.RoutePrefix = "swagger";
});
app.Run($"http://{IPAddress.Any}:{5045}");
}
}
app.UseSerilogRequestLogging();
app.MapControllers();
// 루트 접속 시 Swagger 로 리다이렉트
app.MapGet("/", () => Results.Redirect("/swagger"));
Log.Information("Bitget_Server 시작 — URL http://*:5000, DataDir={DataDir}, LogDir={LogDir}", dataDir, logDir);
try
{
app.Run();
}
finally
{
Log.CloseAndFlush();
}
/// <summary>
/// 애플리케이션 시작 시 appsettings 의 DefaultApiId 로 GlobalOptions.SelectAPI 를 초기화한다.
/// </summary>
internal sealed class ApiKeyBootstrapper : IHostedService
{
private readonly IServiceProvider _sp;
public ApiKeyBootstrapper(IServiceProvider sp) => _sp = sp;
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
var global = _sp.GetRequiredService<GlobalOptions>();
var opts = _sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<BitgetOptions>>().Value;
if (global.SelectAPI == null && opts.ApiKeys?.Count > 0)
{
global.SelectAPI = opts.ApiKeys.FirstOrDefault(k => k.Id == opts.DefaultApiId)
?? opts.ApiKeys.First();
global.Save();
}
}
catch (Exception ex)
{
Log.Warning(ex, "ApiKey 부트스트랩 실패 — 이후 /api/settings/api-key 로 수동 설정 필요");
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,29 +1,27 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:46411",
"sslPort": 0
}
},
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5000"
},
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5045",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"applicationUrl": "http://localhost:5000"
},
"IIS Express": {
"commandName": "IISExpress",
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"ASPNETCORE_ENVIRONMENT": "Production",
"ASPNETCORE_HTTP_PORTS": "5000"
},
"publishAllPorts": true,
"useSSL": false
}
}
},
"$schema": "http://json.schemastore.org/launchsettings.json"
}

View File

@@ -1,8 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
},
"Bitget": {
"LogDebugWrite": true
}
}

View File

@@ -2,8 +2,45 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Bitget": {
"DefaultApiId": 0,
"RateLimitPerSecond": 20,
"DataDirectory": "./data",
"LogDirectory": "./logs",
"LogDebugWrite": false,
"MarketInfoUpdateIntervalMs": 300,
"WebSocketCalcIntervalMs": 1000,
"WebSocketPingIntervalMs": 25000,
"ApiKeys": [
{
"Id": 0,
"Name": "김영호",
"AccessKey": "bg_f93aa530288b6ec9d62095994c51972a",
"SecretKey": "2fce18bcd12ca23a7920c48295a58da031443ea383af8114ed599c1b14ac678e",
"PassphraseKey": "dudgh0505"
},
{
"Id": 1,
"Name": "이장화",
"AccessKey": "bg_fbf275132203dcdbdca0db1e4b2351bb",
"SecretKey": "40ad89ac5347b9c3164ddf8b8e1c464038705266b25049a4f21ca12f509603bd",
"PassphraseKey": "Cjswo13%"
},
{
"Id": 2,
"Name": "이찬균",
"AccessKey": "bg_a1deb9a02b5d9f7ab76e2b851cf89422",
"SecretKey": "325bcfe440c7aee135dc631008e338ab24ba6d01895345e89e3eeb242ea20a80",
"PassphraseKey": "dlatlzxc12"
}
]
}
}