diff --git a/src/crypto_strategies/entrypoints/__init__.py b/src/crypto_strategies/entrypoints/__init__.py index 5f3cf46..4f12f36 100644 --- a/src/crypto_strategies/entrypoints/__init__.py +++ b/src/crypto_strategies/entrypoints/__init__.py @@ -437,13 +437,17 @@ def evaluate_crypto_trend_rotation(ctx: StrategyContext) -> StrategyDecision: def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision: from crypto_strategies.strategies.crypto_equity_combo import compute_signals + legacy_core, legacy_rotation = _load_legacy_modules() config = _merge_runtime_config(ctx, crypto_equity_combo_manifest.default_config) prices = _require_market_data(ctx, "market_prices") indicators_map = _require_market_data(ctx, "derived_indicators") benchmark_snapshot = _require_market_data(ctx, "benchmark_snapshot") portfolio = _resolve_portfolio_snapshot(ctx) + account_metrics = _resolve_account_metrics(ctx) universe_snapshot = list(_require_market_data(ctx, "universe_snapshot")) + state = dict(ctx.state) translator = _resolve_translator(config) + get_symbol_trade_state_fn, set_symbol_trade_state_fn = _resolve_state_helpers(config) weights, signal_desc, has_cash_residual, status_desc, metadata = compute_signals( prices=prices, @@ -451,7 +455,7 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision: universe_snapshot=universe_snapshot, benchmark_snapshot=benchmark_snapshot, portfolio=portfolio, - state=dict(ctx.state), + state=state, translator=translator, btc_weight=float(config.get("btc_weight", 0.30)), trend_weight=float(config.get("trend_weight", 0.70)), @@ -489,11 +493,69 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision: ) ) + btc_target_ratio = float(weights.get("BTCUSDT", 0.0)) + trend_target_ratio = float(sum(weight for symbol, weight in weights.items() if symbol != "BTCUSDT")) + total_equity = float(account_metrics["total_equity"]) + cash_usdt = max(0.0, float(account_metrics["cash_usdt"])) + trend_value = max(0.0, float(account_metrics.get("trend_value", 0.0))) + dca_value = max(0.0, float(account_metrics.get("dca_value", 0.0))) + trend_usdt_pool = max(0.0, min(cash_usdt, (total_equity * trend_target_ratio) - trend_value)) + remaining_cash = max(0.0, cash_usdt - trend_usdt_pool) + dca_usdt_pool = max(0.0, min(remaining_cash, (total_equity * btc_target_ratio) - dca_value)) + btc_base_order_usdt = float(legacy_core.get_dynamic_btc_base_order(total_equity)) + trend_metadata = metadata.get("trend_leg", {}) if isinstance(metadata.get("trend_leg"), Mapping) else {} + selected_candidates = { + str(symbol): { + "weight": float(payload.get("weight", 0.0)), + "relative_score": float(payload.get("relative_score", 0.0)), + "abs_momentum": float(payload.get("abs_momentum", 0.0)), + } + for symbol, payload in dict(trend_metadata.get("rotation_candidates", {})).items() + } + runtime_trend_universe = {symbol: {"base_asset": symbol[:-4]} for symbol in universe_snapshot} + sell_reasons: dict[str, str] = {} + atr_multiplier = float(config.get("atr_multiplier", 2.5)) + for symbol in universe_snapshot: + curr_price = prices.get(symbol) + if curr_price is None: + continue + reason = legacy_rotation.get_trend_sell_reason( + state, + symbol, + curr_price, + indicators_map.get(symbol), + selected_candidates, + atr_multiplier, + get_symbol_trade_state_fn=get_symbol_trade_state_fn, + set_symbol_trade_state_fn=set_symbol_trade_state_fn, + translate_fn=translator, + ) + if reason: + sell_reasons[symbol] = str(reason) + + eligible_buy_symbols, planned_trend_buys = legacy_rotation.plan_trend_buys( + state, + runtime_trend_universe=runtime_trend_universe, + selected_candidates=selected_candidates, + trend_indicators=indicators_map, + prices=prices, + available_trend_buy_budget=trend_usdt_pool, + allow_new_trend_entries=bool(config.get("allow_new_trend_entries", True)), + get_symbol_trade_state_fn=get_symbol_trade_state_fn, + allocate_trend_buy_budget_fn=legacy_core.allocate_trend_buy_budget, + ) + budget_intents = ( BudgetIntent( - name="combo_pool", - amount=0.0, - purpose="combined_allocation", + name="btc_core_dca_pool", + symbol="BTCUSDT", + amount=dca_usdt_pool, + purpose="btc_core_accumulation", + ), + BudgetIntent( + name="trend_rotation_pool", + amount=trend_usdt_pool, + purpose="trend_rotation", ), ) @@ -502,6 +564,8 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision: risk_flags += ("regime_off",) if has_cash_residual: risk_flags += ("cash_residual",) + if not selected_candidates: + risk_flags += ("no_trend_candidates",) if not weights: risk_flags += ("no_positions",) @@ -511,6 +575,24 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision: "metadata": metadata, "managed_symbols": metadata.get("managed_symbols", ()), "profile": metadata.get("profile"), + "trend_pool": tuple(trend_metadata.get("trend_pool", ())), + "rotation_candidates": selected_candidates, + "ranking_preview": tuple(trend_metadata.get("ranking_preview", ())), + "rotation_pool_source_version": trend_metadata.get("rotation_pool_source_version"), + "rotation_pool_source_as_of_date": trend_metadata.get("rotation_pool_source_as_of_date"), + "rotation_pool_last_month": trend_metadata.get("rotation_pool_last_month"), + "sell_reasons": sell_reasons, + "eligible_buy_symbols": tuple(eligible_buy_symbols), + "planned_trend_buys": {symbol: float(amount) for symbol, amount in planned_trend_buys.items()}, + "btc_base_order_usdt": btc_base_order_usdt, + "btc_target_ratio": btc_target_ratio, + "trend_target_ratio": trend_target_ratio, + "artifact_contract": { + "version": config.get("artifact_contract_version"), + "max_age_days": config.get("artifact_max_age_days"), + "acceptable_modes": tuple(config.get("artifact_acceptable_modes", ())), + **dict(ctx.artifacts.get("trend_pool_contract", {})), + }, } return StrategyDecision( diff --git a/src/crypto_strategies/strategies/crypto_equity_combo.py b/src/crypto_strategies/strategies/crypto_equity_combo.py index de64421..d113b46 100644 --- a/src/crypto_strategies/strategies/crypto_equity_combo.py +++ b/src/crypto_strategies/strategies/crypto_equity_combo.py @@ -136,7 +136,7 @@ def _compute_trend_leg( btc_drawdown_threshold: float = 0.30, target_vol: float = 0.40, max_leverage: float = 1.0, -) -> dict[str, float]: +) -> tuple[dict[str, float], dict[str, object]]: """Compute trend leg targets using rotation logic.""" from crypto_strategies.strategies.crypto_live_pool_rotation.core import ( select_rotation_weights, @@ -152,7 +152,7 @@ def _compute_trend_leg( btc_drawdown_threshold=btc_drawdown_threshold, ) if blocked: - return {} + return {}, {"trend_pool": (), "rotation_candidates": {}, "circuit_blocked": True} trend_pool = resolve_authoritative_rotation_pool( state, @@ -165,19 +165,36 @@ def _compute_trend_leg( indicators_map, prices, btc_snapshot, trend_pool, rotation_top_n, weight_mode=weight_mode, ) + trend_metadata: dict[str, object] = { + "trend_pool": tuple(trend_pool), + "rotation_candidates": { + symbol: { + "weight": float(payload.get("weight", 0.0)), + "relative_score": float(payload.get("relative_score", 0.0)), + "abs_momentum": float(payload.get("abs_momentum", 0.0)), + } + for symbol, payload in candidates.items() + }, + "ranking_preview": tuple(trend_pool[: int(trend_pool_size)]), + "rotation_pool_source_version": state.get("rotation_pool_source_version"), + "rotation_pool_source_as_of_date": state.get("rotation_pool_source_as_of_date"), + "rotation_pool_last_month": state.get("rotation_pool_last_month"), + "circuit_blocked": False, + } if not candidates: - return {} + return {}, trend_metadata raw_weights = { sym: float(payload["weight"]) * float(trend_weight) for sym, payload in candidates.items() } - return _apply_volatility_scaling( + weights = _apply_volatility_scaling( raw_weights, indicators_map, vol_scaling_enabled=vol_scaling_enabled, target_vol=target_vol, max_leverage=max_leverage, ) + return weights, trend_metadata def build_target_weights( @@ -243,7 +260,7 @@ def build_target_weights( trend_weights: dict[str, float] = {} try: - trend_weights = _compute_trend_leg( + trend_weights, trend_metadata = _compute_trend_leg( indicators_map, prices, universe_symbols, state, effective_trend, trend_pool_size=int(kwargs.get("trend_pool_size", 5)), rotation_top_n=int(kwargs.get("rotation_top_n", 2)), @@ -257,6 +274,7 @@ def build_target_weights( ) except (ValueError, TypeError, KeyError) as exc: logger.warning("trend_leg failed, using empty weights: %s", exc) + trend_metadata = {"trend_pool": (), "rotation_candidates": {}, "error": str(exc)} # Combine all_symbols = set(btc_weights) | set(trend_weights) @@ -276,7 +294,7 @@ def build_target_weights( "base_trend_weight": trend_weight, }, "btc_leg": {"weights": btc_weights, **btc_leg_metadata}, - "trend_leg": {"weights": trend_weights}, + "trend_leg": {"weights": trend_weights, **trend_metadata}, "regime_off": regime_off, "dynamic_mode": dynamic_mode, "gross_exposure": sum(combined.values()), diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index b7303d9..5dfcd38 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -278,6 +278,80 @@ def test_crypto_live_pool_rotation_entrypoint_uses_authoritative_upstream_pool(s legacy_core.get_dynamic_btc_base_order(account_metrics["total_equity"]), ) + def test_crypto_equity_combo_entrypoint_exposes_binance_execution_contract(self) -> None: + try: + entrypoint = get_strategy_entrypoint("crypto_equity_combo") + except ModuleNotFoundError as exc: + if exc.name == "pandas": + self.skipTest("pandas is not installed") + raise + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={ + "market_prices": {"BTCUSDT": 60000.0, "ETHUSDT": 3000.0, "SOLUSDT": 180.0}, + "derived_indicators": { + "BTCUSDT": { + "close": 60000.0, + "sma200": 50000.0, + "roc20": 0.08, + "roc60": 0.16, + "roc120": 0.30, + "regime_on": True, + }, + "ETHUSDT": { + "close": 3000.0, + "sma20": 2800.0, + "sma60": 2600.0, + "sma200": 2200.0, + "roc20": 0.20, + "roc60": 0.35, + "roc120": 0.60, + "vol20": 0.25, + }, + "SOLUSDT": { + "close": 180.0, + "sma20": 170.0, + "sma60": 160.0, + "sma200": 120.0, + "roc20": 0.28, + "roc60": 0.45, + "roc120": 0.75, + "vol20": 0.30, + }, + }, + "benchmark_snapshot": {"regime_on": True}, + "portfolio_snapshot": PortfolioSnapshot( + as_of="2026-04-06", + total_equity=1000.0, + buying_power=1000.0, + cash_balance=1000.0, + metadata={ + "account_metrics": { + "total_equity": 1000.0, + "cash_usdt": 1000.0, + "trend_value": 0.0, + "dca_value": 0.0, + }, + }, + ), + "universe_snapshot": ("ETHUSDT", "SOLUSDT"), + }, + state={}, + ) + ) + + budget_map = {budget.name: budget.amount for budget in decision.budgets} + self.assertGreater(budget_map["trend_rotation_pool"], 0.0) + self.assertGreater(budget_map["btc_core_dca_pool"], 0.0) + self.assertGreater(decision.diagnostics["btc_base_order_usdt"], 0.0) + self.assertGreater(decision.diagnostics["btc_target_ratio"], 0.0) + self.assertGreater(decision.diagnostics["trend_target_ratio"], 0.0) + self.assertEqual(set(decision.diagnostics["rotation_candidates"]), {"ETHUSDT", "SOLUSDT"}) + self.assertEqual(set(decision.diagnostics["eligible_buy_symbols"]), {"ETHUSDT", "SOLUSDT"}) + self.assertEqual(set(decision.diagnostics["planned_trend_buys"]), {"ETHUSDT", "SOLUSDT"}) + def test_crypto_live_pool_rotation_entrypoint_sets_regime_off_flag_when_btc_regime_is_off(self) -> None: try: entrypoint = get_strategy_entrypoint("crypto_live_pool_rotation")