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
108 changes: 108 additions & 0 deletions config/phase_i5a_intraday_tail_framework.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Phase I5a — intraday tail-rebalance event-framework smoke (architecture, NOT research).
#
# Proves the shared event-driven backtest engine drives an IntradayTailEventModel
# end-to-end on REAL SH/SZ minute data: decision at 14:50, execution at the first
# valid 1min close in [14:51, 14:56:59], exec-to-exec holding returns. The score is
# a single PIT-safe I3 feature (intraday_ret_0930_1450), not a research alpha.
#
# Minute bars are read from the EXISTING intraday cache only (read-only; a cache
# miss is a hard blocker, never a silent warm -> zero stk_mins live calls). The
# daily panel + PIT index membership come through the existing P4 read-through cache.
# SSE50 (000016.SH) is SH/SZ-only; window is short + recent (covered by the cache).

project:
name: quantitative_trading_phase_i5a_intraday_tail
timezone: Asia/Shanghai
data:
source: tushare
freq: D
start: '2026-03-03'
end: '2026-06-12'
external_secret_file: /home/shaofl/Projects/financial_projects/.config.json
tushare_token_key: tushare.token
output_name: i5a_daily
cache:
enabled: true
root_dir: artifacts/cache/tushare/v1
refresh_recent_days: 14
refresh_dimension_days: 30
force_refresh: []
universe:
type: index
index_code: 000016.SH
symbols: []
min_listing_days: 60
filters:
# Selection-level daily tradability flags are DISABLED for this smoke: at the
# 14:50 decision the daily-close-derived limit flag is an EOD value, and the
# honest blocking happens at EXECUTION via minute bars (a suspended/halted name
# has no minute bar and is blocked there). Disclosed in the report.
missing_close: true
suspended: false
st: false
limit_up_down: false
# factors is required by the schema but the I5a runner ignores it: the score is the
# I3 intraday_ret feature, not a config factor. Kept as a minimal valid placeholder.
factors:
- name: momentum_20
enabled: true
params:
window: 20
price_col: close
processing:
drop_missing: true
standardize:
enabled: true
method: zscore
winsorize:
enabled: false
method: mad
n: 3.0
neutralize:
enabled: false
industry_col: industry
size_col: market_cap
industry_level: L1
alpha:
model: equal_weight
params: {}
portfolio:
constructor: topn_equal_weight
top_n: 10
long_only: true
max_weight: null
turnover_cap: null
backtest:
initial_nav: 1.0
rebalance: monthly
event_order: intraday_tail_rebalance
cash_return: 0.0
intraday:
enabled: true
decision_time: '14:50:00'
data_lag: 1min
session_open: '09:30:00'
execution_model: next_minute_close
execution_window:
- '14:51:00'
- '14:56:59'
require_cache_coverage: true
missing_execution: block
cost:
fee_rate: 0.001
slippage_rate: 0.0
turnover_formula: l1
analytics:
forward_return_periods:
- 1
- 5
- 20
quantiles: 5
benchmark: null
output:
root_dir: artifacts
data_dir: artifacts/data
factor_dir: artifacts/factors
report_dir: artifacts/reports
log_dir: artifacts/logs
overwrite: true
30 changes: 30 additions & 0 deletions qt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ def _cmd_run_phase3_subset(args: argparse.Namespace) -> int:
return 0


def _cmd_run_phase_i5a_intraday(args: argparse.Namespace) -> int:
"""Run the I5a intraday tail-rebalance architecture smoke + report."""
from qt.intraday_tail_framework import run_phase_i5a_intraday

try:
result = run_phase_i5a_intraday(args.config)
except (ConfigError, ValueError, FileNotFoundError, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
final_nav = (
result.nav_table["nav"].iloc[-1] if not result.nav_table.empty else float("nan")
)
n_blocked = sum(result.blocked_fill_counts.values())
print(
f"OK run-phase-i5a-intraday: periods={len(result.nav_table)}, "
f"covered={result.covered_symbols}/{result.requested_symbols}, "
f"stk_mins_live_calls={result.minute_live_calls}, blocked_fills={n_blocked}, "
f"final_nav={final_nav:.6f}\n"
f"report: {result.report_path}"
)
return 0


def _cmd_data_update(args: argparse.Namespace) -> int:
"""Warm/update the tushare caches (P4-3); never runs a backtest."""
from qt.data_updater import format_summary, run_data_update
Expand Down Expand Up @@ -239,6 +262,13 @@ def build_parser() -> argparse.ArgumentParser:
p_du.add_argument("--config", required=True, help="Path to the YAML config.")
p_du.set_defaults(func=_cmd_data_update)

p_i5a = sub.add_parser(
"run-phase-i5a-intraday",
help="Run the I5a intraday tail-rebalance architecture smoke (minute cache).",
)
p_i5a.add_argument("--config", required=True, help="Path to the YAML config.")
p_i5a.set_defaults(func=_cmd_run_phase_i5a_intraday)

for name, func, help_text in (
("fetch-data", _cmd_fetch_data, "Run the spine, report data fetch."),
("compute-factors", _cmd_compute_factors, "Run the spine, report factor compute."),
Expand Down
89 changes: 88 additions & 1 deletion qt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,10 +216,76 @@ def _check_top_n(cls, v: int) -> int:
class BacktestCfg(_Strict):
initial_nav: float = 1.0
rebalance: Literal["monthly"] = "monthly"
event_order: str = "close_to_next_period"
# ``close_to_next_period`` = the daily close-to-close model (default, unchanged).
# ``intraday_tail_rebalance`` = the I5a 14:50-decision / 14:51-execution model,
# which requires ``intraday.enabled=true`` (enforced on RootConfig).
event_order: Literal["close_to_next_period", "intraday_tail_rebalance"] = (
"close_to_next_period"
)
cash_return: float = 0.0


# Execution models the intraday tail event model can price (I5a: just the
# conservative first one). Coarser/auction proxies are future work and rejected
# readably so a config can never silently fall back to a wrong model.
_SUPPORTED_INTRADAY_EXECUTION_MODELS: tuple[str, ...] = ("next_minute_close",)


def _parse_hms(value: str) -> int:
"""Parse an ``HH:MM:SS`` clock string to seconds-since-midnight (validation only)."""
parts = str(value).split(":")
if len(parts) != 3:
raise ValueError(f"expected HH:MM:SS, got {value!r}")
h, m, s = (int(p) for p in parts)
if not (0 <= h < 24 and 0 <= m < 60 and 0 <= s < 60):
raise ValueError(f"out-of-range clock value {value!r}")
return h * 3600 + m * 60 + s


class IntradayCfg(_Strict):
"""Opt-in intraday tail-rebalance event model declaration (I5a).

Off by default; all daily configs validate unchanged. When the backtest's
``event_order`` is ``intraday_tail_rebalance`` this section must have
``enabled=true`` (enforced on RootConfig). ``decision_time`` is the signal
cutoff (features must satisfy ``available_time <= decision_time``);
``execution_window`` is where the fill bar is taken; the window must start
strictly after the decision and be non-empty.
"""

enabled: bool = False
decision_time: str = "14:50:00"
data_lag: str = "1min"
session_open: str = "09:30:00"
execution_model: str = "next_minute_close"
execution_window: tuple[str, str] = ("14:51:00", "14:56:59")
require_cache_coverage: bool = True
missing_execution: Literal["block"] = "block"

@model_validator(mode="after")
def _check_execution(self) -> "IntradayCfg":
if self.execution_model not in _SUPPORTED_INTRADAY_EXECUTION_MODELS:
raise ValueError(
f"intraday.execution_model {self.execution_model!r} is not "
f"supported; choose one of {_SUPPORTED_INTRADAY_EXECUTION_MODELS}."
)
try:
decision = _parse_hms(self.decision_time)
start = _parse_hms(self.execution_window[0])
end = _parse_hms(self.execution_window[1])
except ValueError as exc:
raise ValueError(
f"intraday time fields must be HH:MM:SS clock strings: {exc}"
) from exc
if not (start > decision and start <= end):
raise ValueError(
"intraday.execution_window must satisfy "
"decision_time < window_start <= window_end (got "
f"decision={self.decision_time}, window={list(self.execution_window)})."
)
return self


class CostCfg(_Strict):
fee_rate: float = 0.001
slippage_rate: float = 0.0
Expand Down Expand Up @@ -559,6 +625,9 @@ class RootConfig(_Strict):
cost: CostCfg
analytics: AnalyticsCfg = Field(default_factory=AnalyticsCfg)
output: OutputCfg
# P-I5a intraday tail event model (consumed only by run-phase-i5a-intraday and
# any backtest with event_order='intraday_tail_rebalance'). Off by default.
intraday: IntradayCfg | None = None
oos: OOSCfg | None = None
# P3-4 robustness matrix (consumed only by run-phase3-robustness).
robustness: RobustnessCfg | None = None
Expand All @@ -567,6 +636,24 @@ class RootConfig(_Strict):
# P4-3 data updater (consumed only by data-update).
data_update: DataUpdateCfg | None = None

@model_validator(mode="after")
def _check_intraday_event_order(self) -> "RootConfig":
"""``intraday_tail_rebalance`` requires an enabled ``intraday`` section.

The daily default (``close_to_next_period``) needs no intraday section, so
every existing config validates unchanged. Selecting the intraday event
model without ``intraday.enabled=true`` is a configuration error (it would
otherwise silently run the daily model).
"""
if self.backtest.event_order == "intraday_tail_rebalance":
if self.intraday is None or not self.intraday.enabled:
raise ValueError(
"backtest.event_order='intraday_tail_rebalance' requires an "
"'intraday' section with enabled=true; got "
f"intraday={'None' if self.intraday is None else 'enabled=false'}."
)
return self

@model_validator(mode="after")
def _check_subset_groups_reference_enabled_factors(self) -> "RootConfig":
if self.subset_validation is None:
Expand Down
Loading