Skip to content

Commit 2e9f99f

Browse files
Pigbibiclaude
andcommitted
feat: enhance crypto DCA strategies with smart sizing and exit
- crypto_btc_dca: add AHR999 cycle multiplier, drawdown-based sizing, Z-score exit (逃顶), execution window, and bilingual i18n - crypto_trend_rotation: fix empty btc_snapshot bug, add circuit breaker and volatility-based position scaling - crypto_equity_combo: simplify to pure combinator, delegate BTC leg to enhanced smart DCA - Extract shared utilities to _utils.py - Add backtest script comparing 5 DCA strategies (2021-2026) - Update catalog configs and entrypoints Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 198027a commit 2e9f99f

9 files changed

Lines changed: 2516 additions & 220 deletions

File tree

scripts/research_crypto_dca_backtest.py

Lines changed: 632 additions & 0 deletions
Large diffs are not rendered by default.

src/crypto_strategies/_utils.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Shared utilities for crypto strategies (no cross-package dependency)."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
import pandas as pd
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def coerce_float(value: Any, default: float = 0.0) -> float:
14+
try:
15+
numeric = float(value)
16+
except (TypeError, ValueError):
17+
return default
18+
if pd.isna(numeric):
19+
return default
20+
return numeric
21+
22+
23+
def coerce_bool(value: Any, default: bool = False) -> bool:
24+
if value is None:
25+
return default
26+
if isinstance(value, bool):
27+
return value
28+
if isinstance(value, str):
29+
normalized = value.strip().lower()
30+
if normalized in {"1", "true", "yes", "y", "on"}:
31+
return True
32+
if normalized in {"0", "false", "no", "n", "off"}:
33+
return False
34+
return bool(value)
35+
36+
37+
def normalize_symbol(symbol: object) -> str:
38+
return str(symbol or "").strip().upper()
39+
40+
41+
def as_clamped_ratio(value: Any, *, default: float) -> float:
42+
numeric = coerce_float(value, default=float("nan"))
43+
if pd.isna(numeric):
44+
return float(default)
45+
return max(0.0, min(1.0, float(numeric)))
46+
47+
48+
def payload_numeric(payload: dict[str, Any], *keys: str) -> float:
49+
lowered = {str(key).strip().lower(): value for key, value in payload.items()}
50+
for key in keys:
51+
value = lowered.get(key.lower())
52+
numeric = coerce_float(value, default=float("nan"))
53+
if not pd.isna(numeric):
54+
return numeric
55+
return float("nan")
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# i18n
60+
# ---------------------------------------------------------------------------
61+
62+
63+
def translate_with_fallback(
64+
translator,
65+
key: str,
66+
*,
67+
fallback_en: str,
68+
fallback_zh: str,
69+
**kwargs: object,
70+
) -> str:
71+
if translator is None:
72+
template = fallback_zh
73+
else:
74+
try:
75+
translated = translator(key, **kwargs)
76+
except Exception:
77+
translated = key
78+
if translated == key:
79+
template = fallback_zh if translator_uses_zh(translator) else fallback_en
80+
else:
81+
return str(translated)
82+
try:
83+
return template.format(**{str(k): v for k, v in kwargs.items()})
84+
except (KeyError, ValueError):
85+
return template
86+
87+
88+
def translator_uses_zh(translator) -> bool:
89+
try:
90+
sample = str(translator("no_trades"))
91+
except Exception:
92+
return False
93+
return any("一" <= char <= "鿿" for char in sample)

src/crypto_strategies/catalog.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,51 @@
7171
}
7272

7373
CRYPTO_BTC_DCA_DEFAULT_CONFIG = {
74+
"base_investment_usd": 100.0,
75+
"max_investment_usd": None,
76+
"cash_reserve_usd": 0.0,
77+
"min_investment_usd": 5.0,
78+
"smart_multiplier_enabled": True,
79+
"cycle_indicator_enabled": True,
80+
"cadence": "monthly",
81+
"monthly_day": 25,
82+
"monthly_window_calendar_days": 5,
83+
"weekly_day": 4,
84+
"weekly_window_calendar_days": 4,
85+
"quarterly_months": (1, 4, 7, 10),
86+
"quarterly_day": 25,
87+
"quarterly_window_calendar_days": 5,
88+
# Drawdown thresholds
89+
"mild_drawdown_threshold": 0.12,
90+
"deep_drawdown_threshold": 0.25,
91+
"severe_drawdown_threshold": 0.40,
92+
"mild_discount_gap": 0.08,
93+
"deep_discount_gap": 0.18,
94+
"expensive_gap": 0.30,
95+
"very_expensive_gap": 0.60,
96+
"shallow_drawdown_threshold": 0.05,
97+
"overbought_rsi": 75.0,
98+
"base_multiplier": 1.0,
99+
"mild_pullback_multiplier": 1.50,
100+
"deep_pullback_multiplier": 2.25,
101+
"severe_pullback_multiplier": 3.0,
102+
"expensive_multiplier": 1.0,
103+
"very_expensive_multiplier": 1.0,
104+
# AHR999 thresholds
105+
"ahr999_bottom_threshold": 0.45,
106+
"ahr999_accumulation_threshold": 0.80,
107+
"ahr999_dca_threshold": 1.20,
108+
"ahr999_bottom_multiplier": 3.0,
109+
"ahr999_accumulation_multiplier": 2.25,
110+
"ahr999_dca_multiplier": 1.50,
111+
"ahr999_expensive_multiplier": 0.0,
112+
# Z-score exit
113+
"zscore_exit_enabled": True,
114+
"zscore_exit_parking_symbol": "USDT",
115+
"zscore_exit_risk_reduced_exposure": 0.50,
116+
"zscore_exit_risk_off_exposure": 0.25,
117+
"zscore_exit_allow_outside_execution_window": True,
118+
# Legacy params (kept for backward compat)
74119
"target_ratio_min": 0.0,
75120
"target_ratio_max": 0.65,
76121
"ratio_base": 0.14,
@@ -87,12 +132,20 @@
87132
"weight_mode": "inverse_vol",
88133
"allow_rotation_refresh": True,
89134
"atr_multiplier": 2.5,
135+
"circuit_breaker_enabled": True,
136+
"btc_drawdown_threshold": 0.30,
137+
"vol_scaling_enabled": True,
138+
"target_vol": 0.40,
139+
"max_leverage": 1.0,
90140
}
91141

92142
CRYPTO_EQUITY_COMBO_DEFAULT_CONFIG = {
93143
"btc_weight": 0.30,
94144
"trend_weight": 0.70,
95145
"dynamic_mode": True,
146+
"circuit_breaker_enabled": True,
147+
"btc_drawdown_threshold": 0.30,
148+
"vol_scaling_enabled": True,
96149
}
97150

98151
STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
@@ -191,7 +244,7 @@
191244
display_name="Crypto BTC DCA",
192245
description="Dynamic BTC DCA strategy that targets a single BTCUSDT position with equity-scaled allocation.",
193246
aliases=(),
194-
cadence="daily",
247+
cadence="daily_check_monthly_execution",
195248
asset_scope="btc_only",
196249
benchmark="BTC",
197250
role="crypto_core_accumulation",

src/crypto_strategies/entrypoints/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,19 +274,27 @@ def evaluate_crypto_live_pool_rotation(ctx: StrategyContext) -> StrategyDecision
274274
def evaluate_crypto_btc_dca(ctx: StrategyContext) -> StrategyDecision:
275275
from crypto_strategies.strategies.crypto_btc_dca import compute_signals
276276

277+
config = _merge_runtime_config(ctx, crypto_btc_dca_manifest.default_config)
277278
prices = _require_market_data(ctx, "market_prices")
278279
portfolio = _resolve_portfolio_snapshot(ctx)
279280
account_metrics = _resolve_account_metrics(ctx)
280281
total_equity = account_metrics["total_equity"]
282+
derived_indicators = ctx.market_data.get("derived_indicators")
283+
translator = _resolve_translator(config)
281284

282285
result = compute_signals(
283286
prices=prices,
284287
portfolio=portfolio,
285288
total_equity=total_equity,
286289
state=dict(ctx.state),
290+
derived_indicators=derived_indicators,
291+
translator=translator,
292+
**config,
287293
)
288294

289295
btc_target_ratio = float(result.get("btc_target_ratio", 0.0))
296+
metadata = result.get("metadata", {}) if isinstance(result.get("metadata"), dict) else {}
297+
290298
positions = [
291299
PositionTarget(
292300
symbol="BTCUSDT",
@@ -307,11 +315,23 @@ def evaluate_crypto_btc_dca(ctx: StrategyContext) -> StrategyDecision:
307315
risk_flags: tuple[str, ...] = ()
308316
if btc_target_ratio <= 0.0:
309317
risk_flags += ("no_btc_allocation",)
318+
if not metadata.get("actionable", True):
319+
risk_flags += ("no_execute",)
310320

311321
diagnostics = {
312322
"btc_target_ratio": btc_target_ratio,
313323
"total_equity": total_equity,
314324
"profile": result.get("profile"),
325+
"signal_description": metadata.get("signal_description", ""),
326+
"status_description": metadata.get("status_description", ""),
327+
"regime": metadata.get("regime", "ordinary_dca"),
328+
"multiplier": metadata.get("multiplier", 1.0),
329+
"smart_multiplier_enabled": metadata.get("smart_multiplier_enabled", True),
330+
"planned_investment_usd": metadata.get("planned_investment_usd", 0.0),
331+
"in_execution_window": metadata.get("in_execution_window", True),
332+
"zscore_exit": metadata.get("zscore_exit", {}),
333+
"ahr999": metadata.get("ahr999", float("nan")),
334+
"mayer_multiple": metadata.get("mayer_multiple", float("nan")),
315335
}
316336

317337
return StrategyDecision(
@@ -338,6 +358,7 @@ def evaluate_crypto_trend_rotation(ctx: StrategyContext) -> StrategyDecision:
338358
config = _merge_runtime_config(ctx, crypto_trend_rotation_manifest.default_config)
339359
feature_snapshot = _require_market_data(ctx, "derived_indicators")
340360
prices = _require_market_data(ctx, "market_prices")
361+
translator = _resolve_translator(config)
341362

342363
# Build a feature_snapshot from indicators_map
343364
import pandas as pd
@@ -351,11 +372,15 @@ def evaluate_crypto_trend_rotation(ctx: StrategyContext) -> StrategyDecision:
351372
weights, signal_desc, is_emergency, debug_str, metadata = compute_signals(
352373
feature_snapshot=feature_frame,
353374
current_holdings=list(prices.keys()),
375+
translator=translator,
354376
trend_pool_size=int(config.get("trend_pool_size", 5)),
355377
rotation_top_n=int(config.get("rotation_top_n", 2)),
356378
weight_mode=str(config.get("weight_mode", "inverse_vol")),
357379
allow_rotation_refresh=bool(config.get("allow_rotation_refresh", True)),
358380
atr_multiplier=float(config.get("atr_multiplier", 2.5)),
381+
circuit_breaker_enabled=bool(config.get("circuit_breaker_enabled", True)),
382+
btc_drawdown_threshold=float(config.get("btc_drawdown_threshold", 0.30)),
383+
vol_scaling_enabled=bool(config.get("vol_scaling_enabled", True)),
359384
)
360385

361386
positions: list[PositionTarget] = []
@@ -418,6 +443,7 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
418443
benchmark_snapshot = _require_market_data(ctx, "benchmark_snapshot")
419444
portfolio = _resolve_portfolio_snapshot(ctx)
420445
universe_snapshot = list(_require_market_data(ctx, "universe_snapshot"))
446+
translator = _resolve_translator(config)
421447

422448
weights, signal_desc, has_cash_residual, status_desc, metadata = compute_signals(
423449
prices=prices,
@@ -426,9 +452,15 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
426452
benchmark_snapshot=benchmark_snapshot,
427453
portfolio=portfolio,
428454
state=dict(ctx.state),
455+
translator=translator,
429456
btc_weight=float(config.get("btc_weight", 0.30)),
430457
trend_weight=float(config.get("trend_weight", 0.70)),
431458
dynamic_mode=bool(config.get("dynamic_mode", True)),
459+
smart_multiplier_enabled=bool(config.get("smart_multiplier_enabled", True)),
460+
cycle_indicator_enabled=bool(config.get("cycle_indicator_enabled", True)),
461+
zscore_exit_enabled=bool(config.get("zscore_exit_enabled", True)),
462+
circuit_breaker_enabled=bool(config.get("circuit_breaker_enabled", True)),
463+
vol_scaling_enabled=bool(config.get("vol_scaling_enabled", True)),
432464
)
433465

434466
positions: list[PositionTarget] = []

0 commit comments

Comments
 (0)