Files
managing-apps/src/Managing.WebApp/src/components/organism/Backtest/backtestRowDetails.tsx
Oda 082ae8714b Trading bot grain (#33)
* Trading bot Grain

* Fix a bit more of the trading bot

* Advance on the tradingbot grain

* Fix build

* Fix db script

* Fix user login

* Fix a bit backtest

* Fix cooldown and backtest

* start fixing bot start

* Fix startup

* Setup local db

* Fix build and update candles and scenario

* Add bot registry

* Add reminder

* Updateing the grains

* fix bootstraping

* Save stats on tick

* Save bot data every tick

* Fix serialization

* fix save bot stats

* Fix get candles

* use dict instead of list for position

* Switch hashset to dict

* Fix a bit

* Fix bot launch and bot view

* add migrations

* Remove the tolist

* Add agent grain

* Save agent summary

* clean

* Add save bot

* Update get bots

* Add get bots

* Fix stop/restart

* fix Update config

* Update scanner table on new backtest saved

* Fix backtestRowDetails.tsx

* Fix agentIndex

* Update agentIndex

* Fix more things

* Update user cache

* Fix

* Fix account load/start/restart/run
2025-08-05 04:07:06 +07:00

450 lines
16 KiB
TypeScript

import {CardPositionItem, TradeChart} from '..'
import {
Backtest,
BacktestClient,
CandlesWithIndicatorsResponse,
DataClient,
GetCandlesWithIndicatorsRequest,
IndicatorType,
LightSignal,
Position,
SignalType
} from '../../../generated/ManagingApi'
import {CardPosition, CardText} from '../../mollecules'
import {useQuery} from '@tanstack/react-query'
import useApiUrlStore from '../../../app/store/apiStore'
interface IBacktestRowDetailsProps {
backtest: Backtest;
}
const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
backtest
}) => {
const {apiUrl} = useApiUrlStore();
const dataClient = new DataClient({}, apiUrl);
const backtestClient = new BacktestClient({}, apiUrl);
// Use TanStack Query to load full backtest data
const {data: fullBacktestData, isLoading: isLoadingFullBacktest, error: fullBacktestError} = useQuery({
queryKey: ['fullBacktest', backtest.id],
queryFn: async (): Promise<Backtest> => {
return await backtestClient.backtest_Backtest(backtest.id);
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
});
// Use TanStack Query to load candles with indicators
const {data: candlesData, isLoading: isLoadingCandles, error} = useQuery({
queryKey: ['candlesWithIndicators', backtest.id, backtest.config?.scenario?.name],
queryFn: async (): Promise<CandlesWithIndicatorsResponse> => {
// Only fetch if no candles are present
if (backtest.candles && backtest.candles.length > 0) {
return {
candles: backtest.candles,
indicatorsValues: {} // Default empty object since Backtest doesn't have indicatorsValues
};
}
const request: GetCandlesWithIndicatorsRequest = {
ticker: backtest.config.ticker,
startDate: backtest.startDate,
endDate: backtest.endDate,
timeframe: backtest.config.timeframe,
scenario: backtest.config?.scenario ? {
name: backtest.config.scenario.name || '',
indicators: backtest.config.scenario.indicators?.map(indicator => ({
name: indicator.name || '',
type: indicator.type || IndicatorType.RsiDivergence,
signalType: indicator.signalType || SignalType.Signal,
minimumHistory: indicator.minimumHistory || 0,
period: indicator.period,
fastPeriods: indicator.fastPeriods,
slowPeriods: indicator.slowPeriods,
signalPeriods: indicator.signalPeriods,
multiplier: indicator.multiplier,
smoothPeriods: indicator.smoothPeriods,
stochPeriods: indicator.stochPeriods,
cyclePeriods: indicator.cyclePeriods
})) || [],
loopbackPeriod: backtest.config.scenario.loopbackPeriod
} : undefined
};
return await dataClient.data_GetCandlesWithIndicators(request);
},
enabled: !backtest.candles || backtest.candles.length === 0, // Only run query if no candles exist
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
});
// Use the full backtest data if available, otherwise fallback to the original backtest
const currentBacktest = fullBacktestData || backtest;
// Use the data from query or fallback to backtest data
const candles = candlesData?.candles || currentBacktest.candles || [];
const indicatorsValues = candlesData?.indicatorsValues || {};
// Convert positions and signals objects to arrays
const positionsArray: Position[] = Object.values(currentBacktest.positions || {});
const signalsArray: LightSignal[] = Object.values(currentBacktest.signals || {});
const walletBalances = currentBacktest.walletBalances || [];
const statistics = currentBacktest.statistics;
const config = currentBacktest.config;
// Helper function to calculate position open time in hours
const calculateOpenTimeInHours = (position: Position): number => {
const openDate = new Date(position.Open.date);
let closeDate: Date | null = null;
// Determine close date based on realized P&L (matching backend logic)
if (position.ProfitAndLoss?.realized != null) {
if (position.ProfitAndLoss.realized > 0) {
// Profitable close = Take Profit
closeDate = new Date(position.TakeProfit1.date);
} else {
// Loss or breakeven close = Stop Loss
closeDate = new Date(position.StopLoss.date);
}
}
if (closeDate) {
const diffInMs = closeDate.getTime() - openDate.getTime();
return diffInMs / (1000 * 60 * 60); // Convert to hours
}
return 0;
};
// Calculate average open time for winning positions
const getAverageOpenTimeWinning = (): string => {
const winningPositions = positionsArray.filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0;
return realized > 0;
});
if (winningPositions.length === 0) return "0.00";
const totalHours = winningPositions.reduce((sum: number, position: Position) => {
return sum + calculateOpenTimeInHours(position);
}, 0);
const averageHours = totalHours / winningPositions.length;
return averageHours.toFixed(2);
};
// Calculate average open time for losing positions
const getAverageOpenTimeLosing = (): string => {
const losingPositions = positionsArray.filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0;
return realized <= 0;
});
if (losingPositions.length === 0) return "0.00";
const totalHours = losingPositions.reduce((sum: number, position: Position) => {
return sum + calculateOpenTimeInHours(position);
}, 0);
const averageHours = totalHours / losingPositions.length;
return averageHours.toFixed(2);
};
// Calculate maximum open time for winning positions
const getMaxOpenTimeWinning = (): string => {
const winningPositions = positionsArray.filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0;
return realized > 0;
});
if (winningPositions.length === 0) return "0.00";
const openTimes = winningPositions.map((position: Position) => calculateOpenTimeInHours(position));
const maxHours = Math.max(...openTimes);
return maxHours.toFixed(2);
};
// Calculate median opening time for all positions
const getMedianOpenTime = (): string => {
if (positionsArray.length === 0) return "0.00";
const openTimes = positionsArray.map((position: Position) => calculateOpenTimeInHours(position));
const sortedTimes = openTimes.sort((a: number, b: number) => a - b);
const mid = Math.floor(sortedTimes.length / 2);
const median = sortedTimes.length % 2 === 0
? (sortedTimes[mid - 1] + sortedTimes[mid]) / 2
: sortedTimes[mid];
return median.toFixed(2);
};
// Calculate total volume traded with leverage
const getTotalVolumeTraded = (): number => {
let totalVolume = 0;
positionsArray.forEach((position: Position) => {
// Calculate volume for open trade
const openLeverage = position.Open.leverage || 1;
const openVolume = position.Open.quantity * position.Open.price * openLeverage;
totalVolume += openVolume;
// Calculate volume for close trade (stopLoss or takeProfit based on realized P&L)
if (position.ProfitAndLoss?.realized != null) {
let closeTrade;
if (position.ProfitAndLoss.realized > 0) {
// Profitable close = Take Profit
closeTrade = position.TakeProfit1;
} else {
// Loss or breakeven close = Stop Loss
closeTrade = position.StopLoss;
}
if (closeTrade) {
const closeLeverage = closeTrade.leverage || 1;
const closeVolume = closeTrade.quantity * closeTrade.price * closeLeverage;
totalVolume += closeVolume;
}
}
});
return totalVolume;
};
// Calculate estimated UI fee (0.02% of total volume)
const getEstimatedUIFee = (): number => {
const totalVolume = getTotalVolumeTraded();
const uiFeePercentage = 0.001; // 0.1%
return totalVolume * uiFeePercentage;
};
// Calculate recommended cooldown based on positions that fail after a win
const getCooldownRecommendations = () => {
if (positionsArray.length < 2 || !candles || candles.length < 2) {
return { percentile75: "0", average: "0", median: "0" };
}
// Determine candle timeframe in milliseconds
const candleTimeframeMs = new Date(candles[1].date).getTime() - new Date(candles[0].date).getTime();
const sortedPositions = [...positionsArray].sort((a: Position, b: Position) => {
const dateA = new Date(a.Open.date).getTime();
const dateB = new Date(b.Open.date).getTime();
return dateA - dateB;
});
const failAfterWinGaps: number[] = [];
for (let i = 0; i < sortedPositions.length - 1; i++) {
const currentPosition = sortedPositions[i];
const nextPosition = sortedPositions[i + 1];
const currentRealized = currentPosition.ProfitAndLoss?.realized ?? 0;
const nextRealized = nextPosition.ProfitAndLoss?.realized ?? 0;
// Check if current position is winning and next position is losing
if (currentRealized > 0 && nextRealized <= 0) {
// Calculate the close time of the current (winning) position
let currentCloseDate: Date | null = null;
if (currentPosition.ProfitAndLoss?.realized != null) {
if (currentPosition.ProfitAndLoss.realized > 0) {
currentCloseDate = new Date(currentPosition.TakeProfit1.date);
} else {
currentCloseDate = new Date(currentPosition.StopLoss.date);
}
}
if (currentCloseDate) {
const nextOpenDate = new Date(nextPosition.Open.date);
const gapInMs = nextOpenDate.getTime() - currentCloseDate.getTime();
if (gapInMs >= 0) { // Only consider positive gaps
// Convert milliseconds to number of candles
const gapInCandles = Math.floor(gapInMs / candleTimeframeMs);
failAfterWinGaps.push(gapInCandles);
}
}
}
}
if (failAfterWinGaps.length === 0) {
return { percentile75: "0", average: "0", median: "0" };
}
// Calculate the 75th percentile
const sortedGaps = [...failAfterWinGaps].sort((a: number, b: number) => a - b);
const percentile75Index = Math.floor(sortedGaps.length * 0.75);
const percentile75 = sortedGaps[percentile75Index] || 0;
// Calculate the average
const sum = failAfterWinGaps.reduce((acc: number, gap: number) => acc + gap, 0);
const average = sum / failAfterWinGaps.length;
// Calculate the median
const mid = Math.floor(sortedGaps.length / 2);
const median = sortedGaps.length % 2 === 0
? (sortedGaps[mid - 1] + sortedGaps[mid]) / 2
: sortedGaps[mid];
return {
percentile75: Math.ceil(percentile75).toString(),
average: Math.ceil(average).toString(),
median: Math.ceil(median).toString()
};
};
const cooldownRecommendations = getCooldownRecommendations();
// Calculate average trades per day
const getAverageTradesPerDay = (): string => {
if (positionsArray.length === 0) return "0.00";
// Get all trade dates and sort them
const tradeDates = positionsArray.map((position: Position) => new Date(position.Open.date)).sort((a: Date, b: Date) => a.getTime() - b.getTime());
if (tradeDates.length < 2) return positionsArray.length.toString();
// Calculate the date range in days
const firstTradeDate = tradeDates[0];
const lastTradeDate = tradeDates[tradeDates.length - 1];
const diffInMs = lastTradeDate.getTime() - firstTradeDate.getTime();
const diffInDays = Math.max(1, diffInMs / (1000 * 60 * 60 * 24)); // Ensure at least 1 day
const averageTradesPerDay = positionsArray.length / diffInDays;
return averageTradesPerDay.toFixed(2);
};
return (
<>
<div className="grid grid-flow-row">
{(isLoadingCandles || isLoadingFullBacktest) && (
<div className="flex justify-center items-center p-4">
<div className="loading loading-spinner loading-lg"></div>
<span className="ml-2">
{isLoadingFullBacktest ? "Loading backtest data..." : "Loading candles with indicators..."}
</span>
</div>
)}
<div className="grid grid-cols-4 p-5">
<CardPosition
positivePosition={true}
positions={positionsArray.filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0
return realized > 0 ? p : null
})}
></CardPosition>
<CardPosition
positivePosition={false}
positions={positionsArray.filter((p: Position) => {
const realized = p.ProfitAndLoss?.realized ?? 0
return realized <= 0 ? p : null
})}
></CardPosition>
<CardPositionItem positions={positionsArray}></CardPositionItem>
<CardText
title="Max Drowdown"
content={
(statistics?.maxDrawdown?.toFixed(4) || '0.0000') +
'$'
}
></CardText>
<CardText
title="Sharpe Ratio"
content={
(statistics?.sharpeRatio
? statistics.sharpeRatio * 100
: 0
)
.toFixed(4)
.toString() + '%'
}
></CardText>
<CardText
title="Max Drawdown Recovery Time"
content={
(statistics?.maxDrawdownRecoveryTime?.toString() || '0') +
' days'
}
></CardText>
<CardText
title="Money Management"
content={
"SL: " +(config.moneyManagement?.stopLoss * 100).toFixed(2) + "% TP: " +
(config.moneyManagement?.takeProfit * 100).toFixed(2) + "%" + " Lev.: x" + config.moneyManagement?.leverage
}
></CardText>
<CardText
title="Avg Open Time (Winning)"
content={getAverageOpenTimeWinning() + " hours"}
></CardText>
<CardText
title="Avg Open Time (Losing)"
content={getAverageOpenTimeLosing() + " hours"}
></CardText>
<CardText
title="Max Open Time (Winning)"
content={getMaxOpenTimeWinning() + " hours"}
></CardText>
<CardText
title="Median Open Time"
content={getMedianOpenTime() + " hours"}
></CardText>
<CardText
title="Volume Traded"
content={"$" + getTotalVolumeTraded().toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
></CardText>
<CardText
title="Estimated UI Fee"
content={"$" + getEstimatedUIFee().toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
></CardText>
<CardText
title="Total Fees"
content={"$" + currentBacktest.fees.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
></CardText>
<CardText
title="Recommended Cooldown"
content={cooldownRecommendations.percentile75 + " candles"}
></CardText>
<CardText
title="Average Cooldown"
content={cooldownRecommendations.average + " candles"}
></CardText>
<CardText
title="Median Cooldown"
content={cooldownRecommendations.median + " candles"}
></CardText>
<CardText
title="Avg Trades Per Day"
content={getAverageTradesPerDay() + " trades/day"}
></CardText>
</div>
{!isLoadingCandles && !isLoadingFullBacktest && (
<div className="w-full">
<figure className="w-full">
<TradeChart
candles={candles}
positions={positionsArray}
walletBalances={walletBalances}
indicatorsValues={indicatorsValues}
signals={signalsArray}
height={1000}
></TradeChart>
</figure>
</div>
)}
</div>
</>
)
}
export default BacktestRowDetails