From 453806356d6c688ee36d980fbbf18dbb7eef522d Mon Sep 17 00:00:00 2001 From: cryptooda Date: Sat, 21 Jun 2025 12:25:28 +0700 Subject: [PATCH] Add custom scenario --- .../Controllers/BacktestController.cs | 14 +- .../src/app/store/customScenario.tsx | 18 ++ .../organism/Backtest/backtestModal.tsx | 77 ++++- .../CustomScenario/CustomScenario.tsx | 292 ++++++++++++++++++ 4 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 src/Managing.WebApp/src/app/store/customScenario.tsx create mode 100644 src/Managing.WebApp/src/components/organism/CustomScenario/CustomScenario.tsx diff --git a/src/Managing.Api/Controllers/BacktestController.cs b/src/Managing.Api/Controllers/BacktestController.cs index b629fd5..8138f6c 100644 --- a/src/Managing.Api/Controllers/BacktestController.cs +++ b/src/Managing.Api/Controllers/BacktestController.cs @@ -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) diff --git a/src/Managing.WebApp/src/app/store/customScenario.tsx b/src/Managing.WebApp/src/app/store/customScenario.tsx new file mode 100644 index 0000000..7bde76c --- /dev/null +++ b/src/Managing.WebApp/src/app/store/customScenario.tsx @@ -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((set) => ({ + scenario: null, + setCustomScenario: (custom) => { + set((state) => ({ + ...state, + scenario: custom, + })) + }, +})) \ No newline at end of file diff --git a/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx b/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx index a563218..98f3b1e 100644 --- a/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx +++ b/src/Managing.WebApp/src/components/organism/Backtest/backtestModal.tsx @@ -4,23 +4,25 @@ import {type SubmitHandler, useForm} from 'react-hook-form' import useApiUrlStore from '../../../app/store/apiStore' import { - AccountClient, - BacktestClient, - BotType, - DataClient, - MoneyManagement, - MoneyManagementClient, - RunBacktestRequest, - ScenarioClient, - Ticker, - Timeframe, - TradingBotConfig, + AccountClient, + BacktestClient, + BotType, + DataClient, + MoneyManagement, + MoneyManagementClient, + RunBacktestRequest, + Scenario, + ScenarioClient, + Ticker, + Timeframe, + TradingBotConfig, } from '../../../generated/ManagingApi' import type {BacktestModalProps, IBacktestsFormInput,} from '../../../global/type' 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 = ({ showModal, @@ -66,6 +68,13 @@ const BacktestModal: React.FC = ({ const [showCustomMoneyManagement, setShowCustomMoneyManagement] = useState(false) + const [customScenario, setCustomScenario] = + React.useState(undefined) + const [selectedScenario, setSelectedScenario] = + useState() + const [showCustomScenario, setShowCustomScenario] = + useState(false) + const { apiUrl } = useApiUrlStore() const scenarioClient = new ScenarioClient({}, apiUrl) @@ -75,8 +84,15 @@ const BacktestModal: React.FC = ({ const backtestClient = new BacktestClient({}, apiUrl) const onSubmit: SubmitHandler = 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 = ({ 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 = ({ 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 = ({ } } + 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 = ({ @@ -397,6 +433,15 @@ const BacktestModal: React.FC = ({ + {showCustomScenario && ( +
+ +
+ )} + {/* Fourth Row: Balance & Cooldown Period */}
diff --git a/src/Managing.WebApp/src/components/organism/CustomScenario/CustomScenario.tsx b/src/Managing.WebApp/src/components/organism/CustomScenario/CustomScenario.tsx new file mode 100644 index 0000000..2fa4531 --- /dev/null +++ b/src/Managing.WebApp/src/components/organism/CustomScenario/CustomScenario.tsx @@ -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 = ({ + onCreateScenario, + showCustomScenario, +}) => { + const { scenario, setCustomScenario } = useCustomScenario() + + const [name, setName] = useState(scenario?.name || 'Custom Scenario') + const [loopbackPeriod, setLoopbackPeriod] = useState(scenario?.loopbackPeriod || 1) + const [indicators, setIndicators] = useState(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 ? ( +
+ +
+ Custom Scenario Builder +
+
+
+ {/* Scenario basic info */} +
+ + setName(e.target.value)} + type='text' + className='input input-bordered w-full' + placeholder="Enter scenario name" + /> + + + + setLoopbackPeriod(Number(e.target.value))} + type='number' + min="1" + step="1" + className='input input-bordered w-full' + /> + +
+ + {/* Indicators section */} +
+
+

Indicators

+ +
+ + {indicators.map((indicator, index) => ( +
+
+
+ + updateIndicator(index, 'name', e.target.value)} + type='text' + className='input input-bordered w-full' + /> + + + + + +
+ + +
+ + {/* Dynamic parameter inputs based on indicator type */} +
+ {getRequiredParams(indicator.type || indicatorTypes[0].type).map((param) => ( + + 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' + /> + + ))} +
+
+ ))} + + {indicators.length === 0 && ( +
+ No indicators added. Click "Add Indicator" to start building your scenario. +
+ )} +
+
+
+
+ ) : null} + + ) +} + +export default CustomScenario \ No newline at end of file