diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index e9deff972..2c5ec08ca 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -39,8 +39,6 @@ load_futures_order_book, ) from api.tools.constants import ( - RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, - RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES, TOP_GAINER_EARLY_MOMENTUM_ALGO, TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES, TOP_GAINER_FAILURE_REVERSAL_ALGO, @@ -1607,13 +1605,7 @@ def base_order(self) -> BotModel: self.active_bot.deal.opening_timestamp = order.timestamp self.active_bot.status = Status.pending pending_entry_minutes = 5 - if self.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: - pending_entry_minutes = ( - self.interval_ms - * RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES - // 60_000 - ) - elif self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: + if self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: pending_entry_minutes = ( self.interval_ms * TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 93140300e..67bc70d79 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -964,25 +964,6 @@ def test_non_recovery_entry_uses_body_capped_limit_price(monkeypatch): assert any("Body-capped entry" in log for log in deal.active_bot.logs) -def test_relative_strength_impulse_rider_uses_prompt_body_capped_entry_after_reclaim( - monkeypatch, -): - deal = prepare_recovery_entry_deal( - monkeypatch, - position=Position.long, - previous_close=100.0, - current_open=101.0, - candle_range=2.0, - ) - deal.active_bot.name = "relative_strength_impulse_rider" - deal.active_bot.recovery_params = None - - limit_price = deal.body_capped_entry_limit_price() - - assert limit_price == 102.0 - assert any("Body-capped entry" in log for log in deal.active_bot.logs) - - def test_top_gainer_failure_reversal_anchors_prompt_entry_to_current_close( monkeypatch, ): @@ -1405,39 +1386,6 @@ def test_unfilled_capped_base_order_uses_pending_entry_ttl_not_legacy_age_expiry ) -@pytest.mark.parametrize("interval_minutes", [5, 15, 60]) -def test_relative_strength_impulse_rider_pending_entry_waits_one_candle( - interval_minutes, -): - position = cast(Any, FuturesPosition.__new__(FuturesPosition)) - order = OrderModel( - order_id="impulse-retest-entry", - order_type="limit", - pair="KATUSDTM", - timestamp=1, - order_side="buy", - qty=150, - price=0.00625, - status=OrderStatus.NEW, - time_in_force="GTC", - deal_type=DealType.base_order, - ) - position.execution = types.SimpleNamespace( - active_bot=BotModel( - pair="KATUSDTM", - name="relative_strength_impulse_rider", - status=Status.pending, - ) - ) - interval_ms = interval_minutes * 60 * 1000 - position.base_streaming = types.SimpleNamespace( - interval=types.SimpleNamespace(get_ms=lambda: interval_ms) - ) - - assert position.is_pending_base_entry_expired(order, now_ms=interval_ms) is False - assert position.is_pending_base_entry_expired(order, now_ms=interval_ms + 2) is True - - @pytest.mark.parametrize("interval_minutes", [5, 15, 60]) def test_top_gainer_retest_entry_waits_one_configured_candle(interval_minutes): position = cast(Any, FuturesPosition.__new__(FuturesPosition)) @@ -1496,7 +1444,7 @@ def test_top_gainer_failure_reversal_pending_entry_expires_after_two_minutes(): assert position.is_pending_base_entry_expired(order, now_ms=120_002) is True -def test_relative_strength_impulse_rider_delayed_fill_starts_holding_clock_at_fill( +def test_top_gainer_short_delayed_fill_starts_holding_clock_at_fill( monkeypatch, ): fill_time = datetime(2026, 8, 6, 12, 0, tzinfo=timezone.utc) @@ -1510,7 +1458,7 @@ def test_relative_strength_impulse_rider_delayed_fill_starts_holding_clock_at_fi execution = cast(Any, KucoinPositionDeal.__new__(KucoinPositionDeal)) execution.active_bot = BotModel( pair="KATUSDTM", - name="relative_strength_impulse_rider", + name="top_gainer_short", status=Status.pending, ) position.execution = execution @@ -1523,7 +1471,7 @@ def test_relative_strength_impulse_rider_delayed_fill_starts_holding_clock_at_fi created_at=int(observation_time.timestamp() * 1000), ), types.SimpleNamespace( - order_id="filled-impulse-retest-entry", + order_id="filled-top-gainer-short-entry", trade_time=fill_timestamp_ms * 1_000_000, created_at=fill_timestamp_ms, ), @@ -1532,7 +1480,7 @@ def test_relative_strength_impulse_rider_delayed_fill_starts_holding_clock_at_fi ) execution.open_deal = lambda: execution.active_bot order = OrderModel( - order_id="filled-impulse-retest-entry", + order_id="filled-top-gainer-short-entry", order_type="limit", pair="KATUSDTM", timestamp=1, diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index bfe7abed7..5af817ace 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -193,28 +193,11 @@ def close_all(algorithmic_close: bool = False) -> BotModel: assert close_calls == [True] -def test_relative_strength_impulse_rider_closes_at_eight_completed_candles() -> None: - close_calls: list[bool] = [] - deal = _make_strategy_at_holding_limit(name="relative_strength_impulse_rider") - - def close_all(algorithmic_close: bool = False) -> BotModel: - close_calls.append(algorithmic_close) - return deal.execution.active_bot - - deal.execution.close_all = close_all - - result = Lifecycle.exit(deal, close_price=100.0) - - assert result is deal.execution.active_bot - assert close_calls == [True] - - def test_fixed_take_profit_precedes_max_holding_close() -> None: for name in ( "mean_reversion_fade", "liquidation_sweep_pump", "coinrule_price_tracker", - "relative_strength_impulse_rider", ): exit_calls: list[str] = [] deal = _make_strategy_at_holding_limit( diff --git a/api/tests/test_streaming_lifecycle_strategies.py b/api/tests/test_streaming_lifecycle_strategies.py index ad58d1927..4eb2efd88 100644 --- a/api/tests/test_streaming_lifecycle_strategies.py +++ b/api/tests/test_streaming_lifecycle_strategies.py @@ -38,9 +38,6 @@ from streaming.strategies.mean_reversion_fade import ( MeanReversionFadeLifecycleStrategy, ) -from streaming.strategies.relative_strength_impulse_rider import ( - RelativeStrengthImpulseRiderLifecycleStrategy, -) from streaming.strategies.top_gainer_short import TopGainerShortLifecycleStrategy from streaming.strategies.top_gainer_early_momentum import ( TopGainerEarlyMomentumLifecycleStrategy, @@ -142,10 +139,6 @@ def _context( [ ("mean_reversion_fade", MeanReversionFadeLifecycleStrategy), ("liquidation_sweep_pump", LiquidationSweepPumpLifecycleStrategy), - ( - "relative_strength_impulse_rider", - RelativeStrengthImpulseRiderLifecycleStrategy, - ), ("top_gainer_early_momentum", TopGainerEarlyMomentumLifecycleStrategy), ("top_loser_early_momentum", TopGainerEarlyMomentumLifecycleStrategy), ("top_gainer_short", TopGainerShortLifecycleStrategy), @@ -167,13 +160,9 @@ def test_strategy_policies_replace_lifecycle_name_branches() -> None: evaluator = LifecycleContextEvaluator() mean_reversion = evaluator.evaluate(_context(name="mean_reversion_fade")) - relative_strength = evaluator.evaluate( - _context(name="relative_strength_impulse_rider") - ) liquidation_sweep = evaluator.evaluate(_context(name="liquidation_sweep_pump")) assert mean_reversion.policy.low_price_stop_floor_pct is None - assert relative_strength.policy.low_price_stop_floor_pct is None assert liquidation_sweep.policy.emergency_stop_bounds.minimum_pct == 0.35 assert liquidation_sweep.policy.emergency_stop_bounds.maximum_pct == 0.75 assert evaluator.evaluate(_context()).policy.low_price_stop_floor_pct == 4.0 @@ -199,7 +188,6 @@ def test_chop_prone_strategies_block_reversal_after_loss( ("liquidation_sweep_pump", Position.short, False), ("coinrule_price_tracker", Position.long, True), ("coinrule_price_tracker", Position.short, False), - ("relative_strength_impulse_rider", Position.long, True), ("another_strategy", Position.long, False), ], ) diff --git a/api/tools/constants.py b/api/tools/constants.py index cd5f7f8ba..1120f7d45 100644 --- a/api/tools/constants.py +++ b/api/tools/constants.py @@ -11,6 +11,3 @@ TOP_GAINER_FAILURE_REVERSAL_ALGO = "top_gainer_failure_reversal" TOP_GAINER_FAILURE_REVERSAL_PENDING_ENTRY_TTL_MS = 2 * 60 * 1000 - -RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO = "relative_strength_impulse_rider" -RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES = 1 diff --git a/streaming/context_evaluator.py b/streaming/context_evaluator.py index 2e660bbc2..3b6334bf4 100644 --- a/streaming/context_evaluator.py +++ b/streaming/context_evaluator.py @@ -18,9 +18,6 @@ from streaming.strategies.mean_reversion_fade import ( MeanReversionFadeLifecycleStrategy, ) -from streaming.strategies.relative_strength_impulse_rider import ( - RelativeStrengthImpulseRiderLifecycleStrategy, -) from streaming.strategies.top_gainer_short import TopGainerShortLifecycleStrategy from streaming.strategies.top_gainer_early_momentum import ( TopGainerEarlyMomentumLifecycleStrategy, @@ -37,7 +34,6 @@ class LifecycleContextEvaluator: STRATEGY_TYPES = ( MeanReversionFadeLifecycleStrategy, LiquidationSweepPumpLifecycleStrategy, - RelativeStrengthImpulseRiderLifecycleStrategy, TopGainerEarlyMomentumLifecycleStrategy, TopGainerShortLifecycleStrategy, PriceTrackerLifecycleStrategy, diff --git a/streaming/futures_position.py b/streaming/futures_position.py index f2dd7e070..19eb32620 100644 --- a/streaming/futures_position.py +++ b/streaming/futures_position.py @@ -25,8 +25,6 @@ load_futures_order_book, ) from api.tools.constants import ( - RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, - RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES, TOP_GAINER_EARLY_MOMENTUM_ALGO, TOP_GAINER_SHORT_ALGO, TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES, @@ -72,12 +70,7 @@ def __init__( def is_pending_base_entry_expired(self, order: OrderModel, now_ms: int) -> bool: pending_entry_ttl_ms = self.PENDING_ENTRY_TTL_MS - if self.execution.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: - pending_entry_ttl_ms = ( - self.base_streaming.interval.get_ms() - * RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES - ) - elif self.execution.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: + if self.execution.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: pending_entry_ttl_ms = ( self.base_streaming.interval.get_ms() * TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES @@ -176,8 +169,7 @@ def _activate_filled_base_order( self.execution.active_bot.deal.base_order_size = order.qty self.execution.active_bot.deal.opening_timestamp = ( self.execution.matching_exchange_fill_timestamp(order) - if self.execution.active_bot.name - in {RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, TOP_GAINER_SHORT_ALGO} + if self.execution.active_bot.name == TOP_GAINER_SHORT_ALGO else order.timestamp ) if log_message: @@ -571,13 +563,7 @@ def _expire_unfilled_base_order( self.execution.controller.update_order(order) self.execution.active_bot.status = Status.error pending_entry_minutes = self.PENDING_ENTRY_TTL_MS // 60_000 - if self.execution.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: - pending_entry_minutes = ( - self.base_streaming.interval.get_ms() - * RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES - // 60_000 - ) - elif self.execution.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: + if self.execution.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: pending_entry_minutes = ( self.base_streaming.interval.get_ms() * TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES diff --git a/streaming/strategies/relative_strength_impulse_rider.py b/streaming/strategies/relative_strength_impulse_rider.py deleted file mode 100644 index e815e8ca7..000000000 --- a/streaming/strategies/relative_strength_impulse_rider.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import replace - -from pybinbot import Position - -from streaming.strategies.base import ( - LifecycleContext, - LifecyclePolicy, - LifecycleSignal, -) -from streaming.strategies.default import DefaultLifecycleStrategy - - -class RelativeStrengthImpulseRiderLifecycleStrategy(DefaultLifecycleStrategy): - algorithm_names = frozenset({"relative_strength_impulse_rider"}) - policy = LifecyclePolicy(low_price_stop_floor_pct=None) - MAX_HOLDING_BARS = 8 - - def signal(self, context: LifecycleContext) -> LifecycleSignal: - signal = super().signal(context) - return replace( - signal, - exit_intent=self.max_holding_exit( - context, - max_holding_bars=self.MAX_HOLDING_BARS, - positions=frozenset({Position.long}), - ), - ) diff --git a/terminal/src/app/components/BotDetailTabs.tsx b/terminal/src/app/components/BotDetailTabs.tsx index e0f47f9f3..d9f6ed57b 100644 --- a/terminal/src/app/components/BotDetailTabs.tsx +++ b/terminal/src/app/components/BotDetailTabs.tsx @@ -43,7 +43,7 @@ const BotDetailTabs: FC<{ const handleActivation = async (id: string) => { try { const data = await updateBot({ body: bot, id }).unwrap(); - if (data) { + if (data?.bot) { await trigger(id).unwrap(); navigate(getEditPath(id)); } diff --git a/terminal/src/features/bots/botsApiSlice.test.ts b/terminal/src/features/bots/botsApiSlice.test.ts new file mode 100644 index 000000000..ca4232466 --- /dev/null +++ b/terminal/src/features/bots/botsApiSlice.test.ts @@ -0,0 +1,47 @@ +import { vi } from "vitest"; +import { makeStore } from "../../app/store"; +import { singleBot } from "./botInitialState"; +import { botsApiSlice } from "./botsApiSlice"; + +class MockRequest { + url: string; + init?: RequestInit; + + constructor(input: RequestInfo | URL, init?: RequestInit) { + this.url = String(input); + this.init = init; + } + + clone() { + return this; + } +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("returns the edited bot from the API data field so Deal can activate it", async () => { + const bot = { ...singleBot, id: "bot-1" }; + vi.stubGlobal("Request", MockRequest); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + message: "Successfully edited bot.", + error: 0, + data: bot, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await makeStore().dispatch( + botsApiSlice.endpoints.editBot.initiate({ id: bot.id, body: bot }), + ); + + expect(result.data).toEqual({ bot }); + expect(fetchMock).toHaveBeenCalledOnce(); +}); diff --git a/terminal/src/features/bots/botsApiSlice.ts b/terminal/src/features/bots/botsApiSlice.ts index 2067eebf6..e3b4527bd 100644 --- a/terminal/src/features/bots/botsApiSlice.ts +++ b/terminal/src/features/bots/botsApiSlice.ts @@ -7,7 +7,6 @@ import type { Bot, BotEntity } from "./botInitialState"; import { computeTotalProfit } from "./profits"; import { BotStatus } from "../../utils/enums"; import type { - CreateBotResponse, DefaultBotsResponse, EditBotParams, SingleBotResponse, @@ -126,7 +125,7 @@ export const botsApiSlice = userApiSlice.injectEndpoints({ }; }, }), - editBot: build.mutation({ + editBot: build.mutation({ query: ({ body, id }) => ({ url: `${import.meta.env.VITE_GET_BOTS}/${id}`, method: "PUT", @@ -136,15 +135,13 @@ export const botsApiSlice = userApiSlice.injectEndpoints({ "bots", { type: "bot", id }, ], - transformResponse: ({ botId, message, error }, meta, arg) => { + transformResponse: ({ data, message, error }, meta, arg) => { if (error && error === 1) { notifification("error", message); - // Let UI know so we don't navigate return null; - } else { - notifification("success", message); } - return botId; + notifification("success", message); + return { bot: data }; }, }), deleteBot: build.mutation({ diff --git a/terminal/src/utils/charting/index.test.ts b/terminal/src/utils/charting/index.test.ts index c4fcfc4af..9e448fd85 100644 --- a/terminal/src/utils/charting/index.test.ts +++ b/terminal/src/utils/charting/index.test.ts @@ -1,5 +1,5 @@ import { singleBot, type Bot } from "../../features/bots/botInitialState"; -import { BotPosition, BotStatus, MarketType } from "../enums"; +import { BotPosition, BotStatus, MarketType, QuoteAsset } from "../enums"; import { updateOrderLines } from "./index"; function completedTrailingFuturesLong(): Bot { @@ -25,6 +25,48 @@ function completedTrailingFuturesLong(): Bot { } describe("chart order lines", () => { + it("flips futures order, trailing, and stop-loss previews with position", () => { + const bot: Bot = { + ...singleBot, + market_type: MarketType.FUTURES, + quote_asset: QuoteAsset.USDT, + fiat_order_size: 25, + status: BotStatus.INACTIVE, + trailing: true, + trailing_profit: 6, + trailing_deviation: 4, + stop_loss: 3, + }; + const price = 100; + const line = (lines: ReturnType, id: string) => + lines.find((orderLine) => orderLine.id === id); + + const longLines = updateOrderLines(bot, price); + const shortLines = updateOrderLines( + { ...bot, position: BotPosition.SHORT }, + price, + ); + + expect(line(longLines, "base_order")).toMatchObject({ + text: "Base", + price: 100, + }); + expect(line(shortLines, "base_order")).toMatchObject({ + text: "Base (Margin sell)", + price: 100, + }); + expect(line(longLines, "trailing_profit")?.price).toBeCloseTo(106); + expect(line(shortLines, "trailing_profit")?.price).toBeCloseTo(94); + expect(line(longLines, "trailing_stop_loss")?.price).toBeCloseTo( + 106 * 0.96, + ); + expect(line(shortLines, "trailing_stop_loss")?.price).toBeCloseTo( + 94 * 1.04, + ); + expect(line(longLines, "stop_loss")?.price).toBeCloseTo(97); + expect(line(shortLines, "stop_loss")?.price).toBeCloseTo(103); + }); + it("uses the persisted trailing-profit trigger for a completed futures long", () => { const lines = updateOrderLines(completedTrailingFuturesLong(), 0.01052); diff --git a/terminal/src/utils/charting/margin-short.service.ts b/terminal/src/utils/charting/margin-short.service.ts index f49bd2024..66827959c 100644 --- a/terminal/src/utils/charting/margin-short.service.ts +++ b/terminal/src/utils/charting/margin-short.service.ts @@ -100,35 +100,31 @@ export default function marginTrading( price: bot.deal.trailing_stop_loss_price, color: dealColors.take_profit, }); - } else { - // Inactive bot - const price = - bot.deal?.opening_price || currentPrice || bot.deal.current_price; + } else if ( + bot.status === BotStatus.INACTIVE && + bot.trailing && + bot.trailing_deviation > 0 && + bot.trailing_profit > 0 + ) { + // Preview the short's trailing levels below the base order. const trailingProfitPrice = price * (1 - bot.trailing_profit / 100); - if (bot.deal.trailing_profit_price > 0) { - totalOrderLines.push({ - id: "trailing_profit", - text: `Take profit (trailing) ${bot.take_profit}%`, - tooltip: [bot.status, " Breakpoint to increase Take profit"], - quantity: `${qtyText} ${quoteAsset}`, - price: bot.deal?.trailing_profit_price || trailingProfitPrice, - color: dealColors.trailing_profit, - lineStyle: 2, - }); - } - - if (bot.deal.trailing_stop_loss_price > 0) { - totalOrderLines.push({ - id: "trailing_stop_loss", - text: `Trailing stop loss -${bot.trailing_deviation}%`, - tooltip: [bot.status, " Sell order when prices drop here"], - quantity: `${bot.deal.opening_qty || baseOrderSize} ${quoteAsset}`, - price: - bot.deal?.trailing_profit_price || - trailingProfitPrice * (1 + bot.trailing_deviation / 100), - color: dealColors.take_profit, - }); - } + totalOrderLines.push({ + id: "trailing_profit", + text: `Trailing profit ${bot.trailing_profit}%`, + tooltip: [bot.status, " Breakpoint to increase trailing profit"], + quantity: `${qtyText} ${quoteAsset}`, + price: trailingProfitPrice, + color: dealColors.trailing_profit, + lineStyle: 2, + }); + totalOrderLines.push({ + id: "trailing_stop_loss", + text: `Trailing stop loss -${bot.trailing_deviation}%`, + tooltip: [bot.status, " Buy back when prices rise here"], + quantity: `${bot.deal.opening_qty || baseOrderSize} ${quoteAsset}`, + price: trailingProfitPrice * (1 + bot.trailing_deviation / 100), + color: dealColors.take_profit, + }); } } else { if (bot.status === BotStatus.COMPLETED && bot.deal.take_profit_price > 0) { diff --git a/terminal/src/utils/charting/spot-strategy.service.ts b/terminal/src/utils/charting/spot-strategy.service.ts index 9079331ac..c49b20b1b 100644 --- a/terminal/src/utils/charting/spot-strategy.service.ts +++ b/terminal/src/utils/charting/spot-strategy.service.ts @@ -1,7 +1,7 @@ import { type OrderLine } from "./index.d"; import { type Bot } from "../../features/bots/botInitialState"; import { dealColors } from "../../utils/charting/index"; -import { BotStatus, DealType, QuoteAsset } from "../enums"; +import { BotStatus, DealType, MarketType, QuoteAsset } from "../enums"; const getBaseOrderQuoteAsset = (bot: Bot): number => { let baseOrderSize = 0; @@ -21,7 +21,7 @@ export default function spotTrading( let totalOrderLines: OrderLine[] = []; let baseOrderSize = 0; - if (quoteAsset === QuoteAsset.USDC) { + if (bot.market_type === MarketType.FUTURES || quoteAsset === QuoteAsset.USDC) { baseOrderSize = bot.deal.base_order_size > 0 ? bot.deal.base_order_size