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/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/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. 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/components/BotCard.tsx b/terminal/src/app/components/BotCard.tsx index f258527fd..0ff3903d2 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; diff --git a/terminal/src/app/pages/Dashboard.tsx b/terminal/src/app/pages/Dashboard.tsx index cef82d86c..4d4f3d631 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"; @@ -38,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, @@ -92,6 +101,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 +138,68 @@ export const DashboardPage: FC<{}> = () => { .sort((a, b) => b - a) .slice(0, 3) ?? [], ); + const symbolConcentration = 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 { + top_1_symbol_pnl_share: undefined, + top_3_symbol_pnl_share: undefined, + symbol_hhi: undefined, + effective_symbol_count: undefined, + symbol_count: symbolPnl.size, + }; + } + + 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 + ? "" + : 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 +251,7 @@ export const DashboardPage: FC<{}> = () => { if ( !loadingActiveBots && + !loadingAllBots && !loadingBenchmark && !loadingEstimates && !loadingErrorBots && @@ -193,11 +268,13 @@ export const DashboardPage: FC<{}> = () => { }, [ accountData, activeBotEntities, + allBotEntities, errorBotEntities, benchmark, combinedGainersLosers, combinedFuturesRankings, loadingActiveBots, + loadingAllBots, loadingBenchmark, loadingEstimates, loadingErrorBots, @@ -362,6 +439,86 @@ 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

+ +
+ + +

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 && (