From 802640e9deacbf8a4f1a99711329c2dcb0e3c038 Mon Sep 17 00:00:00 2001 From: carkod Date: Tue, 2 Jun 2026 21:55:50 +0100 Subject: [PATCH 1/5] Reduce trailing churn by adding improvement ratio --- .../kucoin/futures/position_deal.py | 35 ++++++-- api/tests/test_kucoin_futures_stop_loss.py | 70 ++++++++++++++++ terminal/src/app/pages/Dashboard.tsx | 83 +++++++++++++++++++ 3 files changed, 180 insertions(+), 8 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/position_deal.py b/api/exchange_apis/kucoin/futures/position_deal.py index b922c53c0..5d8cec3c2 100644 --- a/api/exchange_apis/kucoin/futures/position_deal.py +++ b/api/exchange_apis/kucoin/futures/position_deal.py @@ -40,6 +40,8 @@ class PositionDeal(KucoinPositionDeal): these operations are triggered by websockets """ + TRAILING_STOP_REFRESH_MIN_IMPROVEMENT_RATIO = 0.002 + def __init__( self, bot: BotModel, @@ -55,6 +57,27 @@ def __init__( self.api: KucoinApi | KucoinFutures self.controller: BotTableCrud | PaperTradingTableCrud + def should_refresh_trailing_stop_loss( + self, + current_stop_price: float, + new_stop_price: float, + direction: int, + ) -> bool: + if new_stop_price <= 0: + return False + + if current_stop_price <= 0: + return True + + improvement = (new_stop_price - current_stop_price) * direction + if improvement <= 0: + return False + + min_improvement = ( + abs(current_stop_price) * self.TRAILING_STOP_REFRESH_MIN_IMPROVEMENT_RATIO + ) + return improvement >= min_improvement + def place_reversal_reentry_order( self, contracts: float, @@ -693,14 +716,10 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel: # so time to close with net profit if ( new_trailing_stop_loss - self.active_bot.deal.opening_price - ) * direction > 0 and ( - self.active_bot.deal.trailing_stop_loss_price == 0 - or ( - new_trailing_stop_loss - - self.active_bot.deal.trailing_stop_loss_price - ) - * direction - > 0 + ) * direction > 0 and self.should_refresh_trailing_stop_loss( + current_stop_price=self.active_bot.deal.trailing_stop_loss_price, + new_stop_price=new_trailing_stop_loss, + direction=direction, ): self.active_bot.deal.trailing_stop_loss_price = ( new_trailing_stop_loss diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index 7fa177611..ca41f3671 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -224,6 +224,76 @@ def test_reconcile_trailing_stop_loss_keeps_better_exchange_stop(): assert calls == [] +def test_should_refresh_trailing_stop_loss_allows_first_stop(): + deal = _make_position_deal() + + assert ( + PositionDeal.should_refresh_trailing_stop_loss( + deal, + current_stop_price=0.0, + new_stop_price=99.0, + direction=1, + ) + is True + ) + + +def test_should_refresh_trailing_stop_loss_blocks_small_long_improvement(): + deal = _make_position_deal() + + assert ( + PositionDeal.should_refresh_trailing_stop_loss( + deal, + current_stop_price=100.0, + new_stop_price=100.1, + direction=1, + ) + is False + ) + + +def test_should_refresh_trailing_stop_loss_allows_material_long_improvement(): + deal = _make_position_deal() + + assert ( + PositionDeal.should_refresh_trailing_stop_loss( + deal, + current_stop_price=100.0, + new_stop_price=100.2, + direction=1, + ) + is True + ) + + +def test_should_refresh_trailing_stop_loss_blocks_small_short_improvement(): + deal = _make_position_deal() + + assert ( + PositionDeal.should_refresh_trailing_stop_loss( + deal, + current_stop_price=100.0, + new_stop_price=99.9, + direction=-1, + ) + is False + ) + + +def test_should_refresh_trailing_stop_loss_allows_material_short_improvement(): + deal = _make_position_deal() + + assert ( + PositionDeal.should_refresh_trailing_stop_loss( + deal, + current_stop_price=100.0, + new_stop_price=99.8, + direction=-1, + ) + is True + ) + + def test_reconcile_exchange_sl_skips_for_margin_short_reversal(): calls: list[str] = [] deal = _make_deal(margin_short_reversal=True) diff --git a/terminal/src/app/pages/Dashboard.tsx b/terminal/src/app/pages/Dashboard.tsx index cef82d86c..2433adc96 100644 --- a/terminal/src/app/pages/Dashboard.tsx +++ b/terminal/src/app/pages/Dashboard.tsx @@ -23,6 +23,7 @@ import type { import { BotStatus } from "../../utils/enums"; import { roundDecimals } from "../../utils/math"; import { formatTimestamp } from "../../utils/time"; +import { getNetProfit } from "../../features/bots/profits"; import GainersLosers from "../components/GainersLosers"; import PortfolioBenchmarkChart from "../components/PortfolioBenchmark"; import { SpinnerContext } from "../Layout"; @@ -92,6 +93,9 @@ export const DashboardPage: FC<{}> = () => { useGetBotsQuery({ status: BotStatus.ACTIVE, }); + const { data: allBotEntities, isLoading: loadingAllBots } = useGetBotsQuery({ + status: BotStatus.ALL, + }); const { data: benchmark, isLoading: loadingBenchmark } = useGetBenchmarkQuery(); @@ -126,6 +130,38 @@ export const DashboardPage: FC<{}> = () => { .sort((a, b) => b - a) .slice(0, 3) ?? [], ); + const top_1_symbol_pnl_share = useMemo(() => { + const symbolPnl = new Map(); + + Object.values(allBotEntities?.bots.entities ?? {}).forEach((bot) => { + if (!bot?.pair) return; + + symbolPnl.set( + bot.pair, + (symbolPnl.get(bot.pair) ?? 0) + getNetProfit(bot), + ); + }); + + const absolutePnlBySymbol = [...symbolPnl.values()] + .map((pnl) => Math.abs(pnl)) + .filter((pnl) => pnl > 0); + const totalAbsolutePnl = absolutePnlBySymbol.reduce( + (total, pnl) => total + pnl, + 0, + ); + + if (totalAbsolutePnl <= 0) return undefined; + + return (Math.max(...absolutePnlBySymbol) / totalAbsolutePnl) * 100; + }, [allBotEntities]); + const symbolConcentrationClass = + top_1_symbol_pnl_share === undefined + ? "" + : top_1_symbol_pnl_share < 50 + ? "text-success" + : top_1_symbol_pnl_share < 75 + ? "text-warning" + : "text-danger"; const rankedSignalAlgorithms = useMemo(() => { const algorithms = new Map< string, @@ -177,6 +213,7 @@ export const DashboardPage: FC<{}> = () => { if ( !loadingActiveBots && + !loadingAllBots && !loadingBenchmark && !loadingEstimates && !loadingErrorBots && @@ -193,11 +230,13 @@ export const DashboardPage: FC<{}> = () => { }, [ accountData, activeBotEntities, + allBotEntities, errorBotEntities, benchmark, combinedGainersLosers, combinedFuturesRankings, loadingActiveBots, + loadingAllBots, loadingBenchmark, loadingEstimates, loadingErrorBots, @@ -362,6 +401,50 @@ export const DashboardPage: FC<{}> = () => { + + + + +
+ +
+ + +
+

+ Symbol concentration +

+
+ + {top_1_symbol_pnl_share !== undefined + ? `${roundDecimals(top_1_symbol_pnl_share, 2)}%` + : ""} + +

+ + + + +


+ + +

top_1_symbol_pnl_share

+ + +

bot PnL

+ +
+ +
{activeBotsCount > 0 && ( From 303b241d7cf03ca2536110f512380596527eb8cb Mon Sep 17 00:00:00 2001 From: carkod Date: Tue, 2 Jun 2026 22:00:33 +0100 Subject: [PATCH 2/5] Add symbol concentration params --- terminal/src/app/pages/Dashboard.tsx | 80 ++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/terminal/src/app/pages/Dashboard.tsx b/terminal/src/app/pages/Dashboard.tsx index 2433adc96..4d4f3d631 100644 --- a/terminal/src/app/pages/Dashboard.tsx +++ b/terminal/src/app/pages/Dashboard.tsx @@ -39,6 +39,14 @@ type PortfolioPnlDetails = { portfolioPnlClass: string; }; +type SymbolConcentrationDetails = { + top_1_symbol_pnl_share: number | undefined; + top_3_symbol_pnl_share: number | undefined; + symbol_hhi: number | undefined; + effective_symbol_count: number | undefined; + symbol_count: number; +}; + const usePortfolioPnlDetails = ( benchmark?: BenchmarkCollection, accountData?: BalanceData, @@ -130,7 +138,7 @@ export const DashboardPage: FC<{}> = () => { .sort((a, b) => b - a) .slice(0, 3) ?? [], ); - const top_1_symbol_pnl_share = useMemo(() => { + const symbolConcentration = useMemo(() => { const symbolPnl = new Map(); Object.values(allBotEntities?.bots.entities ?? {}).forEach((bot) => { @@ -150,10 +158,40 @@ export const DashboardPage: FC<{}> = () => { 0, ); - if (totalAbsolutePnl <= 0) return undefined; + if (totalAbsolutePnl <= 0) { + return { + top_1_symbol_pnl_share: undefined, + top_3_symbol_pnl_share: undefined, + symbol_hhi: undefined, + effective_symbol_count: undefined, + symbol_count: symbolPnl.size, + }; + } - return (Math.max(...absolutePnlBySymbol) / totalAbsolutePnl) * 100; + const pnlShares = absolutePnlBySymbol + .map((pnl) => pnl / totalAbsolutePnl) + .sort((left, right) => right - left); + const symbol_hhi = pnlShares.reduce( + (total, share) => total + share * share, + 0, + ); + + return { + top_1_symbol_pnl_share: (pnlShares[0] ?? 0) * 100, + top_3_symbol_pnl_share: + pnlShares.slice(0, 3).reduce((total, share) => total + share, 0) * 100, + symbol_hhi, + effective_symbol_count: symbol_hhi > 0 ? 1 / symbol_hhi : undefined, + symbol_count: pnlShares.length, + }; }, [allBotEntities]); + const { + top_1_symbol_pnl_share, + top_3_symbol_pnl_share, + symbol_hhi, + effective_symbol_count, + symbol_count, + } = symbolConcentration; const symbolConcentrationClass = top_1_symbol_pnl_share === undefined ? "" @@ -443,6 +481,42 @@ export const DashboardPage: FC<{}> = () => {

bot PnL

+ + +

top_3_symbol_pnl_share

+ + +

+ {top_3_symbol_pnl_share !== undefined + ? `${roundDecimals(top_3_symbol_pnl_share, 2)}%` + : ""} +

+ +
+ + +

effective_symbol_count

+ + +

+ {effective_symbol_count !== undefined + ? `${roundDecimals(effective_symbol_count, 2)} / ${symbol_count}` + : ""} +

+ +
+ + +

symbol_hhi

+ + +

+ {symbol_hhi !== undefined + ? roundDecimals(symbol_hhi, 4) + : ""} +

+ +
{activeBotsCount > 0 && ( From bc12d9f44d28bbea1e96568e92bfb0313eb461ec Mon Sep 17 00:00:00 2001 From: carkod Date: Tue, 2 Jun 2026 23:06:18 +0100 Subject: [PATCH 3/5] Append grid ladder execution errors to context --- api/databases/crud/grid_ladder_crud.py | 38 ++++++++++++++++++++------ api/grid_ladders/lifecycle.py | 7 +---- api/tests/test_grid_ladders.py | 26 +++++++++++++++++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/api/databases/crud/grid_ladder_crud.py b/api/databases/crud/grid_ladder_crud.py index 5d28d7c36..63953aaa0 100644 --- a/api/databases/crud/grid_ladder_crud.py +++ b/api/databases/crud/grid_ladder_crud.py @@ -293,20 +293,42 @@ def update_error_logs( ladder_id: UUID, error: Exception | str, ) -> GridLadderTable | None: + ladder = self.session.get(GridLadderTable, ladder_id) + if ladder is None: + return None + + ts = ts_to_humandate(ts=timestamp()) error_message = str(error) if isinstance(error, Exception): error_type = error.__class__.__name__ else: error_type = "error" - return self.update_logs( - ladder_id, - { - "event": "error", - "error_type": error_type, - "message": error_message, - }, - ) + error_entry = { + "timestamp": ts, + "error_type": error_type, + "message": error_message, + } + logs = list(ladder.logs or []) + logs.append({"event": "error", **error_entry}) + + context = dict(ladder.context or {}) + execution_errors = context.get("execution_error") + if execution_errors is None: + execution_errors = [] + elif not isinstance(execution_errors, list): + execution_errors = [execution_errors] + context["execution_error"] = [*execution_errors, error_entry] + + ladder.logs = logs + ladder.context = context + ladder.updated_at = timestamp() + flag_modified(ladder, "logs") + flag_modified(ladder, "context") + self.session.add(ladder) + self.session.commit() + self.session.refresh(ladder) + return ladder def create_levels( self, diff --git a/api/grid_ladders/lifecycle.py b/api/grid_ladders/lifecycle.py index 606dea390..3e9f365e7 100644 --- a/api/grid_ladders/lifecycle.py +++ b/api/grid_ladders/lifecycle.py @@ -416,10 +416,7 @@ def _place_initial_entries(self, ladder: GridLadderTable) -> None: self.crud.update_status_with_context( ladder.id, GridLadderStatus.error, - context_updates={ - "execution_error": str(error), - "cancelled_order_ids": placed_order_ids, - }, + context_updates={"cancelled_order_ids": placed_order_ids}, ) self.crud.update_error_logs(ladder.id, error) @@ -583,7 +580,6 @@ def _mark_order_error( order: GridOrderTable, error: Exception | str, ) -> None: - message = str(error) self.crud.update_order(order.id, status=GRID_ORDER_ERROR_STATUS) if order.level_id: self.crud.update_level_order( @@ -593,7 +589,6 @@ def _mark_order_error( self.crud.update_status_with_context( ladder.id, GridLadderStatus.error, - context_updates={"execution_error": message}, ) self.crud.recalculate_used_margin(ladder.id) self.crud.update_error_logs(ladder.id, error) diff --git a/api/tests/test_grid_ladders.py b/api/tests/test_grid_ladders.py index 0605a6b5f..c3ba249ae 100644 --- a/api/tests/test_grid_ladders.py +++ b/api/tests/test_grid_ladders.py @@ -1254,7 +1254,10 @@ def test_grid_lifecycle_cancels_partial_initial_orders_on_failure( "detail" ] assert detail["status"] == "error" - assert detail["context"]["execution_error"] == "exchange rejected order" + assert detail["context"]["execution_error"][-1]["error_type"] == "RuntimeError" + assert detail["context"]["execution_error"][-1]["message"] == ( + "exchange rejected order" + ) assert fake_api.cancelled_symbols == ["ADAUSDCM"] assert detail["orders"][0]["status"] == OrderStatus.CANCELED.value assert detail["logs"][-1]["event"] == "error" @@ -1262,6 +1265,27 @@ def test_grid_lifecycle_cancels_partial_initial_orders_on_failure( assert detail["logs"][-1]["message"] == "exchange rejected order" +def test_grid_ladder_error_logs_append_to_execution_error(create_test_tables): + with Session(create_test_tables) as session: + ladder = _active_ladder("ERRUSDC") + ladder.context = {"execution_error": "previous error"} + session.add(ladder) + session.commit() + crud = GridLadderCrud(session) + + crud.update_error_logs(ladder.id, ValueError("new error")) + + updated = crud.get(ladder.id) + + assert updated is not None + assert updated.context["execution_error"][0] == "previous error" + assert updated.context["execution_error"][1]["error_type"] == "ValueError" + assert updated.context["execution_error"][1]["message"] == "new error" + assert updated.logs[-1]["event"] == "error" + assert updated.logs[-1]["error_type"] == "ValueError" + assert updated.logs[-1]["message"] == "new error" + + def test_sizer_snaps_contracts_to_lot_size(): """ KuCoin rejects partial lots. Sizer must floor to a multiple of lot_size. From b6f1b7c223880c007b2736f8269ecaf424d50867 Mon Sep 17 00:00:00 2001 From: carkod Date: Wed, 3 Jun 2026 13:51:00 +0100 Subject: [PATCH 4/5] Convert bot profit to PnL --- terminal/src/app/components/BotCard.tsx | 4 +- .../src/app/components/ChartContainer.tsx | 3 +- terminal/src/app/pages/Bots.tsx | 6 +- terminal/src/app/pages/PaperTradingPage.tsx | 6 +- .../src/app/pages/tests/Dashboard.test.tsx | 58 +++++++++++++++++ terminal/src/features/bots/profits.test.ts | 63 +++++++++++++++++++ terminal/src/features/bots/profits.ts | 30 +++++++-- 7 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 terminal/src/features/bots/profits.test.ts diff --git a/terminal/src/app/components/BotCard.tsx b/terminal/src/app/components/BotCard.tsx index f258527fd..15150d4de 100644 --- a/terminal/src/app/components/BotCard.tsx +++ b/terminal/src/app/components/BotCard.tsx @@ -5,7 +5,6 @@ import { type Bot } from "../../features/bots/botInitialState"; import { computeSingleBotProfit } from "../../features/bots/profits"; import { roundDecimals } from "../../utils/math"; import { formatTimestamp, renderDuration } from "../../utils/time"; -import { capitalizeFirst } from "../../utils/strings"; import { MarketType } from "../../utils/enums"; type handleCallback = (id: string) => void; @@ -33,6 +32,7 @@ const BotCard: FC = ({ handleDelete, }) => { const botProfit = computeSingleBotProfit(bot); + const botProfitCurrency = bot.fiat || bot.quote_asset || ""; const navigate = useNavigate(); const { pathname } = useLocation(); const selectedIds = selectedCards ?? []; @@ -60,7 +60,7 @@ const BotCard: FC = ({ 0 ? "success" : "danger"}> - {roundDecimals(botProfit)}% + {roundDecimals(botProfit, 2)} {botProfitCurrency}
(Number(0)); const { data: autotradeSettings } = useGetSettingsQuery(); const marketLabel = capitalizeFirst(marketType.toLowerCase()); + const botProfitCurrency = bot.fiat || bot.quote_asset || quoteAsset; const exchangeState = autotradeSettings?.exchange_id?.toLowerCase() === "kucoin" ? Exchange.KUCOIN @@ -116,7 +117,7 @@ const ChartContainer: FC<{ : "secondary" } > - {botProfit ? botProfit + "% " : "0% "} + {roundDecimals(botProfit, 2)} {botProfitCurrency} {" "} = () => { : botsData; const refetch = symbolState ? refetchSymbol : refetchBots; const isFetching = symbolState ? isFetchingSymbol : isFetchingBots; + const totalProfitCurrency = + Object.values(data?.bots?.entities ?? {})[0]?.fiat || + Object.values(data?.bots?.entities ?? {})[0]?.quote_asset || + ""; // Symbols search dependencies const { data: allSymbols, isFetching: isFetchingSymbols } = @@ -219,7 +223,7 @@ export const BotsPage: FC<{}> = () => { 0 ? "success" : "danger"}> {" "} Profit - {(data.totalProfit || 0) + "%"} + {`${data.totalProfit || 0} ${totalProfitCurrency}`} )} diff --git a/terminal/src/app/pages/PaperTradingPage.tsx b/terminal/src/app/pages/PaperTradingPage.tsx index 2ca4fd27f..226483ea0 100644 --- a/terminal/src/app/pages/PaperTradingPage.tsx +++ b/terminal/src/app/pages/PaperTradingPage.tsx @@ -43,6 +43,10 @@ export const PaperTradingPage: FC = () => { startDate, endDate, }); + const totalProfitCurrency = + Object.values(data?.bots?.entities ?? {})[0]?.fiat || + Object.values(data?.bots?.entities ?? {})[0]?.quote_asset || + ""; const handleSelection = (id: string) => { let newCards = []; @@ -146,7 +150,7 @@ export const PaperTradingPage: FC = () => { 0 ? "success" : "danger"}> {" "} Profit - {(data.totalProfit || 0) + "%"} + {`${data.totalProfit || 0} ${totalProfitCurrency}`} )} diff --git a/terminal/src/app/pages/tests/Dashboard.test.tsx b/terminal/src/app/pages/tests/Dashboard.test.tsx index 0544839e5..139467a43 100644 --- a/terminal/src/app/pages/tests/Dashboard.test.tsx +++ b/terminal/src/app/pages/tests/Dashboard.test.tsx @@ -6,6 +6,7 @@ import DashboardPage from "../Dashboard"; import { SpinnerContext } from "../../Layout"; import { renderWithProviders } from "../../../utils/test-utils"; import { useGetSignalsQuery } from "../../../features/signalsApiSlice"; +import { useGetBotsQuery } from "../../../features/bots/botsApiSlice"; vi.mock("../../../features/balanceApiSlice", () => ({ useGetBalanceQuery: vi.fn(() => ({ @@ -45,6 +46,7 @@ vi.mock("../../../features/bots/botsApiSlice", () => ({ data: { bots: { ids: [], + entities: {}, }, }, isLoading: false, @@ -114,6 +116,62 @@ describe("Dashboard page", () => { expect(rtlScreen.getByText("1.27 BTC")).toBeInTheDocument(); }); + it("weights symbol concentration by bot PnL value instead of percent return", () => { + vi.mocked(useGetBotsQuery).mockImplementation((params = {}) => { + if (params.status === "all") { + return { + data: { + bots: { + ids: ["small-high-return", "large-low-return"], + entities: { + "small-high-return": { + id: "small-high-return", + pair: "SMALLUSDC", + fiat_order_size: 10, + position: "long", + deal: { + base_order_size: 10, + opening_price: 100, + current_price: 120, + closing_price: 0, + }, + }, + "large-low-return": { + id: "large-low-return", + pair: "LARGEUSDC", + fiat_order_size: 10_000, + position: "long", + deal: { + base_order_size: 10_000, + opening_price: 100, + current_price: 101, + closing_price: 0, + }, + }, + }, + }, + }, + isLoading: false, + } as ReturnType; + } + + return { + data: { + bots: { + ids: [], + entities: {}, + }, + }, + isLoading: false, + } as ReturnType; + }); + + renderDashboard(); + + expect(rtlScreen.getByText("Symbol concentration")).toBeInTheDocument(); + expect(rtlScreen.getByText("98.04%")).toBeInTheDocument(); + }); + it("renders signals collapsed and ranked by algorithm count", () => { vi.mocked(useGetSignalsQuery).mockReturnValueOnce({ data: [ diff --git a/terminal/src/features/bots/profits.test.ts b/terminal/src/features/bots/profits.test.ts new file mode 100644 index 000000000..febef546f --- /dev/null +++ b/terminal/src/features/bots/profits.test.ts @@ -0,0 +1,63 @@ +import { BotPosition } from "../../utils/enums"; +import type { Bot } from "./botInitialState"; +import { + computeSingleBotProfit, + computeTotalProfit, + getProfit, +} from "./profits"; + +const makeBot = ({ + id, + pair, + fiatOrderSize, + openingPrice, + currentPrice, +}: { + id: string; + pair: string; + fiatOrderSize: number; + openingPrice: number; + currentPrice: number; +}): Bot => + ({ + id, + pair, + fiat: "USDC", + quote_asset: "USDC", + fiat_order_size: fiatOrderSize, + position: BotPosition.LONG, + deal: { + base_order_size: fiatOrderSize, + opening_price: openingPrice, + current_price: currentPrice, + closing_price: 0, + }, + }) as Bot; + +describe("bot PnL helpers", () => { + it("weights price return by fiat order size", () => { + expect(getProfit(100, 120, BotPosition.LONG, 10)).toBe(2); + expect(getProfit(100, 101, BotPosition.LONG, 10_000)).toBe(100); + }); + + it("computes single and total bot PnL in fiat terms", () => { + const smallHighReturn = makeBot({ + id: "small-high-return", + pair: "SMALLUSDC", + fiatOrderSize: 10, + openingPrice: 100, + currentPrice: 120, + }); + const largeLowReturn = makeBot({ + id: "large-low-return", + pair: "LARGEUSDC", + fiatOrderSize: 10_000, + openingPrice: 100, + currentPrice: 101, + }); + + expect(computeSingleBotProfit(smallHighReturn)).toBe(2); + expect(computeSingleBotProfit(largeLowReturn)).toBe(100); + expect(computeTotalProfit([smallHighReturn, largeLowReturn])).toBe(102); + }); +}); diff --git a/terminal/src/features/bots/profits.ts b/terminal/src/features/bots/profits.ts index 0f1e386e8..a132e3089 100644 --- a/terminal/src/features/bots/profits.ts +++ b/terminal/src/features/bots/profits.ts @@ -6,22 +6,23 @@ export function getProfit( base_price: number, current_price: number, position = BotPosition.LONG, + fiat_order_size: number = 1, ) { - if (base_price && current_price) { + if (base_price && current_price && fiat_order_size > 0) { let percent = ((current_price - base_price) / base_price) * 100; if (position === BotPosition.SHORT) { percent = percent * -1; } - return parseFloat(percent.toFixed(2)); + return roundDecimals((percent * fiat_order_size) / 100, 2); } return 0; } /** - * This function calculates the profit (not including commissions/fees) + * This function calculates PnL (not including commissions/fees) * for a single bot, namely the BotForm and TestBotForm components * by using input data from that individual bot as opposed to computeTotalProfit - * function which uses an accumulator function to aggregate all profits of all bots + * function which uses an accumulator function to aggregate all PnL of all bots */ export function computeSingleBotProfit( bot: Bot, @@ -50,7 +51,12 @@ export function computeSingleBotProfit( : bot.deal.current_price; const buyPrice = bot.deal.opening_price; if (currentPrice > 0) { - const profitChange = getProfit(buyPrice, currentPrice, bot.position); + const profitChange = getProfit( + buyPrice, + currentPrice, + bot.position, + base_order_size, + ); return roundDecimals(profitChange, 2); } return 0; @@ -62,6 +68,7 @@ export function computeSingleBotProfit( bot.deal.opening_price, bot.deal.closing_price, bot.position, + base_order_size, ); return roundDecimals(profitChange, 2); } @@ -79,6 +86,7 @@ export function computeSingleBotProfit( bot.deal.opening_price, closePrice, bot.position, + base_order_size, ); return roundDecimals(profitChange, 2); } @@ -94,7 +102,17 @@ export function computeTotalProfit(bots: Bot[] = []) { return accumulator; } - const profit = getProfit(openingPrice, closingPrice, bot.position); + const orderSize = + bot?.deal?.base_order_size && bot.deal.base_order_size > 0 + ? bot.deal.base_order_size + : bot.fiat_order_size; + + const profit = getProfit( + openingPrice, + closingPrice, + bot.position, + orderSize, + ); return accumulator + profit; }, 0); return roundDecimals(totalProfit, 2); From dc405ba8f91c64fddc33ac29d886a4276effa9e4 Mon Sep 17 00:00:00 2001 From: carkod Date: Wed, 3 Jun 2026 17:31:32 +0100 Subject: [PATCH 5/5] Revert PnL conversion --- terminal/src/app/components/BotCard.tsx | 3 +- .../src/app/components/ChartContainer.tsx | 3 +- terminal/src/app/pages/Bots.tsx | 6 +- terminal/src/app/pages/PaperTradingPage.tsx | 6 +- .../src/app/pages/tests/Dashboard.test.tsx | 58 ----------------- terminal/src/features/bots/profits.test.ts | 63 ------------------- terminal/src/features/bots/profits.ts | 30 ++------- 7 files changed, 10 insertions(+), 159 deletions(-) delete mode 100644 terminal/src/features/bots/profits.test.ts diff --git a/terminal/src/app/components/BotCard.tsx b/terminal/src/app/components/BotCard.tsx index 15150d4de..0ff3903d2 100644 --- a/terminal/src/app/components/BotCard.tsx +++ b/terminal/src/app/components/BotCard.tsx @@ -32,7 +32,6 @@ const BotCard: FC = ({ handleDelete, }) => { const botProfit = computeSingleBotProfit(bot); - const botProfitCurrency = bot.fiat || bot.quote_asset || ""; const navigate = useNavigate(); const { pathname } = useLocation(); const selectedIds = selectedCards ?? []; @@ -60,7 +59,7 @@ const BotCard: FC = ({ 0 ? "success" : "danger"}> - {roundDecimals(botProfit, 2)} {botProfitCurrency} + {roundDecimals(botProfit)}%
(Number(0)); const { data: autotradeSettings } = useGetSettingsQuery(); const marketLabel = capitalizeFirst(marketType.toLowerCase()); - const botProfitCurrency = bot.fiat || bot.quote_asset || quoteAsset; const exchangeState = autotradeSettings?.exchange_id?.toLowerCase() === "kucoin" ? Exchange.KUCOIN @@ -117,7 +116,7 @@ const ChartContainer: FC<{ : "secondary" } > - {roundDecimals(botProfit, 2)} {botProfitCurrency} + {botProfit ? botProfit + "% " : "0% "} {" "} = () => { : botsData; const refetch = symbolState ? refetchSymbol : refetchBots; const isFetching = symbolState ? isFetchingSymbol : isFetchingBots; - const totalProfitCurrency = - Object.values(data?.bots?.entities ?? {})[0]?.fiat || - Object.values(data?.bots?.entities ?? {})[0]?.quote_asset || - ""; // Symbols search dependencies const { data: allSymbols, isFetching: isFetchingSymbols } = @@ -223,7 +219,7 @@ export const BotsPage: FC<{}> = () => { 0 ? "success" : "danger"}> {" "} Profit - {`${data.totalProfit || 0} ${totalProfitCurrency}`} + {(data.totalProfit || 0) + "%"} )} diff --git a/terminal/src/app/pages/PaperTradingPage.tsx b/terminal/src/app/pages/PaperTradingPage.tsx index 226483ea0..2ca4fd27f 100644 --- a/terminal/src/app/pages/PaperTradingPage.tsx +++ b/terminal/src/app/pages/PaperTradingPage.tsx @@ -43,10 +43,6 @@ export const PaperTradingPage: FC = () => { startDate, endDate, }); - const totalProfitCurrency = - Object.values(data?.bots?.entities ?? {})[0]?.fiat || - Object.values(data?.bots?.entities ?? {})[0]?.quote_asset || - ""; const handleSelection = (id: string) => { let newCards = []; @@ -150,7 +146,7 @@ export const PaperTradingPage: FC = () => { 0 ? "success" : "danger"}> {" "} Profit - {`${data.totalProfit || 0} ${totalProfitCurrency}`} + {(data.totalProfit || 0) + "%"} )} diff --git a/terminal/src/app/pages/tests/Dashboard.test.tsx b/terminal/src/app/pages/tests/Dashboard.test.tsx index 139467a43..0544839e5 100644 --- a/terminal/src/app/pages/tests/Dashboard.test.tsx +++ b/terminal/src/app/pages/tests/Dashboard.test.tsx @@ -6,7 +6,6 @@ import DashboardPage from "../Dashboard"; import { SpinnerContext } from "../../Layout"; import { renderWithProviders } from "../../../utils/test-utils"; import { useGetSignalsQuery } from "../../../features/signalsApiSlice"; -import { useGetBotsQuery } from "../../../features/bots/botsApiSlice"; vi.mock("../../../features/balanceApiSlice", () => ({ useGetBalanceQuery: vi.fn(() => ({ @@ -46,7 +45,6 @@ vi.mock("../../../features/bots/botsApiSlice", () => ({ data: { bots: { ids: [], - entities: {}, }, }, isLoading: false, @@ -116,62 +114,6 @@ describe("Dashboard page", () => { expect(rtlScreen.getByText("1.27 BTC")).toBeInTheDocument(); }); - it("weights symbol concentration by bot PnL value instead of percent return", () => { - vi.mocked(useGetBotsQuery).mockImplementation((params = {}) => { - if (params.status === "all") { - return { - data: { - bots: { - ids: ["small-high-return", "large-low-return"], - entities: { - "small-high-return": { - id: "small-high-return", - pair: "SMALLUSDC", - fiat_order_size: 10, - position: "long", - deal: { - base_order_size: 10, - opening_price: 100, - current_price: 120, - closing_price: 0, - }, - }, - "large-low-return": { - id: "large-low-return", - pair: "LARGEUSDC", - fiat_order_size: 10_000, - position: "long", - deal: { - base_order_size: 10_000, - opening_price: 100, - current_price: 101, - closing_price: 0, - }, - }, - }, - }, - }, - isLoading: false, - } as ReturnType; - } - - return { - data: { - bots: { - ids: [], - entities: {}, - }, - }, - isLoading: false, - } as ReturnType; - }); - - renderDashboard(); - - expect(rtlScreen.getByText("Symbol concentration")).toBeInTheDocument(); - expect(rtlScreen.getByText("98.04%")).toBeInTheDocument(); - }); - it("renders signals collapsed and ranked by algorithm count", () => { vi.mocked(useGetSignalsQuery).mockReturnValueOnce({ data: [ diff --git a/terminal/src/features/bots/profits.test.ts b/terminal/src/features/bots/profits.test.ts deleted file mode 100644 index febef546f..000000000 --- a/terminal/src/features/bots/profits.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { BotPosition } from "../../utils/enums"; -import type { Bot } from "./botInitialState"; -import { - computeSingleBotProfit, - computeTotalProfit, - getProfit, -} from "./profits"; - -const makeBot = ({ - id, - pair, - fiatOrderSize, - openingPrice, - currentPrice, -}: { - id: string; - pair: string; - fiatOrderSize: number; - openingPrice: number; - currentPrice: number; -}): Bot => - ({ - id, - pair, - fiat: "USDC", - quote_asset: "USDC", - fiat_order_size: fiatOrderSize, - position: BotPosition.LONG, - deal: { - base_order_size: fiatOrderSize, - opening_price: openingPrice, - current_price: currentPrice, - closing_price: 0, - }, - }) as Bot; - -describe("bot PnL helpers", () => { - it("weights price return by fiat order size", () => { - expect(getProfit(100, 120, BotPosition.LONG, 10)).toBe(2); - expect(getProfit(100, 101, BotPosition.LONG, 10_000)).toBe(100); - }); - - it("computes single and total bot PnL in fiat terms", () => { - const smallHighReturn = makeBot({ - id: "small-high-return", - pair: "SMALLUSDC", - fiatOrderSize: 10, - openingPrice: 100, - currentPrice: 120, - }); - const largeLowReturn = makeBot({ - id: "large-low-return", - pair: "LARGEUSDC", - fiatOrderSize: 10_000, - openingPrice: 100, - currentPrice: 101, - }); - - expect(computeSingleBotProfit(smallHighReturn)).toBe(2); - expect(computeSingleBotProfit(largeLowReturn)).toBe(100); - expect(computeTotalProfit([smallHighReturn, largeLowReturn])).toBe(102); - }); -}); diff --git a/terminal/src/features/bots/profits.ts b/terminal/src/features/bots/profits.ts index a132e3089..0f1e386e8 100644 --- a/terminal/src/features/bots/profits.ts +++ b/terminal/src/features/bots/profits.ts @@ -6,23 +6,22 @@ export function getProfit( base_price: number, current_price: number, position = BotPosition.LONG, - fiat_order_size: number = 1, ) { - if (base_price && current_price && fiat_order_size > 0) { + if (base_price && current_price) { let percent = ((current_price - base_price) / base_price) * 100; if (position === BotPosition.SHORT) { percent = percent * -1; } - return roundDecimals((percent * fiat_order_size) / 100, 2); + return parseFloat(percent.toFixed(2)); } return 0; } /** - * This function calculates PnL (not including commissions/fees) + * This function calculates the profit (not including commissions/fees) * for a single bot, namely the BotForm and TestBotForm components * by using input data from that individual bot as opposed to computeTotalProfit - * function which uses an accumulator function to aggregate all PnL of all bots + * function which uses an accumulator function to aggregate all profits of all bots */ export function computeSingleBotProfit( bot: Bot, @@ -51,12 +50,7 @@ export function computeSingleBotProfit( : bot.deal.current_price; const buyPrice = bot.deal.opening_price; if (currentPrice > 0) { - const profitChange = getProfit( - buyPrice, - currentPrice, - bot.position, - base_order_size, - ); + const profitChange = getProfit(buyPrice, currentPrice, bot.position); return roundDecimals(profitChange, 2); } return 0; @@ -68,7 +62,6 @@ export function computeSingleBotProfit( bot.deal.opening_price, bot.deal.closing_price, bot.position, - base_order_size, ); return roundDecimals(profitChange, 2); } @@ -86,7 +79,6 @@ export function computeSingleBotProfit( bot.deal.opening_price, closePrice, bot.position, - base_order_size, ); return roundDecimals(profitChange, 2); } @@ -102,17 +94,7 @@ export function computeTotalProfit(bots: Bot[] = []) { return accumulator; } - const orderSize = - bot?.deal?.base_order_size && bot.deal.base_order_size > 0 - ? bot.deal.base_order_size - : bot.fiat_order_size; - - const profit = getProfit( - openingPrice, - closingPrice, - bot.position, - orderSize, - ); + const profit = getProfit(openingPrice, closingPrice, bot.position); return accumulator + profit; }, 0); return roundDecimals(totalProfit, 2);