diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index d03de06..93edd9a 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -411,21 +411,20 @@ public class BacktestController : BaseController /// Display name for the bundle (required). /// The bundle backtest request with ID for tracking progress. [HttpPost] - [Route("Bundle")] - public async Task> RunBundle([FromBody] List requests, - [FromQuery] string name) + [Route("BacktestBundle")] + public async Task> RunBundle([FromBody] RunBundleBacktestRequest request) { - if (requests == null || !requests.Any()) + if (request?.Requests == null || !request.Requests.Any()) { return BadRequest("At least one backtest request is required"); } - if (requests.Count > 10) + if (request.Requests.Count > 10) { return BadRequest("Maximum of 10 backtests allowed per bundle request"); } - if (string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(request.Name)) { return BadRequest("Bundle name is required"); } @@ -435,24 +434,24 @@ public class BacktestController : BaseController var user = await GetUser(); // Validate all requests before creating the bundle - foreach (var request in requests) + foreach (var req in request.Requests) { - if (request?.Config == null) + if (req?.Config == null) { return BadRequest("Invalid request: Configuration is required"); } - if (string.IsNullOrEmpty(request.Config.AccountName)) + if (string.IsNullOrEmpty(req.Config.AccountName)) { return BadRequest("Invalid request: Account name is required"); } - if (string.IsNullOrEmpty(request.Config.ScenarioName) && request.Config.Scenario == null) + if (string.IsNullOrEmpty(req.Config.ScenarioName) && req.Config.Scenario == null) { return BadRequest("Invalid request: Either scenario name or scenario object is required"); } - if (string.IsNullOrEmpty(request.Config.MoneyManagementName) && request.Config.MoneyManagement == null) + if (string.IsNullOrEmpty(req.Config.MoneyManagementName) && req.Config.MoneyManagement == null) { return BadRequest( "Invalid request: Either money management name or money management object is required"); @@ -463,16 +462,15 @@ public class BacktestController : BaseController var bundleRequest = new BundleBacktestRequest { User = user, - BacktestRequestsJson = JsonSerializer.Serialize(requests), - TotalBacktests = requests.Count, + BacktestRequestsJson = JsonSerializer.Serialize(request.Requests), + TotalBacktests = request.Requests.Count, CompletedBacktests = 0, FailedBacktests = 0, Status = BundleBacktestRequestStatus.Pending, - Name = name + Name = request.Name }; _backtester.InsertBundleBacktestRequestForUser(user, bundleRequest); - return Ok(bundleRequest); } catch (Exception ex) diff --git a/src/Managing.Api/Models/Requests/RunBundleBacktestRequest.cs b/src/Managing.Api/Models/Requests/RunBundleBacktestRequest.cs new file mode 100644 index 0000000..d964f8e --- /dev/null +++ b/src/Managing.Api/Models/Requests/RunBundleBacktestRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using Managing.Api.Controllers; + +namespace Managing.Api.Models.Requests; + +public class RunBundleBacktestRequest +{ + [Required] public string Name { get; set; } = string.Empty; + + [Required] public List Requests { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Managing.WebApp/src/generated/ManagingApi.ts b/src/Managing.WebApp/src/generated/ManagingApi.ts index f685c1b..bc0faf0 100644 --- a/src/Managing.WebApp/src/generated/ManagingApi.ts +++ b/src/Managing.WebApp/src/generated/ManagingApi.ts @@ -716,13 +716,11 @@ export class BacktestClient extends AuthorizedApiBase { return Promise.resolve(null as any); } - backtest_RunBundle(name: string | null | undefined, requests: RunBacktestRequest[]): Promise { - let url_ = this.baseUrl + "/Backtest/Bundle?"; - if (name !== undefined && name !== null) - url_ += "name=" + encodeURIComponent("" + name) + "&"; + backtest_RunBundle(request: RunBundleBacktestRequest): Promise { + let url_ = this.baseUrl + "/Backtest/BacktestBundle"; url_ = url_.replace(/[?&]$/, ""); - const content_ = JSON.stringify(requests); + const content_ = JSON.stringify(request); let options_: RequestInit = { body: content_, @@ -4064,6 +4062,11 @@ export enum BundleBacktestRequestStatus { Cancelled = "Cancelled", } +export interface RunBundleBacktestRequest { + name: string; + requests: RunBacktestRequest[]; +} + export interface GeneticRequest { requestId: string; user: User; diff --git a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts index e01647a..6ee1042 100644 --- a/src/Managing.WebApp/src/generated/ManagingApiTypes.ts +++ b/src/Managing.WebApp/src/generated/ManagingApiTypes.ts @@ -713,6 +713,11 @@ export enum BundleBacktestRequestStatus { Cancelled = "Cancelled", } +export interface RunBundleBacktestRequest { + name: string; + requests: RunBacktestRequest[]; +} + export interface GeneticRequest { requestId: string; user: User; diff --git a/src/Managing.WebApp/src/pages/backtestPage/backtest.tsx b/src/Managing.WebApp/src/pages/backtestPage/backtest.tsx index 6ce5148..5963231 100644 --- a/src/Managing.WebApp/src/pages/backtestPage/backtest.tsx +++ b/src/Managing.WebApp/src/pages/backtestPage/backtest.tsx @@ -6,10 +6,16 @@ import BacktestScanner from './backtestScanner' import BacktestUpload from './backtestUpload' import BacktestGenetic from './backtestGenetic' import BacktestGeneticBundle from './backtestGeneticBundle' +import BacktestBundleForm from './backtestBundleForm'; import type {TabsType} from '../../global/type.tsx' // Tabs Array const tabs: TabsType = [ + { + Component: BacktestBundleForm, + index: 0, + label: 'Bundle', + }, { Component: BacktestScanner, index: 1, diff --git a/src/Managing.WebApp/src/pages/backtestPage/backtestBundleForm.tsx b/src/Managing.WebApp/src/pages/backtestPage/backtestBundleForm.tsx new file mode 100644 index 0000000..52a71ad --- /dev/null +++ b/src/Managing.WebApp/src/pages/backtestPage/backtestBundleForm.tsx @@ -0,0 +1,434 @@ +import React, {useState} from 'react'; +import {BacktestClient} from '../../generated/ManagingApi'; +import type { + MoneyManagementRequest, + RunBacktestRequest, + ScenarioRequest, + TradingBotConfigRequest, +} from '../../generated/ManagingApiTypes'; +import {Ticker, Timeframe} from '../../generated/ManagingApiTypes'; +import CustomScenario from '../../components/organism/CustomScenario/CustomScenario'; +import {useCustomScenario} from '../../app/store/customScenario'; +import useApiUrlStore from '../../app/store/apiStore'; +import Toast from '../../components/mollecules/Toast/Toast'; +import BundleRequestsTable from './bundleRequestsTable'; + +// Placeholder types (replace with your actual types) +type Indicator = { name: string; params?: Record }; +type MoneyManagementVariant = { leverage: number; tp: number; sl: number }; +type TimeRangeVariant = { start: string; end: string }; + +type Asset = 'BTC' | 'ETH' | 'GMX'; + +const allAssets: Asset[] = ['BTC', 'ETH', 'GMX']; +const allIndicators: Indicator[] = [ + { name: 'EMA Cross' }, + { name: 'MACD Cross' }, + { name: 'SuperTrend', params: { period: 12, multiplier: 4 } }, + { name: 'EMA Trend' }, + { name: 'Chandelier Exit' }, +]; + +const allTimeframes = [ + { label: '5 minutes', value: '5m' }, + { label: '15 minutes', value: '15m' }, + { label: '1 hour', value: '1h' }, + { label: '4 hours', value: '4h' }, + { label: '1 day', value: '1d' }, +]; + +const tickerMap: Record = { + BTC: Ticker.BTC, + ETH: Ticker.ETH, + GMX: Ticker.GMX, +}; +const timeframeMap: Record = { + '5m': Timeframe.FiveMinutes, + '15m': Timeframe.FifteenMinutes, + '1h': Timeframe.OneHour, + '4h': Timeframe.FourHour, + '1d': Timeframe.OneDay, +}; + +const BacktestBundleForm: React.FC = () => { + const {apiUrl} = useApiUrlStore() + // Form state + const [strategyName, setStrategyName] = useState(''); + const [loopback, setLoopback] = useState(14); + // Remove selectedIndicators, use scenario from store + const [selectedAssets, setSelectedAssets] = useState([]); + const [timeframe, setTimeframe] = useState('5m'); + const [moneyManagementVariants, setMoneyManagementVariants] = useState([ + { leverage: 2, tp: 1.5, sl: 1 }, + ]); + const [timeRangeVariants, setTimeRangeVariants] = useState([ + { start: '', end: '' }, + ]); + const [cooldown, setCooldown] = useState(0); + const [maxLossStreak, setMaxLossStreak] = useState(0); + const [maxPositionTime, setMaxPositionTime] = useState(0); + const [positionFlipping, setPositionFlipping] = useState(false); + const [flipOnlyInProfit, setFlipOnlyInProfit] = useState(false); + const [closeEarly, setCloseEarly] = useState(false); + const [startingCapital, setStartingCapital] = useState(10000); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const { scenario, setCustomScenario } = useCustomScenario(); + + // Placeholder for cart summary + const totalBacktests = + moneyManagementVariants.length * timeRangeVariants.length * (selectedAssets.length || 1); + + // Handlers (add/remove variants, select assets/indicators, etc.) + // ... + + // Generate all combinations of variants using scenario from store + const generateRequests = (): RunBacktestRequest[] => { + const requests: RunBacktestRequest[] = []; + if (!scenario) return requests; + selectedAssets.forEach(asset => { + moneyManagementVariants.forEach(mm => { + timeRangeVariants.forEach(tr => { + const mmReq: MoneyManagementRequest = { + name: `${strategyName}-MM`, + leverage: mm.leverage, + takeProfit: mm.tp, + stopLoss: mm.sl, + timeframe: timeframeMap[timeframe], + }; + const config: TradingBotConfigRequest = { + accountName: 'default', // TODO: let user pick + ticker: tickerMap[asset], + timeframe: timeframeMap[timeframe], + isForWatchingOnly: false, + botTradingBalance: startingCapital, + name: `${strategyName} - ${asset}`, + flipPosition: positionFlipping, + cooldownPeriod: cooldown, + maxLossStreak: maxLossStreak, + scenario: scenario as ScenarioRequest, + moneyManagement: mmReq, + maxPositionTimeHours: maxPositionTime, + closeEarlyWhenProfitable: closeEarly, + flipOnlyWhenInProfit: flipOnlyInProfit, + useSynthApi: false, + useForPositionSizing: true, + useForSignalFiltering: true, + useForDynamicStopLoss: true, + }; + requests.push({ + config, + startDate: tr.start ? new Date(tr.start) : undefined, + endDate: tr.end ? new Date(tr.end) : undefined, + save: false, + withCandles: false, + }); + }); + }); + }); + return requests; + }; + + // API call + const handleRunBundle = async () => { + setLoading(true); + setError(null); + setSuccess(null); + const toast = new Toast('Starting bundle backtest...', true); + try { + const client = new BacktestClient({} as any, apiUrl); + const requests = generateRequests(); + if (!strategyName) throw new Error('Strategy name is required'); + if (requests.length === 0) throw new Error('No backtest variants to run'); + await client.backtest_RunBundle({ name: strategyName, requests }); + setSuccess('Bundle backtest started successfully!'); + toast.update('success', 'Bundle backtest started successfully!'); + } catch (e: any) { + setError(e.message || 'Failed to start bundle backtest'); + toast.update('error', e.message || 'Failed to start bundle backtest'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Bundle Backtest

+
+ {/* Left column: Main form */} +
+ {/* Name your strategy */} +
+ + setStrategyName(e.target.value)} + placeholder="Road to $100k..." + /> +
+ + {/* Scenario/Indicators section */} +
+ +
+ + {/* Select asset(s) */} +
+ +
+ {allAssets.map(asset => ( + + ))} +
+
+ + {/* Select timeframe */} +
+ + +
+ + {/* Money management variants */} +
+ + {moneyManagementVariants.map((mm, idx) => ( +
+ { + const v = [...moneyManagementVariants]; + v[idx].leverage = Number(e.target.value); + setMoneyManagementVariants(v); + }} + placeholder="Leverage" + min={1} + /> + { + const v = [...moneyManagementVariants]; + v[idx].tp = Number(e.target.value); + setMoneyManagementVariants(v); + }} + placeholder="TP %" + min={0} + /> + { + const v = [...moneyManagementVariants]; + v[idx].sl = Number(e.target.value); + setMoneyManagementVariants(v); + }} + placeholder="SL %" + min={0} + /> + +
+ ))} + +
+
+ + {/* Right column: Test period, advanced params, capital, cart */} +
+ {/* Test period variants */} +
+ + {timeRangeVariants.map((tr, idx) => ( +
+ { + const v = [...timeRangeVariants]; + v[idx].start = e.target.value; + setTimeRangeVariants(v); + }} + /> + { + const v = [...timeRangeVariants]; + v[idx].end = e.target.value; + setTimeRangeVariants(v); + }} + /> + +
+ ))} + +
+ + {/* Advanced Parameters */} +
+
+ +
Advanced Parameters
+
+
+ setCooldown(Number(e.target.value))} + placeholder="Cooldown" + /> + setMaxLossStreak(Number(e.target.value))} + placeholder="Max Loss Streak" + /> + setMaxPositionTime(Number(e.target.value))} + placeholder="Max Position Time" + /> +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {/* Starting Capital */} +
+ + setStartingCapital(Number(e.target.value))} + min={1} + /> +
+ + {/* Backtest Cart */} +
+
Backtest Cart
+
Total number of backtests: {totalBacktests}
+
Total number of credits used: {totalBacktests}
+
Estimated time: ~ 1 min
+ + + +
+
+
+
+ +
+
+ ); +}; + +export default BacktestBundleForm; \ No newline at end of file diff --git a/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx b/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx new file mode 100644 index 0000000..7491d8a --- /dev/null +++ b/src/Managing.WebApp/src/pages/backtestPage/bundleRequestsTable.tsx @@ -0,0 +1,146 @@ +import React, {useEffect, useState} from 'react'; +import {BacktestClient} from '../../generated/ManagingApi'; +import useApiUrlStore from '../../app/store/apiStore'; +import Table from '../../components/mollecules/Table/Table'; +import {BundleBacktestRequest} from '../../generated/ManagingApiTypes'; +import Toast from '../../components/mollecules/Toast/Toast'; + +const BundleRequestsTable = () => { + const { apiUrl } = useApiUrlStore(); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const fetchData = () => { + setLoading(true); + setError(null); + const client = new BacktestClient({} as any, apiUrl); + client.backtest_GetBundleBacktestRequests() + .then((res) => setData(res)) + .catch((e) => setError(e.message || 'Failed to fetch bundle requests')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchData(); + // eslint-disable-next-line + }, [apiUrl]); + + const handleDelete = async (id: string) => { + setDeletingId(id); + const toast = new Toast('Deleting bundle request...', true); + try { + const client = new BacktestClient({} as any, apiUrl); + await client.backtest_DeleteBundleBacktestRequest(id); + toast.update('success', 'Bundle request deleted'); + fetchData(); + } catch (e: any) { + toast.update('error', e.message || 'Failed to delete bundle request'); + } finally { + setDeletingId(null); + } + }; + + // Helper to get badge color + const getStatusBadgeColor = (status: string) => { + switch (status) { + case 'Pending': + return 'badge-warning'; + case 'Running': + return 'badge-info'; + case 'Completed': + return 'badge-success'; + case 'Failed': + return 'badge-error'; + case 'Cancelled': + return 'badge-neutral'; + default: + return 'badge-neutral'; + } + }; + + const columns = [ + { + Header: 'Request ID', + accessor: 'requestId', + Cell: ({ value }: { value: string }) => ( + {navigator.clipboard.writeText(value); new Toast('Copied RequestId!', false);}}>{value} + ), + }, + { + Header: 'Name', + accessor: 'name', + }, + { + Header: 'Progress & Status', + accessor: 'completedBacktests', + Cell: ({ row }: any) => { + const completed = row.original.completedBacktests || 0; + const total = row.original.totalBacktests || 1; + const failed = row.original.failedBacktests || 0; + const status = row.original.status; + const percent = Math.round((completed / total) * 100); + // Progress color + const getProgressColor = (p: number) => { + if (status === 'Failed') return 'progress-error'; + if (status === 'Completed') return 'progress-success'; + if (p <= 25) return 'progress-error'; + if (p <= 50) return 'progress-warning'; + if (p <= 75) return 'progress-info'; + return 'progress-success'; + }; + return ( +
+
+ {status} +
+ {completed} / {total} {failed > 0 && ({failed} failed)} +
+
+ +
+ ); + }, + }, + { + Header: 'Created At', + accessor: 'createdAt', + Cell: ({ value }: { value: string }) => value ? new Date(value).toLocaleString() : '-', + }, + { + Header: 'Completed At', + accessor: 'completedAt', + Cell: ({ value }: { value: string }) => value ? new Date(value).toLocaleString() : '-', + }, + { + Header: 'Actions', + accessor: 'actions', + disableSortBy: true, + Cell: ({ row }: any) => ( +
+ + +
+ ), + }, + ]; + + if (loading) return
Loading bundle requests...
; + if (error) return
{error}
; + + return ( +
+

Bundle Backtest Requests

+ + + ); +}; + +export default BundleRequestsTable; \ No newline at end of file