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
90 changes: 86 additions & 4 deletions src/crypto_strategies/entrypoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,21 +437,25 @@ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mapping portfolio snapshots in combo entrypoint

When the combo entrypoint receives a mapping-style portfolio_snapshot, which the underlying crypto_equity_combo.build_target_weights path explicitly supports, this new call routes through _resolve_account_metrics; that helper only falls back through object attributes like snapshot.total_equity when no embedded account_metrics exists. Such callers now raise before a StrategyDecision is returned, so handle Mapping snapshots in the account-metrics path or avoid requiring it for the combo budget diagnostics.

Useful? React with 👍 / 👎.

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,
indicators_map=indicators_map,
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)),
Expand Down Expand Up @@ -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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize symbols before planning combo buys

When universe_snapshot contains symbols that need normalization, such as lowercase or padded values, the trend leg resolves candidates with normalized uppercase keys, but this new execution map keeps the raw symbols and passes them to plan_trend_buys. That helper indexes prices[symbol] and checks selected_candidates.get(symbol), so valid candidates can either be skipped or raise a KeyError even though the rotation resolver accepted them; normalize these symbols or reuse the resolved trend pool before building the buy plan.

Useful? React with 👍 / 👎.

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard sell diagnostics when ATR is absent

When combo state marks a trend symbol as holding and the platform supplies the indicator set accepted by selection here (SMA/ROC/vol but no atr14, as in the new combo fixture), this new sell-reason pass calls get_trend_sell_reason, whose ATR stop path unconditionally reads indicators["atr14"]. That raises KeyError before returning any StrategyDecision, so either require/populate atr14 for combo execution or skip/default the ATR stop when it is missing.

Useful? React with 👍 / 👎.

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",
),
)

Expand All @@ -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",)

Expand All @@ -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(
Expand Down
30 changes: 24 additions & 6 deletions src/crypto_strategies/strategies/crypto_equity_combo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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)),
Expand All @@ -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)
Expand All @@ -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()),
Expand Down
74 changes: 74 additions & 0 deletions tests/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading