Skip to content

Commit a830b5d

Browse files
Pigbibicodex
andauthored
Expose combo execution contract (#57)
Co-authored-by: Codex <noreply@openai.com>
1 parent 139681b commit a830b5d

3 files changed

Lines changed: 184 additions & 10 deletions

File tree

src/crypto_strategies/entrypoints/__init__.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,21 +437,25 @@ def evaluate_crypto_trend_rotation(ctx: StrategyContext) -> StrategyDecision:
437437
def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
438438
from crypto_strategies.strategies.crypto_equity_combo import compute_signals
439439

440+
legacy_core, legacy_rotation = _load_legacy_modules()
440441
config = _merge_runtime_config(ctx, crypto_equity_combo_manifest.default_config)
441442
prices = _require_market_data(ctx, "market_prices")
442443
indicators_map = _require_market_data(ctx, "derived_indicators")
443444
benchmark_snapshot = _require_market_data(ctx, "benchmark_snapshot")
444445
portfolio = _resolve_portfolio_snapshot(ctx)
446+
account_metrics = _resolve_account_metrics(ctx)
445447
universe_snapshot = list(_require_market_data(ctx, "universe_snapshot"))
448+
state = dict(ctx.state)
446449
translator = _resolve_translator(config)
450+
get_symbol_trade_state_fn, set_symbol_trade_state_fn = _resolve_state_helpers(config)
447451

448452
weights, signal_desc, has_cash_residual, status_desc, metadata = compute_signals(
449453
prices=prices,
450454
indicators_map=indicators_map,
451455
universe_snapshot=universe_snapshot,
452456
benchmark_snapshot=benchmark_snapshot,
453457
portfolio=portfolio,
454-
state=dict(ctx.state),
458+
state=state,
455459
translator=translator,
456460
btc_weight=float(config.get("btc_weight", 0.30)),
457461
trend_weight=float(config.get("trend_weight", 0.70)),
@@ -489,11 +493,69 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
489493
)
490494
)
491495

496+
btc_target_ratio = float(weights.get("BTCUSDT", 0.0))
497+
trend_target_ratio = float(sum(weight for symbol, weight in weights.items() if symbol != "BTCUSDT"))
498+
total_equity = float(account_metrics["total_equity"])
499+
cash_usdt = max(0.0, float(account_metrics["cash_usdt"]))
500+
trend_value = max(0.0, float(account_metrics.get("trend_value", 0.0)))
501+
dca_value = max(0.0, float(account_metrics.get("dca_value", 0.0)))
502+
trend_usdt_pool = max(0.0, min(cash_usdt, (total_equity * trend_target_ratio) - trend_value))
503+
remaining_cash = max(0.0, cash_usdt - trend_usdt_pool)
504+
dca_usdt_pool = max(0.0, min(remaining_cash, (total_equity * btc_target_ratio) - dca_value))
505+
btc_base_order_usdt = float(legacy_core.get_dynamic_btc_base_order(total_equity))
506+
trend_metadata = metadata.get("trend_leg", {}) if isinstance(metadata.get("trend_leg"), Mapping) else {}
507+
selected_candidates = {
508+
str(symbol): {
509+
"weight": float(payload.get("weight", 0.0)),
510+
"relative_score": float(payload.get("relative_score", 0.0)),
511+
"abs_momentum": float(payload.get("abs_momentum", 0.0)),
512+
}
513+
for symbol, payload in dict(trend_metadata.get("rotation_candidates", {})).items()
514+
}
515+
runtime_trend_universe = {symbol: {"base_asset": symbol[:-4]} for symbol in universe_snapshot}
516+
sell_reasons: dict[str, str] = {}
517+
atr_multiplier = float(config.get("atr_multiplier", 2.5))
518+
for symbol in universe_snapshot:
519+
curr_price = prices.get(symbol)
520+
if curr_price is None:
521+
continue
522+
reason = legacy_rotation.get_trend_sell_reason(
523+
state,
524+
symbol,
525+
curr_price,
526+
indicators_map.get(symbol),
527+
selected_candidates,
528+
atr_multiplier,
529+
get_symbol_trade_state_fn=get_symbol_trade_state_fn,
530+
set_symbol_trade_state_fn=set_symbol_trade_state_fn,
531+
translate_fn=translator,
532+
)
533+
if reason:
534+
sell_reasons[symbol] = str(reason)
535+
536+
eligible_buy_symbols, planned_trend_buys = legacy_rotation.plan_trend_buys(
537+
state,
538+
runtime_trend_universe=runtime_trend_universe,
539+
selected_candidates=selected_candidates,
540+
trend_indicators=indicators_map,
541+
prices=prices,
542+
available_trend_buy_budget=trend_usdt_pool,
543+
allow_new_trend_entries=bool(config.get("allow_new_trend_entries", True)),
544+
get_symbol_trade_state_fn=get_symbol_trade_state_fn,
545+
allocate_trend_buy_budget_fn=legacy_core.allocate_trend_buy_budget,
546+
)
547+
492548
budget_intents = (
493549
BudgetIntent(
494-
name="combo_pool",
495-
amount=0.0,
496-
purpose="combined_allocation",
550+
name="btc_core_dca_pool",
551+
symbol="BTCUSDT",
552+
amount=dca_usdt_pool,
553+
purpose="btc_core_accumulation",
554+
),
555+
BudgetIntent(
556+
name="trend_rotation_pool",
557+
amount=trend_usdt_pool,
558+
purpose="trend_rotation",
497559
),
498560
)
499561

@@ -502,6 +564,8 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
502564
risk_flags += ("regime_off",)
503565
if has_cash_residual:
504566
risk_flags += ("cash_residual",)
567+
if not selected_candidates:
568+
risk_flags += ("no_trend_candidates",)
505569
if not weights:
506570
risk_flags += ("no_positions",)
507571

@@ -511,6 +575,24 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
511575
"metadata": metadata,
512576
"managed_symbols": metadata.get("managed_symbols", ()),
513577
"profile": metadata.get("profile"),
578+
"trend_pool": tuple(trend_metadata.get("trend_pool", ())),
579+
"rotation_candidates": selected_candidates,
580+
"ranking_preview": tuple(trend_metadata.get("ranking_preview", ())),
581+
"rotation_pool_source_version": trend_metadata.get("rotation_pool_source_version"),
582+
"rotation_pool_source_as_of_date": trend_metadata.get("rotation_pool_source_as_of_date"),
583+
"rotation_pool_last_month": trend_metadata.get("rotation_pool_last_month"),
584+
"sell_reasons": sell_reasons,
585+
"eligible_buy_symbols": tuple(eligible_buy_symbols),
586+
"planned_trend_buys": {symbol: float(amount) for symbol, amount in planned_trend_buys.items()},
587+
"btc_base_order_usdt": btc_base_order_usdt,
588+
"btc_target_ratio": btc_target_ratio,
589+
"trend_target_ratio": trend_target_ratio,
590+
"artifact_contract": {
591+
"version": config.get("artifact_contract_version"),
592+
"max_age_days": config.get("artifact_max_age_days"),
593+
"acceptable_modes": tuple(config.get("artifact_acceptable_modes", ())),
594+
**dict(ctx.artifacts.get("trend_pool_contract", {})),
595+
},
514596
}
515597

516598
return StrategyDecision(

src/crypto_strategies/strategies/crypto_equity_combo.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def _compute_trend_leg(
136136
btc_drawdown_threshold: float = 0.30,
137137
target_vol: float = 0.40,
138138
max_leverage: float = 1.0,
139-
) -> dict[str, float]:
139+
) -> tuple[dict[str, float], dict[str, object]]:
140140
"""Compute trend leg targets using rotation logic."""
141141
from crypto_strategies.strategies.crypto_live_pool_rotation.core import (
142142
select_rotation_weights,
@@ -152,7 +152,7 @@ def _compute_trend_leg(
152152
btc_drawdown_threshold=btc_drawdown_threshold,
153153
)
154154
if blocked:
155-
return {}
155+
return {}, {"trend_pool": (), "rotation_candidates": {}, "circuit_blocked": True}
156156

157157
trend_pool = resolve_authoritative_rotation_pool(
158158
state,
@@ -165,19 +165,36 @@ def _compute_trend_leg(
165165
indicators_map, prices, btc_snapshot, trend_pool,
166166
rotation_top_n, weight_mode=weight_mode,
167167
)
168+
trend_metadata: dict[str, object] = {
169+
"trend_pool": tuple(trend_pool),
170+
"rotation_candidates": {
171+
symbol: {
172+
"weight": float(payload.get("weight", 0.0)),
173+
"relative_score": float(payload.get("relative_score", 0.0)),
174+
"abs_momentum": float(payload.get("abs_momentum", 0.0)),
175+
}
176+
for symbol, payload in candidates.items()
177+
},
178+
"ranking_preview": tuple(trend_pool[: int(trend_pool_size)]),
179+
"rotation_pool_source_version": state.get("rotation_pool_source_version"),
180+
"rotation_pool_source_as_of_date": state.get("rotation_pool_source_as_of_date"),
181+
"rotation_pool_last_month": state.get("rotation_pool_last_month"),
182+
"circuit_blocked": False,
183+
}
168184
if not candidates:
169-
return {}
185+
return {}, trend_metadata
170186

171187
raw_weights = {
172188
sym: float(payload["weight"]) * float(trend_weight)
173189
for sym, payload in candidates.items()
174190
}
175-
return _apply_volatility_scaling(
191+
weights = _apply_volatility_scaling(
176192
raw_weights, indicators_map,
177193
vol_scaling_enabled=vol_scaling_enabled,
178194
target_vol=target_vol,
179195
max_leverage=max_leverage,
180196
)
197+
return weights, trend_metadata
181198

182199

183200
def build_target_weights(
@@ -243,7 +260,7 @@ def build_target_weights(
243260

244261
trend_weights: dict[str, float] = {}
245262
try:
246-
trend_weights = _compute_trend_leg(
263+
trend_weights, trend_metadata = _compute_trend_leg(
247264
indicators_map, prices, universe_symbols, state, effective_trend,
248265
trend_pool_size=int(kwargs.get("trend_pool_size", 5)),
249266
rotation_top_n=int(kwargs.get("rotation_top_n", 2)),
@@ -257,6 +274,7 @@ def build_target_weights(
257274
)
258275
except (ValueError, TypeError, KeyError) as exc:
259276
logger.warning("trend_leg failed, using empty weights: %s", exc)
277+
trend_metadata = {"trend_pool": (), "rotation_candidates": {}, "error": str(exc)}
260278

261279
# Combine
262280
all_symbols = set(btc_weights) | set(trend_weights)
@@ -276,7 +294,7 @@ def build_target_weights(
276294
"base_trend_weight": trend_weight,
277295
},
278296
"btc_leg": {"weights": btc_weights, **btc_leg_metadata},
279-
"trend_leg": {"weights": trend_weights},
297+
"trend_leg": {"weights": trend_weights, **trend_metadata},
280298
"regime_off": regime_off,
281299
"dynamic_mode": dynamic_mode,
282300
"gross_exposure": sum(combined.values()),

tests/test_entrypoints.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,80 @@ def test_crypto_live_pool_rotation_entrypoint_uses_authoritative_upstream_pool(s
278278
legacy_core.get_dynamic_btc_base_order(account_metrics["total_equity"]),
279279
)
280280

281+
def test_crypto_equity_combo_entrypoint_exposes_binance_execution_contract(self) -> None:
282+
try:
283+
entrypoint = get_strategy_entrypoint("crypto_equity_combo")
284+
except ModuleNotFoundError as exc:
285+
if exc.name == "pandas":
286+
self.skipTest("pandas is not installed")
287+
raise
288+
289+
decision = entrypoint.evaluate(
290+
StrategyContext(
291+
as_of="2026-04-06",
292+
market_data={
293+
"market_prices": {"BTCUSDT": 60000.0, "ETHUSDT": 3000.0, "SOLUSDT": 180.0},
294+
"derived_indicators": {
295+
"BTCUSDT": {
296+
"close": 60000.0,
297+
"sma200": 50000.0,
298+
"roc20": 0.08,
299+
"roc60": 0.16,
300+
"roc120": 0.30,
301+
"regime_on": True,
302+
},
303+
"ETHUSDT": {
304+
"close": 3000.0,
305+
"sma20": 2800.0,
306+
"sma60": 2600.0,
307+
"sma200": 2200.0,
308+
"roc20": 0.20,
309+
"roc60": 0.35,
310+
"roc120": 0.60,
311+
"vol20": 0.25,
312+
},
313+
"SOLUSDT": {
314+
"close": 180.0,
315+
"sma20": 170.0,
316+
"sma60": 160.0,
317+
"sma200": 120.0,
318+
"roc20": 0.28,
319+
"roc60": 0.45,
320+
"roc120": 0.75,
321+
"vol20": 0.30,
322+
},
323+
},
324+
"benchmark_snapshot": {"regime_on": True},
325+
"portfolio_snapshot": PortfolioSnapshot(
326+
as_of="2026-04-06",
327+
total_equity=1000.0,
328+
buying_power=1000.0,
329+
cash_balance=1000.0,
330+
metadata={
331+
"account_metrics": {
332+
"total_equity": 1000.0,
333+
"cash_usdt": 1000.0,
334+
"trend_value": 0.0,
335+
"dca_value": 0.0,
336+
},
337+
},
338+
),
339+
"universe_snapshot": ("ETHUSDT", "SOLUSDT"),
340+
},
341+
state={},
342+
)
343+
)
344+
345+
budget_map = {budget.name: budget.amount for budget in decision.budgets}
346+
self.assertGreater(budget_map["trend_rotation_pool"], 0.0)
347+
self.assertGreater(budget_map["btc_core_dca_pool"], 0.0)
348+
self.assertGreater(decision.diagnostics["btc_base_order_usdt"], 0.0)
349+
self.assertGreater(decision.diagnostics["btc_target_ratio"], 0.0)
350+
self.assertGreater(decision.diagnostics["trend_target_ratio"], 0.0)
351+
self.assertEqual(set(decision.diagnostics["rotation_candidates"]), {"ETHUSDT", "SOLUSDT"})
352+
self.assertEqual(set(decision.diagnostics["eligible_buy_symbols"]), {"ETHUSDT", "SOLUSDT"})
353+
self.assertEqual(set(decision.diagnostics["planned_trend_buys"]), {"ETHUSDT", "SOLUSDT"})
354+
281355
def test_crypto_live_pool_rotation_entrypoint_sets_regime_off_flag_when_btc_regime_is_off(self) -> None:
282356
try:
283357
entrypoint = get_strategy_entrypoint("crypto_live_pool_rotation")

0 commit comments

Comments
 (0)