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 _logger; public BotController(BackendMqttBridge mqttBridge, ILogger logger) { _mqttBridge = mqttBridge; _logger = logger; } [HttpGet("status")] public async Task GetStatus() { try { var status = await _mqttBridge.SendRpcRequestAsync( "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 GetActivePositions() { try { var positions = await _mqttBridge.SendRpcRequestAsync, object>( "bot_GetPositions", new object(), TimeSpan.FromSeconds(5) ); return Ok(positions ?? new List()); } catch (Exception ex) { _logger.LogError(ex, "Error getting active bot positions"); return StatusCode(500, new { Message = ex.Message }); } } [HttpGet("portfolio/summary")] public async Task GetPortfolioSummary() { try { var summary = await _mqttBridge.SendRpcRequestAsync( "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 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( "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 }); } } /// /// Emergency-closes every open FinlyticBot paper-trading position. Served by FinlyticBot's /// bot_PanicClose RPC handler (FinlyticBot.Util.BotMqttClient.HandlePanicCloseRpcAsync), /// 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 /// rather than silently counted as closed (Rules.md §4). /// [HttpPost("orders/panic-close")] public async Task PanicCloseAllPositions() { try { var result = await _mqttBridge.SendRpcRequestAsync( 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 }); } } /// /// Updates FinlyticBot's dynamic settings. Routed through the same generic /// Dictionary<string, object?> -> {prefix}_settings_Update -> /// List<DynamicSettingDto> contract every other microservice uses (see /// AdminSettingsController.UpdateServiceSettings) rather than a bespoke, nonexistent /// "bot_UpdateSettings" channel with a mismatched contract. FinlyticBot is a /// single shared instance with no per-tenant settings, so the generic mechanism applies directly. /// [HttpPost("settings/update")] public async Task 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(); 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, Dictionary>( 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 }); } } }