Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 24 additions & 53 deletions api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
),
)

Expand Down
5 changes: 2 additions & 3 deletions api/grid_ladders/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions api/tests/test_kucoin_futures_contract_sizing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
31 changes: 30 additions & 1 deletion api/tests/test_streaming_lifecycle_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,7 +117,8 @@ def _context(
"relative_strength_impulse_rider",
RelativeStrengthImpulseRiderLifecycleStrategy,
),
("top_gainer_early_momentum", DefaultLifecycleStrategy),
("top_gainer_early_momentum", TopGainerEarlyMomentumLifecycleStrategy),
("top_loser_early_momentum", TopGainerEarlyMomentumLifecycleStrategy),
("coinrule_price_tracker", PriceTrackerLifecycleStrategy),
("coinrule_buy_the_dip", DefaultLifecycleStrategy),
("bb_extreme_reversion", BBExtremeReversionLifecycleStrategy),
Expand Down Expand Up @@ -248,6 +252,31 @@ def test_default_runtime_strategy_preserves_pullback_adjustment(monkeypatch) ->
assert update.trailing_deviation == 1.55


@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=algorithm_name,
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",
Expand Down
4 changes: 4 additions & 0 deletions streaming/context_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -34,6 +37,7 @@ class LifecycleContextEvaluator:
MeanReversionFadeLifecycleStrategy,
LiquidationSweepPumpLifecycleStrategy,
RelativeStrengthImpulseRiderLifecycleStrategy,
TopGainerEarlyMomentumLifecycleStrategy,
PriceTrackerLifecycleStrategy,
BBExtremeReversionLifecycleStrategy,
)
Expand Down
15 changes: 15 additions & 0 deletions streaming/strategies/top_gainer_early_momentum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from streaming.strategies.default import DefaultLifecycleStrategy


class TopGainerEarlyMomentumLifecycleStrategy(DefaultLifecycleStrategy):
"""Keep volatile top-mover runners alive long enough to express their edge."""

algorithm_names = frozenset(
{"top_gainer_early_momentum", "top_loser_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
1 change: 1 addition & 0 deletions terminal/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading