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 }); }); // ════════════════════════════════════════════════════════════════ // ── 시뮬레이션 전용 엔드포인트 ────────────────────────────────── // ════════════════════════════════════════════════════════════════ /// 현재 시뮬레이션 상태 조회 (Buyer와 무관한 디버그용) 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, }); }); /// 거래 내역 조회 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() ?? "" : "";