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
632 changes: 632 additions & 0 deletions scripts/research_crypto_dca_backtest.py

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions src/crypto_strategies/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Shared utilities for crypto strategies (no cross-package dependency)."""

from __future__ import annotations

import logging
from typing import Any

import pandas as pd

logger = logging.getLogger(__name__)


def coerce_float(value: Any, default: float = 0.0) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return default
if pd.isna(numeric):
return default
return numeric


def coerce_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "y", "on"}:
return True
if normalized in {"0", "false", "no", "n", "off"}:
return False
return bool(value)


def normalize_symbol(symbol: object) -> str:
return str(symbol or "").strip().upper()


def as_clamped_ratio(value: Any, *, default: float) -> float:
numeric = coerce_float(value, default=float("nan"))
if pd.isna(numeric):
return float(default)
return max(0.0, min(1.0, float(numeric)))


def payload_numeric(payload: dict[str, Any], *keys: str) -> float:
lowered = {str(key).strip().lower(): value for key, value in payload.items()}
for key in keys:
value = lowered.get(key.lower())
numeric = coerce_float(value, default=float("nan"))
if not pd.isna(numeric):
return numeric
return float("nan")


# ---------------------------------------------------------------------------
# i18n
# ---------------------------------------------------------------------------


def translate_with_fallback(
translator,
key: str,
*,
fallback_en: str,
fallback_zh: str,
**kwargs: object,
) -> str:
if translator is None:
template = fallback_zh
else:
try:
translated = translator(key, **kwargs)
except Exception:
translated = key
if translated == key:
template = fallback_zh if translator_uses_zh(translator) else fallback_en
else:
return str(translated)
try:
return template.format(**{str(k): v for k, v in kwargs.items()})
except (KeyError, ValueError):
return template


def translator_uses_zh(translator) -> bool:
try:
sample = str(translator("no_trades"))
except Exception:
return False
return any("一" <= char <= "鿿" for char in sample)
55 changes: 54 additions & 1 deletion src/crypto_strategies/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@
}

CRYPTO_BTC_DCA_DEFAULT_CONFIG = {
"base_investment_usd": 100.0,
"max_investment_usd": None,
"cash_reserve_usd": 0.0,
"min_investment_usd": 5.0,
"smart_multiplier_enabled": True,
"cycle_indicator_enabled": True,
"cadence": "monthly",
"monthly_day": 25,
"monthly_window_calendar_days": 5,
"weekly_day": 4,
"weekly_window_calendar_days": 4,
"quarterly_months": (1, 4, 7, 10),
"quarterly_day": 25,
"quarterly_window_calendar_days": 5,
# Drawdown thresholds
"mild_drawdown_threshold": 0.12,
"deep_drawdown_threshold": 0.25,
"severe_drawdown_threshold": 0.40,
"mild_discount_gap": 0.08,
"deep_discount_gap": 0.18,
"expensive_gap": 0.30,
"very_expensive_gap": 0.60,
"shallow_drawdown_threshold": 0.05,
"overbought_rsi": 75.0,
"base_multiplier": 1.0,
"mild_pullback_multiplier": 1.50,
"deep_pullback_multiplier": 2.25,
"severe_pullback_multiplier": 3.0,
"expensive_multiplier": 1.0,
"very_expensive_multiplier": 1.0,
# AHR999 thresholds
"ahr999_bottom_threshold": 0.45,
"ahr999_accumulation_threshold": 0.80,
"ahr999_dca_threshold": 1.20,
"ahr999_bottom_multiplier": 3.0,
"ahr999_accumulation_multiplier": 2.25,
"ahr999_dca_multiplier": 1.50,
"ahr999_expensive_multiplier": 0.0,
# Z-score exit
"zscore_exit_enabled": True,
"zscore_exit_parking_symbol": "USDT",
"zscore_exit_risk_reduced_exposure": 0.50,
"zscore_exit_risk_off_exposure": 0.25,
"zscore_exit_allow_outside_execution_window": True,
# Legacy params (kept for backward compat)
"target_ratio_min": 0.0,
"target_ratio_max": 0.65,
"ratio_base": 0.14,
Expand All @@ -87,12 +132,20 @@
"weight_mode": "inverse_vol",
"allow_rotation_refresh": True,
"atr_multiplier": 2.5,
"circuit_breaker_enabled": True,
"btc_drawdown_threshold": 0.30,
"vol_scaling_enabled": True,
"target_vol": 0.40,
"max_leverage": 1.0,
}

CRYPTO_EQUITY_COMBO_DEFAULT_CONFIG = {
"btc_weight": 0.30,
"trend_weight": 0.70,
"dynamic_mode": True,
"circuit_breaker_enabled": True,
"btc_drawdown_threshold": 0.30,
"vol_scaling_enabled": True,
}

STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
Expand Down Expand Up @@ -191,7 +244,7 @@
display_name="Crypto BTC DCA",
description="Dynamic BTC DCA strategy that targets a single BTCUSDT position with equity-scaled allocation.",
aliases=(),
cadence="daily",
cadence="daily_check_monthly_execution",
asset_scope="btc_only",
benchmark="BTC",
role="crypto_core_accumulation",
Expand Down
32 changes: 32 additions & 0 deletions src/crypto_strategies/entrypoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,19 +274,27 @@ def evaluate_crypto_live_pool_rotation(ctx: StrategyContext) -> StrategyDecision
def evaluate_crypto_btc_dca(ctx: StrategyContext) -> StrategyDecision:
from crypto_strategies.strategies.crypto_btc_dca import compute_signals

config = _merge_runtime_config(ctx, crypto_btc_dca_manifest.default_config)
prices = _require_market_data(ctx, "market_prices")
portfolio = _resolve_portfolio_snapshot(ctx)
account_metrics = _resolve_account_metrics(ctx)
total_equity = account_metrics["total_equity"]
derived_indicators = ctx.market_data.get("derived_indicators")
translator = _resolve_translator(config)

result = compute_signals(
prices=prices,
portfolio=portfolio,
total_equity=total_equity,
state=dict(ctx.state),
derived_indicators=derived_indicators,
translator=translator,
**config,
)

btc_target_ratio = float(result.get("btc_target_ratio", 0.0))
metadata = result.get("metadata", {}) if isinstance(result.get("metadata"), dict) else {}

positions = [
PositionTarget(
symbol="BTCUSDT",
Expand All @@ -307,11 +315,23 @@ def evaluate_crypto_btc_dca(ctx: StrategyContext) -> StrategyDecision:
risk_flags: tuple[str, ...] = ()
if btc_target_ratio <= 0.0:
risk_flags += ("no_btc_allocation",)
if not metadata.get("actionable", True):
risk_flags += ("no_execute",)

diagnostics = {
"btc_target_ratio": btc_target_ratio,
"total_equity": total_equity,
"profile": result.get("profile"),
"signal_description": metadata.get("signal_description", ""),
"status_description": metadata.get("status_description", ""),
"regime": metadata.get("regime", "ordinary_dca"),
"multiplier": metadata.get("multiplier", 1.0),
"smart_multiplier_enabled": metadata.get("smart_multiplier_enabled", True),
"planned_investment_usd": metadata.get("planned_investment_usd", 0.0),
"in_execution_window": metadata.get("in_execution_window", True),
"zscore_exit": metadata.get("zscore_exit", {}),
"ahr999": metadata.get("ahr999", float("nan")),
"mayer_multiple": metadata.get("mayer_multiple", float("nan")),
}

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

# Build a feature_snapshot from indicators_map
import pandas as pd
Expand All @@ -351,11 +372,15 @@ def evaluate_crypto_trend_rotation(ctx: StrategyContext) -> StrategyDecision:
weights, signal_desc, is_emergency, debug_str, metadata = compute_signals(
feature_snapshot=feature_frame,
current_holdings=list(prices.keys()),
translator=translator,
trend_pool_size=int(config.get("trend_pool_size", 5)),
rotation_top_n=int(config.get("rotation_top_n", 2)),
weight_mode=str(config.get("weight_mode", "inverse_vol")),
allow_rotation_refresh=bool(config.get("allow_rotation_refresh", True)),
atr_multiplier=float(config.get("atr_multiplier", 2.5)),
circuit_breaker_enabled=bool(config.get("circuit_breaker_enabled", True)),
btc_drawdown_threshold=float(config.get("btc_drawdown_threshold", 0.30)),
vol_scaling_enabled=bool(config.get("vol_scaling_enabled", True)),
)

positions: list[PositionTarget] = []
Expand Down Expand Up @@ -418,6 +443,7 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
benchmark_snapshot = _require_market_data(ctx, "benchmark_snapshot")
portfolio = _resolve_portfolio_snapshot(ctx)
universe_snapshot = list(_require_market_data(ctx, "universe_snapshot"))
translator = _resolve_translator(config)

weights, signal_desc, has_cash_residual, status_desc, metadata = compute_signals(
prices=prices,
Expand All @@ -426,9 +452,15 @@ def evaluate_crypto_equity_combo(ctx: StrategyContext) -> StrategyDecision:
benchmark_snapshot=benchmark_snapshot,
portfolio=portfolio,
state=dict(ctx.state),
translator=translator,
btc_weight=float(config.get("btc_weight", 0.30)),
trend_weight=float(config.get("trend_weight", 0.70)),
dynamic_mode=bool(config.get("dynamic_mode", True)),
smart_multiplier_enabled=bool(config.get("smart_multiplier_enabled", True)),
cycle_indicator_enabled=bool(config.get("cycle_indicator_enabled", True)),
zscore_exit_enabled=bool(config.get("zscore_exit_enabled", True)),
circuit_breaker_enabled=bool(config.get("circuit_breaker_enabled", True)),
vol_scaling_enabled=bool(config.get("vol_scaling_enabled", True)),
)

positions: list[PositionTarget] = []
Expand Down
Loading
Loading