347 lines
16 KiB
C#
347 lines
16 KiB
C#
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() ?? "" : "";
|