* 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
450 lines
16 KiB
TypeScript
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
|