Add front for bundle

This commit is contained in:
2025-07-21 18:16:01 +07:00
parent 6f49f2659f
commit a32e9c33a8
7 changed files with 623 additions and 20 deletions

View File

@@ -411,21 +411,20 @@ public class BacktestController : BaseController
/// <param name="name">Display name for the bundle (required).</param>
/// <returns>The bundle backtest request with ID for tracking progress.</returns>
[HttpPost]
[Route("Bundle")]
public async Task<ActionResult<BundleBacktestRequest>> RunBundle([FromBody] List<RunBacktestRequest> requests,
[FromQuery] string name)
[Route("BacktestBundle")]
public async Task<ActionResult<BundleBacktestRequest>> 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)

View File

@@ -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<RunBacktestRequest> Requests { get; set; } = new();
}

View File

@@ -716,13 +716,11 @@ export class BacktestClient extends AuthorizedApiBase {
return Promise.resolve<Backtest>(null as any);
}
backtest_RunBundle(name: string | null | undefined, requests: RunBacktestRequest[]): Promise<BundleBacktestRequest> {
let url_ = this.baseUrl + "/Backtest/Bundle?";
if (name !== undefined && name !== null)
url_ += "name=" + encodeURIComponent("" + name) + "&";
backtest_RunBundle(request: RunBundleBacktestRequest): Promise<BundleBacktestRequest> {
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;

View File

@@ -713,6 +713,11 @@ export enum BundleBacktestRequestStatus {
Cancelled = "Cancelled",
}
export interface RunBundleBacktestRequest {
name: string;
requests: RunBacktestRequest[];
}
export interface GeneticRequest {
requestId: string;
user: User;

View File

@@ -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,

View File

@@ -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<string, any> };
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<string, Ticker> = {
BTC: Ticker.BTC,
ETH: Ticker.ETH,
GMX: Ticker.GMX,
};
const timeframeMap: Record<string, Timeframe> = {
'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<Asset[]>([]);
const [timeframe, setTimeframe] = useState('5m');
const [moneyManagementVariants, setMoneyManagementVariants] = useState<MoneyManagementVariant[]>([
{ leverage: 2, tp: 1.5, sl: 1 },
]);
const [timeRangeVariants, setTimeRangeVariants] = useState<TimeRangeVariant[]>([
{ 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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<div className="p-10 max-w-7xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Bundle Backtest</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Left column: Main form */}
<div>
{/* Name your strategy */}
<div className="mb-4">
<label className="label">Name your strategy</label>
<input
className="input input-bordered w-full"
value={strategyName}
onChange={e => setStrategyName(e.target.value)}
placeholder="Road to $100k..."
/>
</div>
{/* Scenario/Indicators section */}
<div className="mb-4">
<CustomScenario
onCreateScenario={setCustomScenario}
showCustomScenario={true}
/>
</div>
{/* Select asset(s) */}
<div className="mb-4">
<label className="label">Select asset(s)</label>
<div className="flex flex-wrap gap-2">
{allAssets.map(asset => (
<button
key={asset}
className={`btn btn-sm ${selectedAssets.includes(asset) ? 'btn-primary' : 'btn-outline'}`}
onClick={e => {
e.preventDefault();
setSelectedAssets(sel =>
sel.includes(asset)
? sel.filter(a => a !== asset)
: [...sel, asset]
);
}}
>
{asset}
</button>
))}
</div>
</div>
{/* Select timeframe */}
<div className="mb-4">
<label className="label">Select timeframe</label>
<select
className="select select-bordered w-full"
value={timeframe}
onChange={e => setTimeframe(e.target.value)}
>
{allTimeframes.map(tf => (
<option key={tf.value} value={tf.value}>
{tf.label}
</option>
))}
</select>
</div>
{/* Money management variants */}
<div className="mb-4">
<label className="label">Choose your money management approach(s)</label>
{moneyManagementVariants.map((mm, idx) => (
<div key={idx} className="flex gap-2 items-center mb-2">
<input
type="number"
className="input input-bordered w-16"
value={mm.leverage}
onChange={e => {
const v = [...moneyManagementVariants];
v[idx].leverage = Number(e.target.value);
setMoneyManagementVariants(v);
}}
placeholder="Leverage"
min={1}
/>
<input
type="number"
className="input input-bordered w-16"
value={mm.tp}
onChange={e => {
const v = [...moneyManagementVariants];
v[idx].tp = Number(e.target.value);
setMoneyManagementVariants(v);
}}
placeholder="TP %"
min={0}
/>
<input
type="number"
className="input input-bordered w-16"
value={mm.sl}
onChange={e => {
const v = [...moneyManagementVariants];
v[idx].sl = Number(e.target.value);
setMoneyManagementVariants(v);
}}
placeholder="SL %"
min={0}
/>
<button
className="btn btn-xs btn-error"
onClick={e => {
e.preventDefault();
setMoneyManagementVariants(v => v.filter((_, i) => i !== idx));
}}
disabled={moneyManagementVariants.length === 1}
>
</button>
</div>
))}
<button
className="btn btn-sm btn-outline"
onClick={e => {
e.preventDefault();
setMoneyManagementVariants(v => [...v, { leverage: 1, tp: 1, sl: 1 }]);
}}
>
+ Add variant
</button>
</div>
</div>
{/* Right column: Test period, advanced params, capital, cart */}
<div>
{/* Test period variants */}
<div className="mb-4">
<label className="label">Select the test period</label>
{timeRangeVariants.map((tr, idx) => (
<div key={idx} className="flex gap-2 items-center mb-2">
<input
type="date"
className="input input-bordered"
value={tr.start}
onChange={e => {
const v = [...timeRangeVariants];
v[idx].start = e.target.value;
setTimeRangeVariants(v);
}}
/>
<input
type="date"
className="input input-bordered"
value={tr.end}
onChange={e => {
const v = [...timeRangeVariants];
v[idx].end = e.target.value;
setTimeRangeVariants(v);
}}
/>
<button
className="btn btn-xs btn-error"
onClick={e => {
e.preventDefault();
setTimeRangeVariants(v => v.filter((_, i) => i !== idx));
}}
disabled={timeRangeVariants.length === 1}
>
</button>
</div>
))}
<button
className="btn btn-sm btn-outline"
onClick={e => {
e.preventDefault();
setTimeRangeVariants(v => [...v, { start: '', end: '' }]);
}}
>
+ Add variant
</button>
</div>
{/* Advanced Parameters */}
<div className="mb-4">
<div className="collapse collapse-arrow bg-base-200">
<input type="checkbox" />
<div className="collapse-title font-medium">Advanced Parameters</div>
<div className="collapse-content">
<div className="flex gap-2 mb-2">
<input
type="number"
className="input input-bordered w-20"
value={cooldown}
onChange={e => setCooldown(Number(e.target.value))}
placeholder="Cooldown"
/>
<input
type="number"
className="input input-bordered w-20"
value={maxLossStreak}
onChange={e => setMaxLossStreak(Number(e.target.value))}
placeholder="Max Loss Streak"
/>
<input
type="number"
className="input input-bordered w-20"
value={maxPositionTime}
onChange={e => setMaxPositionTime(Number(e.target.value))}
placeholder="Max Position Time"
/>
</div>
<div className="form-control mb-2">
<label className="cursor-pointer label">
<span className="label-text">Position flipping</span>
<input
type="checkbox"
className="toggle"
checked={positionFlipping}
onChange={e => setPositionFlipping(e.target.checked)}
/>
</label>
</div>
<div className="form-control mb-2">
<label className="cursor-pointer label">
<span className="label-text">Flip only when in profit</span>
<input
type="checkbox"
className="toggle"
checked={flipOnlyInProfit}
onChange={e => setFlipOnlyInProfit(e.target.checked)}
/>
</label>
</div>
<div className="form-control mb-2">
<label className="cursor-pointer label">
<span className="label-text">Close early when profitable</span>
<input
type="checkbox"
className="toggle"
checked={closeEarly}
onChange={e => setCloseEarly(e.target.checked)}
/>
</label>
</div>
</div>
</div>
</div>
{/* Starting Capital */}
<div className="mb-4">
<label className="label">Starting Capital</label>
<input
type="number"
className="input input-bordered w-full"
value={startingCapital}
onChange={e => setStartingCapital(Number(e.target.value))}
min={1}
/>
</div>
{/* Backtest Cart */}
<div className="mb-4 bg-base-200 rounded-lg p-4">
<div className="font-bold mb-2">Backtest Cart</div>
<div>Total number of backtests: <span className="font-mono">{totalBacktests}</span></div>
<div>Total number of credits used: <span className="font-mono">{totalBacktests}</span></div>
<div>Estimated time: <span className="font-mono">~ 1 min</span></div>
<button className="btn btn-primary w-full mt-4" onClick={handleRunBundle}>
Run Backtest
</button>
<button className="btn btn-outline w-full mt-2">Save this backtest</button>
<button className="btn btn-ghost w-full mt-2">Clear all</button>
</div>
</div>
</div>
<div className="mt-10">
<BundleRequestsTable />
</div>
</div>
);
};
export default BacktestBundleForm;

View File

@@ -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<BundleBacktestRequest[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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 }) => (
<span className="font-mono text-xs cursor-pointer" title="Copy RequestId" onClick={() => {navigator.clipboard.writeText(value); new Toast('Copied RequestId!', false);}}>{value}</span>
),
},
{
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 (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className={`badge badge-sm ${getStatusBadgeColor(status)}`}>{status}</span>
<div className="text-xs">
{completed} / {total} {failed > 0 && <span className="text-error ml-1">({failed} failed)</span>}
</div>
</div>
<progress className={`progress w-40 ${getProgressColor(percent)}`} value={percent} max="100" />
</div>
);
},
},
{
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) => (
<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-error"
onClick={() => handleDelete(row.original.requestId)}
disabled={deletingId === row.original.requestId}
>
{deletingId === row.original.requestId ? 'Deleting...' : 'Delete'}
</button>
</div>
),
},
];
if (loading) return <div className="text-center">Loading bundle requests...</div>;
if (error) return <div className="text-error">{error}</div>;
return (
<div className="w-full">
<h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2>
<Table columns={columns} data={data} showPagination={true} />
</div>
);
};
export default BundleRequestsTable;