Add custom scenario

This commit is contained in:
2025-06-21 12:25:28 +07:00
parent cc330e8cc3
commit 453806356d
4 changed files with 376 additions and 25 deletions

View File

@@ -118,9 +118,9 @@ public class BacktestController : BaseController
return BadRequest("Account name is required");
}
if (string.IsNullOrEmpty(request.Config.ScenarioName))
if (string.IsNullOrEmpty(request.Config.ScenarioName) && request.Config.Scenario == null)
{
return BadRequest("Scenario name is required");
return BadRequest("Either scenario name or scenario object is required");
}
if (string.IsNullOrEmpty(request.MoneyManagementName) && request.MoneyManagement == null)
@@ -131,13 +131,9 @@ public class BacktestController : BaseController
try
{
Backtest backtestResult = null;
var scenario = _scenarioService.GetScenario(request.Config.ScenarioName);
var account = await _accountService.GetAccount(request.Config.AccountName, true, false);
var user = await GetUser();
if (scenario == null)
return BadRequest("No scenario found");
// Get money management
MoneyManagement moneyManagement;
if (!string.IsNullOrEmpty(request.MoneyManagementName))
@@ -152,13 +148,14 @@ public class BacktestController : BaseController
moneyManagement?.FormatPercentage();
}
// Update config with money management
// Update config with money management - TradingBot will handle scenario loading
var backtestConfig = new TradingBotConfig
{
AccountName = request.Config.AccountName,
MoneyManagement = moneyManagement,
Ticker = request.Config.Ticker,
ScenarioName = request.Config.ScenarioName,
Scenario = request.Config.Scenario,
Timeframe = request.Config.Timeframe,
IsForWatchingOnly = request.WatchOnly,
BotTradingBalance = request.Balance,
@@ -170,9 +167,8 @@ public class BacktestController : BaseController
FlipOnlyWhenInProfit = request.Config.FlipOnlyWhenInProfit,
FlipPosition = request.Config.FlipPosition,
Name = request.Config.Name ??
$"Backtest-{request.Config.ScenarioName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}",
$"Backtest-{request.Config.ScenarioName ?? request.Config.Scenario?.Name ?? "Custom"}-{DateTime.UtcNow:yyyyMMdd-HHmmss}",
CloseEarlyWhenProfitable = request.Config.CloseEarlyWhenProfitable,
Scenario = scenario,
};
switch (request.Config.BotType)

View File

@@ -0,0 +1,18 @@
import {create} from 'zustand'
import type {Scenario} from '../../generated/ManagingApi'
type CustomScenarioStore = {
setCustomScenario: (custom: Scenario) => void
scenario: Scenario | null
}
export const useCustomScenario = create<CustomScenarioStore>((set) => ({
scenario: null,
setCustomScenario: (custom) => {
set((state) => ({
...state,
scenario: custom,
}))
},
}))

View File

@@ -11,6 +11,7 @@ import {
MoneyManagement,
MoneyManagementClient,
RunBacktestRequest,
Scenario,
ScenarioClient,
Ticker,
Timeframe,
@@ -21,6 +22,7 @@ import {Loader, Slider} from '../../atoms'
import {Modal, Toast} from '../../mollecules'
import FormInput from '../../mollecules/FormInput/FormInput'
import CustomMoneyManagement from '../CustomMoneyManagement/CustomMoneyManagement'
import CustomScenario from '../CustomScenario/CustomScenario'
const BacktestModal: React.FC<BacktestModalProps> = ({
showModal,
@@ -66,6 +68,13 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
const [showCustomMoneyManagement, setShowCustomMoneyManagement] =
useState(false)
const [customScenario, setCustomScenario] =
React.useState<Scenario | undefined>(undefined)
const [selectedScenario, setSelectedScenario] =
useState<string>()
const [showCustomScenario, setShowCustomScenario] =
useState(false)
const { apiUrl } = useApiUrlStore()
const scenarioClient = new ScenarioClient({}, apiUrl)
@@ -75,8 +84,15 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
const backtestClient = new BacktestClient({}, apiUrl)
const onSubmit: SubmitHandler<IBacktestsFormInput> = async (form) => {
if (!form.scenarioName) {
const t = new Toast('Please select a scenario', false);
// Check if we have either a selected scenario or a custom scenario
if (!form.scenarioName && !customScenario) {
const t = new Toast('Please select a scenario or create a custom scenario', false);
return;
}
// If using custom scenario, validate it has indicators
if (customScenario && (!customScenario.indicators || customScenario.indicators.length === 0)) {
const t = new Toast('Custom scenario must have at least one indicator', false);
return;
}
@@ -108,13 +124,15 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
const t = new Toast(ticker + ' is running')
console.log(customMoneyManagement)
console.log(customScenario)
try {
// Create the TradingBotConfig
const tradingBotConfig: TradingBotConfig = {
accountName: form.accountName,
ticker: ticker as Ticker,
scenarioName: scenarioName,
scenarioName: customScenario ? undefined : scenarioName,
scenario: customScenario,
timeframe: form.timeframe,
botType: form.botType,
isForWatchingOnly: false, // Always false for backtests
@@ -124,7 +142,7 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
maxPositionTimeHours: form.maxPositionTimeHours || null,
flipOnlyWhenInProfit: form.flipOnlyWhenInProfit ?? true,
flipPosition: form.botType === BotType.FlippingBot, // Set based on bot type
name: `Backtest-${scenarioName}-${ticker}-${new Date().toISOString()}`,
name: `Backtest-${customScenario ? customScenario.name : scenarioName}-${ticker}-${new Date().toISOString()}`,
botTradingBalance: form.balance,
moneyManagement: customMoneyManagement || moneyManagements?.find(m => m.name === selectedMoneyManagement) || moneyManagements?.[0] || {
name: 'placeholder',
@@ -189,6 +207,17 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
}
}
function onScenarioChange(e: any) {
if (e.target.value === 'custom') {
setShowCustomScenario(true)
setCustomScenario(e.target.value)
} else {
setShowCustomScenario(false)
setCustomScenario(undefined)
setSelectedScenario(e.target.value)
}
}
const { data: accounts } = useQuery({
queryFn: () => accountClient.account_GetAccounts(),
queryKey: ['accounts'],
@@ -370,7 +399,11 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
<FormInput label="Scenario" htmlFor="scenarioName">
<select
className="select select-bordered w-full"
{...register('scenarioName')}
{...register('scenarioName', {
onChange(event) {
onScenarioChange(event)
},
})}
>
<option value="" disabled>Select a scenario</option>
{scenarios.map((item) => (
@@ -378,6 +411,9 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
{item.name || 'Unnamed Scenario'}
</option>
))}
<option key="custom" value="custom">
Custom
</option>
</select>
</FormInput>
@@ -397,6 +433,15 @@ const BacktestModal: React.FC<BacktestModalProps> = ({
</FormInput>
</div>
{showCustomScenario && (
<div className="mt-6">
<CustomScenario
onCreateScenario={setCustomScenario}
showCustomScenario={showCustomScenario}
></CustomScenario>
</div>
)}
{/* Fourth Row: Balance & Cooldown Period */}
<div className="grid grid-cols-2 gap-4">
<FormInput label="Balance" htmlFor="balance">

View File

@@ -0,0 +1,292 @@
import React, {useEffect, useState} from 'react'
import type {Indicator, Scenario} from '../../../generated/ManagingApi'
import {IndicatorType} from '../../../generated/ManagingApi'
import FormInput from '../../mollecules/FormInput/FormInput'
import {useCustomScenario} from '../../../app/store/customScenario'
type ICustomScenario = {
onCreateScenario: (scenario: Scenario) => void
showCustomScenario: boolean
}
const CustomScenario: React.FC<ICustomScenario> = ({
onCreateScenario,
showCustomScenario,
}) => {
const { scenario, setCustomScenario } = useCustomScenario()
const [name, setName] = useState<string>(scenario?.name || 'Custom Scenario')
const [loopbackPeriod, setLoopbackPeriod] = useState<number>(scenario?.loopbackPeriod || 1)
const [indicators, setIndicators] = useState<Indicator[]>(scenario?.indicators || [])
// Available indicator types with their required parameters
const indicatorTypes = Object.values(IndicatorType).map(type => {
let params: string[] = [];
let label = '';
// Determine required parameters based on indicator type
switch (type) {
case IndicatorType.RsiDivergence:
case IndicatorType.RsiDivergenceConfirm:
case IndicatorType.EmaTrend:
case IndicatorType.EmaCross:
case IndicatorType.StDev:
case IndicatorType.ThreeWhiteSoldiers:
params = ['period'];
break;
case IndicatorType.MacdCross:
params = ['fastPeriods', 'slowPeriods', 'signalPeriods'];
break;
case IndicatorType.DualEmaCross:
params = ['fastPeriods', 'slowPeriods'];
break;
case IndicatorType.SuperTrend:
case IndicatorType.SuperTrendCrossEma:
case IndicatorType.ChandelierExit:
params = ['period', 'multiplier'];
break;
case IndicatorType.StochRsiTrend:
params = ['period', 'stochPeriods', 'signalPeriods', 'smoothPeriods'];
break;
case IndicatorType.Stc:
case IndicatorType.LaggingStc:
params = ['cyclePeriods', 'fastPeriods', 'slowPeriods'];
break;
case IndicatorType.Composite:
params = []; // Composite might not need specific parameters
break;
default:
params = ['period']; // Default fallback
break;
}
// Create more user-friendly labels
switch (type) {
case IndicatorType.RsiDivergence:
label = 'RSI Divergence';
break;
case IndicatorType.RsiDivergenceConfirm:
label = 'RSI Divergence Confirm';
break;
case IndicatorType.MacdCross:
label = 'MACD Cross';
break;
case IndicatorType.EmaCross:
label = 'EMA Cross';
break;
case IndicatorType.DualEmaCross:
label = 'Dual EMA Cross';
break;
case IndicatorType.SuperTrend:
label = 'Super Trend';
break;
case IndicatorType.ChandelierExit:
label = 'Chandelier Exit';
break;
case IndicatorType.EmaTrend:
label = 'EMA Trend';
break;
case IndicatorType.StochRsiTrend:
label = 'Stoch RSI Trend';
break;
case IndicatorType.Stc:
label = 'STC';
break;
case IndicatorType.StDev:
label = 'Standard Deviation';
break;
case IndicatorType.LaggingStc:
label = 'Lagging STC';
break;
case IndicatorType.SuperTrendCrossEma:
label = 'Super Trend Cross EMA';
break;
case IndicatorType.ThreeWhiteSoldiers:
label = 'Three White Soldiers';
break;
case IndicatorType.Composite:
label = 'Composite';
break;
default:
label = type;
break;
}
return { type, label, params };
});
const addIndicator = () => {
const newIndicator: Indicator = {
name: `Indicator ${indicators.length + 1}`,
type: indicatorTypes[0].type,
period: 14,
fastPeriods: 12,
slowPeriods: 26,
signalPeriods: 9,
multiplier: 3.0,
stochPeriods: 14,
smoothPeriods: 3,
cyclePeriods: 10
}
setIndicators([...indicators, newIndicator])
}
const removeIndicator = (index: number) => {
setIndicators(indicators.filter((_, i) => i !== index))
}
const updateIndicator = (index: number, field: string, value: any) => {
const updated = indicators.map((indicator, i) => {
if (i === index) {
return { ...indicator, [field]: value }
}
return indicator
})
setIndicators(updated)
}
const handleCreateScenario = () => {
const scenario: Scenario = {
name,
indicators,
loopbackPeriod,
}
onCreateScenario(scenario)
setCustomScenario(scenario)
}
useEffect(() => {
handleCreateScenario()
}, [name, loopbackPeriod, indicators])
const getRequiredParams = (indicatorType: IndicatorType) => {
return indicatorTypes.find(t => t.type === indicatorType)?.params || []
}
return (
<>
{showCustomScenario ? (
<div className="collapse bg-base-200">
<input type="checkbox" />
<div className="collapse-title text-xs font-medium">
Custom Scenario Builder
</div>
<div className="collapse-content">
<div className="space-y-4">
{/* Scenario basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormInput label="Scenario Name" htmlFor="scenarioName" inline={false}>
<input
id="scenarioName"
value={name}
onChange={(e) => setName(e.target.value)}
type='text'
className='input input-bordered w-full'
placeholder="Enter scenario name"
/>
</FormInput>
<FormInput label="Lookback Period" htmlFor="loopbackPeriod" inline={false}>
<input
id="loopbackPeriod"
value={loopbackPeriod}
onChange={(e) => setLoopbackPeriod(Number(e.target.value))}
type='number'
min="1"
step="1"
className='input input-bordered w-full'
/>
</FormInput>
</div>
{/* Indicators section */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<h4 className="font-medium">Indicators</h4>
<button
type="button"
onClick={addIndicator}
className="btn btn-sm btn-primary"
>
Add Indicator
</button>
</div>
{indicators.map((indicator, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3 bg-base-100">
<div className="flex justify-between items-start">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<FormInput label="Indicator Name" htmlFor={`indicatorName-${index}`} inline={false}>
<input
value={indicator.name || ''}
onChange={(e) => updateIndicator(index, 'name', e.target.value)}
type='text'
className='input input-bordered w-full'
/>
</FormInput>
<FormInput label="Indicator Type" htmlFor={`indicatorType-${index}`} inline={false}>
<select
value={indicator.type || indicatorTypes[0].type}
onChange={(e) => updateIndicator(index, 'type', e.target.value)}
className='select select-bordered w-full'
>
{indicatorTypes.map((type) => (
<option key={type.type} value={type.type}>
{type.label}
</option>
))}
</select>
</FormInput>
</div>
<button
type="button"
onClick={() => removeIndicator(index)}
className="btn btn-sm btn-error ml-2"
>
Remove
</button>
</div>
{/* Dynamic parameter inputs based on indicator type */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{getRequiredParams(indicator.type || indicatorTypes[0].type).map((param) => (
<FormInput key={param} label={param.charAt(0).toUpperCase() + param.slice(1)} htmlFor={`${param}-${index}`} inline={false}>
<input
value={indicator[param as keyof Indicator] as number || ''}
onChange={(e) => updateIndicator(index, param, param.includes('multiplier') ? parseFloat(e.target.value) : parseInt(e.target.value))}
type='number'
step={param.includes('multiplier') ? '0.1' : '1'}
min={param.includes('multiplier') ? '0.1' : '1'}
className='input input-bordered w-full'
/>
</FormInput>
))}
</div>
</div>
))}
{indicators.length === 0 && (
<div className="text-center text-gray-500 py-4">
No indicators added. Click "Add Indicator" to start building your scenario.
</div>
)}
</div>
</div>
</div>
</div>
) : null}
</>
)
}
export default CustomScenario