Add signalr
This commit is contained in:
@@ -27,7 +27,7 @@ namespace Managing.Api.Controllers;
|
|||||||
[Produces("application/json")]
|
[Produces("application/json")]
|
||||||
public class BacktestController : BaseController
|
public class BacktestController : BaseController
|
||||||
{
|
{
|
||||||
private readonly IHubContext<BotHub> _hubContext;
|
private readonly IHubContext<BacktestHub> _hubContext;
|
||||||
private readonly IBacktester _backtester;
|
private readonly IBacktester _backtester;
|
||||||
private readonly IScenarioService _scenarioService;
|
private readonly IScenarioService _scenarioService;
|
||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
@@ -45,7 +45,7 @@ public class BacktestController : BaseController
|
|||||||
/// <param name="geneticService">The service for genetic algorithm operations.</param>
|
/// <param name="geneticService">The service for genetic algorithm operations.</param>
|
||||||
/// <param name="backtestRepository">The repository for backtest operations.</param>
|
/// <param name="backtestRepository">The repository for backtest operations.</param>
|
||||||
public BacktestController(
|
public BacktestController(
|
||||||
IHubContext<BotHub> hubContext,
|
IHubContext<BacktestHub> hubContext,
|
||||||
IBacktester backtester,
|
IBacktester backtester,
|
||||||
IScenarioService scenarioService,
|
IScenarioService scenarioService,
|
||||||
IAccountService accountService,
|
IAccountService accountService,
|
||||||
@@ -537,6 +537,47 @@ public class BacktestController : BaseController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes the client to real-time updates for a bundle backtest request via SignalR.
|
||||||
|
/// The client will receive LightBacktestResponse objects as new backtests are generated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestId">The bundle request ID to subscribe to.</param>
|
||||||
|
[HttpPost]
|
||||||
|
[Route("Bundle/Subscribe")] // POST /Backtest/Bundle/Subscribe
|
||||||
|
public async Task<IActionResult> SubscribeToBundle([FromQuery] string requestId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(requestId))
|
||||||
|
return BadRequest("RequestId is required");
|
||||||
|
|
||||||
|
// Get the connection ID from the SignalR context (assume it's passed via header or query)
|
||||||
|
var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(connectionId))
|
||||||
|
return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header");
|
||||||
|
|
||||||
|
// Add the connection to the SignalR group for this bundle
|
||||||
|
await _hubContext.Groups.AddToGroupAsync(connectionId, $"bundle-{requestId}");
|
||||||
|
return Ok(new { Subscribed = true, RequestId = requestId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsubscribes the client from real-time updates for a bundle backtest request via SignalR.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestId">The bundle request ID to unsubscribe from.</param>
|
||||||
|
[HttpPost]
|
||||||
|
[Route("Bundle/Unsubscribe")] // POST /Backtest/Bundle/Unsubscribe
|
||||||
|
public async Task<IActionResult> UnsubscribeFromBundle([FromQuery] string requestId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(requestId))
|
||||||
|
return BadRequest("RequestId is required");
|
||||||
|
|
||||||
|
var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(connectionId))
|
||||||
|
return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header");
|
||||||
|
|
||||||
|
await _hubContext.Groups.RemoveFromGroupAsync(connectionId, $"bundle-{requestId}");
|
||||||
|
return Ok(new { Unsubscribed = true, RequestId = requestId });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs a genetic algorithm optimization with the specified configuration.
|
/// Runs a genetic algorithm optimization with the specified configuration.
|
||||||
/// This endpoint saves the genetic request to the database and returns the request ID.
|
/// This endpoint saves the genetic request to the database and returns the request ID.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Managing.Domain.Backtests;
|
||||||
using Managing.Domain.Bots;
|
using Managing.Domain.Bots;
|
||||||
|
|
||||||
namespace Managing.Api.Models.Requests;
|
namespace Managing.Api.Models.Requests;
|
||||||
@@ -19,4 +20,28 @@ public class LightBacktestResponse
|
|||||||
[Required] public double? SharpeRatio { get; set; }
|
[Required] public double? SharpeRatio { get; set; }
|
||||||
[Required] public double Score { get; set; }
|
[Required] public double Score { get; set; }
|
||||||
[Required] public string ScoreMessage { get; set; } = string.Empty;
|
[Required] public string ScoreMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LightBacktestResponseMapper
|
||||||
|
{
|
||||||
|
public static LightBacktestResponse MapFromDomain(Backtest b)
|
||||||
|
{
|
||||||
|
if (b == null) return null;
|
||||||
|
return new LightBacktestResponse
|
||||||
|
{
|
||||||
|
Id = b.Id,
|
||||||
|
Config = b.Config,
|
||||||
|
FinalPnl = b.FinalPnl,
|
||||||
|
WinRate = b.WinRate,
|
||||||
|
GrowthPercentage = b.GrowthPercentage,
|
||||||
|
HodlPercentage = b.HodlPercentage,
|
||||||
|
StartDate = b.StartDate,
|
||||||
|
EndDate = b.EndDate,
|
||||||
|
MaxDrawdown = b.Statistics?.MaxDrawdown,
|
||||||
|
Fees = b.Fees,
|
||||||
|
SharpeRatio = (double?)b.Statistics?.SharpeRatio,
|
||||||
|
Score = b.Score,
|
||||||
|
ScoreMessage = b.ScoreMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ public class BundleBacktestWorker : BaseWorker<BundleBacktestWorker>
|
|||||||
backtestConfig,
|
backtestConfig,
|
||||||
runBacktestRequest.StartDate,
|
runBacktestRequest.StartDate,
|
||||||
runBacktestRequest.EndDate,
|
runBacktestRequest.EndDate,
|
||||||
null, // No user context in worker
|
bundleRequest.User, // No user context in worker
|
||||||
runBacktestRequest.Save,
|
runBacktestRequest.Save,
|
||||||
runBacktestRequest.WithCandles,
|
runBacktestRequest.WithCandles,
|
||||||
bundleRequest.RequestId // Use bundleRequestId as requestId for traceability
|
bundleRequest.RequestId // Use bundleRequestId as requestId for traceability
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using Managing.Application.Abstractions.Repositories;
|
using Managing.Application.Abstractions.Repositories;
|
||||||
using Managing.Application.Abstractions.Services;
|
using Managing.Application.Abstractions.Services;
|
||||||
using Managing.Application.Bots;
|
using Managing.Application.Bots;
|
||||||
|
using Managing.Application.Hubs;
|
||||||
using Managing.Core.FixedSizedQueue;
|
using Managing.Core.FixedSizedQueue;
|
||||||
using Managing.Domain.Accounts;
|
using Managing.Domain.Accounts;
|
||||||
using Managing.Domain.Backtests;
|
using Managing.Domain.Backtests;
|
||||||
@@ -13,8 +14,10 @@ using Managing.Domain.Strategies;
|
|||||||
using Managing.Domain.Strategies.Base;
|
using Managing.Domain.Strategies.Base;
|
||||||
using Managing.Domain.Users;
|
using Managing.Domain.Users;
|
||||||
using Managing.Domain.Workflows;
|
using Managing.Domain.Workflows;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static Managing.Common.Enums;
|
using static Managing.Common.Enums;
|
||||||
|
using LightBacktestResponse = Managing.Domain.Backtests.LightBacktest; // Use the domain model for notification
|
||||||
|
|
||||||
namespace Managing.Application.Backtesting
|
namespace Managing.Application.Backtesting
|
||||||
{
|
{
|
||||||
@@ -28,6 +31,7 @@ namespace Managing.Application.Backtesting
|
|||||||
private readonly IAccountService _accountService;
|
private readonly IAccountService _accountService;
|
||||||
private readonly IMessengerService _messengerService;
|
private readonly IMessengerService _messengerService;
|
||||||
private readonly IKaigenService _kaigenService;
|
private readonly IKaigenService _kaigenService;
|
||||||
|
private readonly IHubContext<BacktestHub> _hubContext;
|
||||||
|
|
||||||
public Backtester(
|
public Backtester(
|
||||||
IExchangeService exchangeService,
|
IExchangeService exchangeService,
|
||||||
@@ -37,7 +41,8 @@ namespace Managing.Application.Backtesting
|
|||||||
IScenarioService scenarioService,
|
IScenarioService scenarioService,
|
||||||
IAccountService accountService,
|
IAccountService accountService,
|
||||||
IMessengerService messengerService,
|
IMessengerService messengerService,
|
||||||
IKaigenService kaigenService)
|
IKaigenService kaigenService,
|
||||||
|
IHubContext<BacktestHub> hubContext)
|
||||||
{
|
{
|
||||||
_exchangeService = exchangeService;
|
_exchangeService = exchangeService;
|
||||||
_botFactory = botFactory;
|
_botFactory = botFactory;
|
||||||
@@ -47,6 +52,7 @@ namespace Managing.Application.Backtesting
|
|||||||
_accountService = accountService;
|
_accountService = accountService;
|
||||||
_messengerService = messengerService;
|
_messengerService = messengerService;
|
||||||
_kaigenService = kaigenService;
|
_kaigenService = kaigenService;
|
||||||
|
_hubContext = hubContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
|
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
|
||||||
@@ -604,5 +610,14 @@ namespace Managing.Application.Backtesting
|
|||||||
{
|
{
|
||||||
return _backtestRepository.GetPendingBundleBacktestRequests();
|
return _backtestRepository.GetPendingBundleBacktestRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a LightBacktestResponse to all SignalR subscribers of a bundle request.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SendBundleBacktestUpdateAsync(string requestId, LightBacktestResponse response)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(requestId) || response == null) return;
|
||||||
|
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,20 @@ namespace Managing.Application.Hubs;
|
|||||||
|
|
||||||
public class BacktestHub : Hub
|
public class BacktestHub : Hub
|
||||||
{
|
{
|
||||||
public async override Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
await Clients.Caller.SendAsync("Message", $"Connected successfully on backtest hub. ConnectionId : {Context.ConnectionId}");
|
await Clients.Caller.SendAsync("Message", "Connected to BacktestHub!");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SubscribeBots() =>
|
public async Task SubscribeToBundle(string requestId)
|
||||||
await Clients.All.SendAsync("BacktestsSubscription", "Successfully subscribed");
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(requestId))
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, $"bundle-{requestId}");
|
||||||
|
await Clients.Caller.SendAsync("SubscribedToBundle", requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetConnectionId() => Context.ConnectionId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Managing.Application.Abstractions.Services;
|
||||||
|
using Managing.Application.Hubs;
|
||||||
|
using Managing.Application.Workers.Abstractions;
|
||||||
|
using Managing.Domain.Backtests;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Application.Workers;
|
||||||
|
|
||||||
|
public class NotifyBundleBacktestWorker : BaseWorker<NotifyBundleBacktestWorker>
|
||||||
|
{
|
||||||
|
private readonly IBacktester _backtester;
|
||||||
|
private readonly IHubContext<BacktestHub> _hubContext;
|
||||||
|
private readonly ConcurrentDictionary<string, HashSet<string>> _sentBacktestIds = new();
|
||||||
|
|
||||||
|
public NotifyBundleBacktestWorker(
|
||||||
|
IBacktester backtester,
|
||||||
|
IHubContext<BacktestHub> hubContext,
|
||||||
|
ILogger<NotifyBundleBacktestWorker> logger,
|
||||||
|
IWorkerService workerService)
|
||||||
|
: base(WorkerType.NotifyBundleBacktest, logger, TimeSpan.FromMinutes(1), workerService)
|
||||||
|
{
|
||||||
|
_backtester = backtester;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task Run(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fetch all running bundle requests
|
||||||
|
var runningBundles = _backtester.GetPendingBundleBacktestRequests()
|
||||||
|
.Where(b => b.Status == BundleBacktestRequestStatus.Running)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var bundle in runningBundles)
|
||||||
|
{
|
||||||
|
var requestId = bundle.RequestId;
|
||||||
|
if (string.IsNullOrEmpty(requestId)) continue;
|
||||||
|
|
||||||
|
// Fetch all backtests for this bundle
|
||||||
|
var (backtests, _) = _backtester.GetBacktestsByRequestIdPaginated(requestId, 1, 100);
|
||||||
|
if (!_sentBacktestIds.ContainsKey(requestId))
|
||||||
|
_sentBacktestIds[requestId] = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var backtest in backtests)
|
||||||
|
{
|
||||||
|
if (_sentBacktestIds[requestId].Contains(backtest.Id)) continue;
|
||||||
|
|
||||||
|
// If backtest is already LightBacktest, send directly
|
||||||
|
var lightResponse = backtest as LightBacktest;
|
||||||
|
if (lightResponse != null)
|
||||||
|
{
|
||||||
|
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", lightResponse, stoppingToken);
|
||||||
|
_sentBacktestIds[requestId].Add(backtest.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the bundle is now completed, flush the sent IDs for this requestId
|
||||||
|
if (bundle.Status == BundleBacktestRequestStatus.Completed && _sentBacktestIds.ContainsKey(requestId))
|
||||||
|
{
|
||||||
|
_sentBacktestIds.TryRemove(requestId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in NotifyBundleBacktestWorker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,11 @@ public static class ApiBootstrap
|
|||||||
services.AddHostedService<BalanceTrackingWorker>();
|
services.AddHostedService<BalanceTrackingWorker>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (configuration.GetValue<bool>("WorkerNotifyBundleBacktest", false))
|
||||||
|
{
|
||||||
|
services.AddHostedService<NotifyBundleBacktestWorker>();
|
||||||
|
}
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -384,7 +384,8 @@ public static class Enums
|
|||||||
FundingRatesWatcher,
|
FundingRatesWatcher,
|
||||||
BalanceTracking,
|
BalanceTracking,
|
||||||
GeneticAlgorithm,
|
GeneticAlgorithm,
|
||||||
BundleBacktest
|
BundleBacktest,
|
||||||
|
NotifyBundleBacktest
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkflowUsage
|
public enum WorkflowUsage
|
||||||
|
|||||||
@@ -871,6 +871,90 @@ export class BacktestClient extends AuthorizedApiBase {
|
|||||||
return Promise.resolve<FileResponse>(null as any);
|
return Promise.resolve<FileResponse>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backtest_SubscribeToBundle(requestId: string | null | undefined): Promise<FileResponse> {
|
||||||
|
let url_ = this.baseUrl + "/Backtest/Bundle/Subscribe?";
|
||||||
|
if (requestId !== undefined && requestId !== null)
|
||||||
|
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/octet-stream"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||||
|
return this.http.fetch(url_, transformedOptions_);
|
||||||
|
}).then((_response: Response) => {
|
||||||
|
return this.processBacktest_SubscribeToBundle(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBacktest_SubscribeToBundle(response: Response): Promise<FileResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200 || status === 206) {
|
||||||
|
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
|
||||||
|
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
|
||||||
|
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
|
||||||
|
if (fileName) {
|
||||||
|
fileName = decodeURIComponent(fileName);
|
||||||
|
} else {
|
||||||
|
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
|
||||||
|
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
|
||||||
|
}
|
||||||
|
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<FileResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
backtest_UnsubscribeFromBundle(requestId: string | null | undefined): Promise<FileResponse> {
|
||||||
|
let url_ = this.baseUrl + "/Backtest/Bundle/Unsubscribe?";
|
||||||
|
if (requestId !== undefined && requestId !== null)
|
||||||
|
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/octet-stream"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||||
|
return this.http.fetch(url_, transformedOptions_);
|
||||||
|
}).then((_response: Response) => {
|
||||||
|
return this.processBacktest_UnsubscribeFromBundle(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBacktest_UnsubscribeFromBundle(response: Response): Promise<FileResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200 || status === 206) {
|
||||||
|
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
|
||||||
|
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
|
||||||
|
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
|
||||||
|
if (fileName) {
|
||||||
|
fileName = decodeURIComponent(fileName);
|
||||||
|
} else {
|
||||||
|
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
|
||||||
|
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
|
||||||
|
}
|
||||||
|
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<FileResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
|
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
|
||||||
let url_ = this.baseUrl + "/Backtest/Genetic";
|
let url_ = this.baseUrl + "/Backtest/Genetic";
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
|
import {BundleBacktestRequest, LightBacktestResponse} from '../../generated/ManagingApiTypes';
|
||||||
|
import {BacktestClient} from '../../generated/ManagingApi';
|
||||||
|
import useApiUrlStore from '../../app/store/apiStore';
|
||||||
|
import Toast from '../../components/mollecules/Toast/Toast';
|
||||||
|
import {useQuery} from '@tanstack/react-query';
|
||||||
|
import * as signalR from '@microsoft/signalr';
|
||||||
|
import AuthorizedApiBase from '../../generated/AuthorizedApiBase';
|
||||||
|
|
||||||
|
interface BundleRequestModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
bundle: BundleBacktestRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BundleRequestModal: React.FC<BundleRequestModalProps> = ({ open, onClose, bundle }) => {
|
||||||
|
const { apiUrl } = useApiUrlStore();
|
||||||
|
const [backtests, setBacktests] = useState<LightBacktestResponse[]>([]);
|
||||||
|
const signalRRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: queryBacktests,
|
||||||
|
isLoading,
|
||||||
|
error: queryError,
|
||||||
|
refetch
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['bundle-backtests', bundle?.requestId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!open || !bundle) return [];
|
||||||
|
const client = new BacktestClient({} as any, apiUrl);
|
||||||
|
const res = await client.backtest_GetBacktestsByRequestId(bundle.requestId);
|
||||||
|
if (!res) return [];
|
||||||
|
return res.map((b: any) => ({
|
||||||
|
id: b.id,
|
||||||
|
config: b.config,
|
||||||
|
finalPnl: b.finalPnl,
|
||||||
|
winRate: b.winRate,
|
||||||
|
growthPercentage: b.growthPercentage,
|
||||||
|
hodlPercentage: b.hodlPercentage,
|
||||||
|
startDate: b.startDate,
|
||||||
|
endDate: b.endDate,
|
||||||
|
maxDrawdown: b.maxDrawdown ?? null,
|
||||||
|
fees: b.fees,
|
||||||
|
sharpeRatio: b.sharpeRatio ?? null,
|
||||||
|
score: b.score ?? 0,
|
||||||
|
scoreMessage: b.scoreMessage ?? '',
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
enabled: !!open && !!bundle,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryBacktests) setBacktests(queryBacktests);
|
||||||
|
}, [queryBacktests]);
|
||||||
|
|
||||||
|
// SignalR live updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !bundle) return;
|
||||||
|
if (bundle.status !== 'Pending' && bundle.status !== 'Running') return;
|
||||||
|
let connection: any = null;
|
||||||
|
let connectionId: string = '';
|
||||||
|
let unsubscribed = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl(`${apiUrl.replace(/\/$/, '')}/backtestHub`)
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
await connection.start();
|
||||||
|
connectionId = connection.connectionId;
|
||||||
|
// Subscribe to bundle updates
|
||||||
|
const authBase = new AuthorizedApiBase({} as any);
|
||||||
|
let fetchOptions: any = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-SignalR-ConnectionId': connectionId },
|
||||||
|
};
|
||||||
|
fetchOptions = await authBase.transformOptions(fetchOptions);
|
||||||
|
await fetch(`${apiUrl}/backtest/Bundle/Subscribe?requestId=${bundle.requestId}`, fetchOptions);
|
||||||
|
connection.on('BundleBacktestUpdate', (result: LightBacktestResponse) => {
|
||||||
|
setBacktests((prev) => {
|
||||||
|
if (prev.some((b) => b.id === result.id)) return prev;
|
||||||
|
return [...prev, result];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
signalRRef.current = connection;
|
||||||
|
} catch (e: any) {
|
||||||
|
new Toast('Failed to subscribe to live updates', false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
unsubscribed = true;
|
||||||
|
if (connection && connectionId) {
|
||||||
|
(async () => {
|
||||||
|
const authBase = new AuthorizedApiBase({} as any);
|
||||||
|
let fetchOptions: any = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-SignalR-ConnectionId': connectionId },
|
||||||
|
};
|
||||||
|
fetchOptions = await authBase.transformOptions(fetchOptions);
|
||||||
|
await fetch(`${apiUrl}/backtest/Bundle/Unsubscribe?requestId=${bundle.requestId}`, fetchOptions);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
if (signalRRef.current) {
|
||||||
|
signalRRef.current.stop();
|
||||||
|
signalRRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, bundle, apiUrl]);
|
||||||
|
|
||||||
|
if (!open || !bundle) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-4xl">
|
||||||
|
<h3 className="font-bold text-lg mb-2">Bundle: {bundle.name}</h3>
|
||||||
|
<div className="mb-2 text-sm">
|
||||||
|
<div><b>Request ID:</b> <span className="font-mono text-xs">{bundle.requestId}</span></div>
|
||||||
|
<div><b>Status:</b> <span className={`badge badge-sm ml-1`}>{bundle.status}</span></div>
|
||||||
|
<div><b>Created:</b> {bundle.createdAt ? new Date(bundle.createdAt).toLocaleString() : '-'}</div>
|
||||||
|
<div><b>Completed:</b> {bundle.completedAt ? new Date(bundle.completedAt).toLocaleString() : '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="divider">Backtest Results</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading backtests...</div>
|
||||||
|
) : queryError ? (
|
||||||
|
<div className="text-error">{(queryError as any)?.message || 'Failed to fetch backtests'}</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-96">
|
||||||
|
<table className="table table-zebra w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Final PnL</th>
|
||||||
|
<th>Win Rate</th>
|
||||||
|
<th>Growth %</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{backtests.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td className="font-mono">{b.id}</td>
|
||||||
|
<td>{b.finalPnl}</td>
|
||||||
|
<td>{b.winRate}</td>
|
||||||
|
<td>{b.growthPercentage}</td>
|
||||||
|
<td>{b.startDate ? new Date(b.startDate).toLocaleString() : '-'}</td>
|
||||||
|
<td>{b.endDate ? new Date(b.endDate).toLocaleString() : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BundleRequestModal;
|
||||||
@@ -4,6 +4,7 @@ import useApiUrlStore from '../../app/store/apiStore';
|
|||||||
import Table from '../../components/mollecules/Table/Table';
|
import Table from '../../components/mollecules/Table/Table';
|
||||||
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
|
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
|
||||||
import Toast from '../../components/mollecules/Toast/Toast';
|
import Toast from '../../components/mollecules/Toast/Toast';
|
||||||
|
import BundleRequestModal from './BundleRequestModal';
|
||||||
|
|
||||||
const BundleRequestsTable = () => {
|
const BundleRequestsTable = () => {
|
||||||
const { apiUrl } = useApiUrlStore();
|
const { apiUrl } = useApiUrlStore();
|
||||||
@@ -11,6 +12,8 @@ const BundleRequestsTable = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedBundle, setSelectedBundle] = useState<BundleBacktestRequest | null>(null);
|
||||||
|
|
||||||
const fetchData = () => {
|
const fetchData = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -119,7 +122,15 @@ const BundleRequestsTable = () => {
|
|||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
Cell: ({ row }: any) => (
|
Cell: ({ row }: any) => (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="btn btn-xs btn-outline" onClick={() => new Toast(`RequestId: ${row.original.requestId}`, false)}>View</button>
|
<button
|
||||||
|
className="btn btn-xs btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedBundle(row.original);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-xs btn-error"
|
className="btn btn-xs btn-error"
|
||||||
onClick={() => handleDelete(row.original.requestId)}
|
onClick={() => handleDelete(row.original.requestId)}
|
||||||
@@ -139,6 +150,11 @@ const BundleRequestsTable = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2>
|
<h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2>
|
||||||
<Table columns={columns} data={data} showPagination={true} />
|
<Table columns={columns} data={data} showPagination={true} />
|
||||||
|
<BundleRequestModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
bundle={selectedBundle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user