From 088c61a8c1abe8301a5ddd3bfdb54e13f11cb0a6 Mon Sep 17 00:00:00 2001 From: carkod Date: Sat, 29 Aug 2026 10:21:53 +0100 Subject: [PATCH 1/4] Improve lifecycle profitability in top gainer early momentum --- .../test_streaming_lifecycle_strategies.py | 24 ++++++++++++++++++- streaming/context_evaluator.py | 4 ++++ .../strategies/top_gainer_early_momentum.py | 13 ++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 streaming/strategies/top_gainer_early_momentum.py diff --git a/api/tests/test_streaming_lifecycle_strategies.py b/api/tests/test_streaming_lifecycle_strategies.py index da4b35c8b..d63ddaa03 100644 --- a/api/tests/test_streaming_lifecycle_strategies.py +++ b/api/tests/test_streaming_lifecycle_strategies.py @@ -30,6 +30,9 @@ from streaming.strategies.relative_strength_impulse_rider import ( RelativeStrengthImpulseRiderLifecycleStrategy, ) +from streaming.strategies.top_gainer_early_momentum import ( + TopGainerEarlyMomentumLifecycleStrategy, +) INTERVAL_MS = 15 * 60 * 1000 @@ -114,7 +117,7 @@ def _context( "relative_strength_impulse_rider", RelativeStrengthImpulseRiderLifecycleStrategy, ), - ("top_gainer_early_momentum", DefaultLifecycleStrategy), + ("top_gainer_early_momentum", TopGainerEarlyMomentumLifecycleStrategy), ("coinrule_price_tracker", PriceTrackerLifecycleStrategy), ("coinrule_buy_the_dip", DefaultLifecycleStrategy), ("bb_extreme_reversion", BBExtremeReversionLifecycleStrategy), @@ -248,6 +251,25 @@ def test_default_runtime_strategy_preserves_pullback_adjustment(monkeypatch) -> assert update.trailing_deviation == 1.55 +def test_top_gainer_lifecycle_delays_and_widens_trailing(monkeypatch) -> None: + monkeypatch.setattr( + "streaming.strategies.default.ApexFlowClose", + FakeApexFlowClose, + ) + context = _context( + name="top_gainer_early_momentum", + stop_loss=2.0, + dynamic_trailing=True, + ) + + update = TopGainerEarlyMomentumLifecycleStrategy().signal(context).parameter_update + + assert update is not None + assert update.stop_loss == 2.0 + assert update.trailing_profit == 6.0 + assert update.trailing_deviation == 2.5 + + def test_bb_extreme_reversion_uses_atr_stop_and_bb_trailing() -> None: context = _context( name="bb_extreme_reversion", diff --git a/streaming/context_evaluator.py b/streaming/context_evaluator.py index c4f4d73f1..a16f78ef4 100644 --- a/streaming/context_evaluator.py +++ b/streaming/context_evaluator.py @@ -21,6 +21,9 @@ from streaming.strategies.relative_strength_impulse_rider import ( RelativeStrengthImpulseRiderLifecycleStrategy, ) +from streaming.strategies.top_gainer_early_momentum import ( + TopGainerEarlyMomentumLifecycleStrategy, +) @dataclass(frozen=True) @@ -34,6 +37,7 @@ class LifecycleContextEvaluator: MeanReversionFadeLifecycleStrategy, LiquidationSweepPumpLifecycleStrategy, RelativeStrengthImpulseRiderLifecycleStrategy, + TopGainerEarlyMomentumLifecycleStrategy, PriceTrackerLifecycleStrategy, BBExtremeReversionLifecycleStrategy, ) diff --git a/streaming/strategies/top_gainer_early_momentum.py b/streaming/strategies/top_gainer_early_momentum.py new file mode 100644 index 000000000..d811196fc --- /dev/null +++ b/streaming/strategies/top_gainer_early_momentum.py @@ -0,0 +1,13 @@ +from streaming.strategies.default import DefaultLifecycleStrategy + + +class TopGainerEarlyMomentumLifecycleStrategy(DefaultLifecycleStrategy): + """Keep volatile top-gainer runners alive long enough to express their edge.""" + + algorithm_names = frozenset({"top_gainer_early_momentum"}) + + MIN_STOP_LOSS = 2.0 + MIN_TRAILING_PROFIT = 6.0 + MAX_TRAILING_PROFIT = 8.0 + MIN_TRAILING_DEVIATION = 2.5 + MAX_TRAILING_DEVIATION = 4.0 From 46b0ffee6ac073bde747d76fe62ffe9920b83ae4 Mon Sep 17 00:00:00 2001 From: carkod Date: Sat, 29 Aug 2026 14:16:51 +0200 Subject: [PATCH 2/4] Improve top-gainer entry participation and stop protection --- .../kucoin/futures/futures_deal.py | 77 ++++++------------- api/grid_ladders/lifecycle.py | 5 +- .../test_kucoin_futures_contract_sizing.py | 28 ++++++- 3 files changed, 50 insertions(+), 60 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 192e666e5..377fdd137 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -78,15 +78,14 @@ class KucoinPositionDeal(KucoinBaseBalance): TOP_GAINER_EARLY_MOMENTUM_RETEST_DISCOUNT_PCT = 0.5 TOP_GAINER_EARLY_MOMENTUM_STOP_TRIGGER_BUFFER_PCT = 0.5 ENTRY_LIQUIDITY_PRICE_BAND_BPS = 50.0 - # Spread/slippage ceilings scale with the same ATR-derived allowance used - # for the body-capped entry price, clamped to [MIN, MAX] so a quiet - # symbol is held to a tighter bar than a volatile one. - ENTRY_LIQUIDITY_MIN_SPREAD_BPS = 15.0 - ENTRY_LIQUIDITY_MAX_SPREAD_BPS = 40.0 - ENTRY_LIQUIDITY_SPREAD_ATR_MULTIPLIER = 0.4 - ENTRY_LIQUIDITY_MIN_SLIPPAGE_BPS = 18.0 - ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS = 50.0 - ENTRY_LIQUIDITY_SLIPPAGE_ATR_MULTIPLIER = 0.5 + # Runner entries need enough participation to capture asymmetric upside, + # but execution costs must stay small relative to the configured stop. + # Live top-gainer evidence supported entries through roughly a 30bps + # spread when the requested size still averaged no more than 15bps of + # slippage. Keep both limits explicit so a deep first level cannot hide a + # pathological spread, and a tight top of book cannot hide a costly walk. + ENTRY_LIQUIDITY_MAX_SPREAD_BPS = 30.0 + ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS = 15.0 # Thin books can go many seconds between quote updates without being # genuinely stale (KuCoin's ts reflects the last book change, not "now"), # confirmed against live low-cap futures books (~15s observed on a quiet @@ -576,33 +575,6 @@ def liquidity_snapshot_summary( f"imbalance={imbalance}, data_age={snapshot.data_age_ms}ms" ) - def _atr_scaled_liquidity_thresholds(self) -> tuple[float, float]: - """Spread/slippage ceilings, scaled by the ATR allowance already - computed for the body-capped entry price (falls back to the same - fallback allowance body_capped_entry_limit_price() uses).""" - cached_allowance_pct = getattr(self, "_entry_allowance_pct", None) - allowance_pct = ( - cached_allowance_pct - if cached_allowance_pct is not None - else self.ENTRY_FALLBACK_ALLOWANCE_PCT - ) - atr_bps = allowance_pct * 100 - spread_threshold_bps = max( - self.ENTRY_LIQUIDITY_MIN_SPREAD_BPS, - min( - atr_bps * self.ENTRY_LIQUIDITY_SPREAD_ATR_MULTIPLIER, - self.ENTRY_LIQUIDITY_MAX_SPREAD_BPS, - ), - ) - slippage_threshold_bps = max( - self.ENTRY_LIQUIDITY_MIN_SLIPPAGE_BPS, - min( - atr_bps * self.ENTRY_LIQUIDITY_SLIPPAGE_ATR_MULTIPLIER, - self.ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS, - ), - ) - return spread_threshold_bps, slippage_threshold_bps - def liquidity_gated_contracts( self, requested_contracts: int, candidate_limit_price: float ) -> tuple[int, float]: @@ -611,9 +583,8 @@ def liquidity_gated_contracts( if self.active_bot.position == Position.short else AddOrderReq.SideEnum.BUY ) - spread_threshold_bps, slippage_threshold_bps = ( - self._atr_scaled_liquidity_thresholds() - ) + spread_threshold_bps = self.ENTRY_LIQUIDITY_MAX_SPREAD_BPS + slippage_threshold_bps = self.ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS try: order_book = load_futures_order_book( self.kucoin_futures_api, @@ -651,7 +622,7 @@ def liquidity_gated_contracts( if requested_snapshot.spread_bps > spread_threshold_bps: message = ( "Entry rejected: KuCoin futures spread exceeds " - f"{spread_threshold_bps:.2f}bps (ATR-scaled). {summary}." + f"{spread_threshold_bps:.2f}bps. {summary}." ) self.reject_entry_for_liquidity(message) @@ -679,7 +650,7 @@ def liquidity_gated_contracts( ): message = ( "Entry rejected: expected KuCoin futures slippage exceeds " - f"{slippage_threshold_bps:.2f}bps (ATR-scaled). {summary}." + f"{slippage_threshold_bps:.2f}bps. {summary}." ) self.reject_entry_for_liquidity(message) @@ -700,7 +671,7 @@ def liquidity_gated_contracts( self.active_bot.add_log( f"Futures entry liquidity snapshot: {summary}. " - f"thresholds(ATR-scaled): spread<={spread_threshold_bps:.2f}bps, " + f"thresholds: spread<={spread_threshold_bps:.2f}bps, " f"slippage<={slippage_threshold_bps:.2f}bps." ) if approved_contracts < requested_contracts: @@ -1075,7 +1046,7 @@ def reconcile_exchange_sl(self) -> None: 10**-self.price_precision ): self.active_bot.add_log( - "Bounded top-gainer stop drift detected: " + "Buffered top-gainer stop-market drift detected: " f"expected trigger={expected_trigger_price} exchange={exchange_price}; replacing." ) self.cancel_current_sl() @@ -1316,27 +1287,26 @@ def place_stop_loss(self) -> None: side = AddOrderReq.SideEnum.SELL stop = AddOrderReq.StopEnum.DOWN - bounded_top_gainer_stop = self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO + buffered_top_gainer_stop = ( + self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO + ) trigger_price = ( self.top_gainer_stop_trigger_price(stop_price) - if bounded_top_gainer_stop + if buffered_top_gainer_stop else stop_price ) order_response = self.kucoin_futures_api.place_futures_order( symbol=self.kucoin_symbol, side=side, - order_type=( - OrderType.limit if bounded_top_gainer_stop else OrderType.market - ), - price=stop_price if bounded_top_gainer_stop else None, + order_type=OrderType.market, stop=stop, stop_price=trigger_price, stop_price_type=AddOrderReq.StopPriceTypeEnum.MARK_PRICE, reduce_only=True, size=self.active_bot.deal.opening_qty, leverage=self.symbol_info.futures_leverage, - allow_market_fallback=not bounded_top_gainer_stop, + allow_market_fallback=True, ) if order_response.price and order_response.qty: @@ -1353,9 +1323,10 @@ def place_stop_loss(self) -> None: self.controller.update_logs( bot=self.active_bot, log_message=( - f"Bounded stop loss trigger set @ {trigger_price}, limit @ {stop_price}" - if bounded_top_gainer_stop - else f"Stop loss set @ {stop_price}" + f"Buffered stop-market trigger set @ {trigger_price} " + f"for configured stop @ {stop_price}" + if buffered_top_gainer_stop + else f"Stop-market set @ {stop_price}" ), ) diff --git a/api/grid_ladders/lifecycle.py b/api/grid_ladders/lifecycle.py index 98459bedc..367b7ecca 100644 --- a/api/grid_ladders/lifecycle.py +++ b/api/grid_ladders/lifecycle.py @@ -31,9 +31,8 @@ GRID_LIQUIDITY_PRICE_BAND_BPS = 50.0 # Spread/slippage ceilings scale with the ladder's own initial BB-width -# volatility read (mirrors the ATR-scaled thresholds used for standalone -# futures entries in KucoinPositionDeal), clamped to [MIN, MAX] so a quiet -# symbol is held to a tighter bar than a volatile one. +# volatility read and remain grid-specific. Standalone futures entries use +# separate fixed participation ceilings in KucoinPositionDeal. GRID_LIQUIDITY_MIN_SPREAD_BPS = 15.0 GRID_LIQUIDITY_MAX_SPREAD_BPS = 40.0 GRID_LIQUIDITY_SPREAD_BB_WIDTH_MULTIPLIER = 0.05 diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index c5d0ba929..d344571bc 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -244,6 +244,25 @@ def test_entry_liquidity_gate_preserves_exchange_book_price_when_tightening_buy( assert entry_limit_price == 100.005 +def test_entry_liquidity_gate_allows_qualified_spread_below_thirty_bps(): + deal = make_sizing_deal(multiplier=1) + deal.active_bot.position = Position.long + attach_order_book( + deal, + bids=[[99.875, 100]], + asks=[[100.125, 100]], + ) + + contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 100.5) + + assert contracts == 10 + assert entry_limit_price == 100.125 + assert any( + "thresholds: spread<=30.00bps, slippage<=15.00bps" in log + for log in deal.active_bot.logs + ) + + def test_entry_liquidity_gate_rejects_excessive_spread_and_records_reason(): deal = make_sizing_deal(multiplier=1) deal.active_bot.position = Position.long @@ -276,6 +295,7 @@ def test_entry_liquidity_gate_rejects_excessive_expected_slippage(): deal.liquidity_gated_contracts(10, 100.0) assert "expected_slippage=44.20bps" in deal.active_bot.logs[-1] + assert "exceeds 15.00bps" in deal.active_bot.logs[-1] def test_entry_liquidity_gate_rejects_stale_book_data(): @@ -870,7 +890,7 @@ def test_top_gainer_early_momentum_waits_for_half_percent_retest(monkeypatch): ) -def test_top_gainer_stop_triggers_early_and_never_falls_back_to_market(): +def test_top_gainer_stop_triggers_early_as_stop_market(): deal = make_sizing_deal(multiplier=1) deal.active_bot.name = "top_gainer_early_momentum" deal.active_bot.position = Position.long @@ -898,10 +918,10 @@ def test_top_gainer_stop_triggers_early_and_never_falls_back_to_market(): deal.place_stop_loss() kwargs = place_order.call_args.kwargs - assert kwargs["order_type"] == OrderType.limit - assert kwargs["price"] == 98.0 + assert kwargs["order_type"] == OrderType.market + assert "price" not in kwargs assert kwargs["stop_price"] == 98.49 - assert kwargs["allow_market_fallback"] is False + assert kwargs["allow_market_fallback"] is True assert deal.active_bot.deal.stop_loss_price == 98.0 From 5f404d8b341096e6f07fd8a846c82a56790bed46 Mon Sep 17 00:00:00 2001 From: carkod Date: Wed, 2 Sep 2026 17:45:30 +0100 Subject: [PATCH 3/4] Add gainers losers charts --- terminal/.env | 1 + .../components/GainersLosersSeriesCard.tsx | 159 ++++++++++++++++++ .../tests/GainersLosersSeriesCard.test.tsx | 119 +++++++++++++ terminal/src/app/pages/Dashboard.tsx | 17 +- .../src/app/pages/tests/Dashboard.test.tsx | 33 +++- terminal/src/features/marketApiSlice.ts | 27 ++- 6 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 terminal/src/app/components/GainersLosersSeriesCard.tsx create mode 100644 terminal/src/app/components/tests/GainersLosersSeriesCard.test.tsx diff --git a/terminal/.env b/terminal/.env index 382184394..f06169998 100644 --- a/terminal/.env +++ b/terminal/.env @@ -37,6 +37,7 @@ VITE_RESEARCH_CONTROLLER=/autotrade-settings/bots VITE_TEST_AUTOTRADE=/autotrade-settings/paper-trading VITE_NO_CANNIBALISM_SYMBOLS=/account/symbols/no-cannibal VITE_GAINERS_LOSERS=/charts/gainers-losers +VITE_GAINERS_LOSERS_SERIES=/charts/gainers-losers-series VITE_MARKET_BREADTH=/charts/market-breadth VITE_BALANCE_SERIES=/portfolio/benchmark-series VITE_TEST_BOT=/paper-trading diff --git a/terminal/src/app/components/GainersLosersSeriesCard.tsx b/terminal/src/app/components/GainersLosersSeriesCard.tsx new file mode 100644 index 000000000..87b8b2e53 --- /dev/null +++ b/terminal/src/app/components/GainersLosersSeriesCard.tsx @@ -0,0 +1,159 @@ +import moment from "moment"; +import { type FC } from "react"; +import { Badge, Card, Col, Row } from "react-bootstrap"; +import type { + GainersLosersSnapshot, + TopMover, +} from "../../features/marketApiSlice"; +import { roundDecimals } from "../../utils/math"; +import PlotlyChart from "./PlotlyChart"; + +type GainersLosersSeriesCardProps = { + snapshots: GainersLosersSnapshot[]; +}; + +const averagePriceChange = (movers: TopMover[]): number | null => { + if (movers.length === 0) return null; + + return ( + movers.reduce((total, mover) => total + mover.price_change_percent, 0) / + movers.length + ); +}; + +const formatMovers = (movers: TopMover[]) => + movers + .map( + ({ symbol, price_change_percent }) => + `${symbol}: ${price_change_percent > 0 ? "+" : ""}${roundDecimals(price_change_percent, 2)}%`, + ) + .join("
"); + +const resolveMomentumBadge = (snapshot: GainersLosersSnapshot) => { + const averageGain = averagePriceChange(snapshot.top_gainers); + const averageLoss = averagePriceChange(snapshot.top_losers); + + if (averageGain === null || averageLoss === null) { + return { bg: "secondary", label: "N/A" }; + } + + const netMomentum = roundDecimals(averageGain + averageLoss, 1); + const prefix = netMomentum > 0 ? "+" : ""; + + return { + bg: netMomentum > 0 ? "success" : netMomentum < 0 ? "danger" : "secondary", + label: `${prefix}${netMomentum.toFixed(1)} pts net`, + }; +}; + +const GainersLosersSeriesCard: FC = ({ + snapshots, +}) => { + const chronologicalSnapshots = [...snapshots].reverse(); + const timestamps = chronologicalSnapshots.map( + (snapshot) => snapshot.recorded_at, + ); + const averageGains = chronologicalSnapshots.map((snapshot) => + averagePriceChange(snapshot.top_gainers), + ); + const averageLosses = chronologicalSnapshots.map((snapshot) => + averagePriceChange(snapshot.top_losers), + ); + const gainersHoverText = chronologicalSnapshots.map((snapshot) => + formatMovers(snapshot.top_gainers), + ); + const losersHoverText = chronologicalSnapshots.map((snapshot) => + formatMovers(snapshot.top_losers), + ); + const momentumBadge = resolveMomentumBadge(snapshots[0]); + + return ( + + + + + + + + + Top Gainers & Losers Trend + {momentumBadge.label} + +

+ Average 24h change for the top KuCoin futures movers. +
+ Hover over a point to see every symbol in that snapshot. +

+ +
+
+ + Average: %{y:.2f}%
%{text}Top gainers", + }, + { + x: timestamps, + y: averageLosses, + text: losersHoverText, + type: "scatter", + mode: "lines+markers", + name: "Top losers average", + line: { color: "#dc3545", width: 2 }, + marker: { size: 6 }, + fill: "tozeroy", + fillcolor: "#dc354533", + hovertemplate: + "%{x|%d/%m %H:%M}
Average: %{y:.2f}%
%{text}Top losers", + }, + ]} + layout={{ + autosize: true, + height: 380, + margin: { t: 30, l: 45, r: 20, b: 40 }, + xaxis: { + title: "Time", + tickformat: "%d/%m %H:%M", + showgrid: false, + }, + yaxis: { + title: "24h change (%)", + showgrid: true, + zeroline: true, + zerolinecolor: "#6c757d", + zerolinewidth: 1, + }, + legend: { orientation: "h", y: -0.2 }, + }} + config={{ responsive: true, displayModeBar: false }} + style={{ width: "100%", height: "100%" }} + useResizeHandler={true} + /> +
+ +
+
+ Last updated{" "} + {moment(snapshots[0].recorded_at).format("DD/MM/YYYY HH:mm")} +
+
+
+ ); +}; + +export default GainersLosersSeriesCard; diff --git a/terminal/src/app/components/tests/GainersLosersSeriesCard.test.tsx b/terminal/src/app/components/tests/GainersLosersSeriesCard.test.tsx new file mode 100644 index 000000000..434a5a8f2 --- /dev/null +++ b/terminal/src/app/components/tests/GainersLosersSeriesCard.test.tsx @@ -0,0 +1,119 @@ +import "@testing-library/jest-dom"; +import { render, screen as rtlScreen } from "@testing-library/react"; +import { + beforeEach as beforeEachTest, + describe as describeBlock, + expect as expectValue, + it as testCase, + vi, +} from "vitest"; + +type PlotlyTrace = { + name?: string; + x?: string[]; + y?: Array; + text?: string[]; +}; + +type PlotlyProps = { + data: PlotlyTrace[]; + layout: { + yaxis?: { + zeroline?: boolean; + }; + }; +}; + +const plotlyChartMock = vi.hoisted(() => + vi.fn((props: PlotlyProps) => ( +
+ )), +); + +vi.mock("../PlotlyChart", () => ({ + default: plotlyChartMock, +})); + +import GainersLosersSeriesCard from "../GainersLosersSeriesCard"; + +const snapshots = [ + { + source: "kucoin_futures", + recorded_at: "2026-08-10T10:00:00Z", + top_gainers: [ + { symbol: "AUSDCM", price_change_percent: 12 }, + { symbol: "BUSDCM", price_change_percent: 8 }, + ], + top_losers: [ + { symbol: "CUSDCM", price_change_percent: -4 }, + { symbol: "DUSDCM", price_change_percent: -6 }, + ], + }, + { + source: "kucoin_futures", + recorded_at: "2026-08-10T09:00:00Z", + top_gainers: [ + { symbol: "EUSDCM", price_change_percent: 5 }, + { symbol: "FUSDCM", price_change_percent: 3 }, + ], + top_losers: [ + { symbol: "GUSDCM", price_change_percent: -8 }, + { symbol: "HUSDCM", price_change_percent: -10 }, + ], + }, +]; + +const latestPlotlyProps = () => { + const call = plotlyChartMock.mock.calls.at(-1); + expectValue(call).toBeDefined(); + + return call?.[0] as PlotlyProps; +}; + +describeBlock("GainersLosersSeriesCard", () => { + beforeEachTest(() => { + plotlyChartMock.mockClear(); + }); + + testCase("plots average top-mover changes oldest-to-newest", () => { + render(); + + const props = latestPlotlyProps(); + const [gainersTrace, losersTrace] = props.data; + + expectValue(props.data.map((trace) => trace.name)).toEqual([ + "Top gainers average", + "Top losers average", + ]); + expectValue(gainersTrace.x).toEqual([ + "2026-08-10T09:00:00Z", + "2026-08-10T10:00:00Z", + ]); + expectValue(gainersTrace.y).toEqual([4, 10]); + expectValue(losersTrace.y).toEqual([-9, -5]); + expectValue(props.layout.yaxis?.zeroline).toBe(true); + }); + + testCase("shows snapshot symbols in the hover data", () => { + render(); + + const [gainersTrace, losersTrace] = latestPlotlyProps().data; + + expectValue(gainersTrace.text?.[1]).toContain("AUSDCM: +12%"); + expectValue(losersTrace.text?.[1]).toContain("CUSDCM: -4%"); + }); + + testCase("shows the latest net momentum badge", () => { + render(); + + expectValue(rtlScreen.getByText("+5.0 pts net").className).toContain( + "bg-success", + ); + }); +}); diff --git a/terminal/src/app/pages/Dashboard.tsx b/terminal/src/app/pages/Dashboard.tsx index b3baa1b8a..142fe5a99 100644 --- a/terminal/src/app/pages/Dashboard.tsx +++ b/terminal/src/app/pages/Dashboard.tsx @@ -8,7 +8,10 @@ import { useGetBotsQuery, useGetAlgoRankingQuery, } from "../../features/bots/botsApiSlice"; -import { useMarketBreadthSeriesQuery } from "../../features/marketApiSlice"; +import { + useGainersLosersSeriesQuery, + useMarketBreadthSeriesQuery, +} from "../../features/marketApiSlice"; import { useGetSignalsQuery } from "../../features/signalsApiSlice"; import type { BalanceData, @@ -22,6 +25,7 @@ import GainersLosers from "../components/GainersLosers"; import PortfolioBenchmarkChart from "../components/PortfolioBenchmark"; import { SpinnerContext } from "../spinner-context"; import MarketBreadthCard from "../components/MarketBreadthCard"; +import GainersLosersSeriesCard from "../components/GainersLosersSeriesCard"; import { useFilteredFuturesRankings, useFilteredGainerLosers, @@ -111,6 +115,8 @@ export const DashboardPage: FC<{}> = () => { const { data: marketBreadthSeries, isLoading: loadingMarketBreadthSeries } = useMarketBreadthSeriesQuery(); + const { data: gainersLosersSeries, isLoading: loadingGainersLosersSeries } = + useGainersLosersSeriesQuery(); const { data: algoRanking, isLoading: loadingAlgoRanking } = useGetAlgoRankingQuery(); @@ -253,6 +259,7 @@ export const DashboardPage: FC<{}> = () => { !loadingCombined && !loadingFuturesRankings && !loadingMarketBreadthSeries && + !loadingGainersLosersSeries && !loadingAlgoRanking && !loadingSignals ) { @@ -275,6 +282,7 @@ export const DashboardPage: FC<{}> = () => { loadingErrorBots, loadingCombined, loadingMarketBreadthSeries, + loadingGainersLosersSeries, loadingFuturesRankings, loadingAlgoRanking, loadingSignals, @@ -583,7 +591,7 @@ export const DashboardPage: FC<{}> = () => { - + {marketBreadthSeries?.market_breadth && ( = () => { /> )} + + {gainersLosersSeries && gainersLosersSeries.length > 0 && ( + + )} + {algoRanking && algoRanking.length > 0 && ( diff --git a/terminal/src/app/pages/tests/Dashboard.test.tsx b/terminal/src/app/pages/tests/Dashboard.test.tsx index a1d629b6f..0e7665575 100644 --- a/terminal/src/app/pages/tests/Dashboard.test.tsx +++ b/terminal/src/app/pages/tests/Dashboard.test.tsx @@ -57,7 +57,23 @@ vi.mock("../../../features/bots/botsApiSlice", () => ({ vi.mock("../../../features/marketApiSlice", () => ({ useMarketBreadthSeriesQuery: vi.fn(() => ({ - data: [], + data: { + timestamp: ["2026-08-10T10:00:00Z"], + market_breadth: [0.2], + market_breadth_ma: [0.15], + strength_index: [0.1], + }, + isLoading: false, + })), + useGainersLosersSeriesQuery: vi.fn(() => ({ + data: [ + { + source: "kucoin_futures", + recorded_at: "2026-08-10T10:00:00Z", + top_gainers: [], + top_losers: [], + }, + ], isLoading: false, })), })); @@ -92,6 +108,10 @@ vi.mock("../../components/MarketBreadthCard", () => ({ default: () =>
MarketBreadthCard
, })); +vi.mock("../../components/GainersLosersSeriesCard", () => ({ + default: () =>
GainersLosersSeriesCard
, +})); + describe("Dashboard page", () => { const renderDashboard = () => renderWithProviders( @@ -114,6 +134,17 @@ describe("Dashboard page", () => { expect(rtlScreen.getByText("1.27 BTC")).toBeInTheDocument(); }); + it("renders both market trend charts in half-width desktop columns", () => { + renderDashboard(); + + expect( + rtlScreen.getByText("MarketBreadthCard").closest(".col-lg-6"), + ).toBeInTheDocument(); + expect( + rtlScreen.getByText("GainersLosersSeriesCard").closest(".col-lg-6"), + ).toBeInTheDocument(); + }); + it("renders signals collapsed and ranked by algorithm count", () => { vi.mocked(useGetSignalsQuery).mockReturnValueOnce({ data: [ diff --git a/terminal/src/features/marketApiSlice.ts b/terminal/src/features/marketApiSlice.ts index 62a1f30dd..29f75c536 100644 --- a/terminal/src/features/marketApiSlice.ts +++ b/terminal/src/features/marketApiSlice.ts @@ -24,6 +24,18 @@ export interface MarketBreadthData { strength_index: number[]; } +export interface TopMover { + symbol: string; + price_change_percent: number; +} + +export interface GainersLosersSnapshot { + source: string; + recorded_at: string; + top_gainers: TopMover[]; + top_losers: TopMover[]; +} + /** * Difference between balanceApiSlice and marketApiSlice * is one comes from market the other comes from binance account. @@ -43,10 +55,23 @@ export const marketApiSlice = userApiSlice.injectEndpoints({ notifification("error", message); } + return data; + }, + }), + gainersLosersSeries: build.query({ + query: () => ({ + url: `${import.meta.env.VITE_GAINERS_LOSERS_SERIES || "/charts/gainers-losers-series"}?limit=168`, + }), + transformResponse: ({ data, message, error }) => { + if (error && error === 1) { + notifification("error", message); + } + return data; }, }), }), }); -export const { useMarketBreadthSeriesQuery } = marketApiSlice; +export const { useGainersLosersSeriesQuery, useMarketBreadthSeriesQuery } = + marketApiSlice; From 45f58fdb113c0ae639ac792d29cb174974772a4a Mon Sep 17 00:00:00 2001 From: carkod Date: Thu, 3 Sep 2026 14:16:23 +0100 Subject: [PATCH 4/4] Add gainer early momentum as part of same family of strategies --- api/tests/test_streaming_lifecycle_strategies.py | 11 +++++++++-- streaming/strategies/top_gainer_early_momentum.py | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/api/tests/test_streaming_lifecycle_strategies.py b/api/tests/test_streaming_lifecycle_strategies.py index d63ddaa03..3792bc250 100644 --- a/api/tests/test_streaming_lifecycle_strategies.py +++ b/api/tests/test_streaming_lifecycle_strategies.py @@ -118,6 +118,7 @@ def _context( RelativeStrengthImpulseRiderLifecycleStrategy, ), ("top_gainer_early_momentum", TopGainerEarlyMomentumLifecycleStrategy), + ("top_loser_early_momentum", TopGainerEarlyMomentumLifecycleStrategy), ("coinrule_price_tracker", PriceTrackerLifecycleStrategy), ("coinrule_buy_the_dip", DefaultLifecycleStrategy), ("bb_extreme_reversion", BBExtremeReversionLifecycleStrategy), @@ -251,13 +252,19 @@ def test_default_runtime_strategy_preserves_pullback_adjustment(monkeypatch) -> assert update.trailing_deviation == 1.55 -def test_top_gainer_lifecycle_delays_and_widens_trailing(monkeypatch) -> None: +@pytest.mark.parametrize( + "algorithm_name", + ["top_gainer_early_momentum", "top_loser_early_momentum"], +) +def test_top_mover_lifecycle_delays_and_widens_trailing( + monkeypatch, algorithm_name: str +) -> None: monkeypatch.setattr( "streaming.strategies.default.ApexFlowClose", FakeApexFlowClose, ) context = _context( - name="top_gainer_early_momentum", + name=algorithm_name, stop_loss=2.0, dynamic_trailing=True, ) diff --git a/streaming/strategies/top_gainer_early_momentum.py b/streaming/strategies/top_gainer_early_momentum.py index d811196fc..500196b9d 100644 --- a/streaming/strategies/top_gainer_early_momentum.py +++ b/streaming/strategies/top_gainer_early_momentum.py @@ -2,9 +2,11 @@ class TopGainerEarlyMomentumLifecycleStrategy(DefaultLifecycleStrategy): - """Keep volatile top-gainer runners alive long enough to express their edge.""" + """Keep volatile top-mover runners alive long enough to express their edge.""" - algorithm_names = frozenset({"top_gainer_early_momentum"}) + algorithm_names = frozenset( + {"top_gainer_early_momentum", "top_loser_early_momentum"} + ) MIN_STOP_LOSS = 2.0 MIN_TRAILING_PROFIT = 6.0