Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 12 additions & 34 deletions app/[locale]/dashboard/components/accounts/account-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,23 +293,12 @@ export function AccountTable({
<Table className="min-w-[760px]">
{renderTableHeader()}
<TableBody>
{(() => {
let runningBalance = startingBalance
return metricsBeforeReset.map(metric => {
runningBalance += metric.pnl
if (metric.payout?.status === 'PAID') {
runningBalance -= metric.payout.amount
}
return renderMetricRow(metric, runningBalance, totalPnLBefore)
})
})()}
{renderTotalRow(metricsBeforeReset, totalPnLBefore, metricsBeforeReset.reduce((balance, metric) => {
balance += metric.pnl
if (metric.payout?.status === 'PAID') {
balance -= metric.payout.amount
}
return balance
}, startingBalance))}
{metricsBeforeReset.map(metric => renderMetricRow(metric, metric.totalBalance, totalPnLBefore))}
{renderTotalRow(
metricsBeforeReset,
totalPnLBefore,
metricsBeforeReset.at(-1)?.totalBalance ?? startingBalance
)}
</TableBody>
</Table>
</div>
Expand Down Expand Up @@ -341,23 +330,12 @@ export function AccountTable({
<Table className="min-w-[760px]">
{renderTableHeader()}
<TableBody>
{(() => {
let runningBalance = startingBalance
return metricsAfterReset.map(metric => {
runningBalance += metric.pnl
if (metric.payout?.status === 'PAID') {
runningBalance -= metric.payout.amount
}
return renderMetricRow(metric, runningBalance, totalPnLAfter)
})
})()}
{renderTotalRow(metricsAfterReset, totalPnLAfter, metricsAfterReset.reduce((balance, metric) => {
balance += metric.pnl
if (metric.payout?.status === 'PAID') {
balance -= metric.payout.amount
}
return balance
}, startingBalance))}
{metricsAfterReset.map(metric => renderMetricRow(metric, metric.totalBalance, totalPnLAfter))}
{renderTotalRow(
metricsAfterReset,
totalPnLAfter,
metricsAfterReset.at(-1)?.totalBalance ?? startingBalance
)}
</TableBody>
</Table>
</div>
Expand Down
30 changes: 12 additions & 18 deletions app/[locale]/dashboard/components/accounts/trade-progress-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useI18n } from "@/locales/client"
import { useMemo } from "react"
import { Account } from "@/context/data-provider"
import { useTradesStore } from "@/store/trades-store"
import { clampBalanceAtDrawdownFloor, computeDrawdownLevel } from "@/lib/account-drawdown"

// Add interface for event type
interface ChartEvent {
Expand Down Expand Up @@ -113,29 +114,22 @@ export function TradeProgressChart({
highestBalance = startingBalance
} else {
const prevBalance = index > 0 ? acc[index - 1].balance : startingBalance
balance = prevBalance + event.amount
const rawBalance = prevBalance + event.amount

// Calculate highest balance up to this point
const previousHighest = index > 0 ? acc[index - 1].highestBalance : startingBalance
highestBalance = event.isPayout ? previousHighest : Math.max(previousHighest, balance)
highestBalance = event.isPayout ? previousHighest : Math.max(previousHighest, rawBalance)
balance = rawBalance
}

// Calculate drawdown level based on trailing or fixed drawdown
let drawdownLevel
if (trailingDrawdown) {
const profitMade = Math.max(0, highestBalance - startingBalance)

// If we've hit trailing stop profit, lock the drawdown to that level
if (trailingStopProfit && profitMade >= trailingStopProfit) {
drawdownLevel = (startingBalance + trailingStopProfit) - drawdownThreshold
} else {
// Otherwise, drawdown level trails the highest balance
drawdownLevel = highestBalance - drawdownThreshold
}
} else {
// Fixed drawdown - always relative to starting balance
drawdownLevel = startingBalance - drawdownThreshold
}
const drawdownLevel = computeDrawdownLevel({
startingBalance,
drawdownThreshold,
trailingDrawdown,
trailingStopProfit,
highestBalance,
})
balance = clampBalanceAtDrawdownFloor(balance, drawdownThreshold, drawdownLevel)

return [...acc, {
tradeIndex: index + 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ChartTooltip,
} from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import { clampBalanceAtDrawdownFloor, computeDrawdownLevel } from "@/lib/account-drawdown"

const chartConfig = {
balance: {
Expand Down Expand Up @@ -134,47 +135,44 @@ export function AccountEquityChart({
) => {
const currentDate = new Date(startDate)
let runningBalance = initialBalance
let maxBalanceToDate = initialBalance
let maxDrawdownLevel = initialBalance - drawdownThreshold
let hasReachedStopProfit = false
const stopProfitBalance = initialBalance + trailingStopProfit
let highestBalance = initialBalance

while (currentDate <= endDate) {
const dayEvents = events.filter(event =>
event.date.toDateString() === currentDate.toDateString()
)

// Find payout event for this day if any
const payoutEvent = dayEvents.find(event => event.isPayout)
const tradeTotal = dayEvents
.filter(event => !event.isPayout)
.reduce((sum, event) => sum + event.amount, 0)
const payoutTotal = dayEvents
.filter(event => event.isPayout)
.reduce((sum, event) => sum + event.amount, 0)

const dayTotal = dayEvents.reduce((sum, event) => sum + event.amount, 0)
runningBalance += dayTotal
runningBalance += tradeTotal
highestBalance = Math.max(highestBalance, runningBalance)
runningBalance += payoutTotal

// Calculate current profit relative to initial balance
const currentProfit = runningBalance - initialBalance

if (trailingDrawdown) {
if (trailingStopProfit > 0 && currentProfit >= trailingStopProfit) {
// If we've reached stop profit, lock the drawdown level
if (!hasReachedStopProfit) {
hasReachedStopProfit = true
maxDrawdownLevel = initialBalance + trailingStopProfit - drawdownThreshold
}
} else if (!hasReachedStopProfit) {
// Only update drawdown level if we haven't reached stop profit
if (runningBalance > maxBalanceToDate) {
maxBalanceToDate = runningBalance
maxDrawdownLevel = maxBalanceToDate - drawdownThreshold
}
}
}
const drawdownLevel = computeDrawdownLevel({
startingBalance: initialBalance,
drawdownThreshold,
trailingDrawdown,
trailingStopProfit,
highestBalance,
})
const balance = clampBalanceAtDrawdownFloor(
runningBalance,
drawdownThreshold,
drawdownLevel
)

dailyPoints.push({
date: format(currentDate, "yyyy-MM-dd"),
balance: runningBalance,
balance,
target: initialBalance + profitTarget,
drawdownLevel: maxDrawdownLevel,
balanceFormatted: `$${runningBalance.toLocaleString()}`,
drawdownLevel,
balanceFormatted: `$${balance.toLocaleString()}`,
isPayout: !!payoutEvent,
payoutStatus: (payoutEvent as ChartEvent | undefined)?.payoutStatus,
isAfterReset
Expand Down
104 changes: 104 additions & 0 deletions lib/account-drawdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { clampBalanceAtDrawdownFloor, computeDrawdownLevel } from './account-drawdown'
import { computeAccountMetrics } from './account-metrics'

describe('computeDrawdownLevel', () => {
it('uses static floor relative to starting balance', () => {
expect(
computeDrawdownLevel({
startingBalance: 100_000,
drawdownThreshold: 3_000,
highestBalance: 105_000,
})
).toBe(97_000)
})

it('trails highest balance when trailing drawdown is enabled', () => {
expect(
computeDrawdownLevel({
startingBalance: 100_000,
drawdownThreshold: 3_000,
trailingDrawdown: true,
highestBalance: 115_000,
})
).toBe(112_000)
})

it('locks drawdown after trailing stop profit is reached', () => {
expect(
computeDrawdownLevel({
startingBalance: 100_000,
drawdownThreshold: 3_000,
trailingDrawdown: true,
trailingStopProfit: 10_000,
highestBalance: 120_000,
})
).toBe(107_000)
})
})

describe('clampBalanceAtDrawdownFloor', () => {
it('clamps breached balances to the drawdown floor', () => {
expect(clampBalanceAtDrawdownFloor(96_000, 3_000, 97_000)).toBe(97_000)
})

it('leaves balances above the floor unchanged', () => {
expect(clampBalanceAtDrawdownFloor(105_000, 3_000, 97_000)).toBe(105_000)
})

it('skips clamping when no drawdown threshold is configured', () => {
expect(clampBalanceAtDrawdownFloor(50_000, 0, 97_000)).toBe(50_000)
})
})

describe('computeAccountMetrics drawdown floor', () => {
const baseAccount = {
number: 'TEST',
startingBalance: 100_000,
drawdownThreshold: 3_000,
trailingDrawdown: false,
profitTarget: 10_000,
payouts: [],
resetDate: null,
}

it('clamps breached account balances and zeroes remaining loss', () => {
const trades = [
{ accountNumber: 'TEST', entryDate: '2025-01-01', pnl: 8_000, commission: 0 },
{ accountNumber: 'TEST', entryDate: '2025-01-02', pnl: -12_000, commission: 0 },
]

const result = computeAccountMetrics(baseAccount as any, trades as any)

expect(result.metrics.currentBalance).toBe(97_000)
expect(result.metrics.drawdownLevel).toBe(97_000)
expect(result.metrics.remainingLoss).toBe(0)
expect(result.dailyMetrics.at(-1)?.totalBalance).toBe(97_000)
})

it('leaves non-breached balances unchanged', () => {
const trades = [
{ accountNumber: 'TEST', entryDate: '2025-01-01', pnl: 5_000, commission: 0 },
]

const result = computeAccountMetrics(baseAccount as any, trades as any)

expect(result.metrics.currentBalance).toBe(105_000)
expect(result.metrics.remainingLoss).toBe(8_000)
expect(result.dailyMetrics.at(-1)?.totalBalance).toBe(105_000)
})

it('clamps trailing drawdown breaches using the trailing floor', () => {
const trailingAccount = { ...baseAccount, trailingDrawdown: true }
const trades = [
{ accountNumber: 'TEST', entryDate: '2025-01-01', pnl: 15_000, commission: 0 },
{ accountNumber: 'TEST', entryDate: '2025-01-02', pnl: -20_000, commission: 0 },
]

const result = computeAccountMetrics(trailingAccount as any, trades as any)

expect(result.metrics.currentBalance).toBe(112_000)
expect(result.metrics.remainingLoss).toBe(0)
expect(result.dailyMetrics.at(-1)?.totalBalance).toBe(112_000)
})
})
34 changes: 34 additions & 0 deletions lib/account-drawdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type DrawdownParams = {
startingBalance: number
drawdownThreshold: number
trailingDrawdown?: boolean | null
trailingStopProfit?: number | null
highestBalance: number
}

export function computeDrawdownLevel({
startingBalance,
drawdownThreshold,
trailingDrawdown,
trailingStopProfit,
highestBalance,
}: DrawdownParams): number {
const dd = drawdownThreshold || 0
if (trailingDrawdown) {
const profitMade = Math.max(0, highestBalance - startingBalance)
if (trailingStopProfit && profitMade >= trailingStopProfit) {
return (startingBalance + trailingStopProfit) - dd
}
return highestBalance - dd
}
return startingBalance - dd
}

/** Prop-firm losses beyond the accepted drawdown floor are not displayed as realized. */
export function clampBalanceAtDrawdownFloor(
balance: number,
drawdownThreshold: number,
drawdownLevel: number
): number {
return (drawdownThreshold || 0) > 0 ? Math.max(balance, drawdownLevel) : balance
}
Loading
Loading