Files
Finlytic/FinlyticBackend/Controllers/BotController.cs
T

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&lt;string, object?&gt;</c> -&gt; <c>{prefix}_settings_Update</c> -&gt;
/// <c>List&lt;DynamicSettingDto&gt;</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 });
}
}
}