Files

347 lines
16 KiB
C#
Raw Permalink Normal View History

2026-04-27 17:45:32 +09:00
using Serilog;
using Simulator.Config;
using Simulator.Services;
using System.Text.Json;
// ════════════════════════════════════════════════════════════════
// 로거
// ════════════════════════════════════════════════════════════════
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/simulator-.log",
rollingInterval: RollingInterval.Day,
outputTemplate:
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
// ════════════════════════════════════════════════════════════════
// 설정 로드
// ════════════════════════════════════════════════════════════════
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: false);
builder.Host.UseSerilog();
var cfg = new SimulatorConfig();
builder.Configuration.GetSection("Simulation").Bind(cfg);
// ── 핵심 서비스 ───────────────────────────────────────────────
var clock = new SimulationClock(cfg.StartTime, cfg.EndTime, cfg.SpeedMultiplier);
var reader = new CandleReader(cfg.DataPath);
var state = new SimState(cfg.InitialBalance);
builder.Services.AddSingleton(cfg);
builder.Services.AddSingleton(clock);
builder.Services.AddSingleton(reader);
builder.Services.AddSingleton(state);
var app = builder.Build();
// ════════════════════════════════════════════════════════════════
// 시작 로그
// ════════════════════════════════════════════════════════════════
Log.Information("══════════════════════════════════════════════════");
Log.Information(" Simulator 시작");
Log.Information(" 심볼: {Symbol} ({ProductType})", cfg.Symbol, cfg.ProductType);
Log.Information(" 구간: {S} ~ {E}",
cfg.StartTime.ToString("yyyy-MM-dd HH:mm"),
cfg.EndTime .ToString("yyyy-MM-dd HH:mm"));
Log.Information(" 배속: {Speed}x (실제 1초 = 가상 {Sim}초)",
cfg.SpeedMultiplier, cfg.SpeedMultiplier);
Log.Information(" 잔고: {Balance} USDT", cfg.InitialBalance);
Log.Information(" 데이터: {Path}", Path.GetFullPath(cfg.DataPath));
Log.Information(" Buyer 설정 → RestBaseUrl = \"http://localhost:1234\"");
Log.Information("══════════════════════════════════════════════════");
// ════════════════════════════════════════════════════════════════
// ① 캔들 조회
// GET /api/v2/mix/market/candles
// Buyer의 CandlePoller 및 WarmUp이 이 엔드포인트를 사용합니다.
// ════════════════════════════════════════════════════════════════
app.MapGet("/api/v2/mix/market/candles", (
HttpRequest req,
SimulationClock clock,
CandleReader reader,
SimulatorConfig cfg) =>
{
string symbol = req.Query["symbol"] .FirstOrDefault() ?? cfg.Symbol;
string granularity = req.Query["granularity"].FirstOrDefault() ?? "30m";
int limit = int.TryParse(req.Query["limit"].FirstOrDefault(), out int l) ? l : 3;
var virtualNow = clock.Now;
var candles = reader.GetCandles(
cfg.Exchange, cfg.ProductType, symbol, granularity, virtualNow, limit);
Log.Debug("[Candle] symbol={S} gran={G} limit={L} 가상시각={V:HH:mm:ss} 반환={C}봉",
symbol, granularity, limit, virtualNow.ToLocalTime(), candles.Count);
// Bitget 응답: 최신→과거 순, 각 행 = [ts, o, h, l, c, baseVol, quoteVol]
var data = candles
.OrderByDescending(c => c.OpenTime)
.Select(c => new string[]
{
c.OpenTime.ToString(),
c.Open .ToString("F6"),
c.High .ToString("F6"),
c.Low .ToString("F6"),
c.Close .ToString("F6"),
c.Volume.ToString("F4"),
(c.Volume * c.Close).ToString("F4"),
})
.ToArray();
return Results.Ok(new { code = "00000", msg = "success", data });
});
// ════════════════════════════════════════════════════════════════
// ② 전체 포지션 조회
// GET /api/v2/mix/position/all-position
// StartupReconciler → BitgetPositionClient가 사용합니다.
// ════════════════════════════════════════════════════════════════
app.MapGet("/api/v2/mix/position/all-position", (
SimState state,
SimulationClock clock,
CandleReader reader,
SimulatorConfig cfg) =>
{
double price = reader.GetCurrentPrice(cfg.Exchange, cfg.ProductType, cfg.Symbol, clock.Now);
var data = state.GetAllPositions(cfg.Symbol, cfg.ProductType, price);
Log.Debug("[Position] all-position 조회: {Count}개", data.Count);
return Results.Ok(new { code = "00000", msg = "success", data });
});
// ════════════════════════════════════════════════════════════════
// ③ 주문 접수
// POST /api/v2/mix/order/place-order
// ════════════════════════════════════════════════════════════════
app.MapPost("/api/v2/mix/order/place-order", async (
HttpRequest req,
SimState state,
SimulationClock clock,
CandleReader reader,
SimulatorConfig cfg) =>
{
using var sr = new StreamReader(req.Body);
string body = await sr.ReadToEndAsync();
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
string side = GetStr(root, "side");
string tradeSide = GetStr(root, "tradeSide");
string holdSide = GetStr(root, "holdSide");
double size = double.TryParse(GetStr(root, "size"),
System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out double sz) ? sz : 0;
double price = reader.GetCurrentPrice(cfg.Exchange, cfg.ProductType, cfg.Symbol, clock.Now);
string orderId = state.NextOrderId();
if (tradeSide == "open")
{
bool isLong = side == "buy";
var pos = new SimPosition
{
Side = isLong ? "long" : "short",
Size = size,
AvgPrice = price,
Leverage = 10,
OpenTime = clock.Now,
};
if (isLong) state.LongPosition = pos;
else state.ShortPosition = pos;
state.TradeHistory.Add(new SimTrade
{
Time = clock.Now,
Side = pos.Side,
Action = "open",
Size = size,
Price = price,
});
Log.Information("[Order] {Side} 진입: size={Size} price={Price} orderId={Id}",
pos.Side, size, price, orderId);
}
else // close
{
bool closingLong = holdSide == "long" || (string.IsNullOrEmpty(holdSide) && side == "sell");
var pos = closingLong ? state.LongPosition : state.ShortPosition;
double pnl = 0;
if (pos != null)
{
pnl = closingLong
? (price - pos.AvgPrice) * size
: (pos.AvgPrice - price) * size;
state.Balance += pnl;
if (closingLong) state.LongPosition = null;
else state.ShortPosition = null;
state.TradeHistory.Add(new SimTrade
{
Time = clock.Now,
Side = pos.Side,
Action = "close",
Size = size,
Price = price,
Pnl = pnl,
});
}
Log.Information("[Order] {Side} 청산: size={Size} price={Price} pnl={Pnl:F4} 잔고={Bal:F2} orderId={Id}",
closingLong ? "long" : "short", size, price, pnl, state.Balance, orderId);
}
return Results.Ok(new
{
code = "00000",
msg = "success",
data = new { orderId, clientOid = "" },
});
});
// ════════════════════════════════════════════════════════════════
// ④ 포지션 플래시 청산
// POST /api/v2/mix/order/close-positions
// ════════════════════════════════════════════════════════════════
app.MapPost("/api/v2/mix/order/close-positions", async (
HttpRequest req,
SimState state,
SimulationClock clock,
CandleReader reader,
SimulatorConfig cfg) =>
{
using var sr = new StreamReader(req.Body);
string body = await sr.ReadToEndAsync();
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
string holdSide = GetStr(root, "holdSide");
double price = reader.GetCurrentPrice(cfg.Exchange, cfg.ProductType, cfg.Symbol, clock.Now);
string orderId = state.NextOrderId();
var pos = holdSide == "short" ? state.ShortPosition : state.LongPosition;
if (pos != null)
{
double pnl = holdSide == "short"
? (pos.AvgPrice - price) * pos.Size
: (price - pos.AvgPrice) * pos.Size;
state.Balance += pnl;
if (holdSide == "short") state.ShortPosition = null;
else state.LongPosition = null;
state.TradeHistory.Add(new SimTrade
{
Time = clock.Now,
Side = pos.Side,
Action = "close",
Size = pos.Size,
Price = price,
Pnl = pnl,
});
Log.Information("[Order] 플래시 청산 ({Side}): size={Size} price={Price} pnl={Pnl:F4} 잔고={Bal:F2}",
pos.Side, pos.Size, price, pnl, state.Balance);
}
return Results.Ok(new
{
code = "00000",
msg = "success",
data = new[] { new { orderId } },
});
});
// ════════════════════════════════════════════════════════════════
// ⑤ 미체결 주문 일괄 취소 (시뮬에서는 즉시 성공)
// POST /api/v2/mix/order/cancel-all-orders
// ════════════════════════════════════════════════════════════════
app.MapPost("/api/v2/mix/order/cancel-all-orders",
() => Results.Ok(new { code = "00000", msg = "success", data = new { } }));
// ════════════════════════════════════════════════════════════════
// ⑥ 계좌 설정 (레버리지 / 마진 모드 / 포지션 모드) — 항상 성공
// ════════════════════════════════════════════════════════════════
app.MapPost("/api/v2/mix/account/set-leverage",
() => Results.Ok(new { code = "00000", msg = "success", data = new { } }));
app.MapPost("/api/v2/mix/account/set-margin-mode",
() => Results.Ok(new { code = "00000", msg = "success", data = new { } }));
app.MapPost("/api/v2/mix/account/set-position-mode",
() => Results.Ok(new { code = "00000", msg = "success", data = new { } }));
// ════════════════════════════════════════════════════════════════
// ⑦ 계좌 잔고 조회
// GET /api/v2/mix/account/accounts
// ════════════════════════════════════════════════════════════════
app.MapGet("/api/v2/mix/account/accounts", (SimState state) =>
{
var data = new[]
{
new
{
marginCoin = "USDT",
locked = "0",
available = state.Balance.ToString("F4"),
crossMaxAvailable = state.Balance.ToString("F4"),
fixedMaxAvailable = state.Balance.ToString("F4"),
maxTransferOut = state.Balance.ToString("F4"),
equity = state.Balance.ToString("F4"),
usdtEquity = state.Balance.ToString("F4"),
btcEquity = "0",
}
};
return Results.Ok(new { code = "00000", msg = "success", data });
});
// ════════════════════════════════════════════════════════════════
// ── 시뮬레이션 전용 엔드포인트 ──────────────────────────────────
// ════════════════════════════════════════════════════════════════
/// <summary>현재 시뮬레이션 상태 조회 (Buyer와 무관한 디버그용)</summary>
app.MapGet("/sim/status", (SimulationClock clock, SimState state, SimulatorConfig cfg) =>
{
var now = clock.Now;
return Results.Ok(new
{
virtualNow = now.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
startTime = cfg.StartTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
endTime = cfg.EndTime .ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
speed = cfg.SpeedMultiplier,
isFinished = clock.IsFinished,
balance = Math.Round(state.Balance, 4),
longPos = state.LongPosition == null ? null : new { state.LongPosition.Size, state.LongPosition.AvgPrice },
shortPos = state.ShortPosition == null ? null : new { state.ShortPosition.Size, state.ShortPosition.AvgPrice },
tradeCount = state.TradeHistory.Count,
});
});
/// <summary>거래 내역 조회</summary>
app.MapGet("/sim/trades", (SimState state) =>
Results.Ok(state.TradeHistory.Select(t => new
{
time = t.Time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"),
side = t.Side,
action = t.Action,
size = t.Size,
price = t.Price,
pnl = Math.Round(t.Pnl, 4),
}))
);
app.Run();
// ════════════════════════════════════════════════════════════════
// 헬퍼
// ════════════════════════════════════════════════════════════════
static string GetStr(JsonElement el, string key)
=> el.TryGetProperty(key, out var v) ? v.GetString() ?? "" : "";