You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
550 lines
20 KiB
Python
550 lines
20 KiB
Python
"""Breakout52w — George-Hwang (2004) 52-week-high price-momentum engine.
|
|
|
|
Monthly rebalance, top-N cross-sectional by 20-day volume ratio descending.
|
|
Long-only. Buys liquid mid/large caps that close at a NEW 52-week (252-day)
|
|
high on T-1. Enters at next_open, holds for breakout_holding_days
|
|
trading days (default 21, monthly cadence).
|
|
|
|
Distinct from libs.backtest.vol_breakout_52w (which is a 2-day MOC tactical
|
|
engine with -3%/+5% bracket exits). This engine targets the trend-continuation
|
|
tail catalyzed by George-Hwang's anchoring-bias finding: stocks that print
|
|
new 52-week highs systematically under-react and continue higher.
|
|
|
|
The retired topgainer family (v1-v54, killed at -87% honest replay in
|
|
project_topgainer_phase1_lookahead_2026-05-05.md) used today's daily_high in
|
|
a Phase-1 pre-screen — a fatal look-ahead. This engine structurally cannot
|
|
repeat that bug:
|
|
* BarHistoryProvider returns bars STRICTLY before decision_date.
|
|
* The 52w max-high window excludes T-1 itself: bars[-(lookback+1):-1].
|
|
Wait — actually we compute "new high today" so we INCLUDE T-1's close
|
|
being compared against the prior 252-day max. The prior max is over
|
|
[T-252..T-2], so T-1's close > max(T-252..T-2).high is the breakout.
|
|
* _assert_strictly_before re-checks every used bar date.
|
|
|
|
Mirrors libs.backtest.low_vol_anomaly exactly for monthly rebalance, top-N,
|
|
21d hold, next_open entry — ranking is descending 20d volume ratio (top
|
|
conviction breakouts first) rather than ascending volatility.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import statistics
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any, Iterable, Protocol
|
|
|
|
if TYPE_CHECKING:
|
|
from libs.backtest.breakout_52w_cache import Breakout52wRankCache
|
|
|
|
from libs.backtest.domain import (
|
|
Candidate,
|
|
LookaheadViolationError,
|
|
StrategyEngineConfig,
|
|
)
|
|
from libs.common.logging import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
BREAKOUT_52W_EVENT_TYPE = "breakout_52w"
|
|
|
|
_ET_MARKET_OPEN = dt.time(9, 30)
|
|
_ET_OFFSET = dt.timedelta(hours=-5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider Protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BarHistoryProvider(Protocol):
|
|
def get_bars_before(
|
|
self,
|
|
symbol: str,
|
|
as_of_date: dt.date,
|
|
lookback_days: int,
|
|
) -> list[tuple[dt.date, dict[str, Any]]]: ...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lookahead defense
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _assert_strictly_before(
|
|
symbol: str,
|
|
decision_date: dt.date,
|
|
used_dates: Iterable[dt.date],
|
|
) -> None:
|
|
for d in used_dates:
|
|
if d >= decision_date:
|
|
raise LookaheadViolationError(
|
|
f"Breakout52w {symbol}: bar date {d.isoformat()} "
|
|
f"is not strictly before decision_date {decision_date.isoformat()}"
|
|
)
|
|
|
|
|
|
def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
|
|
"""16:00 ET close on bar_date, tz-aware UTC."""
|
|
et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
|
|
utc_naive = et_naive - _ET_OFFSET
|
|
return utc_naive.replace(tzinfo=dt.timezone.utc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure trigger computations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def is_rebalance_day(
|
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
|
decision_date: dt.date,
|
|
) -> bool:
|
|
"""First trading day of the calendar month iff last_bar's month != decision_date.month."""
|
|
if not bars:
|
|
return False
|
|
last_bar_date = bars[-1][0]
|
|
if last_bar_date >= decision_date:
|
|
raise LookaheadViolationError(
|
|
f"is_rebalance_day: last_bar_date {last_bar_date.isoformat()} "
|
|
f"is not strictly before decision_date {decision_date.isoformat()}"
|
|
)
|
|
return last_bar_date.month != decision_date.month
|
|
|
|
|
|
def compute_52w_high_breakout(
|
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
|
*,
|
|
lookback_days: int = 252,
|
|
) -> tuple[bool, float, float, list[dt.date]]:
|
|
"""Return (is_breakout, last_close, prior_max_high, used_window_dates).
|
|
|
|
The "prior 52-week high" is the max(high) over the window of `lookback_days`
|
|
bars BEFORE T-1 — i.e. bars[-(lookback+1):-1] — so T-1's own bar never
|
|
enters the max. Breakout = T-1's close strictly greater than prior max high.
|
|
|
|
Bars must be chronologically ordered AND strictly before decision_date.
|
|
Returns (False, 0, 0, []) on insufficient history.
|
|
"""
|
|
if len(bars) < 2:
|
|
return False, 0.0, 0.0, []
|
|
last_date, last_bar = bars[-1]
|
|
last_close = float(last_bar.get("close", 0.0))
|
|
if last_close <= 0:
|
|
return False, 0.0, 0.0, []
|
|
|
|
# Window = the 252 bars BEFORE T-1 (excludes T-1 itself).
|
|
prior_window = bars[-(lookback_days + 1):-1]
|
|
if len(prior_window) < max(20, lookback_days // 4):
|
|
return False, last_close, 0.0, []
|
|
|
|
used_dates = [d for d, _ in prior_window]
|
|
prior_max_high = max(float(b.get("high", 0.0)) for _, b in prior_window)
|
|
is_breakout = last_close > prior_max_high
|
|
return bool(is_breakout), last_close, float(prior_max_high), used_dates
|
|
|
|
|
|
def compute_volume_ratio_20d(
|
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
|
) -> float | None:
|
|
"""volume_T-1 / median(volume over 20 bars ending T-2). None on insufficient history."""
|
|
if len(bars) < 21:
|
|
return None
|
|
last_vol = float(bars[-1][1].get("volume", 0.0))
|
|
prior_window = bars[-21:-1]
|
|
prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window]
|
|
if not prior_volumes:
|
|
return None
|
|
median_vol = float(statistics.median(prior_volumes))
|
|
if median_vol <= 0:
|
|
return None
|
|
return last_vol / median_vol
|
|
|
|
|
|
def compute_avg_dollar_volume_20d(
|
|
bars: list[tuple[dt.date, dict[str, Any]]],
|
|
) -> float:
|
|
if not bars:
|
|
return 0.0
|
|
tail = bars[-20:]
|
|
return statistics.fmean(
|
|
float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
|
|
for _, b in tail
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-symbol inputs + universe gates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Breakout52wInputs:
|
|
symbol: str
|
|
decision_date: dt.date
|
|
next_trading_date: dt.date
|
|
last_bar_date: dt.date
|
|
last_bar_timestamp: dt.datetime
|
|
last_close: float
|
|
prior_252d_max_high: float
|
|
is_52w_breakout: bool
|
|
volume_ratio_20d: float
|
|
avg_dollar_volume_20d: float
|
|
|
|
|
|
def evaluate_universe_gates(
|
|
inputs: Breakout52wInputs,
|
|
engine: StrategyEngineConfig,
|
|
) -> tuple[bool, str | None]:
|
|
"""Reject low-quality candidates BEFORE ranking. Pure function."""
|
|
if inputs.last_bar_date >= inputs.decision_date:
|
|
raise LookaheadViolationError(
|
|
f"Breakout52w {inputs.symbol}: last_bar_date "
|
|
f"{inputs.last_bar_date.isoformat()} not strictly before decision_date"
|
|
)
|
|
price_min = float(getattr(engine, "breakout_52w_min_price", 10.0) or 0.0)
|
|
if price_min > 0 and inputs.last_close < price_min:
|
|
return False, f"last_close {inputs.last_close:.2f} < min {price_min:.2f}"
|
|
adv_min = float(getattr(engine, "breakout_52w_min_avg_dollar_volume", 10_000_000.0) or 0.0)
|
|
if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min:
|
|
return False, (
|
|
f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} < min {adv_min:,.0f}"
|
|
)
|
|
if not inputs.is_52w_breakout:
|
|
return False, (
|
|
f"close {inputs.last_close:.4f} <= prior 252d max high "
|
|
f"{inputs.prior_252d_max_high:.4f}"
|
|
)
|
|
return True, None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build candidates (top-N descending volume ratio across universe)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_candidates_from_cache(
|
|
rows: list[dict[str, Any]],
|
|
decision_date: dt.date,
|
|
next_trading_date: dt.date,
|
|
engine: StrategyEngineConfig,
|
|
top_n: int,
|
|
) -> list[Candidate]:
|
|
"""Build top-N candidates from cached ranked rows (already post-quality-gate).
|
|
|
|
Sorts DESCENDING by volume_ratio_20d (highest conviction first).
|
|
"""
|
|
rows_sorted = sorted(rows, key=lambda r: float(r["volume_ratio_20d"]), reverse=True)
|
|
top = rows_sorted[:top_n]
|
|
total_ranked = len(rows_sorted)
|
|
|
|
candidates: list[Candidate] = []
|
|
for rank, row in enumerate(top, start=1):
|
|
last_bar_date = dt.date.fromisoformat(str(row["last_bar_date"]))
|
|
_assert_strictly_before(str(row["symbol"]), decision_date, [last_bar_date])
|
|
inputs = Breakout52wInputs(
|
|
symbol=str(row["symbol"]),
|
|
decision_date=decision_date,
|
|
next_trading_date=next_trading_date,
|
|
last_bar_date=last_bar_date,
|
|
last_bar_timestamp=dt.datetime.fromisoformat(str(row["last_bar_timestamp_iso"])),
|
|
last_close=float(row["last_close"]),
|
|
prior_252d_max_high=float(row["prior_252d_max_high"]),
|
|
is_52w_breakout=True,
|
|
volume_ratio_20d=float(row["volume_ratio_20d"]),
|
|
avg_dollar_volume_20d=float(row["avg_dollar_volume_20d"]),
|
|
)
|
|
candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=total_ranked))
|
|
|
|
if candidates:
|
|
logger.info(
|
|
"breakout_52w_rebalance",
|
|
decision_date=decision_date.isoformat(),
|
|
universe_scanned=total_ranked,
|
|
top_n_emitted=len(candidates),
|
|
top_score=float(top[0]["volume_ratio_20d"]) if top else None,
|
|
bottom_score=float(top[-1]["volume_ratio_20d"]) if top else None,
|
|
cache_hit=True,
|
|
)
|
|
return candidates
|
|
|
|
|
|
def build_candidates(
|
|
decision_date: dt.date,
|
|
next_trading_date: dt.date,
|
|
universe_symbols: Iterable[str],
|
|
engine: StrategyEngineConfig,
|
|
bar_provider: BarHistoryProvider,
|
|
*,
|
|
cache: "Breakout52wRankCache | None" = None,
|
|
) -> list[Candidate]:
|
|
"""Emit top-N synthetic 52w-breakout candidates on rebalance days only.
|
|
|
|
On non-rebalance days, returns []. The universe is scanned once per
|
|
rebalance day; symbols that closed at a NEW 52-week high on T-1 pass the
|
|
universe gates and are ranked DESCENDING by 20d volume ratio. Top-N enter.
|
|
"""
|
|
if not getattr(engine, "breakout_52w_enabled", False):
|
|
return []
|
|
if next_trading_date <= decision_date:
|
|
raise LookaheadViolationError(
|
|
f"Breakout52w next_trading_date {next_trading_date.isoformat()} "
|
|
f"must be strictly after decision_date {decision_date.isoformat()}"
|
|
)
|
|
|
|
lookback = int(getattr(engine, "breakout_52w_lookback_days", 252) or 252)
|
|
top_n = int(getattr(engine, "breakout_52w_top_n", 20) or 20)
|
|
|
|
# Need enough bars for the 252d window + 20d volume ratio buffer + weekend gap allowance.
|
|
fetch_lookback = lookback + 25
|
|
|
|
scored: list[tuple[float, Breakout52wInputs]] = []
|
|
seen: set[str] = set()
|
|
rebalance_checked = False
|
|
|
|
for raw_symbol in universe_symbols:
|
|
symbol = str(raw_symbol).strip().upper()
|
|
if not symbol or symbol in seen:
|
|
continue
|
|
seen.add(symbol)
|
|
|
|
bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback)
|
|
if not bars:
|
|
continue
|
|
|
|
last_bar_date, last_bar = bars[-1]
|
|
if last_bar_date >= decision_date:
|
|
raise LookaheadViolationError(
|
|
f"Breakout52w bar for {symbol} on {last_bar_date.isoformat()} "
|
|
f"is not strictly before decision_date {decision_date.isoformat()}"
|
|
)
|
|
|
|
# Rebalance gate — check once per call, derived from bars (no calendar).
|
|
if not rebalance_checked:
|
|
is_rebalance = is_rebalance_day(bars, decision_date)
|
|
rebalance_checked = True
|
|
if not is_rebalance:
|
|
return []
|
|
# Rebalance confirmed: try cache before scanning remaining universe.
|
|
if cache is not None:
|
|
cached_rows = cache.get_date(decision_date)
|
|
if cached_rows is not None:
|
|
return _build_candidates_from_cache(
|
|
cached_rows, decision_date, next_trading_date, engine, top_n
|
|
)
|
|
|
|
last_close = float(last_bar.get("close", 0.0))
|
|
if last_close <= 0:
|
|
continue
|
|
|
|
is_brk, _last, prior_max, used_dates = compute_52w_high_breakout(
|
|
bars, lookback_days=lookback
|
|
)
|
|
if not is_brk:
|
|
continue
|
|
_assert_strictly_before(symbol, decision_date, used_dates)
|
|
|
|
vol_ratio = compute_volume_ratio_20d(bars)
|
|
if vol_ratio is None:
|
|
continue
|
|
|
|
adv_20d = compute_avg_dollar_volume_20d(bars)
|
|
|
|
inputs = Breakout52wInputs(
|
|
symbol=symbol,
|
|
decision_date=decision_date,
|
|
next_trading_date=next_trading_date,
|
|
last_bar_date=last_bar_date,
|
|
last_bar_timestamp=_bar_close_timestamp(last_bar_date),
|
|
last_close=last_close,
|
|
prior_252d_max_high=prior_max,
|
|
is_52w_breakout=is_brk,
|
|
volume_ratio_20d=vol_ratio,
|
|
avg_dollar_volume_20d=adv_20d,
|
|
)
|
|
|
|
passes, _reason = evaluate_universe_gates(inputs, engine)
|
|
if not passes:
|
|
continue
|
|
|
|
scored.append((vol_ratio, inputs))
|
|
|
|
# Sort by volume_ratio DESCENDING (highest conviction = best), take top N.
|
|
scored.sort(key=lambda t: t[0], reverse=True)
|
|
top = scored[:top_n]
|
|
|
|
# Save full ranked universe to cache (pre-top_n) for future runs.
|
|
if cache is not None and scored:
|
|
cache.save_date(decision_date, [
|
|
{
|
|
"decision_date": decision_date.isoformat(),
|
|
"symbol": inp.symbol,
|
|
"volume_ratio_20d": inp.volume_ratio_20d,
|
|
"avg_dollar_volume_20d": inp.avg_dollar_volume_20d,
|
|
"last_close": inp.last_close,
|
|
"prior_252d_max_high": inp.prior_252d_max_high,
|
|
"last_bar_date": inp.last_bar_date.isoformat(),
|
|
"last_bar_timestamp_iso": inp.last_bar_timestamp.isoformat(),
|
|
}
|
|
for _, inp in scored
|
|
])
|
|
|
|
candidates: list[Candidate] = []
|
|
for rank, (_, inputs) in enumerate(top, start=1):
|
|
candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored)))
|
|
|
|
if candidates:
|
|
logger.info(
|
|
"breakout_52w_rebalance",
|
|
decision_date=decision_date.isoformat(),
|
|
universe_scanned=len(scored),
|
|
top_n_emitted=len(candidates),
|
|
top_score=top[0][0] if top else None,
|
|
bottom_score=top[-1][0] if top else None,
|
|
)
|
|
|
|
return candidates
|
|
|
|
|
|
def _build_candidate(
|
|
inputs: Breakout52wInputs,
|
|
engine: StrategyEngineConfig,
|
|
*,
|
|
rank: int,
|
|
total_ranked: int,
|
|
) -> Candidate:
|
|
holding_days = int(getattr(engine, "breakout_52w_holding_days", 21) or 21)
|
|
stop_pct = float(getattr(engine, "breakout_52w_stop_pct", 0.10) or 0.10)
|
|
target_pct = float(getattr(engine, "breakout_52w_target_pct", 0.30) or 0.30)
|
|
|
|
synthetic_atr = max(inputs.last_close * 0.02, 0.01)
|
|
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 5.0
|
|
target_r = target_pct / stop_pct if stop_pct > 0 else 3.0
|
|
|
|
# Score: higher volume ratio → higher score, monotonic, clipped to [0.5, 0.99].
|
|
# Map vol_ratio [1.0, 5.0] → score [0.5, 0.99] (linear).
|
|
vol_ref = 5.0
|
|
norm = min(1.0, max(0.0, (inputs.volume_ratio_20d - 1.0) / (vol_ref - 1.0)))
|
|
score = 0.5 + 0.49 * norm
|
|
score_bucket = (
|
|
"high" if score >= 0.8
|
|
else "medium_high" if score >= 0.6
|
|
else "medium"
|
|
)
|
|
|
|
event_id = (
|
|
f"synth_breakout52w_{inputs.symbol.lower()}_"
|
|
f"{inputs.decision_date.isoformat()}"
|
|
)
|
|
|
|
features = {
|
|
"breakout_52w_decision_date": inputs.decision_date.isoformat(),
|
|
"breakout_52w_last_close": inputs.last_close,
|
|
"breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high,
|
|
"breakout_52w_volume_ratio_20d": round(inputs.volume_ratio_20d, 4),
|
|
"breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
|
|
"breakout_52w_rank": rank,
|
|
"breakout_52w_total_ranked": total_ranked,
|
|
"breakout_52w_stop_pct": stop_pct,
|
|
"breakout_52w_target_pct": target_pct,
|
|
"breakout_52w_holding_days": holding_days,
|
|
}
|
|
|
|
return Candidate(
|
|
event_id=event_id,
|
|
symbol=inputs.symbol,
|
|
source_symbol=inputs.symbol,
|
|
score=score,
|
|
sector="UNKNOWN",
|
|
event_type=BREAKOUT_52W_EVENT_TYPE,
|
|
event_timestamp=inputs.last_bar_timestamp,
|
|
event_date=inputs.decision_date,
|
|
filing_time_bucket="post_market",
|
|
timing_class="after_close",
|
|
reaction_date=inputs.decision_date,
|
|
execution_date=inputs.next_trading_date,
|
|
entry_price_est=inputs.last_close,
|
|
avg_dollar_volume=inputs.avg_dollar_volume_20d,
|
|
atr_14=synthetic_atr,
|
|
score_bucket=score_bucket,
|
|
engine_id=engine.engine_id,
|
|
entry_timing_policy="next_open",
|
|
trade_direction="long",
|
|
engine_max_holding_days=holding_days,
|
|
engine_risk_budget_pct=engine.engine_risk_budget_pct,
|
|
engine_capital_bucket_id=(
|
|
(engine.capital_bucket_id or engine.engine_id)
|
|
if engine.capital_bucket_allocation_pct is not None
|
|
else None
|
|
),
|
|
engine_capital_bucket_allocation_pct=engine.capital_bucket_allocation_pct,
|
|
engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
|
|
engine_target_1_r=target_r,
|
|
engine_target_1_fraction=1.0,
|
|
engine_stop_atr_multiplier=stop_mult,
|
|
engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
|
|
engine_use_reaction_day_low_stop=False,
|
|
engine_early_failure_close_below_entry_and_reaction_close=False,
|
|
# breakout_52w needs full holding period — explicitly disable NO_PROGRESS / trailing
|
|
# so v9.x base config's tight gates don't fire on synthetic candidates.
|
|
engine_early_failure_no_progress_days=0,
|
|
engine_early_failure_no_progress_r=0.0,
|
|
engine_early_failure_no_progress_fraction=0.0,
|
|
engine_trailing_model="none",
|
|
engine_trailing_warmup_days=999,
|
|
shadow_only=engine.shadow_only,
|
|
features=features,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter (mirrors low_vol_anomaly / xsmom / vol_breakout_52w pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _SnapshotStoreBarAdapter:
|
|
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
|
|
_sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict)
|
|
|
|
def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]:
|
|
sym_upper = symbol.upper()
|
|
cached = self._sorted_cache.get(sym_upper)
|
|
if cached is not None:
|
|
return cached
|
|
sym_bars = self.bars_by_symbol.get(sym_upper)
|
|
if not sym_bars:
|
|
self._sorted_cache[sym_upper] = []
|
|
return self._sorted_cache[sym_upper]
|
|
ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
|
|
self._sorted_cache[sym_upper] = ordered
|
|
return ordered
|
|
|
|
def get_bars_before(
|
|
self,
|
|
symbol: str,
|
|
as_of_date: dt.date,
|
|
lookback_days: int,
|
|
) -> list[tuple[dt.date, dict[str, Any]]]:
|
|
ordered = self._sorted_for(symbol)
|
|
if not ordered:
|
|
return []
|
|
eligible: list[tuple[dt.date, dict[str, Any]]] = []
|
|
for d, b in ordered:
|
|
if d >= as_of_date:
|
|
break
|
|
eligible.append((d, b))
|
|
return eligible[-lookback_days:]
|
|
|
|
|
|
__all__ = [
|
|
"BREAKOUT_52W_EVENT_TYPE",
|
|
"BarHistoryProvider",
|
|
"Breakout52wInputs",
|
|
"_SnapshotStoreBarAdapter",
|
|
"build_candidates",
|
|
"compute_52w_high_breakout",
|
|
"compute_avg_dollar_volume_20d",
|
|
"compute_volume_ratio_20d",
|
|
"evaluate_universe_gates",
|
|
"is_rebalance_day",
|
|
]
|