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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ The backtest output directory still includes `summary.csv`, `portfolio_returns.c
- Entry requires `QQQ > MA200` and positive `MA20` slope.
- Once risk is active, the profile keeps `QQQ 45% / TQQQ 45% / BOXX 8% / cash 2%` while `QQQ` remains above `MA200`; a short-term negative `MA20` slope alone does not force an exit.
- If `QQQ` falls below `MA200`, the profile exits `QQQ` and `TQQQ`, keeps 2% cash, and parks the rest in `BOXX` by default.
- A below-`MA200` pullback state can still re-enable risk when `QQQ > MA20` and `MA20` slope is positive.
- A below-`MA200` pullback state can still re-enable risk when `QQQ > MA20`, `MA20` slope is positive, and `QQQ` has rebounded from its rolling 20-day low by more than the dynamic volatility-scaled gate. The default gate is `2.0x` the recent 20-day `QQQ` daily return volatility, which avoids a fixed 3% constant while still filtering weak MA200 chop without changing the normal above-`MA200` trend rule.

**Income-layer rules (`SPYI` / `QQQI`)**
- The live configuration sets `income_threshold_usd = 1_000_000_000`, so the income layer is disabled for normal account sizes.
Expand All @@ -221,6 +221,10 @@ The backtest output directory still includes `summary.csv`, `portfolio_returns.c
- `DUAL_DRIVE_QQQ_WEIGHT = 0.45`, `DUAL_DRIVE_TQQQ_WEIGHT = 0.45`
- `DUAL_DRIVE_UNLEVERED_SYMBOL = QQQ`
- `DUAL_DRIVE_CASH_RESERVE_RATIO = 0.02`
- `DUAL_DRIVE_PULLBACK_REBOUND_WINDOW = 20`
- `DUAL_DRIVE_PULLBACK_REBOUND_THRESHOLD_MODE = volatility_scaled`
- `DUAL_DRIVE_PULLBACK_REBOUND_VOLATILITY_MULTIPLIER = 2.0`
- `DUAL_DRIVE_PULLBACK_REBOUND_THRESHOLD = 0.0` (fixed-mode fallback only)
- `INCOME_THRESHOLD_USD = 1000000000`
- `CASH_RESERVE_RATIO = 0.02`
- `EXECUTION_CASH_RESERVE_RATIO = 0.0`
Expand Down Expand Up @@ -460,7 +464,7 @@ PYTHONPATH=src:../UsEquityStrategies/src:../QuantPlatformKit/src python scripts/
- 入场需要 `QQQ > MA200` 且 `MA20` 斜率为正。
- 一旦进入风险状态,只要 `QQQ` 仍在 `MA200` 上方,就维持 `QQQ 45% / TQQQ 45% / BOXX 8% / 现金 2%`;短期 `MA20` 斜率转负不会单独触发离场。
- 如果 `QQQ` 跌破 `MA200`,默认退出 `QQQ` 和 `TQQQ`,保留 2% 现金,其余转入 `BOXX`。
- 在 `MA200` 下方也保留一段回调参与逻辑:当 `QQQ > MA20` 且 `MA20` 斜率为正时,可重新打开风险仓位。
- 在 `MA200` 下方也保留一段回调参与逻辑:当 `QQQ > MA20`、`MA20` 斜率为正,且 `QQQ` 较滚动 20 日低点的反弹幅度超过动态波动率门槛时,可重新打开风险仓位。默认门槛是最近 20 日 `QQQ` 日收益波动率的 `2.0x`,避免使用固定 3% 常数,同时继续过滤较弱的 MA200 附近震荡,不改变 `MA200` 上方的主趋势规则

**收入层规则(`SPYI` / `QQQI`)**
- 实盘配置把 `income_threshold_usd` 设为 `1_000_000_000`,普通账户规模下等于关闭收入层。
Expand All @@ -477,6 +481,10 @@ PYTHONPATH=src:../UsEquityStrategies/src:../QuantPlatformKit/src python scripts/
- `DUAL_DRIVE_QQQ_WEIGHT = 0.45`,`DUAL_DRIVE_TQQQ_WEIGHT = 0.45`
- `DUAL_DRIVE_UNLEVERED_SYMBOL = QQQ`
- `DUAL_DRIVE_CASH_RESERVE_RATIO = 0.02`
- `DUAL_DRIVE_PULLBACK_REBOUND_WINDOW = 20`
- `DUAL_DRIVE_PULLBACK_REBOUND_THRESHOLD_MODE = volatility_scaled`
- `DUAL_DRIVE_PULLBACK_REBOUND_VOLATILITY_MULTIPLIER = 2.0`
- `DUAL_DRIVE_PULLBACK_REBOUND_THRESHOLD = 0.0`(仅作为 fixed 模式 fallback)
- `INCOME_THRESHOLD_USD = 1000000000`
- `CASH_RESERVE_RATIO = 0.02`
- `EXECUTION_CASH_RESERVE_RATIO = 0.0`
Expand Down
4 changes: 4 additions & 0 deletions src/us_equity_strategies/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"dual_drive_cash_reserve_ratio": 0.02,
"dual_drive_allow_pullback": True,
"dual_drive_require_ma20_slope": True,
"dual_drive_pullback_rebound_window": 20,
"dual_drive_pullback_rebound_threshold_mode": "volatility_scaled",
"dual_drive_pullback_rebound_threshold": 0.0,
"dual_drive_pullback_rebound_volatility_multiplier": 2.0,
},
SOXL_SOXX_TREND_INCOME_PROFILE: {
"managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"),
Expand Down
6 changes: 6 additions & 0 deletions src/us_equity_strategies/entrypoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ def evaluate_tqqq_growth_income(ctx: StrategyContext) -> StrategyDecision:
"qqq_price": plan["qqq_p"],
"ma200": plan["ma200"],
"exit_line": plan["exit_line"],
"pullback_rebound": plan.get("pullback_rebound"),
"pullback_rebound_window": plan.get("pullback_rebound_window"),
"pullback_rebound_threshold": plan.get("pullback_rebound_threshold"),
"pullback_rebound_threshold_mode": plan.get("pullback_rebound_threshold_mode"),
"pullback_rebound_volatility": plan.get("pullback_rebound_volatility"),
"pullback_rebound_volatility_multiplier": plan.get("pullback_rebound_volatility_multiplier"),
"real_buying_power": plan["real_buying_power"],
"total_equity": plan["total_equity"],
**account_size_diagnostics,
Expand Down
4 changes: 4 additions & 0 deletions src/us_equity_strategies/manifests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ def _manifest(
"dual_drive_cash_reserve_ratio": 0.02,
"dual_drive_allow_pullback": True,
"dual_drive_require_ma20_slope": True,
"dual_drive_pullback_rebound_window": 20,
"dual_drive_pullback_rebound_threshold_mode": "volatility_scaled",
"dual_drive_pullback_rebound_threshold": 0.0,
"dual_drive_pullback_rebound_volatility_multiplier": 2.0,
},
)

Expand Down
60 changes: 60 additions & 0 deletions src/us_equity_strategies/strategies/tqqq_growth_income.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import numpy as np
import pandas as pd

PULLBACK_REBOUND_THRESHOLD_MODE_FIXED = "fixed"
PULLBACK_REBOUND_THRESHOLD_MODE_VOLATILITY_SCALED = "volatility_scaled"
PULLBACK_REBOUND_THRESHOLD_MODES = {
PULLBACK_REBOUND_THRESHOLD_MODE_FIXED,
PULLBACK_REBOUND_THRESHOLD_MODE_VOLATILITY_SCALED,
}


def get_income_ratio(total_equity_usd: float, *, income_threshold_usd: float) -> float:
if total_equity_usd < income_threshold_usd:
Expand All @@ -20,6 +27,31 @@ def get_income_ratio(total_equity_usd: float, *, income_threshold_usd: float) ->
return 0.60


def _resolve_pullback_rebound_threshold(
close: pd.Series,
*,
window: int,
mode: str,
fixed_threshold: float,
volatility_multiplier: float,
) -> tuple[float, float]:
threshold_mode = str(mode or PULLBACK_REBOUND_THRESHOLD_MODE_FIXED).strip().lower()
if threshold_mode not in PULLBACK_REBOUND_THRESHOLD_MODES:
modes = ", ".join(sorted(PULLBACK_REBOUND_THRESHOLD_MODES))
raise ValueError(f"Unsupported pullback rebound threshold mode: {threshold_mode!r}; expected one of {modes}")

fixed_threshold = max(0.0, float(fixed_threshold or 0.0))
if threshold_mode == PULLBACK_REBOUND_THRESHOLD_MODE_FIXED:
return fixed_threshold, np.nan

returns = pd.to_numeric(close, errors="coerce").pct_change(fill_method=None)
rolling_volatility = returns.rolling(int(window), min_periods=int(window)).std().iloc[-1]
if pd.isna(rolling_volatility):
return fixed_threshold, np.nan
multiplier = max(0.0, float(volatility_multiplier or 0.0))
return max(0.0, float(rolling_volatility) * multiplier), float(rolling_volatility)


def build_rebalance_plan(
qqq_history,
snapshot,
Expand All @@ -37,6 +69,10 @@ def build_rebalance_plan(
dual_drive_cash_reserve_ratio=0.02,
dual_drive_allow_pullback=True,
dual_drive_require_ma20_slope=True,
dual_drive_pullback_rebound_window=20,
dual_drive_pullback_rebound_threshold_mode=PULLBACK_REBOUND_THRESHOLD_MODE_VOLATILITY_SCALED,
dual_drive_pullback_rebound_threshold=0.0,
dual_drive_pullback_rebound_volatility_multiplier=2.0,
):
df_qqq = pd.DataFrame(qqq_history)
qqq_p = df_qqq["close"].iloc[-1]
Expand Down Expand Up @@ -77,6 +113,23 @@ def build_rebalance_plan(
reserved = strategy_equity * cash_reserve_ratio

latest_ma20 = ma20.iloc[-1]
pullback_rebound_window = max(1, int(dual_drive_pullback_rebound_window or 20))
pullback_rebound_threshold_mode = str(
dual_drive_pullback_rebound_threshold_mode or PULLBACK_REBOUND_THRESHOLD_MODE_FIXED
).strip().lower()
pullback_rebound_threshold, pullback_rebound_volatility = _resolve_pullback_rebound_threshold(
df_qqq["close"],
window=pullback_rebound_window,
mode=pullback_rebound_threshold_mode,
fixed_threshold=float(dual_drive_pullback_rebound_threshold or 0.0),
volatility_multiplier=float(dual_drive_pullback_rebound_volatility_multiplier or 0.0),
)
pullback_low = df_qqq["close"].rolling(pullback_rebound_window).min().iloc[-1]
pullback_rebound = qqq_p / pullback_low - 1.0 if pd.notna(pullback_low) and pullback_low > 0.0 else np.nan
pullback_rebound_ok = (
pullback_rebound_threshold <= 0.0
or (pd.notna(pullback_rebound) and pullback_rebound > pullback_rebound_threshold)
)
above_ma200 = qqq_p > ma200
positive_ma20_slope = pd.notna(ma20_slope) and ma20_slope > 0.0
slope_ok = positive_ma20_slope if bool(dual_drive_require_ma20_slope) else True
Expand All @@ -92,6 +145,7 @@ def build_rebalance_plan(
and pd.notna(latest_ma20)
and qqq_p > latest_ma20
and positive_ma20_slope
and pullback_rebound_ok
)

target_unlevered_val = 0.0
Expand Down Expand Up @@ -161,6 +215,12 @@ def build_rebalance_plan(
"qqq_p": qqq_p,
"ma200": ma200,
"exit_line": ma200,
"pullback_rebound": pullback_rebound,
"pullback_rebound_window": pullback_rebound_window,
"pullback_rebound_threshold": pullback_rebound_threshold,
"pullback_rebound_threshold_mode": pullback_rebound_threshold_mode,
"pullback_rebound_volatility": pullback_rebound_volatility,
"pullback_rebound_volatility_multiplier": float(dual_drive_pullback_rebound_volatility_multiplier or 0.0),
"allocation_mode": allocation_mode,
"separator": separator,
}
4 changes: 4 additions & 0 deletions tests/test_entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def test_tqqq_growth_income_defaults_to_fixed_dual_drive_live_profile(self) -> N
self.assertEqual(config["dual_drive_tqqq_weight"], 0.45)
self.assertEqual(config["dual_drive_unlevered_symbol"], "QQQ")
self.assertEqual(config["dual_drive_cash_reserve_ratio"], 0.02)
self.assertEqual(config["dual_drive_pullback_rebound_window"], 20)
self.assertEqual(config["dual_drive_pullback_rebound_threshold_mode"], "volatility_scaled")
self.assertEqual(config["dual_drive_pullback_rebound_threshold"], 0.0)
self.assertEqual(config["dual_drive_pullback_rebound_volatility_multiplier"], 2.0)
self.assertEqual(config["cash_reserve_ratio"], 0.02)
self.assertEqual(config["income_threshold_usd"], 1_000_000_000.0)
self.assertEqual(config["execution_cash_reserve_ratio"], 0.0)
Expand Down
79 changes: 79 additions & 0 deletions tests/test_strategy_plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,85 @@ def test_tqqq_growth_income_live_dual_drive_uses_stateful_ma200_exit(self):
self.assertAlmostEqual(active_plan["target_values"]["BOXX"], 100000.0 * 0.08)
self.assertAlmostEqual(active_plan["reserved"], 100000.0 * 0.02)

def test_tqqq_growth_income_pullback_uses_volatility_scaled_rebound_quality(self):
_skip_if_missing_numeric_stack()
from us_equity_strategies.strategies.tqqq_growth_income import (
build_rebalance_plan as build_tqqq_plan,
)

def build_history(recent):
return [{"close": 120.0, "high": 121.0, "low": 119.0} for _ in range(220)] + [
{"close": close, "high": close + 1.0, "low": close - 1.0}
for close in recent
]

snapshot = SimpleNamespace(
positions=[SimpleNamespace(symbol="BOXX", market_value=100000.0, quantity=1000)],
total_equity=100000.0,
buying_power=2000.0,
metadata={"account_hash": "acct-1"},
)
common_kwargs = dict(
signal_text_fn=lambda icon: icon,
translator=_translator,
income_threshold_usd=1_000_000_000.0,
qqqi_income_ratio=0.5,
cash_reserve_ratio=0.02,
rebalance_threshold_ratio=0.01,
dual_drive_qqq_weight=0.45,
dual_drive_tqqq_weight=0.45,
dual_drive_cash_reserve_ratio=0.02,
)

weak_rebound_plan = build_tqqq_plan(
build_history(
[
106,
108,
105,
107,
104,
106,
103,
105,
102,
104,
101,
103,
100,
102,
99,
101,
100,
101,
100.5,
101.2,
102.0,
]
),
snapshot,
**common_kwargs,
)
self.assertEqual(weak_rebound_plan["target_values"]["TQQQ"], 0.0)
self.assertEqual(weak_rebound_plan["target_values"]["QQQ"], 0.0)
self.assertEqual(weak_rebound_plan["pullback_rebound_threshold_mode"], "volatility_scaled")
self.assertGreater(
weak_rebound_plan["pullback_rebound_threshold"],
weak_rebound_plan["pullback_rebound"],
)

strong_rebound_plan = build_tqqq_plan(
build_history([100.0 + index * 0.45 for index in range(21)]),
snapshot,
**common_kwargs,
)
self.assertAlmostEqual(strong_rebound_plan["target_values"]["TQQQ"], 100000.0 * 0.45)
self.assertAlmostEqual(strong_rebound_plan["target_values"]["QQQ"], 100000.0 * 0.45)
self.assertLess(
strong_rebound_plan["pullback_rebound_threshold"],
strong_rebound_plan["pullback_rebound"],
)

def test_soxl_soxx_trend_income_exposes_live_tiered_metadata(self):
_skip_if_missing_numeric_stack()
from us_equity_strategies.strategies.soxl_soxx_trend_income import (
Expand Down