Fix SLTP for backtests

This commit is contained in:
2025-11-12 23:52:58 +07:00
parent 3b176c290c
commit 6d6f70ae00
5 changed files with 226 additions and 13 deletions

View File

@@ -1706,12 +1706,22 @@ public class TradingBotBase : ITradingBot
if (wasStopLossHit) if (wasStopLossHit)
{ {
// Use actual execution price based on direction // For backtesting: use the configured SL price to ensure consistent PnL per money management
closingPrice = position.OriginDirection == TradeDirection.Long // For live trading: use actual execution price to reflect real market conditions (slippage)
? minPriceRecent // For LONG, SL hits at the low if (Config.IsForBacktest)
: maxPriceRecent; // For SHORT, SL hits at the high {
closingPrice = position.StopLoss.Price;
}
else
{
// Use actual execution price based on direction for live trading
closingPrice = position.OriginDirection == TradeDirection.Long
? minPriceRecent // For LONG, SL hits at the low
: maxPriceRecent; // For SHORT, SL hits at the high
position.StopLoss.Price = closingPrice;
}
position.StopLoss.SetPrice(closingPrice, 2);
position.StopLoss.SetDate(currentCandle.Date); position.StopLoss.SetDate(currentCandle.Date);
position.StopLoss.SetStatus(TradeStatus.Filled); position.StopLoss.SetStatus(TradeStatus.Filled);
@@ -1729,17 +1739,28 @@ public class TradingBotBase : ITradingBot
await LogDebug( await LogDebug(
$"🛑 Stop Loss Execution Confirmed\n" + $"🛑 Stop Loss Execution Confirmed\n" +
$"Position: `{position.Identifier}`\n" + $"Position: `{position.Identifier}`\n" +
$"SL Price: `${closingPrice:F2}` was hit (was `${position.StopLoss.Price:F2}`)\n" + $"Closing Price: `${closingPrice:F2}`\n" +
$"Configured SL: `${position.StopLoss.Price:F2}`\n" +
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
} }
else if (wasTakeProfitHit) else if (wasTakeProfitHit)
{ {
// Use actual execution price based on direction // For backtesting: use the configured TP price to ensure consistent PnL per money management
closingPrice = position.OriginDirection == TradeDirection.Long // For live trading: use actual execution price to reflect real market conditions (slippage)
? maxPriceRecent // For LONG, TP hits at the high if (Config.IsForBacktest)
: minPriceRecent; // For SHORT, TP hits at the low {
closingPrice = position.TakeProfit1.Price;
}
else
{
// Use actual execution price based on direction for live trading
closingPrice = position.OriginDirection == TradeDirection.Long
? maxPriceRecent // For LONG, TP hits at the high
: minPriceRecent; // FOR SHORT, TP hits at the low
position.TakeProfit1.Price = closingPrice;
}
position.TakeProfit1.SetPrice(closingPrice, 2);
position.TakeProfit1.SetDate(currentCandle.Date); position.TakeProfit1.SetDate(currentCandle.Date);
position.TakeProfit1.SetStatus(TradeStatus.Filled); position.TakeProfit1.SetStatus(TradeStatus.Filled);
@@ -1752,7 +1773,8 @@ public class TradingBotBase : ITradingBot
await LogDebug( await LogDebug(
$"🎯 Take Profit Execution Confirmed\n" + $"🎯 Take Profit Execution Confirmed\n" +
$"Position: `{position.Identifier}`\n" + $"Position: `{position.Identifier}`\n" +
$"TP Price: `${closingPrice:F2}` was hit (was `${position.TakeProfit1.Price:F2}`)\n" + $"Closing Price: `${closingPrice:F2}`\n" +
$"Configured TP: `${position.TakeProfit1.Price:F2}`\n" +
$"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`"); $"Recent Low: `${minPriceRecent:F2}` | Recent High: `${maxPriceRecent:F2}`");
} }
else else

View File

@@ -1,4 +1,4 @@
import {CardPositionItem, TradeChart} from '..' import {CardPositionItem, PositionsModal, TradeChart} from '..'
import { import {
Backtest, Backtest,
BacktestClient, BacktestClient,
@@ -13,6 +13,7 @@ import {
import {CardPosition, CardText} from '../../mollecules' import {CardPosition, CardText} from '../../mollecules'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import useApiUrlStore from '../../../app/store/apiStore' import useApiUrlStore from '../../../app/store/apiStore'
import {useState} from 'react'
interface IBacktestRowDetailsProps { interface IBacktestRowDetailsProps {
backtest: Backtest; backtest: Backtest;
@@ -24,6 +25,7 @@ const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
const {apiUrl} = useApiUrlStore(); const {apiUrl} = useApiUrlStore();
const dataClient = new DataClient({}, apiUrl); const dataClient = new DataClient({}, apiUrl);
const backtestClient = new BacktestClient({}, apiUrl); const backtestClient = new BacktestClient({}, apiUrl);
const [showPositionsModal, setShowPositionsModal] = useState(false);
// Use TanStack Query to load full backtest data // Use TanStack Query to load full backtest data
const {data: fullBacktestData, isLoading: isLoadingFullBacktest, error: fullBacktestError} = useQuery({ const {data: fullBacktestData, isLoading: isLoadingFullBacktest, error: fullBacktestError} = useQuery({
@@ -427,6 +429,14 @@ const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
content={getAverageTradesPerDay() + " trades/day"} content={getAverageTradesPerDay() + " trades/day"}
></CardText> ></CardText>
</div> </div>
<div className="p-5">
<button
className="btn btn-primary btn-sm"
onClick={() => setShowPositionsModal(true)}
>
View All Positions ({positionsArray.length})
</button>
</div>
{!isLoadingCandles && !isLoadingFullBacktest && ( {!isLoadingCandles && !isLoadingFullBacktest && (
<div className="w-full"> <div className="w-full">
<figure className="w-full"> <figure className="w-full">
@@ -442,6 +452,11 @@ const BacktestRowDetails: React.FC<IBacktestRowDetailsProps> = ({
</div> </div>
)} )}
</div> </div>
<PositionsModal
showModal={showPositionsModal}
onClose={() => setShowPositionsModal(false)}
positions={positionsArray}
/>
</> </>
) )
} }

View File

@@ -0,0 +1,173 @@
import React from 'react'
import {Position, TradeDirection} from '../../../generated/ManagingApi'
import {Modal} from '../../mollecules'
interface PositionsModalProps {
showModal: boolean
onClose: () => void
positions: Position[]
}
function PositionsModal({ showModal, onClose, positions }: PositionsModalProps) {
// Sort positions by date (most recent first)
const sortedPositions = [...positions].sort((a, b) => {
return new Date(b.date).getTime() - new Date(a.date).getTime()
})
// Helper function to get exit price based on PnL
const getExitPrice = (position: Position): number | null => {
if (position.ProfitAndLoss?.realized != null) {
if (position.ProfitAndLoss.realized > 0) {
return position.TakeProfit1?.price || null
} else {
return position.StopLoss?.price || null
}
}
return null
}
// Helper function to get exit date based on PnL
const getExitDate = (position: Position): Date | null => {
if (position.ProfitAndLoss?.realized != null) {
if (position.ProfitAndLoss.realized > 0) {
return position.TakeProfit1?.date || null
} else {
return position.StopLoss?.date || null
}
}
return null
}
// Helper function to format date
const formatDate = (date: Date | null): string => {
if (!date) return 'N/A'
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// Helper function to format currency
const formatCurrency = (value: number | null | undefined): string => {
if (value == null) return 'N/A'
return `$${value.toFixed(4)}`
}
// Helper function to get PnL color class
const getPnLColorClass = (pnl: number | undefined | null): string => {
if (pnl == null) return ''
return pnl > 0 ? 'text-success' : pnl < 0 ? 'text-error' : ''
}
// Helper function to calculate ROI
const calculateROI = (position: Position): number | null => {
const pnl = position.ProfitAndLoss?.realized
if (pnl == null || !position.Open) return null
// Calculate initial position value (considering leverage)
const entryPrice = position.Open.price
const quantity = position.Open.quantity
const leverage = position.Open.leverage || 1
const fee = position.Open.fee || 0
// Initial capital invested = (Entry Price * Quantity) / Leverage + Fee
const initialCapital = (entryPrice * quantity / leverage) + fee
if (initialCapital === 0) return null
// ROI = (Net PnL / Initial Capital) * 100
const roi = (pnl / initialCapital) * 100
return roi
}
// Helper function to format ROI
const formatROI = (roi: number | null): string => {
if (roi == null) return 'N/A'
return `${roi.toFixed(2)}%`
}
return (
<Modal
showModal={showModal}
onClose={onClose}
titleHeader="Backtest Positions"
size="7xl"
>
<div className="overflow-x-auto">
<table className="table table-zebra table-sm w-full">
<thead>
<tr>
<th className="text-xs">#</th>
<th className="text-xs">Open Date</th>
<th className="text-xs">Close Date</th>
<th className="text-xs">Direction</th>
<th className="text-xs">Entry Price</th>
<th className="text-xs">Exit Price</th>
<th className="text-xs">Quantity</th>
<th className="text-xs">Leverage</th>
<th className="text-xs">Realized P&L</th>
<th className="text-xs">ROI</th>
<th className="text-xs">Status</th>
</tr>
</thead>
<tbody>
{sortedPositions.map((position, index) => {
const exitPrice = getExitPrice(position)
const exitDate = getExitDate(position)
const pnl = position.ProfitAndLoss?.realized
const roi = calculateROI(position)
return (
<tr key={position.identifier || index}>
<td className="text-xs">{index + 1}</td>
<td className="text-xs">{formatDate(position.Open?.date)}</td>
<td className="text-xs">{formatDate(exitDate)}</td>
<td className="text-xs">
<span className={`badge badge-sm ${
position.originDirection === TradeDirection.Long
? 'badge-success'
: 'badge-error'
}`}>
{position.originDirection}
</span>
</td>
<td className="text-xs">{formatCurrency(position.Open?.price)}</td>
<td className="text-xs">{formatCurrency(exitPrice)}</td>
<td className="text-xs">{position.Open?.quantity?.toFixed(4) || 'N/A'}</td>
<td className="text-xs">x{position.Open?.leverage || 1}</td>
<td className={`text-xs font-semibold ${getPnLColorClass(pnl)}`}>
{formatCurrency(pnl)}
</td>
<td className={`text-xs font-semibold ${getPnLColorClass(roi)}`}>
{formatROI(roi)}
</td>
<td className="text-xs">
<span className="badge badge-sm badge-outline">
{position.status}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
{sortedPositions.length === 0 && (
<div className="text-center py-8 text-base-content/60">
No positions found
</div>
)}
</div>
<div className="modal-action">
<button type="button" className="btn" onClick={onClose}>
Close
</button>
</div>
</Modal>
)
}
export default PositionsModal

View File

@@ -0,0 +1,2 @@
export { default as PositionsModal } from './PositionsModal'

View File

@@ -13,3 +13,4 @@ export { default as StatusBadge } from './StatusBadge/StatusBadge'
export { default as PositionsList } from './Positions/PositionList' export { default as PositionsList } from './Positions/PositionList'
export { default as ScenarioModal } from './ScenarioModal' export { default as ScenarioModal } from './ScenarioModal'
export { default as BotNameModal } from './BotNameModal/BotNameModal' export { default as BotNameModal } from './BotNameModal/BotNameModal'
export { default as PositionsModal } from './PositionsModal/PositionsModal'