加固桥接服务并更新文档
This commit is contained in:
+425
-203
@@ -1,6 +1,7 @@
|
||||
using MtApi5;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Reflection;
|
||||
using System.Globalization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.WebHost.UseUrls("http://0.0.0.0:8080");
|
||||
@@ -28,9 +29,30 @@ builder.Services.AddSingleton<MtApi5Client>(sp =>
|
||||
}
|
||||
return client;
|
||||
});
|
||||
builder.Services.AddSingleton(new SemaphoreSlim(1, 1));
|
||||
builder.Services.AddHostedService<MtConnectionService>();
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler(errorApp =>
|
||||
{
|
||||
errorApp.Run(async context =>
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalExceptionHandler");
|
||||
var exception = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
|
||||
if (exception != null)
|
||||
{
|
||||
logger.LogError(exception, "Unhandled exception while processing {Method} {Path}", context.Request.Method, context.Request.Path);
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
detail = "The bridge could not process the request.",
|
||||
status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API Key 认证
|
||||
const string API_KEY = "UiHMqtaYLZzwBdcuS4RFmEGhgDO8N2eI";
|
||||
app.Use(async (context, next) =>
|
||||
@@ -46,42 +68,63 @@ app.Use(async (context, next) =>
|
||||
await next();
|
||||
});
|
||||
|
||||
app.MapGet("/health", (MtApi5Client mt) =>
|
||||
app.MapGet("/health", async (MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var connected = mt.ConnectionState == Mt5ConnectionState.Connected;
|
||||
return new { status = connected ? "healthy" : "disconnected", mt5_connected = connected, mt5_version = "unknown", api_version = "1.0.0" };
|
||||
});
|
||||
|
||||
app.MapGet("/account", (MtApi5Client mt) =>
|
||||
{
|
||||
var login = (ulong)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LOGIN);
|
||||
var leverage = (uint)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LEVERAGE);
|
||||
var tradeAllowed = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_ALLOWED) != 0;
|
||||
var tradeExpert = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_EXPERT) != 0;
|
||||
var currency = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_CURRENCY) ?? "USD";
|
||||
var server = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_SERVER) ?? "";
|
||||
var name = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_NAME) ?? "";
|
||||
var company = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_COMPANY) ?? "";
|
||||
var balance = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_BALANCE);
|
||||
var equity = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_EQUITY);
|
||||
var profit = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_PROFIT);
|
||||
var margin = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN);
|
||||
var marginFree = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_FREE);
|
||||
var marginLevel = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_LEVEL);
|
||||
|
||||
return new
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
new { login, leverage, trade_allowed = tradeAllowed, trade_expert = tradeExpert, currency, currency_digits = 2, server, name, company, balance, equity, profit, margin, margin_free = marginFree, margin_level = marginLevel }
|
||||
},
|
||||
count = 1,
|
||||
format = "json"
|
||||
};
|
||||
var connected = mt.ConnectionState == Mt5ConnectionState.Connected;
|
||||
return connected
|
||||
? Results.Ok(new { status = "healthy", mt5_connected = true, mt5_version = "unknown", api_version = "1.0.0" })
|
||||
: Results.Json(new { status = "disconnected", mt5_connected = false, mt5_version = "unknown", api_version = "1.0.0" }, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/symbols/{symbol}", (string symbol, MtApi5Client mt) =>
|
||||
app.MapGet("/account", async (MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var login = (ulong)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LOGIN);
|
||||
var leverage = (uint)mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_LEVERAGE);
|
||||
var tradeAllowed = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_ALLOWED) != 0;
|
||||
var tradeExpert = mt.AccountInfoInteger(ENUM_ACCOUNT_INFO_INTEGER.ACCOUNT_TRADE_EXPERT) != 0;
|
||||
var currency = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_CURRENCY) ?? "USD";
|
||||
var server = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_SERVER) ?? "";
|
||||
var name = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_NAME) ?? "";
|
||||
var company = mt.AccountInfoString(ENUM_ACCOUNT_INFO_STRING.ACCOUNT_COMPANY) ?? "";
|
||||
var balance = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_BALANCE);
|
||||
var equity = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_EQUITY);
|
||||
var profit = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_PROFIT);
|
||||
var margin = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN);
|
||||
var marginFree = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_FREE);
|
||||
var marginLevel = mt.AccountInfoDouble(ENUM_ACCOUNT_INFO_DOUBLE.ACCOUNT_MARGIN_LEVEL);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
new { login, leverage, trade_allowed = tradeAllowed, trade_expert = tradeExpert, currency, currency_digits = 2, server, name, company, balance, equity, profit, margin, margin_free = marginFree, margin_level = marginLevel }
|
||||
},
|
||||
count = 1,
|
||||
format = "json"
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/symbols/{symbol}", async (string symbol, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." });
|
||||
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var digits = (int)mt.SymbolInfoInteger(symbol, ENUM_SYMBOL_INFO_INTEGER.SYMBOL_DIGITS);
|
||||
@@ -101,7 +144,7 @@ app.MapGet("/symbols/{symbol}", (string symbol, MtApi5Client mt) =>
|
||||
try { currencyProfit = mt.SymbolInfoString(symbol, ENUM_SYMBOL_INFO_STRING.SYMBOL_CURRENCY_PROFIT) ?? ""; } catch { }
|
||||
try { path = mt.SymbolInfoString(symbol, ENUM_SYMBOL_INFO_STRING.SYMBOL_PATH) ?? ""; } catch { }
|
||||
|
||||
return Results.Ok(new
|
||||
return Results.Ok(new
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
@@ -109,57 +152,65 @@ app.MapGet("/symbols/{symbol}", (string symbol, MtApi5Client mt) =>
|
||||
},
|
||||
count = 1,
|
||||
format = "json"
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERROR /symbols/{symbol}: {ex.Message}");
|
||||
return Results.Problem($"Error: {ex.Message}");
|
||||
loggerFactory.CreateLogger("SymbolsEndpoint").LogError(ex, "Failed to read symbol info for {Symbol}", symbol);
|
||||
return Results.Problem("Failed to read symbol info.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/symbols/{symbol}/tick", (string symbol, MtApi5Client mt) =>
|
||||
app.MapGet("/symbols/{symbol}/tick", async (string symbol, MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var tick = mt.SymbolInfoTick(symbol);
|
||||
if (tick == null) return Results.NotFound(new { detail = $"Tick for {symbol} not found" });
|
||||
if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." });
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
time = tick.time.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||
bid = tick.bid,
|
||||
ask = tick.ask,
|
||||
last = tick.last,
|
||||
volume = tick.volume,
|
||||
time_msc = tick.time.ToString("yyyy-MM-ddTHH:mm:ss.fff000"),
|
||||
flags = 6,
|
||||
volume_real = tick.volume_real
|
||||
}
|
||||
},
|
||||
count = 1,
|
||||
format = "json"
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/rates/from-pos", (string symbol, string timeframe, int start_pos, int count, MtApi5Client mt) =>
|
||||
{
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var tf = timeframe switch
|
||||
{
|
||||
"TIMEFRAME_M1" => ENUM_TIMEFRAMES.PERIOD_M1,
|
||||
"TIMEFRAME_M5" => ENUM_TIMEFRAMES.PERIOD_M5,
|
||||
"TIMEFRAME_M15" => ENUM_TIMEFRAMES.PERIOD_M15,
|
||||
"TIMEFRAME_M30" => ENUM_TIMEFRAMES.PERIOD_M30,
|
||||
"TIMEFRAME_H1" => ENUM_TIMEFRAMES.PERIOD_H1,
|
||||
"TIMEFRAME_H4" => ENUM_TIMEFRAMES.PERIOD_H4,
|
||||
"TIMEFRAME_D1" => ENUM_TIMEFRAMES.PERIOD_D1,
|
||||
_ => ENUM_TIMEFRAMES.PERIOD_M5
|
||||
};
|
||||
var tick = mt.SymbolInfoTick(symbol);
|
||||
if (tick == null) return Results.NotFound(new { detail = $"Tick for {symbol} not found" });
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
data = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
time = tick.time.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||
bid = tick.bid,
|
||||
ask = tick.ask,
|
||||
last = tick.last,
|
||||
volume = tick.volume,
|
||||
time_msc = tick.time.ToString("yyyy-MM-ddTHH:mm:ss.fff000"),
|
||||
flags = 6,
|
||||
volume_real = tick.volume_real
|
||||
}
|
||||
},
|
||||
count = 1,
|
||||
format = "json"
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/rates/from-pos", async (string symbol, string timeframe, int start_pos, int count, MtApi5Client mt, SemaphoreSlim gate, ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol)) return Results.BadRequest(new { detail = "Symbol is required." });
|
||||
if (!TryParseTimeframe(timeframe, out var tf)) return Results.BadRequest(new { detail = $"Unsupported timeframe '{timeframe}'." });
|
||||
if (start_pos < 0) return Results.BadRequest(new { detail = "start_pos must be greater than or equal to 0." });
|
||||
if (count <= 0 || count > 10000) return Results.BadRequest(new { detail = "count must be between 1 and 10000." });
|
||||
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
const int chunkSize = 1000;
|
||||
var allRates = new List<MqlRates>();
|
||||
int remaining = count;
|
||||
@@ -200,151 +251,243 @@ app.MapGet("/rates/from-pos", (string symbol, string timeframe, int start_pos, i
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERROR /rates/from-pos: {ex.Message}\n{ex.StackTrace}");
|
||||
return Results.Problem($"Error: {ex.Message}");
|
||||
loggerFactory.CreateLogger("RatesEndpoint").LogError(ex, "Failed to read rates for {Symbol} {Timeframe}", symbol, timeframe);
|
||||
return Results.Problem("Failed to read rates.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/positions", (MtApi5Client mt) =>
|
||||
{
|
||||
var total = mt.PositionsTotal();
|
||||
var positions = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
finally
|
||||
{
|
||||
var ticket = mt.PositionGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
positions.Add(new
|
||||
{
|
||||
ticket,
|
||||
symbol = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_SYMBOL),
|
||||
type = (int)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_TYPE),
|
||||
volume = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_VOLUME),
|
||||
price_open = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_OPEN),
|
||||
sl = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SL),
|
||||
tp = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_TP),
|
||||
price_current = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_CURRENT),
|
||||
swap = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SWAP),
|
||||
profit = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PROFIT),
|
||||
comment = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_COMMENT),
|
||||
magic = (ulong)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_MAGIC)
|
||||
});
|
||||
gate.Release();
|
||||
}
|
||||
return new { data = positions, count = positions.Count, format = "json" };
|
||||
});
|
||||
|
||||
app.MapGet("/orders", (string? symbol, MtApi5Client mt) =>
|
||||
app.MapGet("/positions", async (MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var total = mt.OrdersTotal();
|
||||
var orders = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var ticket = mt.OrderGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
var orderSymbol = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_SYMBOL);
|
||||
if (symbol != null && orderSymbol != symbol) continue;
|
||||
orders.Add(new
|
||||
var total = mt.PositionsTotal();
|
||||
var positions = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
ticket,
|
||||
symbol = orderSymbol,
|
||||
type = (int)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_TYPE),
|
||||
volume_initial = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_VOLUME_INITIAL),
|
||||
price_open = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_PRICE_OPEN),
|
||||
sl = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_SL),
|
||||
tp = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_TP),
|
||||
magic = (ulong)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_MAGIC),
|
||||
comment = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_COMMENT)
|
||||
});
|
||||
var ticket = mt.PositionGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
positions.Add(new
|
||||
{
|
||||
ticket,
|
||||
symbol = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_SYMBOL),
|
||||
type = (int)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_TYPE),
|
||||
volume = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_VOLUME),
|
||||
price_open = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_OPEN),
|
||||
sl = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SL),
|
||||
tp = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_TP),
|
||||
price_current = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PRICE_CURRENT),
|
||||
swap = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_SWAP),
|
||||
profit = mt.PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE.POSITION_PROFIT),
|
||||
comment = mt.PositionGetString(ENUM_POSITION_PROPERTY_STRING.POSITION_COMMENT),
|
||||
magic = (ulong)mt.PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER.POSITION_MAGIC)
|
||||
});
|
||||
}
|
||||
return Results.Ok(new { data = positions, count = positions.Count, format = "json" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
return new { data = orders, count = orders.Count, format = "json" };
|
||||
});
|
||||
|
||||
app.MapPost("/order/check", (TradeRequestDto body, MtApi5Client mt) =>
|
||||
app.MapGet("/orders", async (string? symbol, MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var total = mt.OrdersTotal();
|
||||
var orders = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
var ticket = mt.OrderGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
var orderSymbol = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_SYMBOL);
|
||||
if (!string.IsNullOrWhiteSpace(symbol) && orderSymbol != symbol) continue;
|
||||
orders.Add(new
|
||||
{
|
||||
ticket,
|
||||
symbol = orderSymbol,
|
||||
type = (int)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_TYPE),
|
||||
volume_initial = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_VOLUME_INITIAL),
|
||||
price_open = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_PRICE_OPEN),
|
||||
sl = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_SL),
|
||||
tp = mt.OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE.ORDER_TP),
|
||||
magic = (ulong)mt.OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER.ORDER_MAGIC),
|
||||
comment = mt.OrderGetString(ENUM_ORDER_PROPERTY_STRING.ORDER_COMMENT)
|
||||
});
|
||||
}
|
||||
return Results.Ok(new { data = orders, count = orders.Count, format = "json" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/order/check", async (TradeRequestDto body, MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var validationError = ValidateTradeRequest(body);
|
||||
if (validationError != null) return Results.BadRequest(new { detail = validationError });
|
||||
|
||||
var request = ToMqlTradeRequest(body);
|
||||
mt.OrderCheck(request, out MqlTradeCheckResult? checkResult);
|
||||
if (checkResult == null) return new { data = new { retcode = 0u, balance = 0.0, equity = 0.0, profit = 0.0, margin = 0.0, margin_free = 0.0, margin_level = 0.0, comment = "Check failed" }, count = 1, format = "json" };
|
||||
return new { data = new { retcode = checkResult.Retcode, balance = checkResult.Balance, equity = checkResult.Equity, profit = checkResult.Profit, margin = checkResult.Margin, margin_free = checkResult.Margin_free, margin_level = checkResult.Margin_level, comment = checkResult.Comment ?? "" }, count = 1, format = "json" };
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
mt.OrderCheck(request, out MqlTradeCheckResult? checkResult);
|
||||
if (checkResult == null) return Results.Ok(new { data = new { retcode = 0u, balance = 0.0, equity = 0.0, profit = 0.0, margin = 0.0, margin_free = 0.0, margin_level = 0.0, comment = "Check failed" }, count = 1, format = "json" });
|
||||
return Results.Ok(new { data = new { retcode = checkResult.Retcode, balance = checkResult.Balance, equity = checkResult.Equity, profit = checkResult.Profit, margin = checkResult.Margin, margin_free = checkResult.Margin_free, margin_level = checkResult.Margin_level, comment = checkResult.Comment ?? "" }, count = 1, format = "json" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/order/send", (TradeRequestBody body, MtApi5Client mt) =>
|
||||
app.MapPost("/order/send", async (TradeRequestBody body, MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var validationError = ValidateTradeRequest(body.request);
|
||||
if (validationError != null) return Results.BadRequest(new { detail = validationError });
|
||||
|
||||
var request = ToMqlTradeRequest(body.request);
|
||||
mt.OrderSend(request, out MqlTradeResult? result);
|
||||
if (result == null) return new { data = new { retcode = 0u, order = 0ul, comment = "Send failed" }, count = 1, format = "json" };
|
||||
return new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" };
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
mt.OrderSend(request, out MqlTradeResult? result);
|
||||
if (result == null) return Results.Ok(new { data = new { retcode = 0u, order = 0ul, comment = "Send failed" }, count = 1, format = "json" });
|
||||
return Results.Ok(new { data = new { retcode = result.Retcode, order = result.Order, comment = result.Comment ?? "" }, count = 1, format = "json" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/history/deals", (string date_from, string date_to, string? symbol, MtApi5Client mt) =>
|
||||
app.MapGet("/history/deals", async (string date_from, string date_to, string? symbol, MtApi5Client mt, SemaphoreSlim gate) =>
|
||||
{
|
||||
var fromDt = DateTime.Parse(date_from);
|
||||
var toDt = DateTime.Parse(date_to);
|
||||
mt.HistorySelect(fromDt, toDt);
|
||||
var total = mt.HistoryDealsTotal();
|
||||
var dealList = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
if (!TryParseClientDate(date_from, out var fromDt) || !TryParseClientDate(date_to, out var toDt))
|
||||
return Results.BadRequest(new { detail = "date_from and date_to must be valid ISO-8601 or yyyy-MM-dd values." });
|
||||
if (fromDt > toDt)
|
||||
return Results.BadRequest(new { detail = "date_from must be earlier than or equal to date_to." });
|
||||
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var ticket = mt.HistoryDealGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
var dealSymbol = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_SYMBOL);
|
||||
if (symbol != null && dealSymbol != symbol) continue;
|
||||
var dealTime = MtTimeToDateTime(mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_TIME));
|
||||
dealList.Add(new
|
||||
mt.HistorySelect(fromDt, toDt);
|
||||
var total = mt.HistoryDealsTotal();
|
||||
var dealList = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
ticket,
|
||||
time = dealTime.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||
entry = (int)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_ENTRY),
|
||||
magic = (ulong)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_MAGIC),
|
||||
volume = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_VOLUME),
|
||||
price = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PRICE),
|
||||
profit = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PROFIT),
|
||||
commission = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_COMMISSION),
|
||||
swap = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_SWAP),
|
||||
symbol = dealSymbol,
|
||||
comment = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_COMMENT) ?? ""
|
||||
});
|
||||
var ticket = mt.HistoryDealGetTicket(i);
|
||||
if (ticket == 0) continue;
|
||||
var dealSymbol = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_SYMBOL);
|
||||
if (!string.IsNullOrWhiteSpace(symbol) && dealSymbol != symbol) continue;
|
||||
var dealTime = MtTimeToDateTime(mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_TIME));
|
||||
dealList.Add(new
|
||||
{
|
||||
ticket,
|
||||
time = dealTime.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||
entry = (int)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_ENTRY),
|
||||
magic = (ulong)mt.HistoryDealGetInteger(ticket, ENUM_DEAL_PROPERTY_INTEGER.DEAL_MAGIC),
|
||||
volume = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_VOLUME),
|
||||
price = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PRICE),
|
||||
profit = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_PROFIT),
|
||||
commission = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_COMMISSION),
|
||||
swap = mt.HistoryDealGetDouble(ticket, ENUM_DEAL_PROPERTY_DOUBLE.DEAL_SWAP),
|
||||
symbol = dealSymbol,
|
||||
comment = mt.HistoryDealGetString(ticket, ENUM_DEAL_PROPERTY_STRING.DEAL_COMMENT) ?? ""
|
||||
});
|
||||
}
|
||||
return Results.Ok(new { data = dealList, count = dealList.Count, format = "json" });
|
||||
}
|
||||
return new { data = dealList, count = dealList.Count, format = "json" };
|
||||
});
|
||||
|
||||
app.MapGet("/gvar", (HttpContext context) =>
|
||||
{
|
||||
var mt = context.RequestServices.GetRequiredService<MtApi5Client>();
|
||||
var total = mt.GlobalVariablesTotal();
|
||||
var vars = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
finally
|
||||
{
|
||||
var name = mt.GlobalVariableName(i) ?? "";
|
||||
var value = mt.GlobalVariableGet(name);
|
||||
vars.Add(new { name, value });
|
||||
gate.Release();
|
||||
}
|
||||
return Results.Ok(new { data = vars, count = vars.Count, format = "json" });
|
||||
});
|
||||
|
||||
app.MapGet("/gvar/{name}", (HttpContext context, string name) =>
|
||||
app.MapGet("/gvar", async (HttpContext context, SemaphoreSlim gate) =>
|
||||
{
|
||||
var mt = context.RequestServices.GetRequiredService<MtApi5Client>();
|
||||
if (!mt.GlobalVariableCheck(name))
|
||||
return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" });
|
||||
var value = mt.GlobalVariableGet(name);
|
||||
return Results.Ok(new { name, value });
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
var total = mt.GlobalVariablesTotal();
|
||||
var vars = new List<object>();
|
||||
for (int i = 0; i < total; i++)
|
||||
{
|
||||
var name = mt.GlobalVariableName(i) ?? "";
|
||||
var value = mt.GlobalVariableGet(name);
|
||||
vars.Add(new { name, value });
|
||||
}
|
||||
return Results.Ok(new { data = vars, count = vars.Count, format = "json" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/gvar/{name}", async (HttpContext context, string name) =>
|
||||
app.MapGet("/gvar/{name}", async (HttpContext context, string name, SemaphoreSlim gate) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest(new { detail = "Global variable name is required." });
|
||||
|
||||
var mt = context.RequestServices.GetRequiredService<MtApi5Client>();
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!mt.GlobalVariableCheck(name))
|
||||
return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" });
|
||||
var value = mt.GlobalVariableGet(name);
|
||||
return Results.Ok(new { name, value });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/gvar/{name}", async (HttpContext context, string name, SemaphoreSlim gate) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest(new { detail = "Global variable name is required." });
|
||||
|
||||
var mt = context.RequestServices.GetRequiredService<MtApi5Client>();
|
||||
var body = await context.Request.ReadFromJsonAsync<GvarSetDto>();
|
||||
if (body == null) return Results.BadRequest("Invalid body");
|
||||
mt.GlobalVariableSet(name, body.value);
|
||||
return Results.Ok(new { name, value = body.value });
|
||||
if (body == null || double.IsNaN(body.value) || double.IsInfinity(body.value)) return Results.BadRequest(new { detail = "Invalid body." });
|
||||
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
mt.GlobalVariableSet(name, body.value);
|
||||
return Results.Ok(new { name, value = body.value });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapDelete("/gvar/{name}", (HttpContext context, string name) =>
|
||||
app.MapDelete("/gvar/{name}", async (HttpContext context, string name, SemaphoreSlim gate) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return Results.BadRequest(new { detail = "Global variable name is required." });
|
||||
|
||||
var mt = context.RequestServices.GetRequiredService<MtApi5Client>();
|
||||
if (!mt.GlobalVariableCheck(name))
|
||||
return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" });
|
||||
mt.GlobalVariableDel(name);
|
||||
return Results.Ok(new { deleted = name });
|
||||
await gate.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!mt.GlobalVariableCheck(name))
|
||||
return Results.NotFound(new { detail = $"GlobalVariable '{name}' not found" });
|
||||
mt.GlobalVariableDel(name);
|
||||
return Results.Ok(new { deleted = name });
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
@@ -354,6 +497,51 @@ DateTime MtTimeToDateTime(long mtTime)
|
||||
return new DateTime(1970, 1, 1).AddSeconds(mtTime);
|
||||
}
|
||||
|
||||
bool TryParseTimeframe(string timeframe, out ENUM_TIMEFRAMES parsed)
|
||||
{
|
||||
parsed = timeframe switch
|
||||
{
|
||||
"TIMEFRAME_M1" => ENUM_TIMEFRAMES.PERIOD_M1,
|
||||
"TIMEFRAME_M5" => ENUM_TIMEFRAMES.PERIOD_M5,
|
||||
"TIMEFRAME_M15" => ENUM_TIMEFRAMES.PERIOD_M15,
|
||||
"TIMEFRAME_M30" => ENUM_TIMEFRAMES.PERIOD_M30,
|
||||
"TIMEFRAME_H1" => ENUM_TIMEFRAMES.PERIOD_H1,
|
||||
"TIMEFRAME_H4" => ENUM_TIMEFRAMES.PERIOD_H4,
|
||||
"TIMEFRAME_D1" => ENUM_TIMEFRAMES.PERIOD_D1,
|
||||
_ => default
|
||||
};
|
||||
|
||||
return timeframe is "TIMEFRAME_M1" or "TIMEFRAME_M5" or "TIMEFRAME_M15" or "TIMEFRAME_M30" or "TIMEFRAME_H1" or "TIMEFRAME_H4" or "TIMEFRAME_D1";
|
||||
}
|
||||
|
||||
bool TryParseClientDate(string value, out DateTime parsed)
|
||||
{
|
||||
return DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out parsed)
|
||||
|| DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out parsed);
|
||||
}
|
||||
|
||||
string? ValidateTradeRequest(TradeRequestDto request)
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(ENUM_TRADE_REQUEST_ACTIONS), (int)request.action))
|
||||
return "action is invalid.";
|
||||
if (string.IsNullOrWhiteSpace(request.symbol))
|
||||
return "symbol is required.";
|
||||
if (request.volume is not null && (!double.IsFinite(request.volume.Value) || request.volume.Value <= 0))
|
||||
return "volume must be a positive number.";
|
||||
if (request.price is not null && (!double.IsFinite(request.price.Value) || request.price.Value < 0))
|
||||
return "price must be a non-negative number.";
|
||||
if (request.sl is not null && (!double.IsFinite(request.sl.Value) || request.sl.Value < 0))
|
||||
return "sl must be a non-negative number.";
|
||||
if (request.tp is not null && (!double.IsFinite(request.tp.Value) || request.tp.Value < 0))
|
||||
return "tp must be a non-negative number.";
|
||||
if (request.order_type is not null && !Enum.IsDefined(typeof(ENUM_ORDER_TYPE), (int)request.order_type.Value))
|
||||
return "order_type is invalid.";
|
||||
if (request.deviation is not null && request.deviation.Value > 100000)
|
||||
return "deviation is unreasonably large.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
MqlTradeRequest ToMqlTradeRequest(TradeRequestDto r)
|
||||
{
|
||||
return new MqlTradeRequest
|
||||
@@ -403,46 +591,80 @@ class MtConnectionService : BackgroundService
|
||||
{
|
||||
private readonly MtApi5Client _client;
|
||||
private readonly ILogger<MtConnectionService> _logger;
|
||||
private readonly TimeSpan _connectTimeout;
|
||||
private const int Port = 8228;
|
||||
|
||||
public MtConnectionService(MtApi5Client client, ILogger<MtConnectionService> logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_connectTimeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_client.ConnectionStateChanged += (s, e) =>
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_client.ConnectionState != Mt5ConnectionState.Connected)
|
||||
{
|
||||
var firstConnect = _client.ConnectionState == Mt5ConnectionState.Disconnected;
|
||||
_logger.LogWarning(firstConnect
|
||||
? "Connecting to MT5 via MtApi5 on port {Port}..."
|
||||
: "MT5 disconnected, attempting reconnect on port {Port}...", Port);
|
||||
|
||||
var connected = await TryConnectAsync(stoppingToken);
|
||||
if (!connected)
|
||||
{
|
||||
_logger.LogError("MT5 is not connected. Ensure MtApi5 EA is loaded and listening on port {Port}.", Port);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(firstConnect
|
||||
? "MT5 Bridge ready on http://localhost:8080"
|
||||
: "MT5 reconnected.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryConnectAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (_client.ConnectionState == Mt5ConnectionState.Connected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<Mt5ConnectionState>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<Mt5ConnectionEventArgs>? handler = null;
|
||||
handler = (_, e) =>
|
||||
{
|
||||
_logger.LogInformation("MT5 Connection: {Status}", e.Status);
|
||||
if (e.Status == Mt5ConnectionState.Connected || e.Status == Mt5ConnectionState.Failed || e.Status == Mt5ConnectionState.Disconnected)
|
||||
tcs.TrySetResult(e.Status == Mt5ConnectionState.Connected);
|
||||
{
|
||||
tcs.TrySetResult(e.Status);
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogInformation("Connecting to MT5 via MtApi5 on port 8228...");
|
||||
_client.BeginConnect(8228);
|
||||
await tcs.Task;
|
||||
|
||||
if (_client.ConnectionState != Mt5ConnectionState.Connected)
|
||||
_client.ConnectionStateChanged += handler;
|
||||
try
|
||||
{
|
||||
_logger.LogError("Failed to connect to MT5. Ensure MtApi5 EA is loaded in MT5.");
|
||||
return;
|
||||
_client.BeginConnect(Port);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeoutCts.CancelAfter(_connectTimeout);
|
||||
using var registration = timeoutCts.Token.Register(() => tcs.TrySetCanceled(timeoutCts.Token));
|
||||
var status = await tcs.Task;
|
||||
return status == Mt5ConnectionState.Connected;
|
||||
}
|
||||
|
||||
_logger.LogInformation("MT5 Bridge ready on http://localhost:8080");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(30000, stoppingToken);
|
||||
if (_client.ConnectionState != Mt5ConnectionState.Connected)
|
||||
{
|
||||
_logger.LogWarning("MT5 disconnected, attempting reconnect...");
|
||||
tcs = new TaskCompletionSource<bool>();
|
||||
_client.BeginConnect(8228);
|
||||
await tcs.Task;
|
||||
if (_client.ConnectionState == Mt5ConnectionState.Connected)
|
||||
_logger.LogInformation("MT5 reconnected.");
|
||||
}
|
||||
_logger.LogWarning("Timed out waiting {TimeoutSeconds}s for MT5 connection state change.", _connectTimeout.TotalSeconds);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_client.ConnectionStateChanged -= handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user