feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Bot;
|
||||
using FinlyticCore.Dtos.Settings;
|
||||
using FinlyticCore.Util;
|
||||
using FinlyticBackend.Util;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBackend.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/v1/bot")]
|
||||
public class BotController : ControllerBase
|
||||
{
|
||||
private readonly BackendMqttBridge _mqttBridge;
|
||||
private readonly ILogger<BotController> _logger;
|
||||
|
||||
public BotController(BackendMqttBridge mqttBridge, ILogger<BotController> logger)
|
||||
{
|
||||
_mqttBridge = mqttBridge;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _mqttBridge.SendRpcRequestAsync<BotStatusDto, object>(
|
||||
"bot_GetStatus",
|
||||
new object(),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
return Ok(status ?? new BotStatusDto(false, false, 0, 5, 1.0m, 75, "Unknown"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting bot status");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("positions/active")]
|
||||
public async Task<IActionResult> GetActivePositions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var positions = await _mqttBridge.SendRpcRequestAsync<List<BotTradeOrderDto>, object>(
|
||||
"bot_GetPositions",
|
||||
new object(),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
return Ok(positions ?? new List<BotTradeOrderDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting active bot positions");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("portfolio/summary")]
|
||||
public async Task<IActionResult> GetPortfolioSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
var summary = await _mqttBridge.SendRpcRequestAsync<AccountSummaryDto, object>(
|
||||
"bot_GetSummary",
|
||||
new object(),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
if (summary == null)
|
||||
{
|
||||
// No fabricated account numbers (Rules.md §4): if FinlyticBot didn't answer the RPC in time,
|
||||
// report that honestly instead of inventing a fake portfolio.
|
||||
return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Ok(summary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting bot portfolio summary");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("orders/execute")]
|
||||
public async Task<IActionResult> ExecuteProposal([FromBody] ExecuteProposalRequest request)
|
||||
{
|
||||
if (request == null || request.ProposalId == Guid.Empty)
|
||||
{
|
||||
return BadRequest(new { Message = "Valid ProposalId is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var order = await _mqttBridge.SendRpcRequestAsync<BotTradeOrderDto, ExecuteProposalRequest>(
|
||||
"bot_ExecuteProposal",
|
||||
request,
|
||||
TimeSpan.FromSeconds(10)
|
||||
);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
return StatusCode(422, new { Message = "Proposal could not be executed (Risk Gate rejection or not found)." });
|
||||
}
|
||||
|
||||
return Ok(order);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing bot proposal {Id}", request.ProposalId);
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency-closes every open FinlyticBot paper-trading position. Served by FinlyticBot's
|
||||
/// <c>bot_PanicClose</c> RPC handler (<c>FinlyticBot.Util.BotMqttClient.HandlePanicCloseRpcAsync</c>),
|
||||
/// which closes synthetic-ledger positions unconditionally but only closes Alpaca positions the broker
|
||||
/// has actually confirmed liquidating — anything it could not confirm is left open and reported via
|
||||
/// <see cref="PanicCloseResultDto.SkippedCount"/> rather than silently counted as closed (Rules.md §4).
|
||||
/// </summary>
|
||||
[HttpPost("orders/panic-close")]
|
||||
public async Task<IActionResult> PanicCloseAllPositions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mqttBridge.SendRpcRequestAsync<PanicCloseResultDto, object>(
|
||||
MqttTopics.Channels.BotPanicClose,
|
||||
new object(),
|
||||
TimeSpan.FromSeconds(15)
|
||||
);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
// FinlyticBot did not answer in time: do NOT report a fabricated "0 closed" success for an
|
||||
// emergency action — a false-positive panic-close result would be the worst possible outcome
|
||||
// (Rules.md §4). The caller must know this attempt did not go through at all.
|
||||
return Problem(
|
||||
title: "FinlyticBot is currently unreachable. Panic close could NOT be confirmed - positions may still be open.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering panic close");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates FinlyticBot's dynamic settings. Routed through the same generic
|
||||
/// <c>Dictionary<string, object?></c> -> <c>{prefix}_settings_Update</c> ->
|
||||
/// <c>List<DynamicSettingDto></c> contract every other microservice uses (see
|
||||
/// <c>AdminSettingsController.UpdateServiceSettings</c>) rather than a bespoke, nonexistent
|
||||
/// "bot_UpdateSettings" channel with a mismatched <see cref="BotStatusDto"/> contract. FinlyticBot is a
|
||||
/// single shared instance with no per-tenant settings, so the generic mechanism applies directly.
|
||||
/// </summary>
|
||||
[HttpPost("settings/update")]
|
||||
public async Task<IActionResult> UpdateBotSettings([FromBody] UpdateBotSettingsRequest request)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
return BadRequest(new { Message = "Settings payload is required." });
|
||||
}
|
||||
|
||||
// Raw setting-key strings are used here (rather than a shared constants type) because
|
||||
// FinlyticBackend has no project reference to FinlyticBot (by design - the backend is a thin MQTT
|
||||
// aggregation bridge, not a consumer of worker-service internals). The canonical source of truth for
|
||||
// these keys is FinlyticBot/Settings/BotSettingKeys.cs; this mirrors the identical precedent already
|
||||
// used in the opposite direction in FinlyticBot.Util.BotMqttClient.HandleGetStatusRpcAsync, which reads
|
||||
// FinlyticEngine's "Engine.MinCompositeScore" the same way for the same reason.
|
||||
var updates = new Dictionary<string, object?>();
|
||||
if (request.AutoExecutionEnabled.HasValue)
|
||||
{
|
||||
updates["Bot.EnableAutoExecution"] = request.AutoExecutionEnabled.Value;
|
||||
}
|
||||
if (request.MaxPositions.HasValue)
|
||||
{
|
||||
updates["Bot.MaxConcurrentPositions"] = request.MaxPositions.Value;
|
||||
}
|
||||
if (request.RiskPerTradePercent.HasValue)
|
||||
{
|
||||
updates["Bot.RiskPerTradePercent"] = request.RiskPerTradePercent.Value;
|
||||
}
|
||||
|
||||
// request.MinCompositeScore is deliberately NOT forwarded: that value is owned by FinlyticEngine
|
||||
// (EngineSettingKeys.MinCompositeScore == "Engine.MinCompositeScore"), not by FinlyticBot.
|
||||
// FinlyticBot's generic settings_Update handler only persists keys registered under BotSettingKeys
|
||||
// (see BotMqttClient.HandleSettingsGetAllRpcAsync/HandleSettingsUpdateRpcAsync), so writing an
|
||||
// Engine-owned key through the Bot's settings channel would either be silently dropped or written to
|
||||
// a key FinlyticBot itself never reads back — either way it would misrepresent to the caller that the
|
||||
// score was updated. The request DTO field is left in place (not this fix's concern to remove) for a
|
||||
// future change that routes it to FinlyticEngine's own settings channel instead.
|
||||
if (request.MinCompositeScore.HasValue)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Ignoring MinCompositeScore={Score} in bot settings update request: this value is owned by FinlyticEngine, not FinlyticBot, and cannot be set through this endpoint.",
|
||||
request.MinCompositeScore.Value);
|
||||
}
|
||||
|
||||
if (updates.Count == 0)
|
||||
{
|
||||
return BadRequest(new { Message = "At least one settable field (AutoExecutionEnabled, MaxPositions, RiskPerTradePercent) must be provided." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var updatedSettings = await _mqttBridge.SendRpcRequestAsync<List<DynamicSettingDto>, Dictionary<string, object?>>(
|
||||
MqttTopics.Channels.BotSettingsUpdate,
|
||||
updates,
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
if (updatedSettings == null)
|
||||
{
|
||||
// No fabricated success (Rules.md §4): if FinlyticBot didn't answer the RPC in time, report
|
||||
// that honestly instead of inventing a BotStatusDto that implies the settings were applied.
|
||||
return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
return Ok(updatedSettings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating bot settings");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user