243 lines
9.8 KiB
C#
243 lines
9.8 KiB
C#
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 });
|
|
}
|
|
}
|
|
}
|