Skip to content

Commit ffb4e63

Browse files
Pigbibicodex
andcommitted
Add opt-in dual-leg combo regime cap
Co-Authored-By: Codex <noreply@openai.com>
1 parent 6fe378a commit ffb4e63

5 files changed

Lines changed: 268 additions & 22 deletions

File tree

src/crypto_strategies/catalog.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,15 @@
143143
"btc_weight": 0.30,
144144
"trend_weight": 0.70,
145145
"dynamic_mode": True,
146+
"dynamic_regime_mode": "legacy",
146147
"dynamic_regime_off_cut": 0.50,
148+
"dynamic_hard_sma200_ratio": 0.97,
149+
"dynamic_hard_ma200_slope": -0.015,
150+
"dynamic_soft_sma200_ratio": 1.05,
151+
"dynamic_hard_btc_weight": 0.30,
152+
"dynamic_hard_trend_weight": 0.0,
153+
"dynamic_soft_btc_weight": 0.45,
154+
"dynamic_soft_trend_weight": 0.15,
147155
"smart_multiplier_enabled": True,
148156
"cycle_indicator_enabled": True,
149157
"zscore_exit_enabled": True,

src/crypto_strategies/entrypoints/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,15 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
460460
btc_weight=float(config.get("btc_weight", 0.30)),
461461
trend_weight=float(config.get("trend_weight", 0.70)),
462462
dynamic_mode=bool(config.get("dynamic_mode", True)),
463+
dynamic_regime_mode=str(config.get("dynamic_regime_mode", "legacy")),
463464
dynamic_regime_off_cut=float(config.get("dynamic_regime_off_cut", 0.50)),
465+
dynamic_hard_sma200_ratio=float(config.get("dynamic_hard_sma200_ratio", 0.97)),
466+
dynamic_hard_ma200_slope=float(config.get("dynamic_hard_ma200_slope", -0.015)),
467+
dynamic_soft_sma200_ratio=float(config.get("dynamic_soft_sma200_ratio", 1.05)),
468+
dynamic_hard_btc_weight=float(config.get("dynamic_hard_btc_weight", 0.30)),
469+
dynamic_hard_trend_weight=float(config.get("dynamic_hard_trend_weight", 0.0)),
470+
dynamic_soft_btc_weight=float(config.get("dynamic_soft_btc_weight", 0.45)),
471+
dynamic_soft_trend_weight=float(config.get("dynamic_soft_trend_weight", 0.15)),
464472
smart_multiplier_enabled=bool(config.get("smart_multiplier_enabled", True)),
465473
cycle_indicator_enabled=bool(config.get("cycle_indicator_enabled", True)),
466474
zscore_exit_enabled=bool(config.get("zscore_exit_enabled", True)),

src/crypto_strategies/manifests/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,15 @@
146146
"btc_weight": 0.30,
147147
"trend_weight": 0.70,
148148
"dynamic_mode": True,
149+
"dynamic_regime_mode": "legacy",
149150
"dynamic_regime_off_cut": 0.50,
151+
"dynamic_hard_sma200_ratio": 0.97,
152+
"dynamic_hard_ma200_slope": -0.015,
153+
"dynamic_soft_sma200_ratio": 1.05,
154+
"dynamic_hard_btc_weight": 0.30,
155+
"dynamic_hard_trend_weight": 0.0,
156+
"dynamic_soft_btc_weight": 0.45,
157+
"dynamic_soft_trend_weight": 0.15,
150158
"smart_multiplier_enabled": True,
151159
"cycle_indicator_enabled": True,
152160
"zscore_exit_enabled": True,

src/crypto_strategies/strategies/crypto_equity_combo.py

Lines changed: 169 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
configured.
77
88
Static mode: fixed weights per leg (default: 30/70 BTC/trend).
9-
Dynamic mode: regime-based adjustment — when BTC is below SMA200, reduce
10-
trend leg by 50 % and re-allocate to BTC.
9+
Dynamic legacy mode: regime-based adjustment — when BTC is below SMA200,
10+
reduce trend leg by 50 % and re-allocate to BTC.
11+
Dynamic dual-leg mode: opt-in tiered regime adjustment that can cap both BTC
12+
and trend legs, leaving the residual in cash.
1113
1214
Usage
1315
-----
@@ -36,6 +38,8 @@
3638
DEFAULT_BTC_WEIGHT = 0.30
3739
DEFAULT_TREND_WEIGHT = 0.70
3840
DYNAMIC_REGIME_OFF_CUT = 0.50
41+
DYNAMIC_REGIME_MODE_LEGACY = "legacy"
42+
DYNAMIC_REGIME_MODE_DUAL_LEG = "dual_leg"
3943

4044
TREND_ONLY_KWARGS = frozenset({
4145
"trend_pool_size",
@@ -58,6 +62,137 @@ def _clamp_ratio(value: float, *, default: float = 1.0) -> float:
5862
return min(1.0, max(0.0, numeric))
5963

6064

65+
def _normalized_regime_mode(value: object) -> str:
66+
mode = str(value or DYNAMIC_REGIME_MODE_LEGACY).strip().lower().replace("-", "_")
67+
if mode in {"dual", "dual_leg", "tiered", "cash_cap"}:
68+
return DYNAMIC_REGIME_MODE_DUAL_LEG
69+
return DYNAMIC_REGIME_MODE_LEGACY
70+
71+
72+
def _first_finite(payload: dict[str, Any] | None, *keys: str) -> float | None:
73+
if not isinstance(payload, dict):
74+
return None
75+
for key in keys:
76+
value = payload.get(key)
77+
try:
78+
numeric = float(value)
79+
except (TypeError, ValueError):
80+
continue
81+
if numeric == numeric:
82+
return numeric
83+
return None
84+
85+
86+
def _btc_sma200_ratio(
87+
prices: dict[str, float],
88+
benchmark_snapshot: dict[str, Any] | None,
89+
indicators_map: dict[str, Any] | None,
90+
) -> float | None:
91+
gap = _first_finite(
92+
benchmark_snapshot,
93+
"sma200_gap",
94+
"gap_vs_sma200",
95+
"price_vs_sma200",
96+
)
97+
if gap is not None:
98+
return 1.0 + gap
99+
100+
ratio = _first_finite(
101+
benchmark_snapshot,
102+
"price_sma200_ratio",
103+
"sma200_ratio",
104+
"mayer_multiple",
105+
)
106+
if ratio is not None:
107+
return ratio
108+
109+
ma200 = _first_finite(benchmark_snapshot, "ma200", "sma200", "sma_200")
110+
if ma200 is None and isinstance(indicators_map, dict):
111+
btc_indicators = indicators_map.get("BTCUSDT")
112+
ma200 = _first_finite(btc_indicators, "ma200", "sma200", "sma_200")
113+
114+
price = _first_finite(benchmark_snapshot, "close", "price")
115+
if price is None:
116+
price = _first_finite(prices, "BTCUSDT", "BTC")
117+
118+
if price is None or ma200 is None or ma200 <= 0.0:
119+
return None
120+
return price / ma200
121+
122+
123+
def _resolve_dynamic_weights(
124+
prices: dict[str, float],
125+
indicators_map: dict[str, Any] | None,
126+
benchmark_snapshot: dict[str, Any] | None,
127+
*,
128+
btc_weight: float,
129+
trend_weight: float,
130+
dynamic_mode: bool,
131+
dynamic_regime_mode: str,
132+
dynamic_regime_off_cut: float,
133+
dynamic_hard_sma200_ratio: float,
134+
dynamic_hard_ma200_slope: float,
135+
dynamic_soft_sma200_ratio: float,
136+
dynamic_hard_btc_weight: float,
137+
dynamic_hard_trend_weight: float,
138+
dynamic_soft_btc_weight: float,
139+
dynamic_soft_trend_weight: float,
140+
) -> tuple[float, float, dict[str, object]]:
141+
ratio = _btc_sma200_ratio(prices, benchmark_snapshot, indicators_map)
142+
ma200_slope = _first_finite(benchmark_snapshot, "ma200_slope", "sma200_slope")
143+
144+
regime_off = False
145+
if isinstance(benchmark_snapshot, dict):
146+
regime_on = benchmark_snapshot.get("regime_on")
147+
if regime_on is not None:
148+
regime_off = not bool(regime_on)
149+
elif ratio is not None:
150+
regime_off = ratio <= 1.0
151+
else:
152+
btc_snapshot = _extract_btc_snapshot(indicators_map or {})
153+
regime_off = not btc_snapshot.get("regime_on", True)
154+
155+
mode = _normalized_regime_mode(dynamic_regime_mode)
156+
regime_tier = "risk_on"
157+
regime_off_cut = 0.0
158+
effective_btc = btc_weight
159+
effective_trend = trend_weight
160+
161+
if dynamic_mode and mode == DYNAMIC_REGIME_MODE_DUAL_LEG:
162+
hard = regime_off and (
163+
(ratio is None and ma200_slope is None)
164+
or (ratio is not None and ratio < dynamic_hard_sma200_ratio)
165+
or (ma200_slope is not None and ma200_slope < dynamic_hard_ma200_slope)
166+
)
167+
soft = (
168+
regime_off
169+
or (ratio is not None and ratio < dynamic_soft_sma200_ratio)
170+
or (ma200_slope is not None and ma200_slope < 0.0)
171+
)
172+
if hard:
173+
regime_tier = "hard"
174+
effective_btc = _clamp_ratio(dynamic_hard_btc_weight, default=btc_weight)
175+
effective_trend = _clamp_ratio(dynamic_hard_trend_weight, default=0.0)
176+
elif soft:
177+
regime_tier = "soft"
178+
effective_btc = _clamp_ratio(dynamic_soft_btc_weight, default=btc_weight)
179+
effective_trend = _clamp_ratio(dynamic_soft_trend_weight, default=trend_weight)
180+
elif dynamic_mode and regime_off:
181+
regime_tier = "legacy_regime_off"
182+
regime_off_cut = _clamp_ratio(dynamic_regime_off_cut, default=DYNAMIC_REGIME_OFF_CUT)
183+
effective_btc = btc_weight + trend_weight * regime_off_cut
184+
effective_trend = trend_weight * (1.0 - regime_off_cut)
185+
186+
return effective_btc, effective_trend, {
187+
"regime_off": regime_off,
188+
"regime_mode": mode,
189+
"regime_tier": regime_tier,
190+
"dynamic_regime_off_cut": regime_off_cut,
191+
"btc_sma200_ratio": ratio,
192+
"ma200_slope": ma200_slope,
193+
}
194+
195+
61196
def _compute_btc_leg(
62197
total_equity: float,
63198
btc_weight: float,
@@ -208,7 +343,15 @@ def build_target_weights(
208343
btc_weight: float = DEFAULT_BTC_WEIGHT,
209344
trend_weight: float = DEFAULT_TREND_WEIGHT,
210345
dynamic_mode: bool = True,
346+
dynamic_regime_mode: str = DYNAMIC_REGIME_MODE_LEGACY,
211347
dynamic_regime_off_cut: float = DYNAMIC_REGIME_OFF_CUT,
348+
dynamic_hard_sma200_ratio: float = 0.97,
349+
dynamic_hard_ma200_slope: float = -0.015,
350+
dynamic_soft_sma200_ratio: float = 1.05,
351+
dynamic_hard_btc_weight: float = 0.30,
352+
dynamic_hard_trend_weight: float = 0.0,
353+
dynamic_soft_btc_weight: float = 0.45,
354+
dynamic_soft_trend_weight: float = 0.15,
212355
translator=None,
213356
**kwargs: Any,
214357
) -> tuple[dict[str, float], dict[str, object]]:
@@ -233,24 +376,23 @@ def build_target_weights(
233376

234377
state = state or {}
235378

236-
# Dynamic regime adjustment
237-
regime_off = False
238-
if benchmark_snapshot:
239-
regime_on = benchmark_snapshot.get("regime_on")
240-
if regime_on is not None:
241-
regime_off = not bool(regime_on)
242-
else:
243-
btc_snapshot = _extract_btc_snapshot(indicators_map or {})
244-
regime_off = not btc_snapshot.get("regime_on", True)
245-
246-
if dynamic_mode and regime_off:
247-
regime_off_cut = _clamp_ratio(dynamic_regime_off_cut, default=DYNAMIC_REGIME_OFF_CUT)
248-
effective_btc = btc_weight + trend_weight * regime_off_cut
249-
effective_trend = trend_weight * (1.0 - regime_off_cut)
250-
else:
251-
regime_off_cut = 0.0
252-
effective_btc = btc_weight
253-
effective_trend = trend_weight
379+
effective_btc, effective_trend, regime_metadata = _resolve_dynamic_weights(
380+
prices,
381+
indicators_map,
382+
benchmark_snapshot,
383+
btc_weight=btc_weight,
384+
trend_weight=trend_weight,
385+
dynamic_mode=dynamic_mode,
386+
dynamic_regime_mode=dynamic_regime_mode,
387+
dynamic_regime_off_cut=dynamic_regime_off_cut,
388+
dynamic_hard_sma200_ratio=dynamic_hard_sma200_ratio,
389+
dynamic_hard_ma200_slope=dynamic_hard_ma200_slope,
390+
dynamic_soft_sma200_ratio=dynamic_soft_sma200_ratio,
391+
dynamic_hard_btc_weight=dynamic_hard_btc_weight,
392+
dynamic_hard_trend_weight=dynamic_hard_trend_weight,
393+
dynamic_soft_btc_weight=dynamic_soft_btc_weight,
394+
dynamic_soft_trend_weight=dynamic_soft_trend_weight,
395+
)
254396

255397
# Compute legs
256398
btc_weights, btc_leg_metadata = _compute_btc_leg(
@@ -295,11 +437,16 @@ def build_target_weights(
295437
"trend_weight": effective_trend,
296438
"base_btc_weight": btc_weight,
297439
"base_trend_weight": trend_weight,
298-
"dynamic_regime_off_cut": regime_off_cut,
440+
"dynamic_regime_off_cut": regime_metadata["dynamic_regime_off_cut"],
441+
"dynamic_regime_mode": regime_metadata["regime_mode"],
442+
"regime_tier": regime_metadata["regime_tier"],
299443
},
300444
"btc_leg": {"weights": btc_weights, **btc_leg_metadata},
301445
"trend_leg": {"weights": trend_weights, **trend_metadata},
302-
"regime_off": regime_off,
446+
"regime_off": regime_metadata["regime_off"],
447+
"regime_tier": regime_metadata["regime_tier"],
448+
"btc_sma200_ratio": regime_metadata["btc_sma200_ratio"],
449+
"ma200_slope": regime_metadata["ma200_slope"],
303450
"dynamic_mode": dynamic_mode,
304451
"gross_exposure": sum(combined.values()),
305452
"selected_count": len(combined),

tests/test_crypto_equity_combo.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,81 @@ def test_dynamic_regime_off_cut_is_configurable(self) -> None:
156156
self.assertAlmostEqual(custom_metadata["combo"]["trend_weight"], 0.49)
157157
self.assertAlmostEqual(custom_metadata["combo"]["dynamic_regime_off_cut"], 0.30)
158158

159+
def test_dual_leg_regime_hard_caps_btc_and_trend_to_cash(self) -> None:
160+
"""Opt-in dual-leg mode should allow hard risk-off to leave residual cash."""
161+
_, metadata = build_target_weights(
162+
prices={"BTCUSDT": 94000.0},
163+
indicators_map={},
164+
universe_snapshot=[],
165+
benchmark_snapshot={
166+
"regime_on": False,
167+
"ma200": 100000.0,
168+
"ma200_slope": -0.02,
169+
},
170+
portfolio={"total_equity": 100000.0, "buying_power": 1000.0},
171+
btc_weight=0.30,
172+
trend_weight=0.70,
173+
dynamic_regime_mode="dual_leg",
174+
dynamic_hard_btc_weight=0.25,
175+
dynamic_hard_trend_weight=0.0,
176+
smart_multiplier_enabled=False,
177+
)
178+
179+
combo = metadata["combo"]
180+
self.assertAlmostEqual(combo["btc_weight"], 0.25)
181+
self.assertAlmostEqual(combo["trend_weight"], 0.0)
182+
self.assertEqual(combo["dynamic_regime_mode"], "dual_leg")
183+
self.assertEqual(combo["regime_tier"], "hard")
184+
self.assertAlmostEqual(metadata["btc_sma200_ratio"], 0.94)
185+
186+
def test_dual_leg_regime_soft_uses_neutral_cash_cap(self) -> None:
187+
"""Opt-in dual-leg mode should support a soft/neutral tier."""
188+
_, metadata = build_target_weights(
189+
prices={"BTCUSDT": 102000.0},
190+
indicators_map={},
191+
universe_snapshot=[],
192+
benchmark_snapshot={
193+
"regime_on": False,
194+
"ma200": 100000.0,
195+
"ma200_slope": -0.005,
196+
},
197+
portfolio={"total_equity": 100000.0, "buying_power": 1000.0},
198+
btc_weight=0.30,
199+
trend_weight=0.70,
200+
dynamic_regime_mode="dual_leg",
201+
dynamic_soft_btc_weight=0.45,
202+
dynamic_soft_trend_weight=0.15,
203+
smart_multiplier_enabled=False,
204+
)
205+
206+
combo = metadata["combo"]
207+
self.assertAlmostEqual(combo["btc_weight"], 0.45)
208+
self.assertAlmostEqual(combo["trend_weight"], 0.15)
209+
self.assertEqual(combo["regime_tier"], "soft")
210+
211+
def test_dual_leg_regime_keeps_base_weights_when_risk_on(self) -> None:
212+
"""Opt-in dual-leg mode should not alter base weights in risk-on conditions."""
213+
_, metadata = build_target_weights(
214+
prices={"BTCUSDT": 110000.0},
215+
indicators_map={},
216+
universe_snapshot=[],
217+
benchmark_snapshot={
218+
"regime_on": True,
219+
"ma200": 100000.0,
220+
"ma200_slope": 0.02,
221+
},
222+
portfolio={"total_equity": 100000.0, "buying_power": 1000.0},
223+
btc_weight=0.30,
224+
trend_weight=0.70,
225+
dynamic_regime_mode="dual_leg",
226+
smart_multiplier_enabled=False,
227+
)
228+
229+
combo = metadata["combo"]
230+
self.assertAlmostEqual(combo["btc_weight"], 0.30)
231+
self.assertAlmostEqual(combo["trend_weight"], 0.70)
232+
self.assertEqual(combo["regime_tier"], "risk_on")
233+
159234
def test_compute_signals_returns_tuple(self) -> None:
160235
"""compute_signals should return a 5-tuple with weights, signal_desc, cash_residual, status_desc, metadata."""
161236
prices = {"BTCUSDT": 60000.0}

0 commit comments

Comments
 (0)