fix: unify backtest execution and add forward validation candidates
Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -102,13 +102,15 @@ uv run python -m unittest discover tests
|
||||
|
||||
**Data manager** (`data_manager.py`): Persistent price storage in `data/{market}.csv` (close) and `data/{market}_{open,high,low,volume}.csv`. First run downloads 10 years of history; every run re-downloads the last 30 days. New tickers from index rebalances are backfilled automatically; tickers missing from a partial batch are retried alone before being marked unavailable (retried weekly). `update(market, tickers, with_open=True)` returns (close, open); `update(..., fields=[...])` returns `{field: panel}`.
|
||||
|
||||
**Backtest engine** (`main.py`): Orchestrates data loading, strategy execution, and visualization. `backtest()` has two modes. Close mode (no `open_data`, used by `main.py` and old research scripts) is vectorized and trades at the close the signal saw. Live mode (`open_data` given, used by the web UI through `backtest_runner.py`) keeps the book like `trader.py`: same signal timing (`strategy_positions`), holdings that drift, the $50/0.1% order floor, `trader.RESIZE_BAND`, fees on the orders placed (`simulate_live_book`).
|
||||
**Backtest engine** (`main.py`, `backtest_runner.py`): all replay paths use `signals.session_targets` and the cash-funded order ledger in `execution.py`. Price strategies may use the session open and fill at its close; close-only inputs and BarStrategy inputs use prior completed bars. Historical membership restricts ranks and selection separately from price history. Missing quotes and zero volume cannot fill; the book checks available cash after fees, handles whole shares/lots, and applies conservative CN closing-limit restrictions and sell-side stamp tax. Registry replays retain at least 315 prior sessions and score all compared strategies on a common actual window. `--offline` on main/precompute reads a frozen local snapshot. Data coverage and adjusted-share/corporate-action limitations are exposed in API metadata.
|
||||
|
||||
`precompute_backtest.py` reruns the ledger for cash-injection scenarios; metrics use unit NAV after actual fees. The API and `web/src/shared/strategyTypes.ts` share this cash-flow contract. `ENGINE_VERSION` in `execution.py` identifies the execution protocol; API cache keys also include its version.
|
||||
|
||||
**Daily trader** (`trader.py`): Live/forward-testing system with persistent portfolio state in `data/trader_{market}_{strategy}.json`. The `auto` subcommand runs both signal generation and execution in a single invocation — designed for cron. The `simulate` subcommand replays a date range day-by-day with realistic portfolio tracking (fractional shares, cash, commissions). Orders: whole A-share lots are allocated nearest-lot within cash (`_allocate_lots`); a held name is resized only when ≥ `RESIZE_BAND` (10%) off target. Available strategies: `recovery_mom_top10`, `recovery_mom_top20`, `momentum`, `momentum_quality`, `dual_momentum`, `inverse_vol`, `trend_following`, `buy_and_hold`, 32 factor-combo strategies (`fc_{signal}_{freq}` — see `strategies/factor_combo.py`), the robust-momentum bar strategies `overnight_recovery_top10`, `lowrisk_momentum_top15`, `lowbeta_sharpe_mom_top20`, `lowrisk_top20`, the US sleeve portfolios `us_three_sleeve` and `us_momentum_qqq_trend`, and the agent-reviewed `overnight_recovery_agent_top10` (US only, forward only).
|
||||
|
||||
**Live books** (`trader.LIVE_BOOKS`): a book is a cohort of strategies, each with its own account, started on one date with one capital and a flat fee per order. The original book (every live strategy, `MONITOR_CAPITAL`, `MARKET_FEES`) keeps state in `data/trader_*.json`; a named book keeps it in `data/books/<book>/`. The monitor runs every book; `auto`/`status`/`log`/`compare`/`replay` and `bridge.py live` take `--book`, and `bridge.py books` lists them for the UI's book selector. `selected_2026_09` (30 strategies, US $10,000 / CN ¥100,000, $2 / ¥10 per order) starts on 2026-09-25 (US) and 2026-09-28 (CN); `overnight_recovery_agent_top10` joined it on 2026-09-30 and starts with the book.
|
||||
|
||||
**Strategy protocol** (`strategies/base.py`): All strategies inherit from `Strategy` ABC and implement `generate_signals(data) → DataFrame` where the returned DataFrame contains portfolio weights (rows = dates, columns = assets, values sum to ~1.0 per row). Each strategy is responsible for applying its own 1-day lag via `.shift(1)` to avoid lookahead bias — the backtest engine does not shift. A `BarStrategy` declares `fields` (close/open/high/low/volume) and receives `{field: DataFrame}`; live it uses completed bars only (row d from bars through d-1, traded at the close of d), while close-price strategies read the session's open as their latest price.
|
||||
**Strategy protocol** (`strategies/base.py`, `signals.py`): All strategies inherit from `Strategy` ABC and implement `generate_signals(data) → DataFrame` where the returned DataFrame contains portfolio weights (rows = dates, columns = assets, values sum to ~1.0 per row). Each strategy returns lagged weights (row t uses bars through t-1). `signals.session_targets` converts this to a feasible execution-day target; the ledger trades at that day’s close. `StockStrategy` accepts a separate `eligible` mask so pre-admission observations remain usable but nonmembers cannot be selected. A `BarStrategy` declares `fields` (close/open/high/low/volume) and receives `{field: DataFrame}`; live it uses completed bars only (row d from bars through d-1, traded at the close of d), while close-price strategies read the session's open as their latest price.
|
||||
|
||||
**Strategies**:
|
||||
- `buy_and_hold.py` — Equal-weight static allocation
|
||||
@@ -137,6 +139,16 @@ uv run python -m unittest discover tests
|
||||
- Python 3.12+, managed with `uv`.
|
||||
- Do not show matplotlib figures when running backtests; use `--no-plot`.
|
||||
|
||||
The `*_rb20` strategies are execution-only candidates with a 20% resize band; baseline strategies keep 10%. They run alongside their baselines only in `validation_2026_10`, starting with the first market session on/after 2026-10-02, with matching capital and fees. Existing forward histories are preserved and future daily snapshots record the engine version. These candidates were selected from previously examined historical data and have no independent forward performance yet.
|
||||
|
||||
Registered-strategy comparisons use the same ledger:
|
||||
|
||||
```bash
|
||||
uv run python -m research.lab.evaluate --market us --registered overnight_recovery_top10 us_three_sleeve --start 2018-01-01 --end 2026-09-28 --output data/research/results/new_comparison
|
||||
```
|
||||
|
||||
The comparison writes its fixed budget and acceptance rule before running and refuses to overwrite an existing study. Trial producers append versioned JSONL; legacy CSV trial counts remain a lower bound. `stepwise --objective maximin` selects only inside the design period. Previously inspected validation periods must not be described as independent OOS data.
|
||||
|
||||
## Server Deployment
|
||||
|
||||
Three `systemd --user` services, defined in `deploy/systemd/`, run from `/home/gahow/projects/quant-trading`:
|
||||
|
||||
@@ -1,197 +1,229 @@
|
||||
"""
|
||||
Market-level backtests behind the web UI: ``bridge.py backtest`` (on demand)
|
||||
and ``precompute_backtest.py`` (the strategy page) share this one data path.
|
||||
|
||||
* Universe: point-in-time index members in both markets (S&P 500 history from
|
||||
Wikipedia, CSI 300 history in data/csi300_history.json); prices outside a
|
||||
stock's membership are masked, so strategies cannot pick it then.
|
||||
* Book: kept like the live trader keeps it — its signal timing
|
||||
(``main.strategy_positions`` live mode), drifting holdings, the same order
|
||||
size floor and resize band (``main.simulate_live_book``).
|
||||
* Bars: panels a BarStrategy needs (open/high/low/volume) are loaded too.
|
||||
"""
|
||||
|
||||
"""Shared, feasible replay for the API, precompute and strategy comparisons."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import data_manager
|
||||
import universe_history as uh
|
||||
from main import simulate_live_book, strategy_positions
|
||||
from strategies.base import BarStrategy
|
||||
from trader import (
|
||||
ETF_STRATEGY_UNIVERSES,
|
||||
LIVE_EXCLUDED_STRATEGIES,
|
||||
RESIZE_BAND,
|
||||
STRATEGY_REGISTRY,
|
||||
convert_quote_currency,
|
||||
filter_tradable_tickers,
|
||||
price_request,
|
||||
strategy_extra_tickers,
|
||||
strategy_fields,
|
||||
)
|
||||
from execution import ENGINE_VERSION, simulate_book
|
||||
from trader import (ETF_STRATEGY_UNIVERSES, STRATEGY_REGISTRY, RESIZE_BAND, AGENT_REVIEW_STRATEGIES,
|
||||
convert_quote_currency, price_request, strategy_extra_tickers,
|
||||
strategy_fields, strategy_signal_tickers, make_strategy,
|
||||
session_signals, strategy_supports_market, strategy_resize_band)
|
||||
from universe import UNIVERSES
|
||||
|
||||
# Rebalances below this size are not charged the per-trade fee in backtests,
|
||||
# matching the live trader's minimum order size.
|
||||
MIN_TRADE_VALUE = 50.0
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
def _log(msg):
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def trade_events(diff: pd.DataFrame, threshold: float = 0.01) -> list[dict]:
|
||||
"""Trade events from per-ticker traded weights (rows = days)."""
|
||||
trades = []
|
||||
for date in diff.index:
|
||||
row = diff.loc[date]
|
||||
significant = row[row.abs() > threshold]
|
||||
if significant.empty:
|
||||
continue
|
||||
buys = significant[significant > 0]
|
||||
sells = significant[significant < 0]
|
||||
if len(buys) > 0 or len(sells) > 0:
|
||||
trades.append({
|
||||
"date": date.strftime("%Y-%m-%d"),
|
||||
"buys": len(buys),
|
||||
"sells": len(sells),
|
||||
"buyTickers": list(buys.nlargest(3).index),
|
||||
"sellTickers": list(sells.nsmallest(3).index),
|
||||
})
|
||||
return trades
|
||||
rows = []
|
||||
for day, row in diff.iterrows():
|
||||
buy, sell = row[row > threshold], row[row < -threshold]
|
||||
if len(buy) + len(sell):
|
||||
rows.append({"date": str(day.date()), "buys": len(buy), "sells": len(sell),
|
||||
"buyTickers": list(buy.nlargest(3).index),
|
||||
"sellTickers": list(sell.nsmallest(3).index)})
|
||||
return rows
|
||||
|
||||
|
||||
def order_events(trades: list[dict]) -> list[dict]:
|
||||
days = {}
|
||||
for t in trades:
|
||||
day = days.setdefault(t["date"], {"date": t["date"], "buys": 0, "sells": 0,
|
||||
"buyTickers": [], "sellTickers": []})
|
||||
side = "buy" if t["action"] == "BUY" else "sell"
|
||||
day[side + "s"] += 1
|
||||
day[side + "Tickers"].append(t["ticker"])
|
||||
for day in days.values():
|
||||
for key in ("buyTickers", "sellTickers"):
|
||||
day[key] = day[key][:3]
|
||||
return list(days.values())
|
||||
|
||||
|
||||
def benchmark_curve(prices: pd.Series, dates: pd.DatetimeIndex, capital: float) -> list[dict]:
|
||||
"""Benchmark value on ``dates``, rebased to ``capital`` on the first date it has a price."""
|
||||
series = prices.reindex(dates).ffill().dropna()
|
||||
if series.empty:
|
||||
return []
|
||||
values = series / series.iloc[0] * capital
|
||||
return [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)} for d, v in values.items()]
|
||||
return [{"date": str(d.date()), "value": round(float(v), 2)}
|
||||
for d, v in (series / series.iloc[0] * capital).items()]
|
||||
|
||||
|
||||
def _slice_years(panels: dict[str, pd.DataFrame], years: int | None) -> dict[str, pd.DataFrame]:
|
||||
if not years:
|
||||
return panels
|
||||
def _panels(data_market, tickers, fields, refresh):
|
||||
filled = (data_manager.update(data_market, tickers, fields=fields) if refresh
|
||||
else data_manager.load_fields(data_market, fields))
|
||||
raw = data_manager.load_raw(data_market)
|
||||
if raw is None:
|
||||
raise FileNotFoundError(f"No local prices for {data_market}")
|
||||
# Only valuation forward-fills. Indicators and tradability see actual bars.
|
||||
filled["close"] = raw.reindex_like(filled["close"])
|
||||
return filled
|
||||
|
||||
|
||||
def load_stock_panels(market: str, strategy_names: list[str], years: int | None,
|
||||
*, refresh: bool = True) -> dict:
|
||||
u = UNIVERSES[market]
|
||||
# Refresh membership explicitly only in the data-loading stage.
|
||||
if refresh:
|
||||
if market == "us":
|
||||
uh.load_sp500_history()
|
||||
else:
|
||||
uh.load_csi300_history()
|
||||
sample = pd.DatetimeIndex([pd.Timestamp.today().normalize()])
|
||||
ever = list(uh.pit_membership(market, sample).columns)
|
||||
extras = {t for n in strategy_names for t in strategy_extra_tickers(n) + strategy_signal_tickers(n, market)}
|
||||
fields = sorted({"close", "open", "volume"} | {f for n in strategy_names for f in strategy_fields(n)})
|
||||
panels = _panels(market, sorted(set(ever) | extras | {u['benchmark']}), fields, refresh)
|
||||
close = panels["close"]
|
||||
cutoff = close.index[-1] - pd.DateOffset(years=years)
|
||||
return {f: p[p.index >= cutoff] for f, p in panels.items()}
|
||||
member = uh.pit_membership(market, close.index)
|
||||
stocks = sorted(set(member.columns) & set(close.columns) - extras - {u['benchmark']})
|
||||
observed = close.reindex(columns=member.columns).notna() & member
|
||||
coverage = observed.sum(axis=1).div(member.sum(axis=1).replace(0, np.nan))
|
||||
return {"panels": panels, "member": member, "stocks": stocks,
|
||||
"benchmark": u["benchmark"], "benchmark_label": u["benchmark_label"],
|
||||
"coverage": coverage}
|
||||
|
||||
|
||||
def load_stock_panels(market: str, strategy_names: list[str], years: int | None) -> dict:
|
||||
"""PIT-masked panels for the market's stock strategies."""
|
||||
universe = UNIVERSES[market]
|
||||
tickers = universe["fetch"]()
|
||||
benchmark = universe["benchmark"]
|
||||
since = None
|
||||
if years:
|
||||
since = (pd.Timestamp.today() - pd.DateOffset(years=years + 1)).strftime("%Y-%m-%d")
|
||||
ever = uh.pit_tickers_ever(market, since=since) or []
|
||||
extras = {t for n in strategy_names for t in strategy_extra_tickers(n)}
|
||||
request = sorted(set(tickers) | set(ever) | extras | {benchmark})
|
||||
fields = sorted({"close", "open"} | {f for n in strategy_names for f in strategy_fields(n)})
|
||||
_log(f"PIT universe: {len(request)} tickers ({len(ever)} ever index members); fields {fields}")
|
||||
panels = data_manager.update(market, request, fields=fields)
|
||||
panels = _slice_years(panels, years)
|
||||
member = None
|
||||
if ever:
|
||||
member = uh.mask_prices_pit(panels["close"], market).notna() | panels["close"].isna()
|
||||
# Only tickers that were index members: the shared panel also holds
|
||||
# benchmarks and the ETFs of mixed strategies (TQQQ, SOXL, ...), which
|
||||
# the membership mask passes through unmasked.
|
||||
members_ever = set(ever)
|
||||
stocks = [t for t in panels["close"].columns
|
||||
if t in members_ever and t not in extras
|
||||
and panels["close"][t].where(member[t]).notna().any()]
|
||||
else:
|
||||
stocks = filter_tradable_tickers(panels["close"], tickers)
|
||||
return {"panels": panels, "member": member, "stocks": stocks, "benchmark": benchmark,
|
||||
"benchmark_label": universe["benchmark_label"]}
|
||||
def prepare_strategy(name: str, market: str, panels: dict, cols: list[str], *,
|
||||
member: pd.DataFrame | None = None, years: int | None = None,
|
||||
start=None, end=None, strategy=None) -> dict:
|
||||
close = panels["close"]
|
||||
if end is not None:
|
||||
close = close.loc[:end]
|
||||
benchmark = "SPY" if name in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
|
||||
if benchmark in close:
|
||||
close = close.loc[close[benchmark].notna()]
|
||||
extra = strategy_extra_tickers(name) + strategy_signal_tickers(name, market)
|
||||
cols = sorted(set(cols + extra) & set(close.columns))
|
||||
bars = {f: panels[f].reindex(index=close.index, columns=cols)
|
||||
for f in strategy_fields(name)}
|
||||
targets = session_signals(market, name, cols, close.index, close,
|
||||
panels["open"].reindex(index=close.index),
|
||||
bars=bars, member=member, strategy=strategy)
|
||||
cutoff = pd.Timestamp(start) if start is not None else (
|
||||
close.index[-1] - pd.DateOffset(years=years) if years else close.index[0])
|
||||
days = close.index[close.index >= cutoff]
|
||||
if len(days) < 2:
|
||||
raise ValueError("Need at least two replay sessions")
|
||||
return {"name": name, "market": market, "targets": targets.loc[days],
|
||||
"prices": close.reindex(columns=targets.columns).loc[days],
|
||||
"warmup_rows": int((close.index < days[0]).sum()),
|
||||
"resize_band": strategy_resize_band(name),
|
||||
"previous_prices": close.reindex(columns=targets.columns).ffill().shift(1).loc[days],
|
||||
"volume": panels["volume"].reindex(index=days, columns=targets.columns) if "volume" in panels else None}
|
||||
|
||||
|
||||
def _run(strategy, panels: dict[str, pd.DataFrame], cols: list[str], capital: float,
|
||||
tx_cost: float, fixed_fee: float, fee_base: float, fee_per_share: float,
|
||||
member: pd.DataFrame | None = None, min_trade_value: float = MIN_TRADE_VALUE):
|
||||
"""Close-price strategies see prices masked to index membership (they
|
||||
cannot pick a non-member); a BarStrategy sees full histories plus the
|
||||
membership as ``bars["eligible"]``. The book is valued on unmasked closes."""
|
||||
close, opens = panels["close"][cols], panels["open"][cols]
|
||||
if isinstance(strategy, BarStrategy):
|
||||
bars = {f: panels[f][cols] for f in strategy.fields}
|
||||
if member is not None:
|
||||
bars["eligible"] = member.reindex(columns=cols).fillna(True)
|
||||
held = strategy_positions(strategy, close, open_data=opens, bars=bars)
|
||||
else:
|
||||
m = member.reindex(columns=cols).fillna(True) if member is not None else None
|
||||
held = strategy_positions(strategy, close.where(m) if m is not None else close,
|
||||
open_data=opens.where(m) if m is not None else opens)
|
||||
equity, traded = simulate_live_book(held, close, capital, tx_cost, fixed_fee, fee_base,
|
||||
fee_per_share, min_trade_value, RESIZE_BAND)
|
||||
return equity, trade_events(traded)
|
||||
def replay_prepared(plan: dict, capital: float, tx_cost: float, fixed_fee: float,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
*, contributions=None, integer_shares: bool = True,
|
||||
resize_band: float | None = None, cash_returns=None) -> dict:
|
||||
result = simulate_book(plan["targets"], plan["prices"], capital, market=plan["market"],
|
||||
previous_prices=plan.get("previous_prices"), volume=plan.get("volume"),
|
||||
transaction_cost=tx_cost, fixed_fee=fixed_fee,
|
||||
fee_base=fee_base, fee_per_share=fee_per_share,
|
||||
integer_shares=integer_shares,
|
||||
lot_size=100 if plan["market"] == "cn" else 1,
|
||||
min_trade_value=MIN_TRADE_VALUE,
|
||||
resize_band=plan.get("resize_band", RESIZE_BAND) if resize_band is None else resize_band,
|
||||
contributions=contributions, cash_returns=cash_returns)
|
||||
# Include the initial capital so day-one commissions count in all metrics.
|
||||
anchor = plan["prices"].index[0] - pd.Timedelta(days=1)
|
||||
for key in ("equity", "unit_equity"):
|
||||
result[key] = pd.concat([pd.Series([capital], index=[anchor]), result[key]])
|
||||
result.update(name=plan["name"], trades=order_events(result["trade_log"]),
|
||||
warmup_rows=plan.get("warmup_rows", 0))
|
||||
return result
|
||||
|
||||
|
||||
def _run(strategy, panels, cols, capital, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||||
member=None, min_trade_value=MIN_TRADE_VALUE):
|
||||
# Compatibility for callers that already constructed a strategy.
|
||||
from signals import session_targets
|
||||
from strategies.base import BarStrategy
|
||||
close = panels["close"][cols]
|
||||
bars = {f: panels[f][cols] for f in strategy.fields} if isinstance(strategy, BarStrategy) else None
|
||||
targets = session_targets(strategy, close, panels["open"][cols], bars, member)
|
||||
result = simulate_book(targets, close, capital, transaction_cost=tx_cost, fixed_fee=fixed_fee,
|
||||
fee_base=fee_base, fee_per_share=fee_per_share,
|
||||
min_trade_value=min_trade_value, resize_band=RESIZE_BAND)
|
||||
return result["equity"], order_events(result["trade_log"])
|
||||
|
||||
|
||||
def run_market(market: str, strategy_names: list[str], years: int | None, capital: float,
|
||||
tx_cost: float, fixed_fee: float, fee_base: float = 0.0,
|
||||
fee_per_share: float = 0.0) -> dict | None:
|
||||
"""Backtest every strategy once; returns raw equity series and metadata."""
|
||||
stock = [n for n in strategy_names if n.removeprefix("sim_") not in ETF_STRATEGY_UNIVERSES]
|
||||
etf = [n for n in strategy_names if n.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES]
|
||||
fee_per_share: float = 0.0, *, refresh: bool = True,
|
||||
start=None, end=None) -> dict | None:
|
||||
if any(n in AGENT_REVIEW_STRATEGIES for n in strategy_names):
|
||||
raise ValueError("Agent-reviewed strategies are forward-only; use their dated live record")
|
||||
invalid = [n for n in strategy_names if not strategy_supports_market(n, market)]
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported strategies for {market}: {invalid}")
|
||||
fees = dict(tx_cost=tx_cost, fixed_fee=fixed_fee, fee_base=fee_base, fee_per_share=fee_per_share)
|
||||
runs: list[dict] = []
|
||||
out = {"market": market, "benchmark_label": "SPY", "benchmark_curve": [],
|
||||
"start_date": None, "end_date": None, "tx_cost": tx_cost, "fixed_fee": fixed_fee}
|
||||
|
||||
if stock:
|
||||
data = load_stock_panels(market, stock, years)
|
||||
panels, stocks = data["panels"], data["stocks"]
|
||||
close = panels["close"]
|
||||
_log(f"Universe: {len(stocks)} stocks, {close.index[0].date()} to {close.index[-1].date()}")
|
||||
for name in stock:
|
||||
_log(f" Running: {name}")
|
||||
try:
|
||||
strategy = STRATEGY_REGISTRY[name]()
|
||||
cols = stocks + [t for t in strategy_extra_tickers(name)
|
||||
if t in close.columns and t not in stocks]
|
||||
# Strategies the live book cannot hold (e.g. equal weight over
|
||||
# ~500 names) are shown without the live order-size floor.
|
||||
floor = 0.0 if name in LIVE_EXCLUDED_STRATEGIES else MIN_TRADE_VALUE
|
||||
equity, trades = _run(strategy, panels, cols, capital, **fees,
|
||||
member=data["member"], min_trade_value=floor)
|
||||
runs.append({"name": name, "equity": equity, "trades": trades})
|
||||
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
|
||||
except Exception as e: # noqa: BLE001 — one broken strategy must not sink the page
|
||||
_log(f" FAILED: {name}: {e}")
|
||||
out.update(benchmark_label=data["benchmark_label"],
|
||||
benchmark_curve=benchmark_curve(close[data["benchmark"]], close.index, capital),
|
||||
start_date=close.index[0].strftime("%Y-%m-%d"),
|
||||
end_date=close.index[-1].strftime("%Y-%m-%d"))
|
||||
|
||||
for name in etf:
|
||||
_log(f" Running ETF strategy: {name}")
|
||||
stocks = [n for n in strategy_names if n not in ETF_STRATEGY_UNIVERSES]
|
||||
stock = load_stock_panels(market, stocks, years, refresh=refresh) if stocks else None
|
||||
etf_names = [n for n in strategy_names if n in ETF_STRATEGY_UNIVERSES]
|
||||
etfs = None
|
||||
if etf_names:
|
||||
request = sorted(set(t for n in etf_names for t in ETF_STRATEGY_UNIVERSES[n]) | {"SPY"})
|
||||
etfs = _panels("etfs", price_request(market, request), ["close", "open"], refresh)
|
||||
etfs = {f: convert_quote_currency(p) for f, p in etfs.items()}
|
||||
histories = []
|
||||
for panel, benchmark in ((stock["panels"], stock["benchmark"]) if stock else (None, None),
|
||||
(etfs, "SPY")):
|
||||
if panel is not None:
|
||||
sessions = panel["close"].index[panel["close"][benchmark].notna()]
|
||||
if end is not None:
|
||||
sessions = sessions[sessions <= pd.Timestamp(end)]
|
||||
if len(sessions) <= 315:
|
||||
raise ValueError("Registered replay needs 315 pre-window sessions")
|
||||
histories.append(sessions)
|
||||
common_end = min(h[-1] for h in histories)
|
||||
requested_start = (pd.Timestamp(start) if start is not None else
|
||||
common_end - pd.DateOffset(years=years) if years else min(h[0] for h in histories))
|
||||
common_start = max(requested_start, max(h[315] for h in histories))
|
||||
runs, errors = [], []
|
||||
for name in strategy_names:
|
||||
try:
|
||||
etf_tickers = ETF_STRATEGY_UNIVERSES[name.removeprefix("sim_")]
|
||||
close, opens = data_manager.update(
|
||||
"etfs", price_request(market, sorted(set(etf_tickers + ["SPY"]))), with_open=True)
|
||||
panels = _slice_years({"close": convert_quote_currency(close),
|
||||
"open": convert_quote_currency(opens)}, years)
|
||||
strategy = STRATEGY_REGISTRY[name]()
|
||||
equity, trades = _run(strategy, panels, filter_tradable_tickers(panels["close"], etf_tickers),
|
||||
capital, **fees)
|
||||
runs.append({"name": name, "equity": equity, "trades": trades})
|
||||
_log(f" OK: {name} (final: {equity.iloc[-1]:,.0f})")
|
||||
if not stock and not out["benchmark_curve"] and "SPY" in panels["close"].columns:
|
||||
spy = panels["close"]["SPY"]
|
||||
out.update(benchmark_label="SPY (Benchmark)",
|
||||
benchmark_curve=benchmark_curve(spy, spy.index, capital),
|
||||
start_date=spy.index[0].strftime("%Y-%m-%d"),
|
||||
end_date=spy.index[-1].strftime("%Y-%m-%d"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_log(f" FAILED: {name}: {e}")
|
||||
|
||||
panels = etfs if name in ETF_STRATEGY_UNIVERSES else stock["panels"]
|
||||
cols = ETF_STRATEGY_UNIVERSES[name] if name in ETF_STRATEGY_UNIVERSES else stock["stocks"]
|
||||
member = None if name in ETF_STRATEGY_UNIVERSES else stock["member"]
|
||||
plan = prepare_strategy(name, market, panels, cols, member=member, start=common_start, end=common_end)
|
||||
result = replay_prepared(plan, capital, **fees)
|
||||
result["_plan"] = plan
|
||||
result["_fees"] = fees
|
||||
runs.append(result)
|
||||
_log(f" {name}: {result['equity'].iloc[-1]:,.2f}")
|
||||
except (ValueError, KeyError, FileNotFoundError) as exc:
|
||||
errors.append({"strategy": name, "error": str(exc)})
|
||||
_log(f" FAILED {name}: {exc}")
|
||||
if not runs:
|
||||
if errors:
|
||||
raise ValueError(str(errors))
|
||||
return None
|
||||
out["strategy_runs"] = runs
|
||||
return out
|
||||
first, last = runs[0]["equity"].index[[0, -1]]
|
||||
bench = stock["benchmark"] if stock else "SPY"
|
||||
panels = stock["panels"] if stock else etfs
|
||||
dates = runs[0]["equity"].index
|
||||
curve = benchmark_curve(panels["close"][bench], dates[1:], capital)
|
||||
curve.insert(0, {"date": str(first.date()), "value": capital})
|
||||
coverage = stock["coverage"].loc[first:last].dropna() if stock else pd.Series(dtype=float)
|
||||
warnings = ["使用复权价格:历史整股、整手及公司行动的模拟仍有近似。",
|
||||
"缺少完整退市结算和市场冲击数据,收益为研究估计。"]
|
||||
if market == "cn":
|
||||
warnings.append("沪深300历史成分股按月记录;缺少完整历史ST状态及集合竞价数据,涨跌停成交采用保守限制。")
|
||||
if common_start > requested_start:
|
||||
warnings.append(f"为保留315个交易日的预热历史,统一统计窗口从 {common_start.date()} 开始;更早区间需补齐行情。")
|
||||
if len(coverage) and coverage.min() < .99:
|
||||
warnings.append(f"历史成分股报价覆盖率最低为 {coverage.min():.1%},仍存在样本缺失偏差。")
|
||||
return {"market": market, "benchmark_label": stock["benchmark_label"] if stock else "SPY",
|
||||
"benchmark_curve": curve, "start_date": str(first.date()), "end_date": str(last.date()),
|
||||
**fees, "strategy_runs": runs, "engine_version": ENGINE_VERSION,
|
||||
"actual_years": round((last - first).days / 365.25, 1),
|
||||
"warnings": warnings, "errors": errors,
|
||||
"coverage": {"minimum": float(coverage.min()), "median": float(coverage.median())}
|
||||
if len(coverage) else None}
|
||||
|
||||
51
bridge.py
51
bridge.py
@@ -27,7 +27,7 @@ from trader import (
|
||||
LIVE_EXCLUDED_STRATEGIES,
|
||||
MARKET_FEE_TIERED,
|
||||
MONITOR_CAPITAL,
|
||||
STRATEGY_REGISTRY,
|
||||
STRATEGY_REGISTRY, EXECUTION_VARIANTS,
|
||||
book_dir,
|
||||
book_strategies,
|
||||
convert_quote_currency,
|
||||
@@ -482,6 +482,11 @@ STRATEGY_META = {
|
||||
}
|
||||
|
||||
|
||||
for _name, (_base, _market) in EXECUTION_VARIANTS.items():
|
||||
STRATEGY_META[_name] = {**STRATEGY_META[_base], "markets": [_market],
|
||||
"label": STRATEGY_META[_base]["label"] + " · 20%再平衡带(前瞻候选)"}
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
@@ -740,7 +745,8 @@ def cmd_backtest(args: argparse.Namespace) -> None:
|
||||
raw = run_market(market, known, years, capital, tx_cost, fixed_fee,
|
||||
**MARKET_FEE_TIERED.get(market, {}))
|
||||
results = [
|
||||
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital)
|
||||
_build_strategy_result(run["name"], run["equity"], run["trades"], injection_schedule, capital,
|
||||
execution_plan=run.get("_plan"), fee_params=run.get("_fees"))
|
||||
for run in (raw["strategy_runs"] if raw else [])
|
||||
]
|
||||
benchmark_label = raw["benchmark_label"] if raw else "SPY"
|
||||
@@ -749,13 +755,18 @@ def cmd_backtest(args: argparse.Namespace) -> None:
|
||||
output = {
|
||||
"meta": {
|
||||
"market": market,
|
||||
"years": years,
|
||||
"years": raw.get("actual_years", years) if raw else years,
|
||||
"requestedYears": years,
|
||||
"initialCapital": capital,
|
||||
"startDate": results[0]["equityCurve"][0]["date"] if results else None,
|
||||
"endDate": results[0]["equityCurve"][-1]["date"] if results else None,
|
||||
"txCost": tx_cost,
|
||||
"fixedFee": fixed_fee,
|
||||
"injectionSchedule": injection_schedule,
|
||||
"engineVersion": raw.get("engine_version") if raw else None,
|
||||
"warnings": raw.get("warnings", []) if raw else [],
|
||||
"coverage": raw.get("coverage") if raw else None,
|
||||
"errors": raw.get("errors", []) if raw else [],
|
||||
},
|
||||
"strategies": results,
|
||||
"benchmark": {
|
||||
@@ -773,21 +784,29 @@ def _build_strategy_result(
|
||||
trade_events: list[dict],
|
||||
injection_schedule: dict | None,
|
||||
initial_capital: float,
|
||||
*, execution_plan: dict | None = None, fee_params: dict | None = None,
|
||||
) -> dict:
|
||||
"""Build a strategy result dict with optional injection applied.
|
||||
|
||||
Return and risk metrics, drawdowns and periodic returns are time-weighted
|
||||
(computed on the curve without injections), so injected cash never counts
|
||||
as performance. ``capitalFlows`` reports the money view: total invested,
|
||||
(computed from cash-flow-adjusted NAV), so injected cash never counts
|
||||
as performance. The orders and fees are replayed at the actual capital. ``capitalFlows`` reports the money view: total invested,
|
||||
final value and profit of the injected curve.
|
||||
"""
|
||||
injection_events = []
|
||||
equity_with_inj = None
|
||||
|
||||
metric_equity = equity
|
||||
if injection_schedule:
|
||||
returns = equity.pct_change().fillna(0.0)
|
||||
contributions, injection_events = build_injection_schedule(equity, injection_schedule)
|
||||
equity_with_inj = equity_with_cashflows(returns, contributions, initial_capital)
|
||||
if execution_plan is None or fee_params is None:
|
||||
raise ValueError("Cash injections require an order replay; scaling a return curve is not supported")
|
||||
from backtest_runner import replay_prepared
|
||||
days = execution_plan["prices"].index
|
||||
contributions, injection_events = build_injection_schedule(equity.reindex(days), injection_schedule)
|
||||
replay = replay_prepared(execution_plan, initial_capital, contributions=contributions, **fee_params)
|
||||
equity_with_inj = replay["equity"]
|
||||
metric_equity = replay["unit_equity"]
|
||||
trade_events = replay["trades"]
|
||||
|
||||
# Curve shown to the user: the money curve when cash is injected.
|
||||
final_equity = equity_with_inj if equity_with_inj is not None else equity
|
||||
@@ -804,14 +823,10 @@ def _build_strategy_result(
|
||||
base_curve = [{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 2)}
|
||||
for d, v in equity.items()]
|
||||
|
||||
# Time-weighted metrics on the curve without injections
|
||||
strat_metrics = metrics.raw_summary(equity)
|
||||
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(
|
||||
equity, trade_events,
|
||||
)
|
||||
|
||||
drawdowns = compute_drawdown_periods(equity)
|
||||
monthly_ret, yearly_ret = compute_periodic_returns(equity)
|
||||
strat_metrics = metrics.raw_summary(metric_equity)
|
||||
strat_metrics["rebalanceWinRate"] = compute_rebalance_win_rate(metric_equity, trade_events)
|
||||
drawdowns = compute_drawdown_periods(metric_equity)
|
||||
monthly_ret, yearly_ret = compute_periodic_returns(metric_equity)
|
||||
|
||||
meta = STRATEGY_META.get(name, {"label": name, "category": "other"})
|
||||
|
||||
@@ -1060,7 +1075,9 @@ def cmd_books() -> None:
|
||||
"strategies": {m: book_strategies(None, m) for m in ("us", "cn")}}]
|
||||
for book_id, cfg in LIVE_BOOKS.items():
|
||||
books.append({"id": book_id, "label": cfg["label"], "startDate": cfg["start"],
|
||||
"capital": cfg["capital"], "feePerOrder": cfg["fee_per_order"],
|
||||
"capital": cfg["capital"],
|
||||
"feePerOrder": None if "fee_schedule" in cfg else cfg["fee_per_order"],
|
||||
"feeSchedule": cfg.get("fee_schedule"),
|
||||
"strategies": {m: book_strategies(book_id, m) for m in ("us", "cn")}})
|
||||
json.dump(books, sys.stdout)
|
||||
|
||||
|
||||
@@ -258,11 +258,10 @@ def _merge_refresh(existing: pd.DataFrame | None, new: pd.DataFrame,
|
||||
|
||||
|
||||
def _fill_open(close: pd.DataFrame, open_raw: pd.DataFrame | None) -> pd.DataFrame:
|
||||
"""Align open prices to the close panel; fall back to close where open is missing."""
|
||||
if open_raw is None or open_raw.empty:
|
||||
return close.copy()
|
||||
aligned = open_raw.reindex(index=close.index, columns=close.columns).ffill()
|
||||
return aligned.fillna(close)
|
||||
"""Align quotes without manufacturing a print from a stale or later price."""
|
||||
if open_raw is None:
|
||||
return pd.DataFrame(np.nan, index=close.index, columns=close.columns)
|
||||
return open_raw.reindex(index=close.index, columns=close.columns)
|
||||
|
||||
|
||||
YAHOO_FIELDS = {"close": "Close", "open": "Open", "high": "High", "low": "Low", "volume": "Volume"}
|
||||
|
||||
325
execution.py
Normal file
325
execution.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Shared long-only order sizing and cash-funded execution for replay and trading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ENGINE_VERSION = "cash-ledger-pit-v2"
|
||||
|
||||
|
||||
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
|
||||
return cash + sum(shares * prices.get(t, 0.0) for t, shares in holdings.items())
|
||||
|
||||
|
||||
def lot_rule(ticker: str, lot_size: int) -> tuple[int, int]:
|
||||
# STAR orders start at 200 shares, then increase by one (SSE rule 6.7).
|
||||
return (1, 200) if lot_size == 100 and ticker.startswith("688") and ticker.endswith(".SS") else (lot_size, lot_size)
|
||||
|
||||
|
||||
def market_permissions(market, day, raw: pd.Series, previous: pd.Series, volume: pd.Series | None = None):
|
||||
fresh = raw.notna() & np.isfinite(raw) & (raw > 0)
|
||||
if volume is not None:
|
||||
fresh &= volume.reindex(raw.index).gt(0)
|
||||
buy, sell = fresh.copy(), fresh.copy()
|
||||
tax = 0.0
|
||||
if market == "cn":
|
||||
limits = pd.Series(.10, index=raw.index)
|
||||
for t in raw.index:
|
||||
if str(t).startswith("688") or (str(t).startswith("30") and day >= pd.Timestamp("2020-08-24")):
|
||||
limits[t] = .20
|
||||
change = raw / previous.reindex(raw.index) - 1
|
||||
# A closing print at the limit does not establish an available fill.
|
||||
buy &= ~(change >= limits - .002)
|
||||
sell &= ~(change <= -limits + .002)
|
||||
# MOF announcement 2023-39: half-rate from 2023-08-28, sells only.
|
||||
tax = .0005 if day >= pd.Timestamp("2023-08-28") else .001
|
||||
return set(raw.index[buy]), set(raw.index[sell]), tax
|
||||
|
||||
|
||||
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
|
||||
lot_size: int, reserve: float) -> dict:
|
||||
"""Whole-lot share targets: round each move toward zero, then spend the
|
||||
remaining cash one lot at a time on the names furthest below target,
|
||||
as long as the lot brings them nearer (nearest-lot rounding)."""
|
||||
final, spend = {}, 0.0
|
||||
for t, want in desired.items():
|
||||
cur = holdings.get(t, 0.0)
|
||||
step, minimum = lot_rule(t, lot_size)
|
||||
if want <= 0:
|
||||
final[t] = 0.0 # full exit, odd lot included
|
||||
else:
|
||||
delta = int((want - cur) / step) * step
|
||||
final[t] = cur + (delta if abs(delta) >= minimum else 0)
|
||||
spend += (final[t] - cur) * prices[t]
|
||||
budget = cash - spend - reserve
|
||||
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
|
||||
for gap, t in reversed(short):
|
||||
step, minimum = lot_rule(t, lot_size)
|
||||
extra = minimum if final[t] == holdings.get(t, 0) else step
|
||||
lot_value = extra * prices[t]
|
||||
if gap > 0.5 * lot_value and lot_value <= budget:
|
||||
final[t] += extra
|
||||
budget -= lot_value
|
||||
return final
|
||||
|
||||
|
||||
def compute_trades(holdings: dict, cash: float, target_weights: dict,
|
||||
prices: dict, min_trade_value: float = 50.0,
|
||||
integer_shares: bool = False, lot_size: int = 1,
|
||||
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
|
||||
"""
|
||||
Compute trades needed to move from current holdings to target weights.
|
||||
|
||||
If integer_shares=True, positions move in whole multiples of ``lot_size``
|
||||
(e.g. 100-share A-share board lots): each move is rounded toward zero and
|
||||
leftover cash then buys one more lot for the names furthest below target
|
||||
when that lot brings them nearer; a position whose target is zero is
|
||||
always sold in full, odd lot included.
|
||||
A held position with a nonzero target is not resized while it is within
|
||||
``resize_band`` (fraction of its target value) of the target.
|
||||
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
|
||||
|
||||
Returns list of {ticker, shares_delta, direction, est_value}.
|
||||
"""
|
||||
weights = np.array(list(target_weights.values()), dtype=float)
|
||||
if not np.isfinite(weights).all() or (weights < 0).any() or weights.sum() > 1 + 1e-8:
|
||||
raise ValueError("Targets must be finite, long-only and cash-funded (sum <= 1)")
|
||||
total = portfolio_value(holdings, prices, cash)
|
||||
frozen = frozen or set()
|
||||
desired = {}
|
||||
for ticker in sorted(set(target_weights) | set(holdings)):
|
||||
if ticker in frozen:
|
||||
continue
|
||||
price = prices.get(ticker, 0.0)
|
||||
if not math.isfinite(price) or price <= 0:
|
||||
continue
|
||||
target_weight = target_weights.get(ticker, 0.0)
|
||||
target_value = total * target_weight
|
||||
current_shares = holdings.get(ticker, 0.0)
|
||||
if (current_shares > 0 and target_weight > 0
|
||||
and abs(target_value - current_shares * price) < resize_band * target_value):
|
||||
continue
|
||||
desired[ticker] = target_value / price
|
||||
|
||||
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
|
||||
reserve=0.002 * total + 10.0 * len(desired))
|
||||
if integer_shares else desired)
|
||||
|
||||
raw = []
|
||||
for ticker, target_shares in targets.items():
|
||||
price = prices[ticker]
|
||||
current_shares = holdings.get(ticker, 0.0)
|
||||
delta = target_shares - current_shares
|
||||
trade_value = abs(delta * price)
|
||||
if trade_value < min_trade_value and target_shares > 0:
|
||||
continue
|
||||
if abs(delta) < 1e-10:
|
||||
continue
|
||||
if integer_shares and abs(delta) < 1:
|
||||
continue
|
||||
|
||||
raw.append({
|
||||
"ticker": ticker,
|
||||
"shares_delta": int(round(delta)) if integer_shares else delta,
|
||||
"direction": "BUY" if delta > 0 else "SELL",
|
||||
"est_value": round(trade_value, 2),
|
||||
"price": round(price, 6),
|
||||
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
|
||||
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
|
||||
})
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def _per_trade_commission(
|
||||
shares: float,
|
||||
price: float,
|
||||
tx_cost: float,
|
||||
fixed_fee: float,
|
||||
fee_base: float = 0.0,
|
||||
fee_per_share: float = 0.0,
|
||||
) -> float:
|
||||
"""Commission for one trade.
|
||||
|
||||
Matches the IBKR-style tiered formula used by the backtest engine:
|
||||
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
|
||||
With fee_base=0 and fee_per_share=0 this degenerates to the flat
|
||||
fixed-fee model (legacy behavior).
|
||||
"""
|
||||
bps_cost = abs(shares) * price * tx_cost
|
||||
per_trade = fee_base + fee_per_share * abs(shares)
|
||||
floor = max(fixed_fee, per_trade)
|
||||
return bps_cost + floor
|
||||
|
||||
|
||||
def execute_trades(state: dict, trades: list[dict], prices: dict,
|
||||
tx_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
trade_date: str = "", integer_shares: bool = False,
|
||||
lot_size: int = 1, sell_tax: float = 0.0) -> None:
|
||||
"""Execute trades: update holdings and cash in state, append to trade_log.
|
||||
|
||||
Sells release cash before buys. Every buy must fit cash after all fees,
|
||||
including fractional-share orders.
|
||||
|
||||
Per-trade commission supports both the legacy flat ``fixed_fee`` and
|
||||
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
|
||||
schedule used by the backtest engine.
|
||||
"""
|
||||
holdings = state["holdings"]
|
||||
cash = state["cash"]
|
||||
|
||||
# Sell first so buys cannot implicitly borrow against unsettled target changes.
|
||||
sells = [t for t in trades if t["shares_delta"] < 0]
|
||||
buys = [t for t in trades if t["shares_delta"] > 0]
|
||||
ordered = sells + buys
|
||||
|
||||
us_fee_per_share = fee_per_share
|
||||
for trade in ordered:
|
||||
ticker = trade["ticker"]
|
||||
delta = trade["shares_delta"]
|
||||
price = prices.get(ticker)
|
||||
if price is None or not math.isfinite(price) or price <= 0:
|
||||
continue
|
||||
if delta < 0:
|
||||
delta = -min(-delta, holdings.get(ticker, 0.0))
|
||||
if abs(delta) < 1e-10:
|
||||
continue
|
||||
cost = abs(delta * price)
|
||||
# The per-share schedule applies to US-listed shares only; HK lines
|
||||
# (often < $1/share) pay the proportional cost and the ticket floor.
|
||||
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
|
||||
commission = _per_trade_commission(
|
||||
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||||
)
|
||||
|
||||
if delta > 0:
|
||||
# BUY — resize to the cash available after fees.
|
||||
if (cost + commission) > cash:
|
||||
# Try buying fewer shares that we can afford, accounting for
|
||||
# the per-share variable component of the commission.
|
||||
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
|
||||
if affordable_price_unit <= 0:
|
||||
continue
|
||||
# Both branches of max(floor, base + per-share fee) must fit.
|
||||
affordable = min((cash - fixed_fee) / (price * (1 + tx_cost)),
|
||||
(cash - fee_base) / affordable_price_unit)
|
||||
if integer_shares:
|
||||
step, minimum = lot_rule(ticker, lot_size)
|
||||
affordable = max(0, math.floor(affordable / step)) * step
|
||||
if affordable < minimum:
|
||||
continue
|
||||
else:
|
||||
affordable = max(0.0, affordable * (1 - 1e-12))
|
||||
if affordable <= 1e-10:
|
||||
continue
|
||||
delta = affordable
|
||||
cost = abs(delta * price)
|
||||
commission = _per_trade_commission(
|
||||
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||||
)
|
||||
cash -= (cost + commission)
|
||||
holdings[ticker] = holdings.get(ticker, 0.0) + delta
|
||||
else:
|
||||
# SELL
|
||||
commission += cost * sell_tax
|
||||
if commission > cash + cost:
|
||||
continue
|
||||
cash += (cost - commission)
|
||||
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
|
||||
|
||||
# Remove zero holdings
|
||||
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 1e-10):
|
||||
del holdings[ticker]
|
||||
|
||||
state["trade_log"].append({
|
||||
"date": trade_date,
|
||||
"action": "BUY" if delta > 0 else "SELL",
|
||||
"ticker": ticker,
|
||||
"shares": abs(delta),
|
||||
"price": round(price, 6),
|
||||
"value": cost,
|
||||
"commission": commission,
|
||||
})
|
||||
|
||||
if cash < -1e-7:
|
||||
raise ValueError("Execution would create an unfunded cash balance")
|
||||
state["cash"] = max(cash, 0.0)
|
||||
if integer_shares:
|
||||
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
|
||||
else:
|
||||
state["holdings"] = {k: v for k, v in holdings.items() if abs(v) >= 1e-10}
|
||||
|
||||
|
||||
def rebalance(state: dict, targets: dict, prices: dict, fresh: set, *,
|
||||
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
min_trade_value: float = 50.0, resize_band: float = 0.10,
|
||||
integer_shares: bool = False, lot_size: int = 1,
|
||||
trade_date: str = "", can_buy: set | None = None,
|
||||
can_sell: set | None = None, sell_tax: float = 0.0) -> list[dict]:
|
||||
value = portfolio_value(state["holdings"], prices, state["cash"])
|
||||
floor = max(min_trade_value, .001 * value) if min_trade_value else 0.0
|
||||
frozen = (set(targets) | set(state["holdings"])) - fresh
|
||||
orders = compute_trades(state["holdings"], state["cash"], targets, prices,
|
||||
floor, integer_shares, lot_size, frozen, resize_band)
|
||||
orders = [o for o in orders
|
||||
if ((o["shares_delta"] > 0 and (can_buy is None or o["ticker"] in can_buy))
|
||||
or (o["shares_delta"] < 0 and (can_sell is None or o["ticker"] in can_sell)))]
|
||||
first = len(state["trade_log"])
|
||||
execute_trades(state, orders, prices, transaction_cost, fixed_fee, fee_base,
|
||||
fee_per_share, trade_date, integer_shares, lot_size, sell_tax)
|
||||
return state["trade_log"][first:]
|
||||
|
||||
|
||||
def simulate_book(targets: pd.DataFrame, prices: pd.DataFrame, capital: float,
|
||||
*, fresh_prices: pd.DataFrame | None = None,
|
||||
contributions: pd.Series | None = None,
|
||||
cash_returns: pd.Series | None = None,
|
||||
can_buy: pd.DataFrame | None = None, can_sell: pd.DataFrame | None = None,
|
||||
sell_tax: pd.Series | None = None, market: str | None = None,
|
||||
previous_prices: pd.DataFrame | None = None,
|
||||
volume: pd.DataFrame | None = None, **options):
|
||||
"""Targets are executed at their row's close; stale marks never authorize fills."""
|
||||
raw = prices if fresh_prices is None else fresh_prices.reindex_like(prices)
|
||||
marks = prices.ffill()
|
||||
previous = marks.shift(1) if previous_prices is None else previous_prices.reindex_like(prices)
|
||||
targets = targets.reindex_like(prices).fillna(0.0)
|
||||
flows = (contributions.reindex(prices.index).fillna(0.0) if contributions is not None
|
||||
else pd.Series(0.0, index=prices.index))
|
||||
rates = (cash_returns.reindex(prices.index).fillna(0.0) if cash_returns is not None
|
||||
else pd.Series(0.0, index=prices.index))
|
||||
state = {"holdings": {}, "cash": float(capital), "trade_log": []}
|
||||
equity, units, values, cash = [], [], [], []
|
||||
unit_count = float(capital)
|
||||
for i, day in enumerate(prices.index):
|
||||
quote = marks.iloc[i].dropna().to_dict()
|
||||
state["cash"] *= 1 + float(rates.iloc[i])
|
||||
before = portfolio_value(state["holdings"], quote, state["cash"])
|
||||
flow = float(flows.iloc[i])
|
||||
if flow < 0:
|
||||
raise ValueError("Only nonnegative contributions are supported")
|
||||
unit_count += flow / (before / unit_count) if before > 0 else 0.0
|
||||
state["cash"] += flow
|
||||
fresh = set(raw.columns[(raw.iloc[i].notna() & (raw.iloc[i] > 0)).to_numpy()])
|
||||
allowed_buy, allowed_sell, tax = market_permissions(market, day, raw.iloc[i], previous.iloc[i],
|
||||
volume.loc[day] if volume is not None else None)
|
||||
row = targets.iloc[i]
|
||||
rebalance(state, row[row != 0].to_dict(), quote, fresh,
|
||||
can_buy=set(can_buy.columns[can_buy.loc[day]]) if can_buy is not None else allowed_buy,
|
||||
can_sell=set(can_sell.columns[can_sell.loc[day]]) if can_sell is not None else allowed_sell,
|
||||
sell_tax=float(sell_tax.loc[day]) if sell_tax is not None else tax,
|
||||
trade_date=str(day.date()), **options)
|
||||
value = portfolio_value(state["holdings"], quote, state["cash"])
|
||||
equity.append(value)
|
||||
units.append(value / unit_count * capital)
|
||||
cash.append(state["cash"])
|
||||
values.append(before)
|
||||
return {"equity": pd.Series(equity, index=prices.index),
|
||||
"unit_equity": pd.Series(units, index=prices.index),
|
||||
"cash": pd.Series(cash, index=prices.index),
|
||||
"pretrade_equity": pd.Series(values, index=prices.index),
|
||||
"trade_log": state["trade_log"], "holdings": state["holdings"]}
|
||||
253
main.py
253
main.py
@@ -10,6 +10,8 @@ import metrics
|
||||
import universe_history as uh
|
||||
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
|
||||
from strategies.base import BarStrategy
|
||||
from signals import session_targets
|
||||
from execution import simulate_book
|
||||
from strategies.buy_and_hold import BuyAndHoldStrategy
|
||||
from strategies.dual_momentum import DualMomentumStrategy
|
||||
from strategies.inverse_vol import InverseVolatilityStrategy
|
||||
@@ -27,96 +29,40 @@ from universe import UNIVERSES
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def strategy_positions(strategy, data: pd.DataFrame, open_data: pd.DataFrame | None = None,
|
||||
bars: dict[str, pd.DataFrame] | None = None) -> pd.DataFrame:
|
||||
"""Weights actually held over (close t-1, close t] for each row t.
|
||||
|
||||
Close mode (``open_data`` is None): row t's weights use closes through t-1
|
||||
and trade at the close of t-1 — the same bar the signal saw.
|
||||
Live mode (``open_data`` given), matching trader.py:
|
||||
* close-price strategies read the session's open as their latest price
|
||||
and trade at that session's close;
|
||||
* a BarStrategy reads completed bars through t-1 and trades at the close
|
||||
of t.
|
||||
"""
|
||||
if isinstance(strategy, BarStrategy):
|
||||
weights = strategy.generate_signals(bars if bars is not None else {"close": data})
|
||||
weights = weights.reindex(data.index).fillna(0.0)
|
||||
return weights.shift(1).fillna(0.0) if open_data is not None else weights
|
||||
if open_data is not None:
|
||||
# The strategy's shift(1) on opens gives weights[t] = f(open_{t-1});
|
||||
# undo it so the row-t decision uses open_t, then hold it from close t.
|
||||
weights = strategy.generate_signals(open_data).shift(-1).fillna(0.0)
|
||||
return weights.reindex(data.index).fillna(0.0).shift(1).fillna(0.0)
|
||||
return strategy.generate_signals(data).reindex(data.index).fillna(0.0)
|
||||
bars: dict[str, pd.DataFrame] | None = None,
|
||||
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
"""Weights held over (close t-1, close t], after a feasible prior close fill."""
|
||||
if eligible is None and bars is not None:
|
||||
eligible = bars.get("eligible")
|
||||
return session_targets(strategy, data, open_data, bars, eligible).shift(1).fillna(0.0)
|
||||
|
||||
|
||||
def simulate_live_book(held: pd.DataFrame, data: pd.DataFrame, initial_capital: float,
|
||||
transaction_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
min_trade_value: float = 0.0,
|
||||
resize_band: float = 0.0) -> tuple[pd.Series, pd.DataFrame]:
|
||||
"""Day-by-day book kept the way trader.py keeps it.
|
||||
|
||||
``held`` row t holds the target weights for (close t-1, close t]. Holdings
|
||||
drift with prices; at each close the book trades toward the next row's
|
||||
targets: entries and exits always, a held name only once it is at least
|
||||
``resize_band`` (relative to its target) away. Orders smaller than
|
||||
``max(min_trade_value, 0.1% of equity)`` are skipped unless they close a
|
||||
position. Costs are charged on the orders placed.
|
||||
|
||||
Returns (equity, traded weight per ticker on the row whose holdings changed).
|
||||
"""
|
||||
targets = held.reindex(index=data.index, columns=data.columns).fillna(0.0).to_numpy()
|
||||
prices = data.ffill().to_numpy(dtype=float)
|
||||
rets = np.nan_to_num(data.ffill().pct_change(fill_method=None).to_numpy(dtype=float), nan=0.0)
|
||||
per_share = np.array([0.0 if str(c).endswith(".HK") else fee_per_share for c in data.columns])
|
||||
n, m = targets.shape
|
||||
w = np.zeros(m)
|
||||
value = float(initial_capital)
|
||||
equity = np.empty(n)
|
||||
traded = np.zeros((n, m))
|
||||
for t in range(n):
|
||||
if value <= 0:
|
||||
equity[t:] = 0.0
|
||||
break
|
||||
gross = w * (1.0 + rets[t])
|
||||
growth = gross.sum() + (1.0 - w.sum()) # idle cash earns nothing
|
||||
value *= growth
|
||||
w = gross / growth if growth > 0 else np.zeros(m)
|
||||
if t + 1 < n:
|
||||
tgt = targets[t + 1]
|
||||
new = w.copy()
|
||||
entry_exit = (tgt <= 0) | (w <= 0)
|
||||
resize = ~entry_exit & (np.abs(tgt - w) >= resize_band * tgt)
|
||||
change = (entry_exit | resize) & (np.abs(tgt - w) > 0)
|
||||
new[change] = tgt[change]
|
||||
if new.sum() > 1.0: # kept winners leave less room
|
||||
buys = change & (new > w)
|
||||
room = max(1.0 - new[~buys].sum(), 0.0)
|
||||
new[buys] *= room / new[buys].sum() if new[buys].sum() > 0 else 0.0
|
||||
delta = new - w
|
||||
floor = (max(min_trade_value, 0.001 * value) / value) if min_trade_value > 0 else 1e-12
|
||||
skip = (np.abs(delta) < floor) & (new > 0)
|
||||
delta[skip] = 0.0
|
||||
new[skip] = w[skip]
|
||||
orders = np.abs(delta) > 1e-12
|
||||
if orders.any():
|
||||
dollars = np.abs(delta) * value
|
||||
cost = dollars.sum() * transaction_cost
|
||||
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
shares = np.where(prices[t] > 0, dollars / prices[t], 0.0)
|
||||
fee = np.maximum(fixed_fee, fee_base + per_share * shares)
|
||||
cost += fee[orders].sum()
|
||||
if cost >= value: # fees ate the book
|
||||
equity[t:] = 0.0
|
||||
break
|
||||
w = new * value / (value - cost)
|
||||
value -= cost
|
||||
traded[t + 1] = delta
|
||||
equity[t] = value
|
||||
return (pd.Series(equity, index=data.index),
|
||||
pd.DataFrame(traded, index=data.index, columns=data.columns))
|
||||
min_trade_value: float = 0.0, resize_band: float = 0.0,
|
||||
*, fresh_prices: pd.DataFrame | None = None,
|
||||
integer_shares: bool = False, lot_size: int = 1):
|
||||
targets = held.reindex_like(data).shift(-1).fillna(0.0)
|
||||
# No decision after the last row is represented by `held`; preserve the book.
|
||||
result = simulate_book(targets.iloc[:-1], data.iloc[:-1], initial_capital,
|
||||
fresh_prices=fresh_prices.iloc[:-1] if fresh_prices is not None else None,
|
||||
transaction_cost=transaction_cost, fixed_fee=fixed_fee,
|
||||
fee_base=fee_base, fee_per_share=fee_per_share,
|
||||
min_trade_value=min_trade_value, resize_band=resize_band,
|
||||
integer_shares=integer_shares, lot_size=lot_size)
|
||||
traded = pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
||||
for trade in result["trade_log"]:
|
||||
i = data.index.get_loc(pd.Timestamp(trade["date"]))
|
||||
value = result["pretrade_equity"].iloc[i]
|
||||
traded.iloc[i + 1, data.columns.get_loc(trade["ticker"])] += (
|
||||
trade["value"] / value * (1 if trade["action"] == "BUY" else -1)) if value else 0.0
|
||||
marks = data.ffill().iloc[-1].dropna().to_dict()
|
||||
from execution import portfolio_value
|
||||
last = portfolio_value(result["holdings"], marks,
|
||||
float(result["cash"].iloc[-1]) if len(result["cash"]) else initial_capital)
|
||||
equity = pd.concat([result["equity"], pd.Series([last], index=data.index[-1:])])
|
||||
return equity, traded
|
||||
|
||||
|
||||
def backtest(
|
||||
@@ -133,7 +79,7 @@ def backtest(
|
||||
resize_band: float = 0.0,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
Vectorized backtest.
|
||||
Cash-funded backtest with next-session execution and drifting holdings.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -173,62 +119,8 @@ def backtest(
|
||||
Daily equity curve.
|
||||
"""
|
||||
positions = strategy_positions(strategy, data, open_data=open_data, bars=bars)
|
||||
if open_data is not None:
|
||||
equity, _ = simulate_live_book(
|
||||
positions, data, initial_capital, transaction_cost, fixed_fee, fee_base,
|
||||
fee_per_share, min_trade_value, resize_band,
|
||||
)
|
||||
return equity
|
||||
|
||||
# A missing close carries the last price (zero return), as before.
|
||||
daily_returns = data.ffill().pct_change(fill_method=None).fillna(0.0)
|
||||
portfolio_returns = (daily_returns * positions).sum(axis=1)
|
||||
|
||||
# Turnover cost: sum of absolute weight changes each day
|
||||
turnover = positions.diff().abs().sum(axis=1).fillna(0.0)
|
||||
portfolio_returns -= turnover * transaction_cost
|
||||
|
||||
# Per-trade fee. Supports both flat ($2/trade) and tiered (IBKR-style)
|
||||
# schedules: fee = max(fixed_fee, fee_base + fee_per_share * shares).
|
||||
if fixed_fee > 0 or fee_base > 0 or fee_per_share > 0:
|
||||
weight_changes = positions.diff().fillna(0.0)
|
||||
equity_running = (1 + portfolio_returns).cumprod() * initial_capital
|
||||
eq_prev = equity_running.shift(1).fillna(initial_capital)
|
||||
if min_trade_value > 0:
|
||||
threshold = np.maximum(min_trade_value, 0.001 * eq_prev.abs())
|
||||
charged = weight_changes.abs().mul(eq_prev.abs(), axis=0).ge(threshold, axis=0)
|
||||
else:
|
||||
charged = weight_changes.abs() > 1e-8
|
||||
|
||||
if fee_per_share > 0:
|
||||
# Convert per-ticker weight change into share count traded.
|
||||
# dollar_traded[i, t] = |w[i,t] - w[i,t-1]| * equity[t-1]
|
||||
# shares_traded[i, t] = dollar_traded / price[i, t]
|
||||
dollar_traded = weight_changes.abs().mul(eq_prev, axis=0)
|
||||
shares_traded = dollar_traded.div(data).replace(
|
||||
[np.inf, -np.inf], 0.0,
|
||||
).fillna(0.0)
|
||||
# The per-share schedule applies to US-listed shares only.
|
||||
per_share = pd.Series(fee_per_share, index=data.columns)
|
||||
per_share[[c for c in data.columns if str(c).endswith(".HK")]] = 0.0
|
||||
per_trade_fee = (fee_base + shares_traded.mul(per_share, axis=1)).clip(
|
||||
lower=fixed_fee,
|
||||
)
|
||||
per_trade_fee = per_trade_fee.where(charged, 0.0)
|
||||
daily_fee = per_trade_fee.sum(axis=1)
|
||||
else:
|
||||
n_trades = charged.sum(axis=1)
|
||||
daily_fee = n_trades * fixed_fee
|
||||
|
||||
fee_impact = daily_fee / eq_prev
|
||||
portfolio_returns -= fee_impact
|
||||
|
||||
equity = (1 + portfolio_returns).cumprod() * initial_capital
|
||||
# A portfolio that loses everything stays at zero; compounding negative
|
||||
# equity would otherwise produce meaningless values and metrics.
|
||||
ruined = (equity <= 0).cummax()
|
||||
if ruined.any():
|
||||
equity = equity.where(~ruined, 0.0)
|
||||
equity, _ = simulate_live_book(positions, data, initial_capital, transaction_cost,
|
||||
fixed_fee, fee_base, fee_per_share, min_trade_value, resize_band)
|
||||
return equity
|
||||
|
||||
|
||||
@@ -309,52 +201,34 @@ def main() -> None:
|
||||
"--attribution-export", default=None,
|
||||
help="Directory to export factor attribution CSVs",
|
||||
)
|
||||
parser.add_argument("--offline", action="store_true", help="Use local prices and membership without refreshing")
|
||||
args = parser.parse_args()
|
||||
initial_capital = args.capital if args.capital is not None else 10_000
|
||||
use_open = args.execution == "open-close"
|
||||
|
||||
universe = UNIVERSES[args.market]
|
||||
tickers = universe["fetch"]()
|
||||
tickers = [] if args.offline else universe["fetch"]()
|
||||
benchmark = universe["benchmark"]
|
||||
benchmark_label = universe["benchmark_label"]
|
||||
|
||||
# PIT universe: include all historical index members for US market
|
||||
pit_intervals = None
|
||||
if args.market == "us":
|
||||
pit_intervals = uh.load_sp500_history()
|
||||
historical_tickers = uh.all_tickers_ever(pit_intervals)
|
||||
all_tickers = sorted(set(tickers + historical_tickers + [benchmark]))
|
||||
print(f"--- PIT universe: {len(all_tickers)} tickers (current + historical members) ---")
|
||||
else:
|
||||
all_tickers = sorted(set(tickers + [benchmark]))
|
||||
|
||||
result = data_manager.update(args.market, all_tickers, with_open=use_open)
|
||||
if use_open:
|
||||
data, open_data = result
|
||||
else:
|
||||
data = result
|
||||
open_data = None
|
||||
|
||||
if args.years:
|
||||
cutoff = data.index[-1] - pd.DateOffset(years=args.years)
|
||||
data = data[data.index >= cutoff]
|
||||
if open_data is not None:
|
||||
open_data = open_data[open_data.index >= cutoff]
|
||||
print(f"--- Sliced to last {args.years} years: {data.index[0].date()} to {data.index[-1].date()} ---")
|
||||
|
||||
# Apply PIT mask: NaN out prices for non-member dates
|
||||
if pit_intervals is not None:
|
||||
print("--- Applying PIT membership mask (survivorship-bias fix) ---")
|
||||
data = uh.mask_prices(data, pit_intervals)
|
||||
if open_data is not None:
|
||||
open_data = uh.mask_prices(open_data, pit_intervals)
|
||||
|
||||
# Filter tickers to only those with any valid data
|
||||
if pit_intervals is not None:
|
||||
tickers = [t for t in data.columns if t != benchmark and data[t].notna().any()]
|
||||
else:
|
||||
tickers = [t for t in tickers if t in data.columns]
|
||||
print(f"--- Universe: {len(tickers)} stocks + {benchmark} benchmark ---")
|
||||
membership = uh.pit_membership(args.market, pd.DatetimeIndex([pd.Timestamp.today()]), refresh=not args.offline)
|
||||
all_tickers = sorted(set(tickers) | set(membership.columns) | {benchmark})
|
||||
fields = ["close", "open", "volume"]
|
||||
panels = (data_manager.load_fields(args.market, fields) if args.offline else
|
||||
data_manager.update(args.market, all_tickers, fields=fields))
|
||||
data = data_manager.load_raw(args.market).reindex_like(panels["close"])
|
||||
sessions = data.index[data[benchmark].notna()]
|
||||
data = data.loc[sessions]
|
||||
open_data = panels["open"].reindex_like(data) if use_open else None
|
||||
member = uh.pit_membership(args.market, data.index).reindex(columns=data.columns, fill_value=False)
|
||||
member[benchmark] = True
|
||||
tickers = sorted(t for t in member.columns if t != benchmark and member[t].any())
|
||||
if len(data) <= 315:
|
||||
raise ValueError("Need 315 pre-window trading sessions")
|
||||
requested = data.index[-1] - pd.DateOffset(years=args.years) if args.years else data.index[0]
|
||||
start = max(requested, data.index[315])
|
||||
days = data.index[data.index >= start]
|
||||
print(f"--- Scoring {days[0].date()} to {days[-1].date()}; prior history is retained ---")
|
||||
|
||||
top_n = args.top_n if args.top_n else max(5, len(tickers) // 10)
|
||||
print(f"--- Selective strategies will pick top {top_n} stocks ---")
|
||||
@@ -369,7 +243,7 @@ def main() -> None:
|
||||
|
||||
strategies = {
|
||||
# --- Original strategies ---
|
||||
"Buy & Hold (EW)": (BuyAndHoldStrategy(), data[tickers], open_tickers),
|
||||
"Buy & Hold": (BuyAndHoldStrategy(asset=benchmark), data[[benchmark]], open_data[[benchmark]] if open_data is not None else None),
|
||||
"Momentum": (MomentumStrategy(lookback=252, skip=21, top_n=top_n), data[tickers], open_tickers),
|
||||
"Inverse Volatility": (InverseVolatilityStrategy(vol_window=20), data[tickers], open_tickers),
|
||||
"Multi-Factor": (MultiFactorStrategy(tickers=tickers, benchmark=benchmark, top_n=top_n), data, open_all),
|
||||
@@ -386,12 +260,19 @@ def main() -> None:
|
||||
results: dict[str, pd.Series] = {}
|
||||
for name, (strategy, strat_data, strat_open) in strategies.items():
|
||||
print(f"\nRunning: {name}")
|
||||
results[name] = backtest(strategy, strat_data, initial_capital=initial_capital,
|
||||
fixed_fee=args.fixed_fee, open_data=strat_open)
|
||||
targets = session_targets(strategy, strat_data, strat_open,
|
||||
eligible=member.reindex_like(strat_data))
|
||||
run = simulate_book(targets.loc[days], strat_data.loc[days], initial_capital,
|
||||
previous_prices=strat_data.ffill().shift(1).loc[days],
|
||||
volume=panels["volume"].reindex(index=days, columns=strat_data.columns),
|
||||
market=args.market, integer_shares=True,
|
||||
lot_size=100 if args.market == "cn" else 1,
|
||||
fixed_fee=args.fixed_fee, min_trade_value=50, resize_band=.10)
|
||||
anchor = pd.Series([initial_capital], index=[days[0] - pd.Timedelta(days=1)])
|
||||
results[name] = pd.concat([anchor, run["equity"]])
|
||||
|
||||
# Add benchmark (normalized to same initial capital)
|
||||
bench = data[benchmark].dropna()
|
||||
results[benchmark_label] = (bench / bench.iloc[0]) * initial_capital
|
||||
bench = data[benchmark].reindex(days).ffill().dropna()
|
||||
results[benchmark_label] = pd.concat([anchor, bench / bench.iloc[0] * initial_capital])
|
||||
|
||||
results_df = pd.DataFrame(results)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest_runner import run_market
|
||||
from backtest_runner import run_market, ENGINE_VERSION
|
||||
from bridge import (
|
||||
STRATEGY_META,
|
||||
_build_strategy_result,
|
||||
@@ -102,6 +102,7 @@ def build_scenario_for_market(
|
||||
try:
|
||||
result = _build_strategy_result(
|
||||
run["name"], equity, run["trades"], injection_schedule, capital,
|
||||
execution_plan=run.get("_plan"), fee_params=run.get("_fees"),
|
||||
)
|
||||
strategy_results.append(result)
|
||||
except Exception as e:
|
||||
@@ -124,13 +125,18 @@ def build_scenario_for_market(
|
||||
return {
|
||||
"meta": {
|
||||
"market": raw["market"],
|
||||
"years": years,
|
||||
"years": raw.get("actual_years", years),
|
||||
"requestedYears": years,
|
||||
"initialCapital": capital,
|
||||
"startDate": raw["start_date"],
|
||||
"endDate": raw["end_date"],
|
||||
"txCost": raw["tx_cost"],
|
||||
"fixedFee": raw["fixed_fee"],
|
||||
"injectionSchedule": injection_schedule,
|
||||
"engineVersion": raw.get("engine_version"),
|
||||
"warnings": raw.get("warnings", []),
|
||||
"coverage": raw.get("coverage"),
|
||||
"errors": raw.get("errors", []),
|
||||
},
|
||||
"strategies": strategy_results,
|
||||
"benchmark": {
|
||||
@@ -150,6 +156,7 @@ def main():
|
||||
"--years", type=int, default=10,
|
||||
help="Backtest period in years (default: 10)",
|
||||
)
|
||||
parser.add_argument("--offline", action="store_true", help="Use the frozen local data snapshot")
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = args.output or os.path.join(
|
||||
@@ -182,7 +189,7 @@ def main():
|
||||
"us", us_strategies,
|
||||
years=args.years, capital=us_capital,
|
||||
tx_cost=0.001, fixed_fee=2.0,
|
||||
fee_base=1.88, fee_per_share=0.009,
|
||||
fee_base=1.88, fee_per_share=0.009, refresh=not args.offline,
|
||||
)
|
||||
|
||||
log("=" * 60)
|
||||
@@ -191,7 +198,7 @@ def main():
|
||||
cn_raw = run_market(
|
||||
"cn", cn_strategies,
|
||||
years=args.years, capital=cn_capital,
|
||||
tx_cost=0.001, fixed_fee=5.0,
|
||||
tx_cost=0.001, fixed_fee=5.0, refresh=not args.offline,
|
||||
)
|
||||
|
||||
# Materialize every scenario × market by applying its injection schedule.
|
||||
@@ -218,13 +225,14 @@ def main():
|
||||
|
||||
output = {
|
||||
"computedAt": datetime.now().isoformat(),
|
||||
"config": {"years": args.years, "initialCapital": MARKET_CAPITAL},
|
||||
"config": {"years": args.years, "initialCapital": MARKET_CAPITAL, "engineVersion": ENGINE_VERSION},
|
||||
"scenarios": scenarios_out,
|
||||
}
|
||||
|
||||
output_path = os.path.join(output_dir, "backtest_results.json")
|
||||
with open(output_path, "w") as f:
|
||||
with open(output_path + ".tmp", "w") as f:
|
||||
json.dump(_sanitize_for_json(output), f, default=str)
|
||||
os.replace(output_path + ".tmp", output_path)
|
||||
|
||||
file_size = os.path.getsize(output_path) / (1024 * 1024)
|
||||
log("=" * 60)
|
||||
|
||||
@@ -20,6 +20,7 @@ from research.lab.engine import Costs, simulate
|
||||
from research.lab.factor_scan import RESULTS, load
|
||||
from research.lab.report import deflated_sharpe, stats, yearly
|
||||
from research.lab.stepwise import SUBPERIODS
|
||||
from research.lab.trials import count_trials
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
@@ -81,7 +82,15 @@ def main() -> None:
|
||||
ap.add_argument("--market", default="us")
|
||||
ap.add_argument("--add", nargs="*", default=[], help="extra 'name=combo|top|every|buffer'")
|
||||
ap.add_argument("--n-trials", type=int, default=0, help="override trial count for DSR")
|
||||
ap.add_argument("--registered", nargs="+", help="Validate registered strategies using the production ledger")
|
||||
ap.add_argument("--start")
|
||||
ap.add_argument("--end")
|
||||
ap.add_argument("--output")
|
||||
args = ap.parse_args()
|
||||
if args.registered:
|
||||
from research.lab.registered import evaluate_registered
|
||||
evaluate_registered(args)
|
||||
return
|
||||
mk = "us" if args.market == "us" else "cn"
|
||||
p, _ = load(args.market)
|
||||
cands = dict(CANDIDATES[mk])
|
||||
@@ -89,8 +98,9 @@ def main() -> None:
|
||||
name, rest = spec.split("=", 1)
|
||||
combo, top, every, buf = rest.split("|")
|
||||
cands[name] = (combo, dict(top_n=int(top), every=int(every), buffer=float(buf)))
|
||||
trials_path = os.path.join(RESULTS, f"trials_{args.market}.csv")
|
||||
n_trials = args.n_trials or (len(pd.read_csv(trials_path)) if os.path.exists(trials_path) else 100)
|
||||
n_trials = args.n_trials or count_trials(RESULTS, args.market)
|
||||
if n_trials < 1:
|
||||
raise ValueError("DSR needs a trial ledger or an explicit --n-trials count")
|
||||
full = (SUBPERIODS[mk][0][0], WINDOW[1])
|
||||
out = {}
|
||||
fee, cap = LIVE_FEES[mk]
|
||||
@@ -115,7 +125,8 @@ def main() -> None:
|
||||
expo = (0.5 + 0.25 * slow + 0.25 * fast).where(slow + fast < 2, 1.0)
|
||||
res["window_trend_overlay"] = summarize(phase_runs(p, s, *WINDOW, exposure=expo, **kw), *WINDOW)
|
||||
dsr = deflated_sharpe(res["full"]["_rets"], n_trials)
|
||||
res["dsr_full"] = dsr
|
||||
res["dsr_full"] = {**dsr, "observed_trials_lower_bound": n_trials,
|
||||
"evidence_role": "development, not independent OOS"}
|
||||
out[name] = res
|
||||
w, f = res["window"], res["full"]
|
||||
print(f"{name:42s} 2016-26: CAGR {w['cagr']*100:5.1f}% [{w['cagr_phase_min']*100:4.1f},{w['cagr_phase_max']*100:4.1f}] "
|
||||
|
||||
111
research/lab/registered.py
Normal file
111
research/lab/registered.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Fixed-budget validation of registered strategies on the shared execution engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import metrics
|
||||
import data_manager
|
||||
from backtest_runner import (ENGINE_VERSION, load_stock_panels, prepare_strategy, replay_prepared)
|
||||
from trader import make_strategy
|
||||
from strategies.robust_momentum import RobustMomentumStrategy
|
||||
from research.lab.trials import append_trials
|
||||
|
||||
|
||||
def _stats(equity):
|
||||
return {k: v for k, v in metrics.raw_summary(equity).items()
|
||||
if k in ('annualizedReturn', 'maxDrawdown', 'sharpeRatio', 'annualizedVolatility')}
|
||||
|
||||
|
||||
def evaluate_registered(args):
|
||||
market = 'cn' if args.market.startswith('cn') else 'us'
|
||||
destination = Path(args.output or f'data/research/results/registered_{market}')
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
panels = load_stock_panels(market, args.registered, None, refresh=False)
|
||||
end = args.end or str(panels['panels']['close'].index[-1].date())
|
||||
start = args.start or '2018-01-01'
|
||||
profiles = ['baseline', 'resize20', 'buffer3'] + (['cash_shy'] if market == 'us' else [])
|
||||
protocol = {
|
||||
'created_at': datetime.now(timezone.utc).isoformat(), 'engine': ENGINE_VERSION,
|
||||
'market': market, 'strategies': args.registered, 'start': start, 'end': end,
|
||||
'profiles': profiles, 'one_way_costs': [.001, .0025], 'capital': 10000 if market == 'us' else 70000,
|
||||
'selection_rule': 'CAGR improves at both costs; drawdown deterioration <= 2pp; improves in >=2/3 time blocks',
|
||||
'role': 'historical development comparison; not independent OOS; do not auto-promote',
|
||||
'source_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in [Path('execution.py'), Path('signals.py'), Path('backtest_runner.py')]
|
||||
+ sorted(Path('strategies').glob('*.py'))},
|
||||
'data_hashes': {str(p): hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in [Path('data') / f'{market}.csv', Path('data') / f'{market}_open.csv',
|
||||
Path('data/sp500_history.json' if market == 'us' else 'data/csi300_history.json')]},
|
||||
}
|
||||
protocol_path = destination / 'protocol.json'
|
||||
if protocol_path.exists():
|
||||
raise FileExistsError(f'Choose a new --output to preserve the completed study: {destination}')
|
||||
protocol_path.write_text(json.dumps(protocol, indent=2))
|
||||
rows, verdicts = [], []
|
||||
fees = {'tx_cost': .001, 'fixed_fee': 2 if market == 'us' else 5,
|
||||
'fee_base': 1.88 if market == 'us' else 0, 'fee_per_share': .009 if market == 'us' else 0}
|
||||
for name in args.registered:
|
||||
base_strategy = make_strategy(name, market)
|
||||
base_plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
|
||||
start=start, end=end, strategy=base_strategy)
|
||||
if base_plan['warmup_rows'] < 315:
|
||||
raise ValueError('Registered comparison requires >=315 pre-window sessions; move --start or add history')
|
||||
dates = base_plan['prices'].index
|
||||
cuts = [0, len(dates)//3, 2*len(dates)//3, len(dates)]
|
||||
for profile in profiles:
|
||||
plan, band = base_plan, .1
|
||||
if profile == 'resize20':
|
||||
band = .2
|
||||
elif profile == 'buffer3':
|
||||
if not isinstance(base_strategy, RobustMomentumStrategy):
|
||||
continue
|
||||
changed = copy.deepcopy(base_strategy); changed.buffer = 3.0
|
||||
plan = prepare_strategy(name, market, panels['panels'], panels['stocks'], member=panels['member'],
|
||||
start=start, end=end, strategy=changed)
|
||||
elif profile == 'cash_shy':
|
||||
if 'SHY' not in panels['panels']['close']:
|
||||
raise ValueError('SHY bars are required for a traded cash sleeve')
|
||||
plan = dict(base_plan)
|
||||
plan['prices'] = base_plan['prices'].copy()
|
||||
plan['prices']['SHY'] = panels['panels']['close']['SHY'].reindex(dates)
|
||||
if base_plan.get('volume') is not None:
|
||||
plan['volume'] = base_plan['volume'].copy()
|
||||
plan['volume']['SHY'] = panels['panels']['volume']['SHY'].reindex(dates)
|
||||
target = base_plan['targets'].reindex(columns=plan['prices'].columns, fill_value=0).copy()
|
||||
target['SHY'] += (1-target.sum(axis=1)).clip(lower=0)
|
||||
plan['targets'] = target
|
||||
for cost in protocol['one_way_costs']:
|
||||
run = replay_prepared(plan, protocol['capital'], **{**fees, 'tx_cost': cost}, resize_band=band)
|
||||
row = {'strategy': name, 'profile': profile, 'cost': cost, **_stats(run['equity']),
|
||||
'orders': len(run['trade_log']), 'fees': sum(t['commission'] for t in run['trade_log']),
|
||||
'minimum_cash': float(run['cash'].min()), 'blocks': []}
|
||||
for a, b in zip(cuts[:-1], cuts[1:]):
|
||||
row['blocks'].append(_stats(run['equity'].iloc[a:b+1]))
|
||||
rows.append(row)
|
||||
append_trials(destination/'trials.jsonl', [row], protocol)
|
||||
print(name, profile, cost, f"CAGR {row['annualizedReturn']:.2%}",
|
||||
f"DD {row['maxDrawdown']:.2%}", flush=True)
|
||||
baseline = {r['cost']: r for r in rows if r['strategy'] == name and r['profile'] == 'baseline'}
|
||||
for profile in profiles[1:]:
|
||||
candidates = [r for r in rows if r['strategy'] == name and r['profile'] == profile]
|
||||
if not candidates:
|
||||
continue
|
||||
passed = all(r['annualizedReturn'] > baseline[r['cost']]['annualizedReturn']
|
||||
and r['maxDrawdown'] >= baseline[r['cost']]['maxDrawdown']-.02
|
||||
and sum(a['annualizedReturn'] > b['annualizedReturn']
|
||||
for a,b in zip(r['blocks'],baseline[r['cost']]['blocks'])) >= 2
|
||||
for r in candidates)
|
||||
verdicts.append({'strategy': name, 'profile': profile,
|
||||
'verdict': 'candidate_for_forward_only' if passed else 'retain_baseline'})
|
||||
summary = {'protocol': protocol, 'comparisons': rows, 'decisions': verdicts,
|
||||
'boundary': 'Missing historical constituents and adjusted-share execution remain limitations.'}
|
||||
(destination/'results.json').write_text(json.dumps(summary, indent=2, allow_nan=False))
|
||||
pd.DataFrame([{k:v for k,v in r.items() if k!='blocks'} for r in rows]).to_csv(destination/'results.csv',index=False)
|
||||
print('Saved',destination/'results.json',flush=True)
|
||||
return summary
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Forward stepwise selection of equal-weight rank composites, scored on the
|
||||
DESIGN period only; the holdout is evaluated once for the final picks.
|
||||
DESIGN period only; historical validation is reported separately. Previously inspected history
|
||||
is not a fresh out-of-sample test.
|
||||
|
||||
PYTHONPATH=. uv run python -m research.lab.stepwise --market us --max-k 4
|
||||
|
||||
Every evaluated composite is appended to the trial ledger
|
||||
(data/research/results/trials_{market}.csv) so deflated Sharpe can count them.
|
||||
(data/research/results/trials_{market}.jsonl) so deflated Sharpe can count them.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -18,6 +19,7 @@ import pandas as pd
|
||||
|
||||
from research.lab.factor_scan import RESULTS, load, phase_avg
|
||||
from research.lab.factors import FACTORS, zrank
|
||||
from research.lab.trials import append_trials
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
@@ -55,7 +57,8 @@ def main() -> None:
|
||||
print(f"{len(names)} factor ranks in {time.time() - t:.0f}s", flush=True)
|
||||
a, b = periods["design"]
|
||||
ledger = []
|
||||
subs = SUBPERIODS[mk]
|
||||
subs = [(max(sa, a), min(sb, b)) for sa, sb in SUBPERIODS[mk]
|
||||
if max(sa, a) <= min(sb, b)]
|
||||
|
||||
def evaluate(combo: tuple[str, ...]) -> dict:
|
||||
s = sum(ranks[n] for n in combo) / len(combo)
|
||||
@@ -93,23 +96,25 @@ def main() -> None:
|
||||
best_overall += ranked[:args.beam]
|
||||
|
||||
if args.objective == "maximin":
|
||||
_save(ledger, args.market)
|
||||
_save(ledger, args.market, {"selection_start": a, "selection_end": b,
|
||||
"role": "development", "config": vars(args)})
|
||||
return
|
||||
# holdout, once, for the beam winners of each k
|
||||
print("=== holdout of design winners ===")
|
||||
print("=== historical validation of design winners (previously inspected data) ===")
|
||||
ha, hb = periods["holdout"]
|
||||
for c, st in best_overall:
|
||||
s = sum(ranks[n] for n in c) / len(c)
|
||||
hs = phase_avg(p, s, ha, hb, args.top_n, args.every)
|
||||
print(f" {'+'.join(c):55s} D sh {st['sharpe']:.2f} cagr {st['cagr']*100:5.1f}% | "
|
||||
f"H sh {hs['sharpe']:.2f} cagr {hs['cagr']*100:5.1f}% mdd {hs['mdd']*100:5.1f}%", flush=True)
|
||||
_save(ledger, args.market)
|
||||
_save(ledger, args.market, {"selection_start": a, "selection_end": b,
|
||||
"role": "development", "config": vars(args)})
|
||||
|
||||
|
||||
def _save(ledger: list[dict], market: str) -> None:
|
||||
def _save(ledger: list[dict], market: str, protocol: dict) -> None:
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
path = os.path.join(RESULTS, f"trials_{market}.csv")
|
||||
pd.DataFrame(ledger).to_csv(path, mode="a", header=not os.path.exists(path), index=False)
|
||||
path = os.path.join(RESULTS, f"trials_{market}.jsonl")
|
||||
append_trials(path, ledger, protocol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
45
research/lab/trials.py
Normal file
45
research/lab/trials.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Append-only trial records; legacy CSVs are read without discarding wide rows."""
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import fcntl
|
||||
|
||||
|
||||
def append_trials(path, rows, protocol):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
created = datetime.now(timezone.utc).isoformat()
|
||||
with path.open('a') as fh:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX)
|
||||
for row in rows:
|
||||
record = {'schema': 1, 'created_at': created, 'protocol': protocol, 'result': row}
|
||||
fh.write(json.dumps(record, allow_nan=False, default=float) + '\n')
|
||||
|
||||
|
||||
def count_trials(directory, market):
|
||||
"""Observed trials are a lower bound; unrecorded searches remain unknown."""
|
||||
total = 0
|
||||
paths = [Path(directory) / f'trials_{market}.csv', Path(directory) / f'trials_{market}.jsonl']
|
||||
if market == 'us':
|
||||
paths += [Path(directory) / 'trials_us_sweep.csv', Path(directory) / 'trials_us_sweep.jsonl']
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
with path.open() as fh:
|
||||
if path.suffix == '.jsonl':
|
||||
for line in fh:
|
||||
if line.strip():
|
||||
record = json.loads(line)
|
||||
if record.get('schema') != 1:
|
||||
raise ValueError(f'Unknown trial schema: {path}')
|
||||
total += 1
|
||||
else:
|
||||
rows = csv.reader(fh)
|
||||
next(rows, None)
|
||||
for row in rows:
|
||||
if row:
|
||||
if len(row) < 2:
|
||||
raise ValueError(f'Incomplete trial row: {path}')
|
||||
total += 1
|
||||
return total
|
||||
@@ -130,16 +130,13 @@ def main() -> None:
|
||||
print(line(f" {c} 2006-", describe(rr[c].dropna())))
|
||||
|
||||
# deflated Sharpe: every configuration this and the previous round tried
|
||||
n = 0
|
||||
for f in ("trials_us.csv", "trials_us_sweep.csv"):
|
||||
path = os.path.join(RESULTS, f)
|
||||
if os.path.exists(path):
|
||||
with open(path) as fh:
|
||||
n += sum(1 for line in fh if not line.startswith(("combo,", "score,")))
|
||||
n += 12 + 60 + 20 # rotation schemes, fund_eval variants, defensive variants
|
||||
from research.lab.trials import count_trials
|
||||
n = count_trials(RESULTS, "us")
|
||||
if n < 1:
|
||||
raise ValueError("A trial ledger is required for DSR")
|
||||
r = monthly_blend(R.loc[SPAN[0]:SPAN[1]].fillna(0.0), three)
|
||||
dsr = deflated_sharpe(r, n)
|
||||
out["dsr"] = {"n_trials": n, **{k: float(v) for k, v in dsr.items()}}
|
||||
out["dsr"] = {"observed_trials_lower_bound": n, "role": "development", **{k: float(v) for k, v in dsr.items()}}
|
||||
print(f"\nDSR of the 3-sleeve book: {dsr['dsr']:.3f} (Sharpe {dsr['sr_annual']:.2f} vs "
|
||||
f"expected max of {n} unskilled trials {dsr['sr0_annual']:.2f})")
|
||||
print("\nyearly returns (3 sleeves vs SPY):")
|
||||
|
||||
@@ -5,7 +5,7 @@ rules (PIT S&P 500 members, next-open fills, 10 bp costs, phase averaging).
|
||||
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part construction
|
||||
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part overlays
|
||||
|
||||
Every configuration is appended to data/research/results/trials_us_sweep.csv
|
||||
Every configuration is appended to data/research/results/trials_us_sweep.jsonl
|
||||
so the deflated Sharpe of the final pick can count them.
|
||||
"""
|
||||
|
||||
@@ -100,11 +100,10 @@ def fmt(name, st) -> str:
|
||||
|
||||
|
||||
def log_trials(rows, part):
|
||||
os.makedirs(RESULTS, exist_ok=True)
|
||||
path = os.path.join(RESULTS, "trials_us_sweep.csv")
|
||||
df = pd.DataFrame([{k: v for k, v in r.items() if not k.startswith("_") and k != "blocks"} for r in rows])
|
||||
df["part"] = part
|
||||
df.to_csv(path, mode="a", header=not os.path.exists(path), index=False)
|
||||
from research.lab.trials import append_trials
|
||||
records = [{k: v for k, v in r.items() if not k.startswith("_") and k != "blocks"} for r in rows]
|
||||
append_trials(os.path.join(RESULTS, "trials_us_sweep.jsonl"), records,
|
||||
{"part": part, "role": "development; historical data already examined"})
|
||||
|
||||
|
||||
def construction(p, S) -> list[dict]:
|
||||
|
||||
44
signals.py
Normal file
44
signals.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""One timing and point-in-time eligibility contract for every execution path."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from strategies.base import BarStrategy
|
||||
|
||||
|
||||
def session_targets(strategy, close: pd.DataFrame, open_data: pd.DataFrame | None = None,
|
||||
bars: dict[str, pd.DataFrame] | None = None,
|
||||
eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
if close.empty:
|
||||
return pd.DataFrame(0.0, index=close.index, columns=close.columns)
|
||||
member = (eligible.reindex_like(close).fillna(False).astype(bool) if eligible is not None
|
||||
else pd.DataFrame(True, index=close.index, columns=close.columns))
|
||||
if isinstance(strategy, BarStrategy):
|
||||
inputs = dict(bars if bars is not None else {"close": close})
|
||||
inputs["eligible"] = member & inputs["close"].notna()
|
||||
weights = strategy.generate_signals(inputs)
|
||||
known = member.shift(1, fill_value=False)
|
||||
else:
|
||||
# Price history remains observable before index admission. Membership
|
||||
# restricts cross-sectional selection and the eventual execution targets.
|
||||
inputs = close if open_data is None else open_data.reindex_like(close)
|
||||
native = getattr(strategy, "accepts_eligibility", False)
|
||||
kwargs = {"eligible": member} if native else {}
|
||||
inputs = inputs if native else inputs.where(member)
|
||||
if open_data is not None:
|
||||
nxt = inputs.index[-1] + pd.Timedelta(days=1)
|
||||
padded = pd.concat([inputs, inputs.iloc[[-1]].set_axis([nxt], axis=0)])
|
||||
if native:
|
||||
kwargs["eligible"] = pd.concat([member, member.iloc[[-1]].set_axis([nxt], axis=0)])
|
||||
weights = strategy.generate_signals(padded, **kwargs).shift(-1).reindex(close.index)
|
||||
known = member
|
||||
else:
|
||||
weights = strategy.generate_signals(inputs, **kwargs)
|
||||
known = member.shift(1, fill_value=False)
|
||||
weights = weights.reindex(index=close.index, columns=close.columns).fillna(0.0)
|
||||
weights = weights.where(known & member, 0.0)
|
||||
arr = weights.to_numpy()
|
||||
if (not np.isfinite(arr).all() or (arr < -1e-10).any()
|
||||
or (weights.sum(axis=1) > 1 + 1e-8).any()):
|
||||
raise ValueError("Strategy produced non-finite, short or unfunded targets")
|
||||
return weights.clip(lower=0.0)
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class AdaptiveMomentumStrategy(Strategy):
|
||||
class AdaptiveMomentumStrategy(StockStrategy):
|
||||
"""
|
||||
Momentum with dynamic position sizing via inverse-volatility weighting.
|
||||
|
||||
@@ -25,18 +25,19 @@ class AdaptiveMomentumStrategy(Strategy):
|
||||
self.vol_window = vol_window
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Momentum ranking
|
||||
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip)
|
||||
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip, fill_method=None)
|
||||
|
||||
momentum = mask_scores(momentum, eligible)
|
||||
n_valid = momentum.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
|
||||
score_rank = momentum.rank(axis=1, ascending=False, na_option="bottom")
|
||||
score_rank = mask_scores(momentum, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
# Inverse-vol weighting among selected stocks
|
||||
returns = data.pct_change()
|
||||
returns = data.pct_change(fill_method=None)
|
||||
vol = returns.rolling(self.vol_window).std().replace(0, np.nan)
|
||||
inv_vol = (1.0 / vol).where(top_mask, 0.0)
|
||||
|
||||
@@ -44,7 +45,7 @@ class AdaptiveMomentumStrategy(Strategy):
|
||||
signals = inv_vol.div(row_sums, axis=0).fillna(0.0)
|
||||
|
||||
# Regime scaling: compare current market vol to its 1-year median
|
||||
market_vol = vol.mean(axis=1)
|
||||
market_vol = mask_scores(vol, eligible).mean(axis=1)
|
||||
vol_median = market_vol.rolling(252).median()
|
||||
vol_scale = (vol_median / market_vol).clip(0.3, 1.5)
|
||||
signals = signals.mul(vol_scale, axis=0)
|
||||
|
||||
@@ -121,6 +121,7 @@ class AgentReviewStrategy(BarStrategy):
|
||||
eligible = close.notna()
|
||||
if "eligible" in bars:
|
||||
eligible &= bars["eligible"].reindex_like(close).fillna(False).astype(bool)
|
||||
panel["eligible"] = eligible
|
||||
return self._score(panel).where(eligible)
|
||||
|
||||
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
|
||||
@@ -20,6 +20,14 @@ class Strategy(ABC):
|
||||
pass
|
||||
|
||||
|
||||
def mask_scores(scores: pd.DataFrame, eligible: pd.DataFrame | None) -> pd.DataFrame:
|
||||
return scores if eligible is None else scores.where(eligible.reindex_like(scores).fillna(False))
|
||||
|
||||
|
||||
class StockStrategy(Strategy):
|
||||
accepts_eligibility = True
|
||||
|
||||
|
||||
class BarStrategy(Strategy):
|
||||
"""Strategy on completed daily bars with several fields.
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
class BuyAndHoldStrategy(Strategy):
|
||||
"""
|
||||
A simple buy and hold strategy.
|
||||
"""
|
||||
|
||||
def generate_signals(self, data):
|
||||
"""
|
||||
Generates equal weights for all assets.
|
||||
"""
|
||||
tickers = data.columns
|
||||
weights = pd.DataFrame(1 / len(tickers), index=data.index, columns=tickers)
|
||||
class BuyAndHoldStrategy(StockStrategy):
|
||||
def __init__(self, asset: str | None = None):
|
||||
self.asset = asset
|
||||
|
||||
def generate_signals(self, data, eligible=None):
|
||||
if self.asset is not None:
|
||||
if self.asset not in data:
|
||||
raise ValueError(f"Missing buy-and-hold benchmark: {self.asset}")
|
||||
weights = pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
||||
weights[self.asset] = data[self.asset].notna().astype(float)
|
||||
else:
|
||||
# A fixed initial basket; new listings never enter retrospectively.
|
||||
first = data.iloc[0]
|
||||
values = data.ffill().div(first).where(first.notna(), 0.0).fillna(0.0)
|
||||
weights = values.div(values.sum(axis=1).replace(0, float('nan')), axis=0).fillna(0.0)
|
||||
return weights.shift(1).fillna(0.0)
|
||||
|
||||
@@ -15,10 +15,10 @@ With:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class CompositeAlphaStrategy(Strategy):
|
||||
class CompositeAlphaStrategy(StockStrategy):
|
||||
"""
|
||||
Multi-factor alpha composite with regime gating.
|
||||
"""
|
||||
@@ -49,7 +49,9 @@ class CompositeAlphaStrategy(Strategy):
|
||||
self.regime_gate = regime_gate
|
||||
self.regime_ma = regime_ma
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
if self.regime_gate and self.benchmark not in data.columns:
|
||||
raise ValueError(f"Missing regime benchmark: {self.benchmark}")
|
||||
# Separate benchmark from stocks if needed
|
||||
if self.tickers is not None:
|
||||
stock_data = data[[t for t in self.tickers if t in data.columns]]
|
||||
@@ -63,17 +65,17 @@ class CompositeAlphaStrategy(Strategy):
|
||||
).min() - 1
|
||||
|
||||
# --- Factor 2: Intermediate momentum (7-month, skip 1 month) ---
|
||||
int_mom = stock_data.shift(self.skip).pct_change(self.intermediate_period - self.skip)
|
||||
int_mom = stock_data.shift(self.skip).pct_change(self.intermediate_period - self.skip, fill_method=None)
|
||||
|
||||
# --- Factor 3: Quality composite ---
|
||||
# Consistency: fraction of positive 21-day returns
|
||||
monthly_ret = stock_data.pct_change(21)
|
||||
monthly_ret = stock_data.pct_change(21, fill_method=None)
|
||||
consistency = (monthly_ret > 0).astype(float).rolling(
|
||||
self.quality_window, min_periods=self.quality_window // 2
|
||||
).mean()
|
||||
|
||||
# Up-volume proxy: sum of positive daily returns over 20 days
|
||||
daily_ret = stock_data.pct_change()
|
||||
daily_ret = stock_data.pct_change(fill_method=None)
|
||||
up_vol_proxy = daily_ret.where(daily_ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
|
||||
# --- Factor 4: Above MA200 (per-stock trend filter) ---
|
||||
@@ -81,10 +83,10 @@ class CompositeAlphaStrategy(Strategy):
|
||||
above_ma = (stock_data > ma200)
|
||||
|
||||
# --- Cross-sectional ranks ---
|
||||
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = int_mom.rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
|
||||
upv_rank = up_vol_proxy.rank(axis=1, pct=True, na_option="keep")
|
||||
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(int_mom, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
upv_rank = mask_scores(up_vol_proxy, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
# Composite: recovery 30%, int_momentum 30%, consistency 20%, up_volume 20%
|
||||
composite = (0.30 * rec_rank + 0.30 * mom_rank +
|
||||
@@ -94,7 +96,8 @@ class CompositeAlphaStrategy(Strategy):
|
||||
composite = composite.where(above_ma, np.nan)
|
||||
|
||||
# --- Select top_n ---
|
||||
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
composite = mask_scores(composite, eligible)
|
||||
n_valid = composite.notna().sum(axis=1)
|
||||
enough = n_valid >= min(self.top_n, 5)
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class DualMomentumStrategy(Strategy):
|
||||
class DualMomentumStrategy(StockStrategy):
|
||||
"""
|
||||
Dual momentum with multi-timeframe confirmation.
|
||||
|
||||
@@ -19,29 +19,31 @@ class DualMomentumStrategy(Strategy):
|
||||
def __init__(self, top_n: int = 20):
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Three timeframe returns (each skipping most recent 5 days)
|
||||
skip = 5
|
||||
short_mom = data.shift(skip).pct_change(63 - skip) # ~3 month
|
||||
med_mom = data.shift(skip).pct_change(126 - skip) # ~6 month
|
||||
long_mom = data.shift(skip).pct_change(252 - skip) # ~12 month
|
||||
short_mom = data.shift(skip).pct_change(63 - skip, fill_method=None) # ~3 month
|
||||
med_mom = data.shift(skip).pct_change(126 - skip, fill_method=None) # ~6 month
|
||||
long_mom = data.shift(skip).pct_change(252 - skip, fill_method=None) # ~12 month
|
||||
|
||||
# All three must be positive (absolute momentum across timeframes)
|
||||
all_positive = (short_mom > 0) & (med_mom > 0) & (long_mom > 0)
|
||||
|
||||
# Composite score: average percentile rank across timeframes
|
||||
short_rank = short_mom.rank(axis=1, pct=True, na_option="keep")
|
||||
med_rank = med_mom.rank(axis=1, pct=True, na_option="keep")
|
||||
long_rank = long_mom.rank(axis=1, pct=True, na_option="keep")
|
||||
short_rank = mask_scores(short_mom, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
med_rank = mask_scores(med_mom, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
long_rank = mask_scores(long_mom, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
composite = (short_rank + med_rank + long_rank) / 3
|
||||
|
||||
# Only consider stocks passing absolute filter
|
||||
composite_filtered = composite.where(all_positive, np.nan)
|
||||
|
||||
composite_filtered = composite_filtered.where(data.notna())
|
||||
composite_filtered = mask_scores(composite_filtered, eligible)
|
||||
n_valid = composite_filtered.notna().sum(axis=1)
|
||||
enough = n_valid >= 1
|
||||
|
||||
rank = composite_filtered.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(composite_filtered, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
effective_n = n_valid.clip(upper=self.top_n)
|
||||
top_mask = (rank <= effective_n.values.reshape(-1, 1)) & enough.values.reshape(-1, 1)
|
||||
top_mask = top_mask & all_positive
|
||||
|
||||
@@ -11,10 +11,10 @@ Improvements over base RecoveryMomentumStrategy:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class EnhancedRecoveryMomentumStrategy(Strategy):
|
||||
class EnhancedRecoveryMomentumStrategy(StockStrategy):
|
||||
"""
|
||||
Recovery + momentum with inverse-vol weighting and regime awareness.
|
||||
"""
|
||||
@@ -39,34 +39,35 @@ class EnhancedRecoveryMomentumStrategy(Strategy):
|
||||
self.top_n = top_n
|
||||
self.regime_scale = regime_scale
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Factor 1: Recovery — price / rolling min
|
||||
recovery = data / data.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
|
||||
|
||||
# Factor 2: 12-1 month momentum
|
||||
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
|
||||
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
|
||||
|
||||
# Factor 3: Intermediate momentum (6m) as a filter gate
|
||||
# Only consider stocks with positive intermediate trend
|
||||
intermediate = data.shift(self.mom_skip).pct_change(self.intermediate_mom - self.mom_skip)
|
||||
intermediate = data.shift(self.mom_skip).pct_change(self.intermediate_mom - self.mom_skip, fill_method=None)
|
||||
trend_gate = intermediate > 0
|
||||
|
||||
# Cross-sectional percentile ranks
|
||||
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
||||
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
# Composite score (50/50), gated by intermediate trend
|
||||
composite = 0.5 * rec_rank + 0.5 * mom_rank
|
||||
composite = composite.where(trend_gate, np.nan)
|
||||
|
||||
# Select top_n
|
||||
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
composite = mask_scores(composite, eligible)
|
||||
n_valid = composite.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
# Inverse-vol weighting among selected stocks
|
||||
returns = data.pct_change()
|
||||
returns = data.pct_change(fill_method=None)
|
||||
vol = returns.rolling(self.vol_window, min_periods=30).std().replace(0, np.nan)
|
||||
inv_vol = (1.0 / vol).where(top_mask, 0.0)
|
||||
|
||||
@@ -75,7 +76,7 @@ class EnhancedRecoveryMomentumStrategy(Strategy):
|
||||
|
||||
# Regime scaling: reduce exposure when market vol is high
|
||||
if self.regime_scale:
|
||||
market_vol = vol.mean(axis=1)
|
||||
market_vol = mask_scores(vol, eligible).mean(axis=1)
|
||||
vol_median = market_vol.rolling(252, min_periods=126).median()
|
||||
vol_ratio = (vol_median / market_vol).clip(0.5, 1.2)
|
||||
signals = signals.mul(vol_ratio, axis=0)
|
||||
|
||||
@@ -19,14 +19,14 @@ New approach:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
def _rank(df):
|
||||
return df.rank(axis=1, pct=True, na_option="keep")
|
||||
def _rank(df, eligible=None):
|
||||
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
|
||||
class EnsembleAlphaStrategy(Strategy):
|
||||
class EnsembleAlphaStrategy(StockStrategy):
|
||||
"""
|
||||
Ensemble of the two strongest signals with tail-risk protection.
|
||||
"""
|
||||
@@ -45,28 +45,28 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
self.tail_threshold = tail_threshold
|
||||
self.tail_scale = tail_scale
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
|
||||
# === Signal A: rec_mfilt + deep_upvol (from FactorCombo) ===
|
||||
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
|
||||
mom_filter = p.shift(21).pct_change(105)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
deep_upvol = _rank(rec_126) * _rank(up_vol)
|
||||
deep_upvol_r = _rank(deep_upvol)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
|
||||
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
|
||||
|
||||
# === Signal B: Recovery 63d + 12-1 momentum (from RecoveryMom) ===
|
||||
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
|
||||
mom_12_1 = p.shift(21).pct_change(231)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
|
||||
rec_63_r = _rank(rec_63)
|
||||
mom_r = _rank(mom_12_1)
|
||||
rec_63_r = _rank(rec_63, eligible)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
|
||||
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
|
||||
|
||||
@@ -74,7 +74,9 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
ensemble = 0.5 * signal_a + 0.5 * signal_b
|
||||
|
||||
# === Select top_n ===
|
||||
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = ensemble.where(data.notna())
|
||||
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = mask_scores(ensemble, eligible)
|
||||
n_valid = ensemble.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -99,7 +101,7 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
# otherwise daily flips force half-out/half-in trades that burn
|
||||
# the account through fixed per-trade fees.
|
||||
if self.tail_protection:
|
||||
mkt_ret = ret.mean(axis=1, skipna=True)
|
||||
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
in_tail = mkt_dd < self.tail_threshold
|
||||
@@ -111,7 +113,7 @@ class EnsembleAlphaStrategy(Strategy):
|
||||
return signals.shift(1).fillna(0.0)
|
||||
|
||||
|
||||
class EnhancedFactorComboStrategy(Strategy):
|
||||
class EnhancedFactorComboStrategy(StockStrategy):
|
||||
"""
|
||||
FactorCombo signal enhanced with:
|
||||
1. Additional momentum confirmation (12-1 momentum rank as tiebreaker)
|
||||
@@ -131,31 +133,33 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
self.mom_boost = mom_boost
|
||||
self.tail_protection = tail_protection
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
|
||||
# Core signal: rec_mfilt + deep_upvol
|
||||
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
|
||||
mom_filter = p.shift(21).pct_change(105)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
deep_upvol = _rank(rec_126) * _rank(up_vol)
|
||||
deep_upvol_r = _rank(deep_upvol)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
|
||||
base_signal = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
|
||||
|
||||
# Momentum boost: 12-1 month return rank
|
||||
mom_12_1 = p.shift(21).pct_change(231)
|
||||
mom_r = _rank(mom_12_1)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
|
||||
# Combined: base + momentum tiebreaker
|
||||
signal = (1 - self.mom_boost) * base_signal + self.mom_boost * mom_r
|
||||
|
||||
# Select top_n
|
||||
rank = signal.rank(axis=1, ascending=False, na_option="bottom")
|
||||
signal = signal.where(data.notna())
|
||||
rank = mask_scores(signal, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
signal = mask_scores(signal, eligible)
|
||||
n_valid = signal.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -177,7 +181,7 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
|
||||
# Tail protection (rebal-gated to avoid daily-flip turnover)
|
||||
if self.tail_protection:
|
||||
mkt_ret = ret.mean(axis=1, skipna=True)
|
||||
mkt_ret = mask_scores(ret, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
in_tail = mkt_dd < -0.15
|
||||
@@ -189,7 +193,7 @@ class EnhancedFactorComboStrategy(Strategy):
|
||||
return signals.shift(1).fillna(0.0)
|
||||
|
||||
|
||||
class RiskManagedEnsembleStrategy(Strategy):
|
||||
class RiskManagedEnsembleStrategy(StockStrategy):
|
||||
"""
|
||||
EnsembleAlpha with market-aware drawdown risk management.
|
||||
|
||||
@@ -230,15 +234,15 @@ class RiskManagedEnsembleStrategy(Strategy):
|
||||
self.vol_spike_lookback = vol_spike_lookback
|
||||
self.vol_spike_floor = vol_spike_floor
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Step 1: Get raw signals from the ensemble (already shifted by 1)
|
||||
raw = self.ensemble.generate_signals(data)
|
||||
raw = self.ensemble.generate_signals(data, eligible=eligible)
|
||||
|
||||
# Step 2: Compute MARKET returns over valid (non-masked) columns.
|
||||
# `daily_rets` keeps NaN for PIT-masked tickers so they don't dilute
|
||||
# the cross-sectional mean to ~0.
|
||||
daily_rets = data.pct_change()
|
||||
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
||||
daily_rets = data.pct_change(fill_method=None)
|
||||
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
|
||||
|
||||
# Step 3: Market drawdown dampener
|
||||
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
||||
@@ -272,7 +276,7 @@ class RiskManagedEnsembleStrategy(Strategy):
|
||||
return raw.mul(final_scale, axis=0)
|
||||
|
||||
|
||||
class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
class SharpeBoostedEnsembleStrategy(StockStrategy):
|
||||
"""
|
||||
Optimized ensemble targeting Sharpe >1.5 while maintaining high CAGR.
|
||||
|
||||
@@ -310,33 +314,35 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
self.dd_floor = dd_floor
|
||||
self.dd_denom = dd_denom
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
|
||||
# === Signal A: rec_mfilt + deep_upvol ===
|
||||
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
|
||||
mom_filter = p.shift(21).pct_change(105)
|
||||
mom_filter = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec_126.where(mom_filter > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
deep_upvol = _rank(rec_126) * _rank(up_vol)
|
||||
deep_upvol_r = _rank(deep_upvol)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
signal_a = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
|
||||
|
||||
# === Signal B: Recovery 63d + 12-1 momentum ===
|
||||
rec_63 = p / p.rolling(63, min_periods=63).min() - 1
|
||||
mom_12_1 = p.shift(21).pct_change(231)
|
||||
rec_63_r = _rank(rec_63)
|
||||
mom_r = _rank(mom_12_1)
|
||||
mom_12_1 = p.shift(21).pct_change(231, fill_method=None)
|
||||
rec_63_r = _rank(rec_63, eligible)
|
||||
mom_r = _rank(mom_12_1, eligible)
|
||||
signal_b = 0.5 * rec_63_r + 0.5 * mom_r
|
||||
|
||||
# === Ensemble: equal-weight average of both signals ===
|
||||
ensemble = 0.5 * signal_a + 0.5 * signal_b
|
||||
|
||||
# === Select top_n ===
|
||||
rank = ensemble.rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = ensemble.where(data.notna())
|
||||
rank = mask_scores(ensemble, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
ensemble = mask_scores(ensemble, eligible)
|
||||
n_valid = ensemble.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -361,7 +367,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
# burning the account through fixed per-trade fees ($2 US / $5 CN).
|
||||
# Use cross-sectional mean of non-masked returns so PIT-masked
|
||||
# NaN→0 fills don't dilute the market signal.
|
||||
daily_rets = data.pct_change()
|
||||
daily_rets = data.pct_change(fill_method=None)
|
||||
port_rets = (signals * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
|
||||
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
|
||||
vol_median = short_vol.rolling(252, min_periods=126).median()
|
||||
@@ -371,7 +377,7 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
||||
asym_scale_raw[high_vol_neg] = self.asym_vol_floor
|
||||
|
||||
# === Market-DD dampener (rebal-gated, NaN-aware market mean) ===
|
||||
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
||||
mkt_rets = mask_scores(daily_rets, eligible).mean(axis=1, skipna=True)
|
||||
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
||||
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
||||
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
|
||||
|
||||
@@ -18,7 +18,7 @@ Each can run at daily/weekly/biweekly/monthly rebalancing frequency.
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -26,11 +26,11 @@ from strategies.base import Strategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mom_12_1(p):
|
||||
return p.shift(21).pct_change(231)
|
||||
return p.shift(21).pct_change(231, fill_method=None)
|
||||
|
||||
|
||||
def _mom_intermediate(p):
|
||||
return p.shift(21).pct_change(147)
|
||||
return p.shift(21).pct_change(147, fill_method=None)
|
||||
|
||||
|
||||
def _rec_63(p):
|
||||
@@ -46,29 +46,29 @@ def _above_ma200(p):
|
||||
|
||||
|
||||
def _up_volume_proxy(p):
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
return ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
|
||||
|
||||
def _gap_up_freq(p):
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
return (ret > 0.01).astype(float).rolling(60, min_periods=40).mean()
|
||||
|
||||
|
||||
def _consistent_returns(p):
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
return (ret > 0).astype(float).rolling(252, min_periods=126).mean()
|
||||
|
||||
|
||||
def _rec_mom_filtered(p):
|
||||
rec = p / p.rolling(126, min_periods=126).min() - 1
|
||||
mom = p.shift(21).pct_change(105)
|
||||
mom = p.shift(21).pct_change(105, fill_method=None)
|
||||
return rec.where(mom > 0, np.nan)
|
||||
|
||||
|
||||
def _up_capture(p):
|
||||
ret = p.pct_change()
|
||||
mkt = ret.mean(axis=1)
|
||||
def _up_capture(p, eligible=None):
|
||||
ret = p.pct_change(fill_method=None)
|
||||
mkt = mask_scores(ret, eligible).mean(axis=1)
|
||||
up_mkt = mkt > 0
|
||||
arr = ret.values.copy()
|
||||
arr[~up_mkt.values, :] = np.nan
|
||||
@@ -79,9 +79,9 @@ def _up_capture(p):
|
||||
return stock_avg.div(mkt_avg, axis=0)
|
||||
|
||||
|
||||
def _down_resilience(p):
|
||||
ret = p.pct_change()
|
||||
mkt = ret.mean(axis=1)
|
||||
def _down_resilience(p, eligible=None):
|
||||
ret = p.pct_change(fill_method=None)
|
||||
mkt = mask_scores(ret, eligible).mean(axis=1)
|
||||
down_mkt = mkt < 0
|
||||
arr = ret.values.copy()
|
||||
arr[~down_mkt.values, :] = np.nan
|
||||
@@ -89,16 +89,16 @@ def _down_resilience(p):
|
||||
return -down_ret.rolling(120, min_periods=30).mean()
|
||||
|
||||
|
||||
def _quality_mom(p):
|
||||
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
|
||||
con_r = _consistent_returns(p).rank(axis=1, pct=True, na_option="keep")
|
||||
up_r = _up_volume_proxy(p).rank(axis=1, pct=True, na_option="keep")
|
||||
def _quality_mom(p, eligible=None):
|
||||
mom_r = _rank(_mom_12_1(p), eligible)
|
||||
con_r = _rank(_consistent_returns(p), eligible)
|
||||
up_r = _rank(_up_volume_proxy(p), eligible)
|
||||
return 0.4 * mom_r + 0.3 * con_r + 0.3 * up_r
|
||||
|
||||
|
||||
def _mom_x_gap(p):
|
||||
mom_r = _mom_12_1(p).rank(axis=1, pct=True, na_option="keep")
|
||||
gap_r = _gap_up_freq(p).rank(axis=1, pct=True, na_option="keep")
|
||||
def _mom_x_gap(p, eligible=None):
|
||||
mom_r = _rank(_mom_12_1(p), eligible)
|
||||
gap_r = _rank(_gap_up_freq(p), eligible)
|
||||
return mom_r * gap_r
|
||||
|
||||
|
||||
@@ -106,47 +106,47 @@ def _mom_x_gap(p):
|
||||
# Combo signal constructors (weighted rank sums)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _rank(df):
|
||||
return df.rank(axis=1, pct=True, na_option="keep")
|
||||
def _rank(df, eligible=None):
|
||||
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
|
||||
# US combos
|
||||
def signal_rec_mfilt_deep_upvol(p):
|
||||
rec_mfilt_r = _rank(_rec_mom_filtered(p))
|
||||
deep_upvol_r = _rank(_rec_126(p)) * _rank(_up_volume_proxy(p))
|
||||
deep_upvol_rr = _rank(deep_upvol_r)
|
||||
def signal_rec_mfilt_deep_upvol(p, eligible=None):
|
||||
rec_mfilt_r = _rank(_rec_mom_filtered(p), eligible)
|
||||
deep_upvol_r = _rank(_rec_126(p), eligible) * _rank(_up_volume_proxy(p), eligible)
|
||||
deep_upvol_rr = _rank(deep_upvol_r, eligible)
|
||||
return 0.5 * rec_mfilt_r + 0.5 * deep_upvol_rr
|
||||
|
||||
|
||||
def signal_ma200_mom7m_rec126(p):
|
||||
return (0.33 * _rank(_above_ma200(p))
|
||||
+ 0.33 * _rank(_mom_intermediate(p))
|
||||
+ 0.34 * _rank(_rec_126(p)))
|
||||
def signal_ma200_mom7m_rec126(p, eligible=None):
|
||||
return (0.33 * _rank(_above_ma200(p), eligible)
|
||||
+ 0.33 * _rank(_mom_intermediate(p), eligible)
|
||||
+ 0.34 * _rank(_rec_126(p), eligible))
|
||||
|
||||
|
||||
def signal_rec_mfilt_ma200(p):
|
||||
return 0.5 * _rank(_rec_mom_filtered(p)) + 0.5 * _rank(_above_ma200(p))
|
||||
def signal_rec_mfilt_ma200(p, eligible=None):
|
||||
return 0.5 * _rank(_rec_mom_filtered(p), eligible) + 0.5 * _rank(_above_ma200(p), eligible)
|
||||
|
||||
|
||||
def signal_mom7m_rec126(p):
|
||||
return 0.5 * _rank(_mom_intermediate(p)) + 0.5 * _rank(_rec_126(p))
|
||||
def signal_mom7m_rec126(p, eligible=None):
|
||||
return 0.5 * _rank(_mom_intermediate(p), eligible) + 0.5 * _rank(_rec_126(p), eligible)
|
||||
|
||||
|
||||
# CN combos
|
||||
def signal_up_cap_quality_mom(p):
|
||||
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_quality_mom(p))
|
||||
def signal_up_cap_quality_mom(p, eligible=None):
|
||||
return 0.5 * _rank(_up_capture(p, eligible), eligible) + 0.5 * _rank(_quality_mom(p, eligible), eligible)
|
||||
|
||||
|
||||
def signal_down_resil_qual_mom(p):
|
||||
return 0.5 * _rank(_down_resilience(p)) + 0.5 * _rank(_quality_mom(p))
|
||||
def signal_down_resil_qual_mom(p, eligible=None):
|
||||
return 0.5 * _rank(_down_resilience(p, eligible), eligible) + 0.5 * _rank(_quality_mom(p, eligible), eligible)
|
||||
|
||||
|
||||
def signal_rec63_mom_gap(p):
|
||||
return 0.5 * _rank(_rec_63(p)) + 0.5 * _rank(_mom_x_gap(p))
|
||||
def signal_rec63_mom_gap(p, eligible=None):
|
||||
return 0.5 * _rank(_rec_63(p), eligible) + 0.5 * _rank(_mom_x_gap(p, eligible), eligible)
|
||||
|
||||
|
||||
def signal_up_cap_mom_gap(p):
|
||||
return 0.5 * _rank(_up_capture(p)) + 0.5 * _rank(_mom_x_gap(p))
|
||||
def signal_up_cap_mom_gap(p, eligible=None):
|
||||
return 0.5 * _rank(_up_capture(p, eligible), eligible) + 0.5 * _rank(_mom_x_gap(p, eligible), eligible)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -171,7 +171,7 @@ SIGNAL_REGISTRY = {
|
||||
# Strategy class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FactorComboStrategy(Strategy):
|
||||
class FactorComboStrategy(StockStrategy):
|
||||
"""
|
||||
Generic factor-combination strategy with configurable rebalancing frequency.
|
||||
|
||||
@@ -192,11 +192,12 @@ class FactorComboStrategy(Strategy):
|
||||
self.rebal_freq = rebal_freq
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
sig = self.signal_func(data)
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
sig = self.signal_func(data, eligible=eligible).where(data.notna())
|
||||
|
||||
# Select top_n by signal rank
|
||||
rank = sig.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(sig, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
sig = mask_scores(sig, eligible)
|
||||
n_valid = sig.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
@@ -14,14 +14,14 @@ Also tests:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
def _rank(df):
|
||||
return df.rank(axis=1, pct=True, na_option="keep")
|
||||
def _rank(df, eligible=None):
|
||||
return mask_scores(df, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
|
||||
class HybridAlphaStrategy(Strategy):
|
||||
class HybridAlphaStrategy(StockStrategy):
|
||||
"""
|
||||
Combines FactorCombo's best signal with risk-parity weighting.
|
||||
"""
|
||||
@@ -40,28 +40,29 @@ class HybridAlphaStrategy(Strategy):
|
||||
self.use_invvol = use_invvol
|
||||
self.regime_dampen = regime_dampen
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
|
||||
# --- Signal: rec_mfilt + deep x upvol (from FactorCombo) ---
|
||||
# Recovery momentum-filtered
|
||||
rec = p / p.rolling(126, min_periods=126).min() - 1
|
||||
mom = p.shift(21).pct_change(105)
|
||||
mom = p.shift(21).pct_change(105, fill_method=None)
|
||||
rec_mfilt = rec.where(mom > 0, np.nan)
|
||||
rec_mfilt_r = _rank(rec_mfilt)
|
||||
rec_mfilt_r = _rank(rec_mfilt, eligible)
|
||||
|
||||
# Deep recovery x up-volume
|
||||
rec_126 = p / p.rolling(126, min_periods=126).min() - 1
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
deep_upvol = _rank(rec_126) * _rank(up_vol)
|
||||
deep_upvol_r = _rank(deep_upvol)
|
||||
deep_upvol = _rank(rec_126, eligible) * _rank(up_vol, eligible)
|
||||
deep_upvol_r = _rank(deep_upvol, eligible)
|
||||
|
||||
# Combined signal
|
||||
signal = 0.5 * rec_mfilt_r + 0.5 * deep_upvol_r
|
||||
|
||||
# --- Select top_n ---
|
||||
rank = signal.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(signal, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
signal = mask_scores(signal, eligible)
|
||||
n_valid = signal.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
@@ -81,7 +82,7 @@ class HybridAlphaStrategy(Strategy):
|
||||
# --- Light regime dampening ---
|
||||
if self.regime_dampen < 1.0:
|
||||
# Use market-wide vol regime instead of MA200 (more responsive)
|
||||
market_vol = ret.mean(axis=1).rolling(20).std() * np.sqrt(252)
|
||||
market_vol = mask_scores(ret, eligible).mean(axis=1).rolling(20).std() * np.sqrt(252)
|
||||
vol_90th = market_vol.rolling(252, min_periods=126).quantile(0.90)
|
||||
high_vol = market_vol > vol_90th
|
||||
regime_scale = pd.Series(1.0, index=data.index)
|
||||
@@ -101,7 +102,7 @@ class HybridAlphaStrategy(Strategy):
|
||||
return signals.shift(1).fillna(0.0)
|
||||
|
||||
|
||||
class RecoveryQualityBlendStrategy(Strategy):
|
||||
class RecoveryQualityBlendStrategy(StockStrategy):
|
||||
"""
|
||||
Blends recovery, momentum, and quality without strict MA200 filter.
|
||||
Uses intermediate momentum as a soft signal (not hard gate).
|
||||
@@ -125,34 +126,34 @@ class RecoveryQualityBlendStrategy(Strategy):
|
||||
self.rebal_freq = rebal_freq
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
p = data
|
||||
|
||||
# Recovery
|
||||
recovery = p / p.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
|
||||
|
||||
# 12-1 month momentum
|
||||
momentum = p.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
|
||||
momentum = p.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
|
||||
|
||||
# Intermediate momentum (7m)
|
||||
int_mom = p.shift(self.mom_skip).pct_change(147 - self.mom_skip)
|
||||
int_mom = p.shift(self.mom_skip).pct_change(147 - self.mom_skip, fill_method=None)
|
||||
|
||||
# Quality: consistency
|
||||
monthly_ret = p.pct_change(21)
|
||||
monthly_ret = p.pct_change(21, fill_method=None)
|
||||
consistency = (monthly_ret > 0).astype(float).rolling(
|
||||
self.quality_window, min_periods=self.quality_window // 2
|
||||
).mean()
|
||||
|
||||
# Up-volume proxy
|
||||
ret = p.pct_change()
|
||||
ret = p.pct_change(fill_method=None)
|
||||
up_vol = ret.where(ret > 0, 0).rolling(20, min_periods=15).sum()
|
||||
|
||||
# Cross-sectional ranks
|
||||
rec_rank = _rank(recovery)
|
||||
mom_rank = _rank(momentum)
|
||||
int_mom_rank = _rank(int_mom)
|
||||
con_rank = _rank(consistency)
|
||||
upv_rank = _rank(up_vol)
|
||||
rec_rank = _rank(recovery, eligible)
|
||||
mom_rank = _rank(momentum, eligible)
|
||||
int_mom_rank = _rank(int_mom, eligible)
|
||||
con_rank = _rank(consistency, eligible)
|
||||
upv_rank = _rank(up_vol, eligible)
|
||||
|
||||
# Composite: weighted blend of all factors
|
||||
# Recovery 25%, momentum 25%, intermediate momentum 20%, quality 15%, up_vol 15%
|
||||
@@ -160,7 +161,8 @@ class RecoveryQualityBlendStrategy(Strategy):
|
||||
0.15 * con_rank + 0.15 * upv_rank)
|
||||
|
||||
# Select top_n
|
||||
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
composite = mask_scores(composite, eligible)
|
||||
n_valid = composite.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
@@ -11,10 +11,11 @@ Improvements over base MomentumQualityStrategy:
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
from strategies.momentum_quality import rolling_max_drawdown
|
||||
|
||||
|
||||
class ImprovedMomentumQualityStrategy(Strategy):
|
||||
class ImprovedMomentumQualityStrategy(StockStrategy):
|
||||
"""
|
||||
Momentum + quality + recovery with monthly rebal and inv-vol weighting.
|
||||
"""
|
||||
@@ -37,45 +38,44 @@ class ImprovedMomentumQualityStrategy(Strategy):
|
||||
self.rebal_freq = rebal_freq
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# --- Momentum factor ---
|
||||
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip)
|
||||
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip, fill_method=None)
|
||||
|
||||
# --- Quality: return consistency (vectorized) ---
|
||||
# Fraction of positive 21-day returns over rolling window
|
||||
monthly_ret = data.pct_change(21)
|
||||
monthly_ret = data.pct_change(21, fill_method=None)
|
||||
positive_indicator = (monthly_ret > 0).astype(float)
|
||||
consistency = positive_indicator.rolling(
|
||||
self.quality_window, min_periods=self.quality_window // 2
|
||||
).mean()
|
||||
|
||||
# --- Quality: inverse max drawdown ---
|
||||
rolling_max = data.rolling(self.quality_window, min_periods=self.quality_window // 2).max()
|
||||
drawdown = data / rolling_max - 1
|
||||
worst_dd = drawdown.rolling(self.quality_window, min_periods=self.quality_window // 2).min()
|
||||
inv_dd = -worst_dd # higher = smaller drawdown = better
|
||||
quality_dd = rolling_max_drawdown(data, self.quality_window)
|
||||
|
||||
# --- Recovery factor ---
|
||||
recovery = data / data.rolling(self.recovery_window, min_periods=self.recovery_window).min() - 1
|
||||
|
||||
# --- Cross-sectional ranking ---
|
||||
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
|
||||
dd_rank = inv_dd.rank(axis=1, pct=True, na_option="keep")
|
||||
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
dd_rank = mask_scores(quality_dd, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
# Composite: momentum 35%, recovery 25%, consistency 20%, drawdown 20%
|
||||
scores = (0.35 * mom_rank + 0.25 * rec_rank +
|
||||
0.20 * con_rank + 0.20 * dd_rank)
|
||||
|
||||
# --- Select top_n ---
|
||||
scores = scores.where(data.notna())
|
||||
scores = mask_scores(scores, eligible)
|
||||
n_valid = scores.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
|
||||
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
# --- Inverse-vol weighting ---
|
||||
returns = data.pct_change()
|
||||
returns = data.pct_change(fill_method=None)
|
||||
vol = returns.rolling(self.vol_window, min_periods=30).std().replace(0, np.nan)
|
||||
inv_vol = (1.0 / vol).where(top_mask, 0.0)
|
||||
row_sums = inv_vol.sum(axis=1).replace(0, np.nan)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class InverseVolatilityStrategy(Strategy):
|
||||
class InverseVolatilityStrategy(StockStrategy):
|
||||
"""
|
||||
Risk parity via inverse-volatility weighting.
|
||||
|
||||
@@ -17,13 +17,13 @@ class InverseVolatilityStrategy(Strategy):
|
||||
def __init__(self, vol_window: int = 20):
|
||||
self.vol_window = vol_window
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
returns = data.pct_change()
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
returns = data.pct_change(fill_method=None)
|
||||
vol = returns.rolling(self.vol_window).std()
|
||||
|
||||
# Replace zero vol with NaN to avoid division by zero
|
||||
vol = vol.replace(0, np.nan)
|
||||
inv_vol = 1.0 / vol
|
||||
inv_vol = mask_scores((1.0 / vol).where(data.notna()), eligible)
|
||||
|
||||
row_sums = inv_vol.sum(axis=1).replace(0, np.nan)
|
||||
signals = inv_vol.div(row_sums, axis=0).fillna(0.0)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class MeanReversionStrategy(Strategy):
|
||||
class MeanReversionStrategy(StockStrategy):
|
||||
"""
|
||||
Monthly "buy the dip" with momentum confirmation.
|
||||
|
||||
@@ -20,17 +20,18 @@ class MeanReversionStrategy(Strategy):
|
||||
self.rebal_freq = rebal_freq
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
long_mom = data.pct_change(self.mom_lookback)
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
long_mom = data.pct_change(self.mom_lookback, fill_method=None)
|
||||
has_momentum = long_mom > 0
|
||||
|
||||
short_ret = data.pct_change(self.dip_window)
|
||||
short_ret = data.pct_change(self.dip_window, fill_method=None)
|
||||
|
||||
# Among positive-momentum stocks, rank by biggest dip
|
||||
scores = short_ret.where(has_momentum, np.nan)
|
||||
|
||||
# Rank: most negative = rank 1 (biggest dip)
|
||||
rank = scores.rank(axis=1, ascending=True, na_option="bottom")
|
||||
rank = mask_scores(scores, eligible).rank(axis=1, ascending=True, na_option="bottom")
|
||||
scores = mask_scores(scores, eligible)
|
||||
n_valid = scores.notna().sum(axis=1)
|
||||
effective_n = n_valid.clip(upper=self.top_n)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class MomentumStrategy(Strategy):
|
||||
class MomentumStrategy(StockStrategy):
|
||||
"""
|
||||
Classic cross-sectional momentum strategy (Jegadeesh & Titman, 1993).
|
||||
|
||||
@@ -19,14 +19,16 @@ class MomentumStrategy(Strategy):
|
||||
self.skip = skip # ~1 month — skip to avoid reversal
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Momentum: return from T-(lookback) to T-skip
|
||||
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip)
|
||||
momentum = data.shift(self.skip).pct_change(self.lookback - self.skip, fill_method=None)
|
||||
|
||||
momentum = momentum.where(data.notna())
|
||||
momentum = mask_scores(momentum, eligible)
|
||||
n_valid = momentum.notna().sum(axis=1)
|
||||
enough_data = n_valid >= self.top_n
|
||||
|
||||
score_rank = momentum.rank(axis=1, ascending=False, na_option="bottom")
|
||||
score_rank = mask_scores(momentum, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
top_mask = (score_rank <= self.top_n) & enough_data.values.reshape(-1, 1)
|
||||
|
||||
raw = top_mask.astype(float)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class MomentumQualityStrategy(Strategy):
|
||||
def rolling_max_drawdown(prices: pd.DataFrame, window: int) -> pd.DataFrame:
|
||||
return prices.rolling(window).apply(
|
||||
lambda x: np.min(x / np.maximum.accumulate(x) - 1), raw=True)
|
||||
|
||||
|
||||
class MomentumQualityStrategy(StockStrategy):
|
||||
"""
|
||||
Momentum + quality factor strategy using price-derived signals only.
|
||||
|
||||
@@ -23,44 +28,39 @@ class MomentumQualityStrategy(Strategy):
|
||||
self.quality_window = quality_window
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# --- Momentum factor ---
|
||||
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip)
|
||||
momentum = data.shift(self.skip).pct_change(self.momentum_period - self.skip, fill_method=None)
|
||||
|
||||
# --- Quality factor 1: return consistency ---
|
||||
# Rolling 21-day (monthly) returns, then count fraction positive over past year
|
||||
monthly_ret = data.pct_change(21)
|
||||
monthly_ret = data.pct_change(21, fill_method=None)
|
||||
consistency = monthly_ret.rolling(self.quality_window).apply(
|
||||
lambda x: (x > 0).sum() / len(x), raw=True
|
||||
)
|
||||
|
||||
# --- Quality factor 2: inverse max drawdown ---
|
||||
# Smaller drawdown = higher quality
|
||||
def rolling_max_dd(prices: pd.DataFrame, window: int) -> pd.DataFrame:
|
||||
rolling_max = prices.rolling(window).max()
|
||||
drawdown = prices / rolling_max - 1
|
||||
# Rolling worst drawdown (most negative)
|
||||
worst_dd = drawdown.rolling(window).min()
|
||||
# Invert: less negative = better, so negate
|
||||
return -worst_dd # higher = smaller drawdown = better
|
||||
inv_dd = rolling_max_dd(data, self.quality_window)
|
||||
quality_dd = rolling_max_drawdown(data, self.quality_window)
|
||||
|
||||
# --- Cross-sectional ranking ---
|
||||
# na_option="keep" so NaN stocks stay NaN in the composite. With
|
||||
# "bottom" + default ascending=True, NaN entries receive pct=1.0 and
|
||||
# the additive composite ends up maximal for NaN rows, silently
|
||||
# selecting pre-IPO / delisted names as "top".
|
||||
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = consistency.rank(axis=1, pct=True, na_option="keep")
|
||||
dd_rank = inv_dd.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
con_rank = mask_scores(consistency, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
dd_rank = mask_scores(quality_dd, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
# Composite: momentum 50%, consistency 25%, drawdown 25%
|
||||
scores = 0.50 * mom_rank + 0.25 * con_rank + 0.25 * dd_rank
|
||||
|
||||
# --- Select top_n ---
|
||||
scores = scores.where(data.notna())
|
||||
scores = mask_scores(scores, eligible)
|
||||
n_valid = scores.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
|
||||
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
top_mask = (score_rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
raw = top_mask.astype(float)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class MultiFactorStrategy(Strategy):
|
||||
class MultiFactorStrategy(StockStrategy):
|
||||
"""
|
||||
Multi-factor strategy combining momentum and value signals with a market-timing filter.
|
||||
|
||||
@@ -33,7 +33,7 @@ class MultiFactorStrategy(Strategy):
|
||||
self.value_period = value_period
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
stock = data[self.tickers]
|
||||
|
||||
# --- Market timing filter ---
|
||||
@@ -42,7 +42,7 @@ class MultiFactorStrategy(Strategy):
|
||||
|
||||
# --- Momentum factor ---
|
||||
# Return from T-(momentum_period+skip) to T-skip, avoiding the last month
|
||||
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period)
|
||||
momentum = stock.shift(self.momentum_skip).pct_change(self.momentum_period, fill_method=None)
|
||||
|
||||
# --- Value factor ---
|
||||
# min_price_over_period / current_price (higher = more "undervalued" vs recent range)
|
||||
@@ -51,16 +51,17 @@ class MultiFactorStrategy(Strategy):
|
||||
# --- Cross-sectional ranking (each row ranked across assets) ---
|
||||
# na_option="keep" so NaN stocks (pre-IPO / delisted / masked) stay
|
||||
# NaN in the composite score instead of being assigned pct=1.0.
|
||||
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
||||
val_rank = value.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
val_rank = mask_scores(value, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
scores = mom_rank + val_rank # combined score, higher = better
|
||||
|
||||
# --- Select top_n assets per row ---
|
||||
# Only allocate rows that have enough valid scores
|
||||
scores = mask_scores(scores, eligible)
|
||||
n_valid = scores.notna().sum(axis=1)
|
||||
enough_data = n_valid >= self.top_n
|
||||
|
||||
score_rank = scores.rank(axis=1, ascending=False, na_option="bottom")
|
||||
score_rank = mask_scores(scores, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
top_mask = (score_rank <= self.top_n) & enough_data.values.reshape(-1, 1)
|
||||
|
||||
# Equal-weight allocation among selected assets
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class RecoveryMomentumStrategy(Strategy):
|
||||
class RecoveryMomentumStrategy(StockStrategy):
|
||||
"""
|
||||
Recovery + momentum composite factor strategy.
|
||||
|
||||
@@ -29,26 +29,27 @@ class RecoveryMomentumStrategy(Strategy):
|
||||
self.rebal_freq = rebal_freq
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Factor 1: Recovery — price / rolling min
|
||||
recovery = data / data.rolling(self.recovery_window).min() - 1
|
||||
|
||||
# Factor 2: 12-1 month momentum
|
||||
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip)
|
||||
momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip, fill_method=None)
|
||||
|
||||
# Cross-sectional percentile ranks. na_option="keep" is critical:
|
||||
# with "bottom" + ascending=True default, NaN stocks get pct=1.0, so
|
||||
# non-member / delisted / pre-IPO names leak into the composite as
|
||||
# "top" candidates. "keep" propagates NaN and the final ascending=False
|
||||
# + "bottom" rank pushes them to the end where they are not selected.
|
||||
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
||||
rec_rank = mask_scores(recovery, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
mom_rank = mask_scores(momentum, eligible).rank(axis=1, pct=True, na_option="keep")
|
||||
|
||||
# Composite score (50/50)
|
||||
composite = 0.5 * rec_rank + 0.5 * mom_rank
|
||||
|
||||
# Select top_n
|
||||
rank = composite.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(composite, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
composite = mask_scores(composite, eligible)
|
||||
n_valid = composite.notna().sum(axis=1)
|
||||
enough = n_valid >= self.top_n
|
||||
top_mask = (rank <= self.top_n) & enough.values.reshape(-1, 1)
|
||||
|
||||
@@ -27,7 +27,7 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from numpy.lib.stride_tricks import sliding_window_view
|
||||
|
||||
from strategies.base import BarStrategy
|
||||
from strategies.base import BarStrategy, mask_scores
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -60,10 +60,10 @@ def sharpe_momentum(close: pd.DataFrame) -> pd.DataFrame:
|
||||
return momentum_12_1(close) / _returns(close).rolling(252, min_periods=180).std()
|
||||
|
||||
|
||||
def low_beta(close: pd.DataFrame, window: int = 252) -> pd.DataFrame:
|
||||
def low_beta(close: pd.DataFrame, window: int = 252, eligible=None) -> pd.DataFrame:
|
||||
"""Negative rolling beta to the equal-weight average of the panel."""
|
||||
r = _returns(close)
|
||||
m = r.mean(axis=1).fillna(0.0)
|
||||
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
|
||||
minp = window * 180 // 252
|
||||
e_xy = r.mul(m, axis=0).rolling(window, min_periods=minp).mean()
|
||||
e_x = r.rolling(window, min_periods=minp).mean()
|
||||
@@ -73,12 +73,12 @@ def low_beta(close: pd.DataFrame, window: int = 252) -> pd.DataFrame:
|
||||
|
||||
|
||||
def idiosyncratic_vol(close: pd.DataFrame, beta_window: int = 252,
|
||||
vol_window: int = 63) -> pd.DataFrame:
|
||||
vol_window: int = 63, eligible=None) -> pd.DataFrame:
|
||||
"""Negative 63-day volatility of returns net of the equal-weight market
|
||||
times a trailing (lagged) 252-day beta; Ang, Hodrick, Xing & Zhang (2006)."""
|
||||
r = _returns(close)
|
||||
m = r.mean(axis=1).fillna(0.0)
|
||||
beta = -low_beta(close, beta_window).shift(1)
|
||||
m = mask_scores(r, eligible).mean(axis=1).fillna(0.0)
|
||||
beta = -low_beta(close, beta_window, eligible).shift(1)
|
||||
resid = r - beta.mul(m, axis=0)
|
||||
return -resid.rolling(vol_window, min_periods=40).std()
|
||||
|
||||
@@ -111,28 +111,29 @@ def _rank(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return df.rank(axis=1, pct=True)
|
||||
|
||||
|
||||
def _composite(*factors: pd.DataFrame) -> pd.DataFrame:
|
||||
return sum(_rank(f) for f in factors) / len(factors)
|
||||
def _composite(*factors: pd.DataFrame, eligible=None) -> pd.DataFrame:
|
||||
return sum(_rank(mask_scores(f, eligible)) for f in factors) / len(factors)
|
||||
|
||||
|
||||
def signal_overnight_recovery(bars):
|
||||
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]))
|
||||
return _composite(overnight_momentum(bars["open"], bars["close"]), recovery(bars["close"]), eligible=bars.get("eligible"))
|
||||
|
||||
|
||||
def signal_lowrisk_momentum(bars):
|
||||
c = bars["close"]
|
||||
return _composite(amplitude_cut_momentum(bars["high"], bars["low"], c), low_beta(c),
|
||||
price_volume_corr(c, bars["volume"]), sharpe_momentum(c))
|
||||
return _composite(amplitude_cut_momentum(bars["high"], bars["low"], c), low_beta(c, eligible=bars.get("eligible")),
|
||||
price_volume_corr(c, bars["volume"]), sharpe_momentum(c), eligible=bars.get("eligible"))
|
||||
|
||||
|
||||
def signal_lowbeta_sharpe_mom(bars):
|
||||
c = bars["close"]
|
||||
return _composite(low_beta(c), sharpe_momentum(c))
|
||||
return _composite(low_beta(c, eligible=bars.get("eligible")), sharpe_momentum(c), eligible=bars.get("eligible"))
|
||||
|
||||
|
||||
def signal_low_risk(bars):
|
||||
c = bars["close"]
|
||||
return _composite(idiosyncratic_vol(c), low_beta(c))
|
||||
return _composite(idiosyncratic_vol(c, eligible=bars.get("eligible")),
|
||||
low_beta(c, eligible=bars.get("eligible")), eligible=bars.get("eligible"))
|
||||
|
||||
|
||||
# name -> (bar fields, score function)
|
||||
@@ -202,6 +203,8 @@ class RobustMomentumStrategy(BarStrategy):
|
||||
def generate_weights(self, bars: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
close = bars["close"]
|
||||
panel = {f: bars[f].reindex(index=close.index, columns=close.columns) for f in self.fields}
|
||||
if "eligible" in bars:
|
||||
panel["eligible"] = bars["eligible"]
|
||||
score = self._score(panel)
|
||||
# names without a price are not eligible; a backtest also passes
|
||||
# "eligible" (point-in-time index membership) so signals can use a
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from strategies.base import Strategy
|
||||
from strategies.base import StockStrategy, mask_scores
|
||||
|
||||
|
||||
class TrendFollowingStrategy(Strategy):
|
||||
class TrendFollowingStrategy(StockStrategy):
|
||||
"""
|
||||
Per-stock trend following with momentum ranking.
|
||||
|
||||
@@ -21,21 +21,22 @@ class TrendFollowingStrategy(Strategy):
|
||||
self.momentum_period = momentum_period
|
||||
self.top_n = top_n
|
||||
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible: pd.DataFrame | None = None) -> pd.DataFrame:
|
||||
# Per-stock trend filter: price > MA
|
||||
ma = data.rolling(self.ma_window).mean()
|
||||
in_uptrend = data > ma
|
||||
|
||||
# Momentum score among trending stocks
|
||||
momentum = data.pct_change(self.momentum_period)
|
||||
momentum = data.pct_change(self.momentum_period, fill_method=None)
|
||||
# Mask out stocks not in uptrend
|
||||
momentum_filtered = momentum.where(in_uptrend, np.nan)
|
||||
|
||||
# Rank and select top_n
|
||||
momentum_filtered = mask_scores(momentum_filtered, eligible)
|
||||
n_valid = momentum_filtered.notna().sum(axis=1)
|
||||
enough = n_valid >= 1
|
||||
|
||||
rank = momentum_filtered.rank(axis=1, ascending=False, na_option="bottom")
|
||||
rank = mask_scores(momentum_filtered, eligible).rank(axis=1, ascending=False, na_option="bottom")
|
||||
effective_n = n_valid.clip(upper=self.top_n)
|
||||
top_mask = (rank <= effective_n.values.reshape(-1, 1)) & enough.values.reshape(-1, 1)
|
||||
# Ensure we only pick stocks that are actually in uptrend
|
||||
|
||||
@@ -46,6 +46,8 @@ from strategies.factor_combo import SIGNAL_REGISTRY
|
||||
class TrendRiderV6(TrendRiderV5):
|
||||
"""Stock-sleeve TrendRider with V5 regime engine."""
|
||||
|
||||
accepts_eligibility = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
@@ -98,10 +100,10 @@ class TrendRiderV6(TrendRiderV5):
|
||||
self.leverage_overlay_symbol, *self.risk_off_basket})
|
||||
return [c for c in prices.columns if c not in non_stock]
|
||||
|
||||
def _stock_top_n_weights(self, prices: pd.DataFrame, universe: list[str]) -> pd.DataFrame:
|
||||
def _stock_top_n_weights(self, prices: pd.DataFrame, universe: list[str], eligible=None) -> pd.DataFrame:
|
||||
"""Top-N selection by signal, inv-vol weighted within selection."""
|
||||
stock_panel = prices[universe]
|
||||
sig = self.signal_func(stock_panel)
|
||||
sig = self.signal_func(stock_panel, eligible=eligible)
|
||||
# Top-N by signal rank (highest score = top)
|
||||
rank = sig.rank(axis=1, ascending=False, na_option="bottom")
|
||||
n_valid = sig.notna().sum(axis=1)
|
||||
@@ -154,7 +156,7 @@ class TrendRiderV6(TrendRiderV5):
|
||||
return {best: 1.0}
|
||||
|
||||
# ---- Override generate_signals ----
|
||||
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
def generate_signals(self, data: pd.DataFrame, eligible=None) -> pd.DataFrame:
|
||||
if self.signal not in data.columns:
|
||||
raise ValueError(f"Required regime signal {self.signal!r} not in data.")
|
||||
universe = self._resolve_universe(data)
|
||||
@@ -163,7 +165,7 @@ class TrendRiderV6(TrendRiderV5):
|
||||
|
||||
# 1) Build sleeve weights — stock sleeve, anchor sleeve
|
||||
# (defensive sleeve is single-pick, computed per-bar inside the loop)
|
||||
stock_w = self._stock_top_n_weights(data, universe)
|
||||
stock_w = self._stock_top_n_weights(data, universe, eligible)
|
||||
anchor_w = pd.DataFrame(0.0, index=data.index, columns=[self.moderate_anchor])
|
||||
if self.moderate_anchor in data.columns:
|
||||
anchor_w[self.moderate_anchor] = 1.0
|
||||
|
||||
@@ -18,21 +18,15 @@ Three sequential layers, each PIT-safe:
|
||||
Restore when gain drops below 20% (hysteresis band = 10%).
|
||||
Entry price = yesterday's close at symbol change (PIT-safe).
|
||||
|
||||
Why profit-take works on leveraged ETFs
|
||||
---------------------------------------
|
||||
3x ETFs (TQQQ, UPRO) suffer from volatility drag: daily rebalancing
|
||||
erodes multi-day compound returns proportional to variance. After a
|
||||
+30% gain, the position sits on a large base where subsequent
|
||||
volatility causes disproportionate drag. Clearing the position:
|
||||
|
||||
1. Locks in geometric gains before vol drag erodes them.
|
||||
2. Reduces the base on which the drag operates.
|
||||
3. Forces rebalancing from an asset that has become "overweight."
|
||||
|
||||
This is not alpha from prediction — it is alpha from mechanical
|
||||
rebalancing of a negatively-convex instrument, a structural effect
|
||||
documented in the leveraged ETF literature.
|
||||
Research boundary
|
||||
-----------------
|
||||
Profit-taking is an empirical timing rule. Daily leverage compounding alone
|
||||
neither proves that the 30% threshold earns alpha nor makes it independent
|
||||
of the development window. Compare against no profit-taking and a simple
|
||||
trend rule using the shared cash ledger and out-of-period stress scenarios.
|
||||
|
||||
The following legacy figures used the earlier execution model and are not
|
||||
comparable to cash-ledger-pit-v2 results:
|
||||
Empirical 10y (2016-05-16 to 2026-05-13, 10bps one-way cost):
|
||||
Ann 54.7%, Vol 24.2%, Sharpe(rf=5%) 1.72, MaxDD -25.7%,
|
||||
Sortino 2.23, Calmar 2.13.
|
||||
|
||||
139
tests/test_execution_contract.py
Normal file
139
tests/test_execution_contract.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import unittest
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import data_manager
|
||||
import trader
|
||||
from execution import simulate_book, rebalance, portfolio_value
|
||||
from execution import compute_trades, market_permissions
|
||||
from signals import session_targets
|
||||
from strategies.momentum import MomentumStrategy
|
||||
from strategies.momentum_quality import rolling_max_drawdown
|
||||
from strategies.composite_alpha import CompositeAlphaStrategy
|
||||
from strategies.robust_momentum import RobustMomentumStrategy
|
||||
|
||||
|
||||
class ExecutionContracts(unittest.TestCase):
|
||||
def test_a_halt_cannot_be_sold_at_the_carried_mark(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=5)
|
||||
raw = pd.DataFrame({"A": [100, np.nan, np.nan, 10, 10]}, index=dates)
|
||||
targets = pd.DataFrame({"A": [1, 0, 0, 0, 0]}, index=dates)
|
||||
result = simulate_book(targets, raw, 10000, transaction_cost=0, min_trade_value=0)
|
||||
self.assertAlmostEqual(result["equity"].iloc[-1], 1000)
|
||||
self.assertEqual([t["date"] for t in result["trade_log"]],
|
||||
[str(dates[0].date()), str(dates[3].date())])
|
||||
|
||||
def test_fractional_and_integer_orders_are_funded_after_all_fees(self):
|
||||
prices = {"A": 73., "B": 29.}
|
||||
for integer in [False, True]:
|
||||
state = {"holdings": {}, "cash": 1000., "trade_log": []}
|
||||
rebalance(state, {"A": .5, "B": .5}, prices, set(prices),
|
||||
fixed_fee=2, fee_base=1.88, fee_per_share=.009,
|
||||
integer_shares=integer, min_trade_value=0)
|
||||
self.assertGreaterEqual(state["cash"], 0)
|
||||
equity = portfolio_value(state["holdings"], prices, state["cash"])
|
||||
self.assertAlmostEqual(equity, 1000 - sum(t["commission"] for t in state["trade_log"]), places=2)
|
||||
|
||||
def test_batch_replay_matches_session_ledger_with_a_halt_and_drift(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=5)
|
||||
prices = pd.DataFrame({"A": [100, 110, np.nan, 70, 90], "B": [20, 21, 22, 22, 23]}, index=dates)
|
||||
targets = pd.DataFrame([[.5, .5], [.5, .5], [0, 1], [0, 1], [.5, .5]], index=dates, columns=prices.columns)
|
||||
options = dict(transaction_cost=.001, fixed_fee=2., integer_shares=True, resize_band=.1)
|
||||
batch = simulate_book(targets, prices, 10000, **options)
|
||||
state = trader.init_state("us", "test", 10000)
|
||||
fees = {"tx_cost": .001, "fixed_fee": 2.}
|
||||
for day in dates:
|
||||
trader._run_session(state, "us", day, "B", prices.ffill(), prices, fees, True, targets.loc[day])
|
||||
self.assertAlmostEqual(state["daily_equity"][str(day.date())], batch["equity"].loc[day], places=2)
|
||||
self.assertEqual(state["trade_log"], batch["trade_log"])
|
||||
|
||||
def test_contributions_are_executed_and_fees_affect_unit_returns(self):
|
||||
dates = pd.bdate_range("2026-01-05", periods=3)
|
||||
prices = pd.DataFrame({"A": [100., 100., 100.]}, index=dates)
|
||||
weights = pd.DataFrame(1., index=dates, columns=["A"])
|
||||
base = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2)
|
||||
injected = simulate_book(weights, prices, 1000, transaction_cost=0, fixed_fee=2,
|
||||
contributions=pd.Series([0, 1000, 0], index=dates))
|
||||
self.assertEqual(len(base["trade_log"]), 1)
|
||||
self.assertEqual(len(injected["trade_log"]), 2)
|
||||
self.assertAlmostEqual(injected["equity"].iloc[-1], 1996)
|
||||
self.assertLess(injected["unit_equity"].iloc[-1], base["unit_equity"].iloc[-1])
|
||||
|
||||
def test_missing_open_never_sees_same_day_close(self):
|
||||
close = pd.DataFrame({"A": [100., 200.]})
|
||||
raw_open = pd.DataFrame({"A": [np.nan, np.nan]})
|
||||
self.assertTrue(data_manager._fill_open(close, raw_open).isna().all().all())
|
||||
|
||||
def test_china_limit_permissions_tax_and_star_minimum(self):
|
||||
raw = pd.Series({"600000.SS": 11., "000001.SZ": 9., "688001.SS": 12.})
|
||||
buy, sell, tax = market_permissions("cn", pd.Timestamp("2026-09-30"), raw, raw * 0 + 10)
|
||||
self.assertNotIn("600000.SS", buy)
|
||||
self.assertNotIn("000001.SZ", sell)
|
||||
self.assertNotIn("688001.SS", buy)
|
||||
self.assertEqual(tax, .0005)
|
||||
self.assertEqual(market_permissions("cn", pd.Timestamp("2023-08-25"), raw, raw * 0 + 10)[2], .001)
|
||||
orders = compute_trades({}, 10000, {"688001.SS": .7}, {"688001.SS": 23.},
|
||||
integer_shares=True, lot_size=100)
|
||||
self.assertEqual(orders[0]["shares_delta"], 304)
|
||||
|
||||
|
||||
class SignalContracts(unittest.TestCase):
|
||||
def test_live_refresh_updates_the_membership_snapshot_before_selection(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cache = Path(tmp) / "sp500_history.json"
|
||||
cache.write_text(json.dumps({"date": "2000-01-01", "intervals": {"OLD": [["2020-01-01", None]]}}))
|
||||
history = {"OLD": [["2020-01-01", "2026-09-30"]], "NEW": [["2026-09-30", None]]}
|
||||
with mock.patch.object(trader.universe_history, "CACHE_DIR", tmp), \
|
||||
mock.patch.object(trader.universe_history, "build_sp500_history", return_value=history), \
|
||||
mock.patch.object(trader, "strategy_universe", return_value=(["NEW"], "SPY")), \
|
||||
mock.patch.object(data_manager, "update"):
|
||||
trader.refresh_panels("us", ["momentum"], "test")
|
||||
member = trader.index_members("us", pd.DatetimeIndex(["2026-10-01"]))
|
||||
self.assertTrue(member.iloc[0]["NEW"])
|
||||
self.assertFalse(member.iloc[0]["OLD"])
|
||||
|
||||
def test_close_signal_is_unaffected_by_its_execution_days_close(self):
|
||||
dates = pd.bdate_range("2024-01-01", periods=30)
|
||||
c = pd.DataFrame({"A": np.arange(100., 130.), "B": np.arange(130., 100., -1)}, index=dates)
|
||||
s = MomentumStrategy(lookback=5, skip=1, top_n=1)
|
||||
base = session_targets(s, c)
|
||||
shocked = c.copy(); shocked.iloc[20:] = shocked.iloc[20:] * [0.1, 10]
|
||||
actual = session_targets(s, shocked)
|
||||
pd.testing.assert_frame_equal(base.iloc[:21], actual.iloc[:21])
|
||||
|
||||
def test_trader_and_backtest_use_identical_membership_histories(self):
|
||||
rng = np.random.default_rng(42)
|
||||
dates = pd.bdate_range("2024-01-01", periods=370)
|
||||
close = pd.DataFrame(100 * np.exp(rng.normal(.0005, .02, (370, 25)).cumsum(0)),
|
||||
index=dates, columns=[f"S{i}" for i in range(25)])
|
||||
opens = close.shift(1).fillna(100)
|
||||
member = pd.DataFrame(True, index=dates, columns=close.columns)
|
||||
member.iloc[:320, 0] = False
|
||||
member.iloc[330:, 1] = False
|
||||
bars = {"close": close, "open": opens}
|
||||
strategy = RobustMomentumStrategy("overnight_recovery", top_n=5)
|
||||
expected = session_targets(strategy, close, opens, bars, member)
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
|
||||
actual = trader.session_signals("us", "overnight_recovery_top10", list(close), dates[-20:],
|
||||
close, opens, bars=bars, strategy=strategy)
|
||||
pd.testing.assert_frame_equal(actual, expected.reindex(index=actual.index, columns=actual.columns))
|
||||
self.assertTrue((actual["S1"] == 0).all())
|
||||
|
||||
def test_quality_prefers_a_smaller_drawdown_and_needs_only_one_window(self):
|
||||
p = pd.DataFrame({"small": [100, 110, 99, 108], "large": [100, 110, 55, 108]})
|
||||
dd = rolling_max_drawdown(p, 4)
|
||||
self.assertGreater(dd.iloc[-1]["small"], dd.iloc[-1]["large"])
|
||||
self.assertAlmostEqual(dd.iloc[-1]["small"], -.1)
|
||||
|
||||
def test_missing_regime_benchmark_is_an_error(self):
|
||||
with self.assertRaisesRegex(ValueError, "benchmark"):
|
||||
CompositeAlphaStrategy().generate_signals(pd.DataFrame({"A": [1, 2]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,6 +77,7 @@ class LiveBookTests(unittest.TestCase):
|
||||
mock.patch.object(trader, "strategy_universe", lambda m, n: (["AAA", "BBB"], "SPY")), \
|
||||
mock.patch.object(trader, "pit_candidates", lambda m, n, s: []), \
|
||||
mock.patch.object(trader, "load_strategy_prices", lambda *a, **k: (close, close, close)), \
|
||||
mock.patch.object(data_manager, "load_raw", lambda *a, **k: None), \
|
||||
mock.patch.object(trader, "session_signals", lambda m, n, t, days, c, o: equal.loc[days]):
|
||||
return trader._process_sessions(args, state, refresh=False)
|
||||
|
||||
@@ -151,20 +152,17 @@ class ReportingTests(unittest.TestCase):
|
||||
equity = pd.Series(10_000 * 1.0003 ** np.arange(400), index=idx)
|
||||
schedule = {"amount": 5_000, "frequency": "yearly_months", "months": [2, 8], "startYear": 1}
|
||||
plain = bridge._build_strategy_result("x", equity, [], None, 10_000)
|
||||
injected = bridge._build_strategy_result("x", equity, [], schedule, 10_000)
|
||||
self.assertAlmostEqual(injected["metrics"]["totalReturn"], plain["metrics"]["totalReturn"])
|
||||
flows = injected["capitalFlows"]
|
||||
self.assertEqual(flows["invested"], 10_000 + 5_000 * len(injected["injections"]))
|
||||
self.assertAlmostEqual(flows["profit"], flows["finalValue"] - flows["invested"], places=2)
|
||||
with self.assertRaisesRegex(ValueError, "order replay"):
|
||||
bridge._build_strategy_result("x", equity, [], schedule, 10_000)
|
||||
|
||||
def test_ruined_portfolio_stays_at_zero(self):
|
||||
def test_unfunded_leverage_is_rejected(self):
|
||||
class TwiceLevered:
|
||||
def generate_signals(self, data):
|
||||
return pd.DataFrame(2.0, index=data.index, columns=data.columns)
|
||||
|
||||
prices = pd.DataFrame({"X": [10.0, 10.0, 4.0, 5.0]}, index=pd.bdate_range("2026-01-01", periods=4))
|
||||
equity = backtest(TwiceLevered(), prices, initial_capital=100)
|
||||
self.assertTrue((equity.iloc[2:] == 0).all(), equity)
|
||||
with self.assertRaisesRegex(ValueError, "unfunded"):
|
||||
backtest(TwiceLevered(), prices, initial_capital=100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -155,12 +155,11 @@ class BacktestUniverseTests(unittest.TestCase):
|
||||
panel = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "SPY", "TQQQ"])
|
||||
fake_universe = {"us": {"fetch": lambda: ["AAA"], "benchmark": "SPY",
|
||||
"benchmark_label": "SPY"}}
|
||||
members = pd.DataFrame(True, index=idx, columns=["AAA", "BBB"])
|
||||
with mock.patch.object(backtest_runner, "UNIVERSES", fake_universe), \
|
||||
mock.patch.object(backtest_runner.uh, "pit_tickers_ever", lambda m, since=None: ["AAA", "BBB"]), \
|
||||
mock.patch.object(backtest_runner.uh, "mask_prices_pit", lambda p, m: p), \
|
||||
mock.patch.object(backtest_runner.data_manager, "update",
|
||||
lambda m, t, fields=None: {f: panel for f in fields}):
|
||||
data = backtest_runner.load_stock_panels("us", ["momentum"], None)
|
||||
mock.patch.object(backtest_runner.uh, "pit_membership", lambda m, days: members.reindex(days).fillna(False)), \
|
||||
mock.patch.object(backtest_runner, "_panels", lambda *a: {"close": panel, "open": panel}):
|
||||
data = backtest_runner.load_stock_panels("us", ["momentum"], None, refresh=False)
|
||||
self.assertEqual(data["stocks"], ["AAA", "BBB"])
|
||||
|
||||
|
||||
@@ -180,15 +179,13 @@ class MembershipTests(unittest.TestCase):
|
||||
bars["open"]["S00"] = bars["close"]["S00"]
|
||||
days = bars["close"].index[-5:]
|
||||
others = [c for c in bars["close"].columns if c != "S00"]
|
||||
members = pd.DataFrame(True, index=days[:-1], columns=bars["close"].columns)
|
||||
members["S00"] = False # joins the index on the last day
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: members.loc[d]):
|
||||
runs = trader.session_universes("us", "momentum", others + ["S00"], days)
|
||||
members = pd.DataFrame(True, index=bars["close"].index, columns=bars["close"].columns)
|
||||
members.loc[members.index < days[-1], "S00"] = False
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: members.reindex(d)):
|
||||
weights = trader.session_signals("us", "momentum", others + ["S00"], days,
|
||||
bars["close"], bars["open"])
|
||||
self.assertEqual([len(r) for _, r in runs], [4, 1])
|
||||
self.assertNotIn("S00", runs[0][0])
|
||||
self.assertTrue((weights.loc[days[:-1], "S00"] == 0).all())
|
||||
# The historical observations were public before index admission.
|
||||
self.assertGreater(weights.loc[days[-1], "S00"], 0)
|
||||
|
||||
def test_changes_table_with_two_level_headers_is_found(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ kept to their own columns, shares that drift like separate accounts, and the
|
||||
live trader's session timing."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -82,6 +83,17 @@ class SleevePortfolioTests(unittest.TestCase):
|
||||
got = trader.bar_session_weights(strat, bars, days)
|
||||
pd.testing.assert_frame_equal(got, full)
|
||||
|
||||
def test_trader_truncates_all_bar_fields_before_padding_sleeves(self):
|
||||
bars = _bars()
|
||||
days = bars["close"].index[-4:-2]
|
||||
stocks = [c for c in bars["close"] if c not in ETF_UNIVERSE]
|
||||
member = pd.DataFrame(True, index=bars["close"].index, columns=stocks)
|
||||
expected = us_three_sleeve().generate_signals({f: b.loc[:days[-1]] for f, b in bars.items()}).loc[days]
|
||||
with mock.patch.object(trader, "index_members", lambda m, d: member.reindex(d)):
|
||||
actual = trader.session_signals("us", "us_three_sleeve", list(bars["close"]), days,
|
||||
bars["close"], bars["open"])
|
||||
pd.testing.assert_frame_equal(actual, expected.reindex(columns=actual.columns))
|
||||
|
||||
def test_registered_as_a_us_mixed_strategy(self):
|
||||
self.assertIsInstance(trader.STRATEGY_REGISTRY["us_three_sleeve"](), SleevePortfolioStrategy)
|
||||
self.assertEqual(set(trader.strategy_extra_tickers("us_three_sleeve")), set(ETF_UNIVERSE))
|
||||
|
||||
503
trader.py
503
trader.py
@@ -40,6 +40,9 @@ import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
import data_manager
|
||||
from signals import session_targets
|
||||
from execution import (ENGINE_VERSION, compute_trades, execute_trades, portfolio_value, rebalance, market_permissions,
|
||||
_allocate_lots, _per_trade_commission)
|
||||
import universe_history
|
||||
from strategies.base import BarStrategy
|
||||
from strategies.agent_review import AgentReviewStrategy
|
||||
@@ -312,6 +315,54 @@ STRATEGY_REGISTRY = {
|
||||
"trend_rider_v6_top10": lambda **kw: TrendRiderV6(top_n=10),
|
||||
}
|
||||
|
||||
# Execution-only candidates retain their baseline signals and a separate record.
|
||||
EXECUTION_VARIANTS = {
|
||||
"overnight_recovery_top10_rb20": ("overnight_recovery_top10", "us"),
|
||||
"us_three_sleeve_rb20": ("us_three_sleeve", "us"),
|
||||
"us_momentum_qqq_trend_rb20": ("us_momentum_qqq_trend", "us"),
|
||||
"lowbeta_sharpe_mom_top20_rb20": ("lowbeta_sharpe_mom_top20", "cn"),
|
||||
}
|
||||
|
||||
|
||||
def _resize_variant(factory):
|
||||
def build(**kwargs):
|
||||
strategy = factory(**kwargs)
|
||||
strategy.execution_resize_band = .20
|
||||
return strategy
|
||||
return build
|
||||
|
||||
|
||||
for _variant, (_base, _market) in EXECUTION_VARIANTS.items():
|
||||
STRATEGY_REGISTRY[_variant] = _resize_variant(STRATEGY_REGISTRY[_base])
|
||||
|
||||
|
||||
def canonical_strategy_name(name):
|
||||
name = name.removeprefix("sim_")
|
||||
return EXECUTION_VARIANTS.get(name, (name, None))[0]
|
||||
|
||||
|
||||
def strategy_resize_band(name):
|
||||
return .20 if name.removeprefix("sim_") in EXECUTION_VARIANTS else RESIZE_BAND
|
||||
|
||||
|
||||
LIVE_BOOKS["validation_2026_10"] = {
|
||||
"label": "修复后前瞻对照(2026-10-02 起)",
|
||||
"start": {"us": "2026-10-02", "cn": "2026-10-02"},
|
||||
"capital": {"us": 10_000.0, "cn": 70_000.0},
|
||||
"fee_per_order": {"us": 2.0, "cn": 5.0},
|
||||
"fee_schedule": {
|
||||
"us": {"tx_cost": .001, "fixed_fee": 2., "fee_base": 1.88, "fee_per_share": .009},
|
||||
"cn": {"tx_cost": .001, "fixed_fee": 5., "fee_base": 0., "fee_per_share": 0.},
|
||||
},
|
||||
"strategies": {
|
||||
"us": ["overnight_recovery_top10", "overnight_recovery_top10_rb20",
|
||||
"us_three_sleeve", "us_three_sleeve_rb20",
|
||||
"us_momentum_qqq_trend", "us_momentum_qqq_trend_rb20"],
|
||||
"cn": ["lowbeta_sharpe_mom_top20", "lowbeta_sharpe_mom_top20_rb20"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
ETF_STRATEGY_UNIVERSES = {
|
||||
"trend_rider_v3_us": sorted(set(ETF_UNIVERSE)),
|
||||
"trend_rider_v3_global": sorted(set(GLOBAL_ETF_UNIVERSE)),
|
||||
@@ -347,18 +398,17 @@ AGENT_REVIEW_STRATEGIES = {"overnight_recovery_agent_top10"}
|
||||
|
||||
# Registered strategies the live monitor does not run, with the reason.
|
||||
LIVE_EXCLUDED_STRATEGIES = {
|
||||
"buy_and_hold": "equal weight across ~500 names is not investable with whole "
|
||||
"shares at live capital (every slice is below the minimum trade)",
|
||||
"buy_and_hold": "benchmark is reported separately from strategy forward accounts",
|
||||
}
|
||||
|
||||
DEFAULT_MONITOR_STRATEGIES = [
|
||||
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES
|
||||
name for name in STRATEGY_REGISTRY if name not in LIVE_EXCLUDED_STRATEGIES and name not in EXECUTION_VARIANTS
|
||||
]
|
||||
|
||||
|
||||
def strategy_extra_tickers(strategy_name: str) -> list[str]:
|
||||
"""Fixed ETF tickers a mixed stock+ETF strategy needs beside its stock universe."""
|
||||
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(strategy_name.removeprefix("sim_"), []))
|
||||
return list(MIXED_STRATEGY_EXTRA_TICKERS.get(canonical_strategy_name(strategy_name), []))
|
||||
|
||||
|
||||
def strategy_fields(strategy_name: str) -> tuple[str, ...]:
|
||||
@@ -370,6 +420,8 @@ def strategy_fields(strategy_name: str) -> tuple[str, ...]:
|
||||
def strategy_supports_market(strategy_name: str, market: str) -> bool:
|
||||
"""ETF-based strategies trade USD-listed ETFs, so they only run in the US book."""
|
||||
base_name = strategy_name.removeprefix("sim_")
|
||||
if base_name in EXECUTION_VARIANTS:
|
||||
return market == EXECUTION_VARIANTS[base_name][1]
|
||||
if base_name in ETF_STRATEGY_UNIVERSES or base_name in MIXED_STRATEGY_EXTRA_TICKERS \
|
||||
or base_name in AGENT_REVIEW_STRATEGIES:
|
||||
return market == "us"
|
||||
@@ -383,7 +435,7 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
|
||||
trade fixed USD/HK ETF baskets and use SPY as the regime benchmark.
|
||||
Mixed strategies (e.g. V6) get the stock universe + extra ETF tickers.
|
||||
"""
|
||||
base_name = strategy_name.removeprefix("sim_")
|
||||
base_name = canonical_strategy_name(strategy_name)
|
||||
if base_name in ETF_STRATEGY_UNIVERSES:
|
||||
return ETF_STRATEGY_UNIVERSES[base_name], "SPY"
|
||||
|
||||
@@ -400,66 +452,69 @@ def strategy_universe(market: str, strategy_name: str) -> tuple[list[str], str]:
|
||||
def index_members(market: str, days: pd.DatetimeIndex) -> pd.DataFrame | None:
|
||||
"""Point-in-time index membership (``days`` x every ticker that was a member
|
||||
on one of them), or None for a market without membership history."""
|
||||
if market == "us":
|
||||
mask = universe_history.membership_mask(days, tickers=universe_history.all_tickers_ever())
|
||||
elif market == "cn":
|
||||
snaps = universe_history.load_csi300_history()
|
||||
mask = universe_history.csi300_membership_mask(
|
||||
days, universe_history.all_csi300_tickers_ever(snaps), snaps)
|
||||
else:
|
||||
if market not in ("us", "cn"):
|
||||
return None
|
||||
return mask.loc[:, mask.any()]
|
||||
|
||||
|
||||
def session_universes(market: str, strategy_name: str, tickers: list[str],
|
||||
days: pd.DatetimeIndex) -> list[tuple[list[str], pd.DatetimeIndex]]:
|
||||
"""Split ``days`` into runs of sessions that share one stock universe.
|
||||
|
||||
The latest session trades ``tickers`` (today's list). Earlier sessions
|
||||
trade the index members of their own day: a replay or catch-up over past
|
||||
sessions with today's list would buy names before they joined the index,
|
||||
i.e. names that were added because they had risen. ETF strategies keep
|
||||
their fixed basket.
|
||||
"""
|
||||
if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES or len(days) < 2:
|
||||
return [(list(tickers), days)]
|
||||
members = index_members(market, days[:-1])
|
||||
if members is None:
|
||||
return [(list(tickers), days)]
|
||||
extras = set(strategy_extra_tickers(strategy_name))
|
||||
runs: list[tuple[list[str], list]] = []
|
||||
for day in days:
|
||||
if day == days[-1]:
|
||||
universe = sorted(tickers)
|
||||
else:
|
||||
universe = sorted(set(members.columns[members.loc[day].to_numpy()]) | extras)
|
||||
if runs and runs[-1][0] == universe:
|
||||
runs[-1][1].append(day)
|
||||
else:
|
||||
runs.append((universe, [day]))
|
||||
return [(u, pd.DatetimeIndex(d)) for u, d in runs]
|
||||
return universe_history.pit_membership(market, days)
|
||||
|
||||
|
||||
def pit_candidates(market: str, strategy_name: str, since: pd.Timestamp | None) -> list[str]:
|
||||
"""Tickers that were index members on any day from ``since`` to today, so a
|
||||
catch-up or replay has prices for names that have left the index since."""
|
||||
days = pd.bdate_range(since, pd.Timestamp.today().normalize()) if since is not None else []
|
||||
days = pd.bdate_range(since or "2000-01-01", pd.Timestamp.today().normalize())
|
||||
if len(days) == 0 or strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES:
|
||||
return []
|
||||
members = index_members(market, days)
|
||||
return [] if members is None else list(members.columns)
|
||||
|
||||
|
||||
def make_strategy(name: str, market: str):
|
||||
strategy = STRATEGY_REGISTRY[name.removeprefix("sim_")]()
|
||||
if isinstance(strategy, CompositeAlphaStrategy):
|
||||
strategy.benchmark = UNIVERSES[market]["benchmark"]
|
||||
if isinstance(strategy, BuyAndHoldStrategy):
|
||||
strategy.asset = UNIVERSES[market]["benchmark"]
|
||||
return strategy
|
||||
|
||||
|
||||
def strategy_signal_tickers(name: str, market: str) -> list[str]:
|
||||
strategy = make_strategy(name, market)
|
||||
if isinstance(strategy, CompositeAlphaStrategy):
|
||||
return [strategy.benchmark]
|
||||
return [strategy.asset] if isinstance(strategy, BuyAndHoldStrategy) else []
|
||||
|
||||
|
||||
def session_signals(market: str, strategy_name: str, tickers: list[str], days: pd.DatetimeIndex,
|
||||
close: pd.DataFrame, open_data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Target weights for each day in ``days``, each computed on that day's
|
||||
point-in-time universe (see ``session_universes``); columns are the union."""
|
||||
parts = []
|
||||
for universe, run in session_universes(market, strategy_name, tickers, days):
|
||||
universe = filter_tradable_tickers(close, universe)
|
||||
bars = strategy_bars(market, strategy_name, universe, close, open_data)
|
||||
parts.append(_signals_for_sessions(strategy_name, open_data[universe], run, bars))
|
||||
return pd.concat(parts).fillna(0.0)
|
||||
close: pd.DataFrame, open_data: pd.DataFrame,
|
||||
*, bars: dict | None = None, member: pd.DataFrame | None = None,
|
||||
strategy=None) -> pd.DataFrame:
|
||||
if len(days) == 0:
|
||||
return pd.DataFrame(index=days, columns=tickers, dtype=float)
|
||||
close = close.loc[:days[-1]]
|
||||
benchmark = "SPY" if strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES else UNIVERSES[market]["benchmark"]
|
||||
if benchmark in close and benchmark in open_data:
|
||||
valid_calendar = close[benchmark].notna() | open_data[benchmark].reindex(close.index).notna()
|
||||
close = close.loc[valid_calendar]
|
||||
if days[-1] not in close.index:
|
||||
close = close.reindex(close.index.union(days))
|
||||
pure_etf = strategy_name.removeprefix("sim_") in ETF_STRATEGY_UNIVERSES
|
||||
if member is None and not pure_etf:
|
||||
member = index_members(market, close.index)
|
||||
extras = strategy_extra_tickers(strategy_name) + strategy_signal_tickers(strategy_name, market)
|
||||
cols = (list(member.columns) + extras if member is not None else list(tickers) + extras)
|
||||
cols = sorted(set(filter_tradable_tickers(close, cols)))
|
||||
eligible = (member.reindex(index=close.index, columns=cols).fillna(False)
|
||||
if member is not None else pd.DataFrame(True, index=close.index, columns=cols))
|
||||
for t in extras:
|
||||
if t in eligible:
|
||||
eligible[t] = True
|
||||
strategy = strategy if strategy is not None else make_strategy(strategy_name, market)
|
||||
if bars is None:
|
||||
bars = strategy_bars(market, strategy_name, cols, close, open_data)
|
||||
if bars is not None:
|
||||
bars = {f: b.reindex(index=close.index, columns=cols) for f, b in bars.items()}
|
||||
targets = session_targets(strategy, close[cols], open_data.reindex(index=close.index, columns=cols),
|
||||
bars, eligible)
|
||||
return targets.reindex(days).fillna(0.0)
|
||||
|
||||
|
||||
def strategy_data_market(market: str, strategy_name: str) -> str:
|
||||
@@ -483,7 +538,7 @@ def convert_quote_currency(panel: pd.DataFrame) -> pd.DataFrame:
|
||||
if not hk:
|
||||
return panel
|
||||
if HKD_FX_TICKER in panel.columns:
|
||||
fx = panel[HKD_FX_TICKER].ffill().bfill().fillna(HKD_PEG_MIDPOINT)
|
||||
fx = panel[HKD_FX_TICKER].ffill().fillna(HKD_PEG_MIDPOINT)
|
||||
else:
|
||||
print(f" WARNING: {HKD_FX_TICKER} not in panel; converting HK prices at "
|
||||
f"the peg midpoint {HKD_PEG_MIDPOINT}", file=sys.stderr)
|
||||
@@ -501,6 +556,13 @@ def price_request(market: str, tickers: list[str]) -> list[str]:
|
||||
return request
|
||||
|
||||
|
||||
def refresh_membership(market: str) -> None:
|
||||
if market == "us":
|
||||
universe_history.load_sp500_history()
|
||||
elif market == "cn":
|
||||
universe_history.load_csi300_history()
|
||||
|
||||
|
||||
def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
|
||||
benchmark: str, with_open: bool = False, refresh: bool = True):
|
||||
"""Load a strategy's prices in its portfolio currency, refreshing them first
|
||||
@@ -511,9 +573,10 @@ def load_strategy_prices(market: str, strategy_name: str, tickers: list[str],
|
||||
stale forward-filled price from a real one.
|
||||
"""
|
||||
data_market = strategy_data_market(market, strategy_name)
|
||||
fields = ["close", "open"] if with_open else ["close"]
|
||||
fields = ["close", "open", "volume"] if with_open else ["close", "volume"]
|
||||
fields += [f for f in strategy_fields(strategy_name) if f not in fields]
|
||||
if refresh:
|
||||
refresh_membership(market)
|
||||
request = price_request(market, list(tickers) + [benchmark])
|
||||
panels = data_manager.update(data_market, request, fields=fields)
|
||||
else:
|
||||
@@ -609,10 +672,6 @@ def init_state(market: str, strategy_name: str, capital: float) -> dict:
|
||||
# Core logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def portfolio_value(holdings: dict, prices: dict, cash: float) -> float:
|
||||
stock_value = sum(holdings.get(t, 0) * prices.get(t, 0) for t in holdings)
|
||||
return cash + stock_value
|
||||
|
||||
|
||||
def strategy_bars(market: str, strategy_name: str, tickers: list[str],
|
||||
close: pd.DataFrame, open_data: pd.DataFrame | None) -> dict | None:
|
||||
@@ -643,192 +702,6 @@ def bar_session_weights(strategy: BarStrategy, bars: dict, days: pd.DatetimeInde
|
||||
return strategy.generate_signals(hist).reindex(days).fillna(0.0)
|
||||
|
||||
|
||||
def _allocate_lots(desired: dict, holdings: dict, prices: dict, cash: float,
|
||||
lot_size: int, reserve: float) -> dict:
|
||||
"""Whole-lot share targets: round each move toward zero, then spend the
|
||||
remaining cash one lot at a time on the names furthest below target,
|
||||
as long as the lot brings them nearer (nearest-lot rounding)."""
|
||||
final, spend = {}, 0.0
|
||||
for t, want in desired.items():
|
||||
cur = holdings.get(t, 0.0)
|
||||
if want <= 0:
|
||||
final[t] = 0.0 # full exit, odd lot included
|
||||
else:
|
||||
final[t] = cur + int((want - cur) / lot_size) * lot_size
|
||||
spend += (final[t] - cur) * prices[t]
|
||||
budget = cash - spend - reserve
|
||||
short = sorted(((desired[t] - final[t]) * prices[t], t) for t in desired if desired[t] > 0)
|
||||
for gap, t in reversed(short):
|
||||
lot_value = lot_size * prices[t]
|
||||
if gap > 0.5 * lot_value and lot_value <= budget:
|
||||
final[t] += lot_size
|
||||
budget -= lot_value
|
||||
return final
|
||||
|
||||
|
||||
def compute_trades(holdings: dict, cash: float, target_weights: dict,
|
||||
prices: dict, min_trade_value: float = 50.0,
|
||||
integer_shares: bool = False, lot_size: int = 1,
|
||||
frozen: set | None = None, resize_band: float = 0.0) -> list[dict]:
|
||||
"""
|
||||
Compute trades needed to move from current holdings to target weights.
|
||||
|
||||
If integer_shares=True, positions move in whole multiples of ``lot_size``
|
||||
(e.g. 100-share A-share board lots): each move is rounded toward zero and
|
||||
leftover cash then buys one more lot for the names furthest below target
|
||||
when that lot brings them nearer; a position whose target is zero is
|
||||
always sold in full, odd lot included.
|
||||
A held position with a nonzero target is not resized while it is within
|
||||
``resize_band`` (fraction of its target value) of the target.
|
||||
Tickers in ``frozen`` (no fresh price today) are neither bought nor sold.
|
||||
|
||||
Returns list of {ticker, shares_delta, direction, est_value}.
|
||||
"""
|
||||
total = portfolio_value(holdings, prices, cash)
|
||||
frozen = frozen or set()
|
||||
desired = {}
|
||||
for ticker in sorted(set(target_weights) | set(holdings)):
|
||||
if ticker in frozen:
|
||||
continue
|
||||
price = prices.get(ticker, 0.0)
|
||||
if price <= 0:
|
||||
continue
|
||||
target_weight = target_weights.get(ticker, 0.0)
|
||||
target_value = total * target_weight
|
||||
current_shares = holdings.get(ticker, 0.0)
|
||||
if (current_shares > 0 and target_weight > 0
|
||||
and abs(target_value - current_shares * price) < resize_band * target_value):
|
||||
continue
|
||||
desired[ticker] = target_value / price
|
||||
|
||||
targets = (_allocate_lots(desired, holdings, prices, cash, lot_size,
|
||||
reserve=0.002 * total + 10.0 * len(desired))
|
||||
if integer_shares else desired)
|
||||
|
||||
raw = []
|
||||
for ticker, target_shares in targets.items():
|
||||
price = prices[ticker]
|
||||
current_shares = holdings.get(ticker, 0.0)
|
||||
delta = target_shares - current_shares
|
||||
trade_value = abs(delta * price)
|
||||
if trade_value < min_trade_value:
|
||||
continue
|
||||
if integer_shares and abs(delta) < 1:
|
||||
continue
|
||||
|
||||
raw.append({
|
||||
"ticker": ticker,
|
||||
"shares_delta": int(round(delta)) if integer_shares else round(delta, 4),
|
||||
"direction": "BUY" if delta > 0 else "SELL",
|
||||
"est_value": round(trade_value, 2),
|
||||
"price": round(price, 6),
|
||||
"target_shares": int(round(target_shares)) if integer_shares else round(target_shares, 4),
|
||||
"current_shares": int(current_shares) if integer_shares else round(current_shares, 4),
|
||||
})
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def _per_trade_commission(
|
||||
shares: float,
|
||||
price: float,
|
||||
tx_cost: float,
|
||||
fixed_fee: float,
|
||||
fee_base: float = 0.0,
|
||||
fee_per_share: float = 0.0,
|
||||
) -> float:
|
||||
"""Commission for one trade.
|
||||
|
||||
Matches the IBKR-style tiered formula used by the backtest engine:
|
||||
commission = bps_cost + max(fixed_fee, fee_base + fee_per_share * shares)
|
||||
With fee_base=0 and fee_per_share=0 this degenerates to the flat
|
||||
fixed-fee model (legacy behavior).
|
||||
"""
|
||||
bps_cost = abs(shares) * price * tx_cost
|
||||
per_trade = fee_base + fee_per_share * abs(shares)
|
||||
floor = max(fixed_fee, per_trade)
|
||||
return bps_cost + floor
|
||||
|
||||
|
||||
def execute_trades(state: dict, trades: list[dict], prices: dict,
|
||||
tx_cost: float = 0.001, fixed_fee: float = 0.0,
|
||||
fee_base: float = 0.0, fee_per_share: float = 0.0,
|
||||
trade_date: str = "", integer_shares: bool = False,
|
||||
lot_size: int = 1) -> None:
|
||||
"""Execute trades: update holdings and cash in state, append to trade_log.
|
||||
|
||||
When integer_shares=True, sells are executed first to free up cash,
|
||||
then buys are executed only if sufficient cash is available.
|
||||
|
||||
Per-trade commission supports both the legacy flat ``fixed_fee`` and
|
||||
the IBKR-style tiered ``max(fixed_fee, fee_base + fee_per_share*shares)``
|
||||
schedule used by the backtest engine.
|
||||
"""
|
||||
holdings = state["holdings"]
|
||||
cash = state["cash"]
|
||||
|
||||
# When using integer shares, execute sells first to free cash for buys
|
||||
sells = [t for t in trades if t["shares_delta"] < 0]
|
||||
buys = [t for t in trades if t["shares_delta"] > 0]
|
||||
ordered = sells + buys if integer_shares else trades
|
||||
|
||||
us_fee_per_share = fee_per_share
|
||||
for trade in ordered:
|
||||
ticker = trade["ticker"]
|
||||
delta = trade["shares_delta"]
|
||||
price = prices.get(ticker, trade["price"])
|
||||
cost = abs(delta * price)
|
||||
# The per-share schedule applies to US-listed shares only; HK lines
|
||||
# (often < $1/share) pay the proportional cost and the ticket floor.
|
||||
fee_per_share = 0.0 if ticker.endswith(".HK") else us_fee_per_share
|
||||
commission = _per_trade_commission(
|
||||
abs(delta), price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||||
)
|
||||
|
||||
if delta > 0:
|
||||
# BUY — skip if insufficient cash in integer mode
|
||||
if integer_shares and (cost + commission) > cash:
|
||||
# Try buying fewer shares that we can afford, accounting for
|
||||
# the per-share variable component of the commission.
|
||||
affordable_price_unit = price * (1 + tx_cost) + fee_per_share
|
||||
if affordable_price_unit <= 0:
|
||||
continue
|
||||
affordable = int((cash - max(fixed_fee, fee_base)) / affordable_price_unit)
|
||||
affordable = affordable // lot_size * lot_size
|
||||
if affordable < 1:
|
||||
continue
|
||||
delta = affordable
|
||||
cost = abs(delta * price)
|
||||
commission = _per_trade_commission(
|
||||
delta, price, tx_cost, fixed_fee, fee_base, fee_per_share,
|
||||
)
|
||||
cash -= (cost + commission)
|
||||
holdings[ticker] = holdings.get(ticker, 0.0) + delta
|
||||
else:
|
||||
# SELL
|
||||
cash += (cost - commission)
|
||||
holdings[ticker] = holdings.get(ticker, 0.0) + delta # delta is negative
|
||||
|
||||
# Remove zero holdings
|
||||
if ticker in holdings and abs(holdings[ticker]) < (0.5 if integer_shares else 0.001):
|
||||
del holdings[ticker]
|
||||
|
||||
state["trade_log"].append({
|
||||
"date": trade_date,
|
||||
"action": "BUY" if delta > 0 else "SELL",
|
||||
"ticker": ticker,
|
||||
"shares": abs(delta) if integer_shares else round(abs(delta), 4),
|
||||
"price": round(price, 6),
|
||||
"value": round(cost, 2),
|
||||
"commission": round(commission, 2),
|
||||
})
|
||||
|
||||
state["cash"] = round(cash, 2)
|
||||
if integer_shares:
|
||||
state["holdings"] = {k: int(round(v)) for k, v in holdings.items() if abs(v) >= 0.5}
|
||||
else:
|
||||
state["holdings"] = {k: round(v, 4) for k, v in holdings.items() if abs(v) >= 0.001}
|
||||
|
||||
|
||||
def record_daily_snapshot(state: dict, date_str: str, prices: dict,
|
||||
trades: list[dict], prev_equity: float) -> None:
|
||||
@@ -865,6 +738,7 @@ def record_daily_snapshot(state: dict, date_str: str, prices: dict,
|
||||
})
|
||||
|
||||
entry = {
|
||||
"engine_version": state.get("engine_version"),
|
||||
"date": date_str,
|
||||
"equity": round(total, 2),
|
||||
"cash": round(cash, 2),
|
||||
@@ -927,6 +801,8 @@ def _book_capital(args, market: str) -> float:
|
||||
def _strategy_fees(args, market: str) -> dict:
|
||||
book = getattr(args, "book", None)
|
||||
if book:
|
||||
if "fee_schedule" in LIVE_BOOKS[book]:
|
||||
return dict(LIVE_BOOKS[book]["fee_schedule"][market])
|
||||
return {"tx_cost": 0.0, "fixed_fee": LIVE_BOOKS[book]["fee_per_order"][market],
|
||||
"fee_base": 0.0, "fee_per_share": 0.0}
|
||||
tier = MARKET_FEE_TIERED.get(market, {})
|
||||
@@ -949,18 +825,19 @@ def _signals_for_sessions(strategy_name: str, open_data: pd.DataFrame,
|
||||
depend on data after t), so one pass equals running each day separately.
|
||||
"""
|
||||
strategy = STRATEGY_REGISTRY[strategy_name]()
|
||||
if isinstance(strategy, BarStrategy):
|
||||
return bar_session_weights(strategy, bars, days)
|
||||
hist = open_data.loc[:days[-1]]
|
||||
dummy = pd.DataFrame([hist.iloc[-1].values], index=[hist.index[-1] + pd.Timedelta(days=1)],
|
||||
columns=hist.columns)
|
||||
weights = strategy.generate_signals(pd.concat([hist, dummy]))
|
||||
return weights.shift(-1).reindex(days).fillna(0.0)
|
||||
close = bars["close"].loc[:days[-1]] if bars is not None else hist
|
||||
if days[-1] not in close.index:
|
||||
close = close.reindex(close.index.union(days))
|
||||
if bars is not None:
|
||||
bars = {f: b.reindex(close.index) for f, b in bars.items()}
|
||||
return session_targets(strategy, close, hist.reindex(close.index), bars).reindex(days).fillna(0.0)
|
||||
|
||||
|
||||
def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
|
||||
close_data: pd.DataFrame, close_raw: pd.DataFrame, fees: dict,
|
||||
integer_shares: bool, signal_row: pd.Series) -> dict:
|
||||
integer_shares: bool, signal_row: pd.Series,
|
||||
volume_row: pd.Series | None = None) -> dict:
|
||||
"""Execute ``signal_row`` (targets from ``day``'s open; a BarStrategy: bars
|
||||
through the previous session, see ``session_signals``) at ``day``'s close
|
||||
and record the day.
|
||||
@@ -969,6 +846,10 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
|
||||
valued but not traded.
|
||||
"""
|
||||
day_str = str(day.date())
|
||||
if state.get("engine_version") != ENGINE_VERSION:
|
||||
state.setdefault("engine_changes", []).append({"first_session": day_str, "version": ENGINE_VERSION,
|
||||
"recorded_at": datetime.now(timezone.utc).isoformat()})
|
||||
state["engine_version"] = ENGINE_VERSION
|
||||
lot_size = MARKET_LOT_SIZE.get(market, 1) if integer_shares else 1
|
||||
target_weights = {t: float(w) for t, w in signal_row.items() if w > 1e-6}
|
||||
|
||||
@@ -980,15 +861,18 @@ def _run_session(state: dict, market: str, day: pd.Timestamp, benchmark: str,
|
||||
frozen = {t for t in all_held if t not in raw_row.index or pd.isna(raw_row[t])}
|
||||
|
||||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||
trades = compute_trades(
|
||||
state["holdings"], state["cash"], target_weights, close_prices,
|
||||
min_trade_value=max(50, pre_value * 0.001),
|
||||
integer_shares=integer_shares, lot_size=lot_size, frozen=frozen,
|
||||
resize_band=RESIZE_BAND,
|
||||
)
|
||||
previous_rows = close_data.loc[close_data.index < day]
|
||||
previous = previous_rows.iloc[-1] if len(previous_rows) else pd.Series(dtype=float)
|
||||
allowed_buy, allowed_sell, sell_tax = market_permissions(market, day, raw_row, previous, volume_row)
|
||||
n_log = len(state["trade_log"])
|
||||
execute_trades(state, trades, close_prices, trade_date=day_str,
|
||||
integer_shares=integer_shares, lot_size=lot_size, **fees)
|
||||
actual = rebalance(state, target_weights, close_prices, set(raw_row.dropna().index),
|
||||
transaction_cost=fees["tx_cost"], fixed_fee=fees["fixed_fee"],
|
||||
fee_base=fees.get("fee_base", 0), fee_per_share=fees.get("fee_per_share", 0),
|
||||
integer_shares=integer_shares, lot_size=lot_size,
|
||||
trade_date=day_str, resize_band=strategy_resize_band(state["strategy"]),
|
||||
can_buy=allowed_buy, can_sell=allowed_sell, sell_tax=sell_tax)
|
||||
trades = [{**t, "direction": t["action"], "est_value": t["value"],
|
||||
"shares_delta": t["shares"] * (1 if t["action"] == "BUY" else -1)} for t in actual]
|
||||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||
|
||||
previous = [d for d in sorted(state["daily_equity"]) if d < day_str]
|
||||
@@ -1031,7 +915,7 @@ def cmd_morning(args):
|
||||
print(f"--- Initialized new portfolio: {capital:,.0f} cash ---")
|
||||
|
||||
# Download data (close + open)
|
||||
close_data, open_data, _ = load_strategy_prices(
|
||||
close_data, open_data, close_raw = load_strategy_prices(
|
||||
market, strategy_name, tickers, benchmark, with_open=True,
|
||||
refresh=getattr(args, "refresh", True),
|
||||
)
|
||||
@@ -1053,9 +937,8 @@ def cmd_morning(args):
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Run strategy on open prices (a BarStrategy: on completed bars)
|
||||
bars = strategy_bars(market, strategy_name, tickers, close_data, open_data)
|
||||
row = _signals_for_sessions(strategy_name, open_data[tickers],
|
||||
pd.DatetimeIndex([today]), bars).loc[today]
|
||||
row = session_signals(market, strategy_name, tickers, pd.DatetimeIndex([today]),
|
||||
close_raw, open_data).loc[today]
|
||||
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
|
||||
|
||||
# Get today's open prices for trade computation
|
||||
@@ -1069,7 +952,7 @@ def cmd_morning(args):
|
||||
open_prices, min_trade_value=max(50, total * 0.001),
|
||||
integer_shares=args.integer_shares,
|
||||
lot_size=MARKET_LOT_SIZE.get(market, 1),
|
||||
resize_band=RESIZE_BAND)
|
||||
resize_band=strategy_resize_band(strategy_name))
|
||||
|
||||
# Store pending
|
||||
state["pending_trades"] = {
|
||||
@@ -1140,7 +1023,7 @@ def cmd_evening(args):
|
||||
return
|
||||
|
||||
# Get close prices
|
||||
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||||
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||||
tickers = filter_tradable_tickers(close_data, tickers)
|
||||
|
||||
target_date = pd.Timestamp(trade_date)
|
||||
@@ -1166,24 +1049,16 @@ def cmd_evening(args):
|
||||
# Recompute trades with close prices for execution
|
||||
target_weights = pending.get("target_weights", {})
|
||||
lot_size = MARKET_LOT_SIZE.get(market, 1)
|
||||
exec_trades = compute_trades(
|
||||
state["holdings"], state["cash"], target_weights,
|
||||
close_prices, min_trade_value=max(50, pre_value * 0.001),
|
||||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||||
)
|
||||
execute_trades(state, exec_trades, close_prices, trade_date=trade_date,
|
||||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||||
**_strategy_fees(args, market))
|
||||
first_log = len(state["trade_log"])
|
||||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||||
_run_session(state, market, target_date, benchmark, close_data, close_raw,
|
||||
_strategy_fees(args, market), args.integer_shares,
|
||||
pd.Series(target_weights, dtype=float),
|
||||
volume_row=volume.reindex(close_data.index).loc[target_date] if volume is not None else None)
|
||||
exec_trades = [{**t, "direction": t["action"], "est_value": t["value"]}
|
||||
for t in state["trade_log"][first_log:]]
|
||||
|
||||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||
state["daily_equity"][trade_date] = round(post_value, 2)
|
||||
|
||||
# Record daily snapshot so daily_log stays complete even on no-trade days
|
||||
eq_vals = list(state["daily_equity"].values())
|
||||
prev_eq = eq_vals[-2] if len(eq_vals) >= 2 else state["initial_capital"]
|
||||
record_daily_snapshot(state, trade_date, close_prices, exec_trades, prev_eq)
|
||||
|
||||
state["pending_trades"] = None
|
||||
state["last_evening"] = trade_date
|
||||
save_state(state, market, strategy_name, book)
|
||||
|
||||
@@ -1225,7 +1100,7 @@ def cmd_status(args):
|
||||
|
||||
# Get latest prices
|
||||
tickers, benchmark = strategy_universe(market, strategy_name)
|
||||
close_data, _, _ = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||||
close_data, _, close_raw = load_strategy_prices(market, strategy_name, tickers, benchmark)
|
||||
|
||||
last_date = close_data.index[-1]
|
||||
all_held = list(state["holdings"].keys())
|
||||
@@ -1536,13 +1411,14 @@ def cmd_simulate(args):
|
||||
|
||||
# Load both open and close data
|
||||
request = sorted(set(tickers) | set(pit_candidates(market, strategy_name, start)))
|
||||
close_data, open_data, _ = load_strategy_prices(
|
||||
close_data, open_data, close_raw = load_strategy_prices(
|
||||
market, strategy_name, request, benchmark, with_open=True,
|
||||
)
|
||||
tickers = filter_tradable_tickers(close_data, tickers)
|
||||
|
||||
# Date range
|
||||
trading_days = close_data.index[(close_data.index >= start) & (close_data.index <= end)]
|
||||
sessions = completed_sessions(close_raw, strategy_data_market(market, strategy_name), benchmark)
|
||||
trading_days = sessions[(sessions >= start) & (sessions <= end)]
|
||||
|
||||
if len(trading_days) == 0:
|
||||
print(f"No trading days found between {args.start} and {args.end}")
|
||||
@@ -1554,7 +1430,7 @@ def cmd_simulate(args):
|
||||
# Pre-compute strategy weights for the full period (much faster than per-day),
|
||||
# each day on its point-in-time index members
|
||||
full_weights = session_signals(market, strategy_name, tickers, trading_days,
|
||||
close_data, open_data)
|
||||
close_raw, open_data)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f" SIMULATION: {strategy_name} | {market.upper()}")
|
||||
@@ -1566,52 +1442,16 @@ def cmd_simulate(args):
|
||||
total_trades = 0
|
||||
total_commission = 0.0
|
||||
|
||||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||||
for i, day in enumerate(trading_days):
|
||||
day_str = str(day.date())
|
||||
|
||||
# Morning: get target weights from pre-computed matrix
|
||||
if day in full_weights.index:
|
||||
row = full_weights.loc[day]
|
||||
target_weights = {t: float(w) for t, w in row.items() if w > 1e-6}
|
||||
else:
|
||||
target_weights = {}
|
||||
|
||||
# Get open and close prices
|
||||
all_held = list(set(list(target_weights.keys()) + list(state["holdings"].keys())))
|
||||
open_prices = get_prices_for_date(all_held, day, open_data)
|
||||
close_prices = get_prices_for_date(all_held + [benchmark], day, close_data)
|
||||
|
||||
if not close_prices:
|
||||
continue
|
||||
|
||||
# Compute portfolio value before trades (at close, reflecting overnight changes)
|
||||
pre_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||
|
||||
# Compute and execute trades at close prices
|
||||
lot_size = MARKET_LOT_SIZE.get(market, 1)
|
||||
trades = compute_trades(
|
||||
state["holdings"], state["cash"], target_weights,
|
||||
close_prices, min_trade_value=max(50, pre_value * 0.001),
|
||||
integer_shares=args.integer_shares, lot_size=lot_size,
|
||||
resize_band=RESIZE_BAND,
|
||||
)
|
||||
|
||||
execute_trades(state, trades, close_prices,
|
||||
tx_cost=args.tx_cost, fixed_fee=args.fixed_fee,
|
||||
trade_date=day_str, integer_shares=args.integer_shares,
|
||||
lot_size=lot_size)
|
||||
|
||||
post_value = portfolio_value(state["holdings"], close_prices, state["cash"])
|
||||
state["daily_equity"][day_str] = round(post_value, 2)
|
||||
|
||||
# Record daily snapshot (holdings + operations)
|
||||
prev_eq_val = args.capital if i == 0 else list(state["daily_equity"].values())[-2] if len(state["daily_equity"]) >= 2 else args.capital
|
||||
record_daily_snapshot(state, day_str, close_prices, trades, prev_eq_val)
|
||||
|
||||
day_trades = len(trades)
|
||||
day_commission = sum(
|
||||
t.get("commission", 0) for t in state["trade_log"][-day_trades:]
|
||||
) if day_trades else 0
|
||||
stats = _run_session(state, market, day, benchmark, close_data, close_raw,
|
||||
_strategy_fees(args, market), args.integer_shares,
|
||||
full_weights.loc[day],
|
||||
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
|
||||
post_value = stats["value"]
|
||||
day_trades, day_commission = stats["trades"], stats["commission"]
|
||||
total_trades += day_trades
|
||||
total_commission += day_commission
|
||||
|
||||
@@ -1935,6 +1775,7 @@ def cmd_monitor(args):
|
||||
|
||||
def refresh_panels(market: str, strategy_names: list[str], label: str) -> None:
|
||||
"""Refresh every price panel the strategies read, once, for the union of their tickers."""
|
||||
refresh_membership(market)
|
||||
requests: dict[str, set[str]] = {}
|
||||
fields: dict[str, set[str]] = {}
|
||||
for name in strategy_names:
|
||||
@@ -2001,10 +1842,12 @@ def _process_sessions(args, state: dict | None, start: pd.Timestamp | None = Non
|
||||
f"{todo[0].date()} .. {todo[-1].date()}")
|
||||
|
||||
fees = _strategy_fees(args, market)
|
||||
signals = session_signals(market, strategy_name, tickers, todo, close_data, open_data)
|
||||
volume = data_manager.load_raw(strategy_data_market(market, strategy_name), "volume")
|
||||
signals = session_signals(market, strategy_name, tickers, todo, close_raw, open_data)
|
||||
for day in todo:
|
||||
r = _run_session(state, market, day, benchmark, close_data, close_raw, fees,
|
||||
args.integer_shares, signal_row=signals.loc[day])
|
||||
args.integer_shares, signal_row=signals.loc[day],
|
||||
volume_row=(volume.loc[day] if day in volume.index else pd.Series(dtype=float)) if volume is not None else None)
|
||||
frozen = f" | no fresh price: {', '.join(r['frozen'])}" if r["frozen"] else ""
|
||||
print(f"[{label}] {r['date']} {strategy_name:<34} value {r['value']:>12,.2f} "
|
||||
f"({r['daily_return']:+.2%}) trades {r['trades']:>2} "
|
||||
|
||||
@@ -173,14 +173,14 @@ def _cache_path() -> str:
|
||||
return os.path.join(CACHE_DIR, "sp500_history.json")
|
||||
|
||||
|
||||
def load_sp500_history(force_refresh: bool = False) -> dict[str, list[list[str | None]]]:
|
||||
def load_sp500_history(force_refresh: bool = False, *, refresh: bool = True) -> dict[str, list[list[str | None]]]:
|
||||
"""Load cached membership history, or rebuild if stale (>1 day old)."""
|
||||
path = _cache_path()
|
||||
if not force_refresh and os.path.exists(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
if data.get("date") == str(date.today()):
|
||||
if not refresh or data.get("date") == str(date.today()):
|
||||
return data["intervals"]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -285,13 +285,13 @@ def baostock_to_yahoo(code: str) -> str:
|
||||
return f"{num}.{'SS' if ex == 'sh' else 'SZ'}"
|
||||
|
||||
|
||||
def load_csi300_history(current: list[str] | None = None) -> dict[str, list[str]]:
|
||||
def load_csi300_history(current: list[str] | None = None, *, refresh: bool = True) -> dict[str, list[str]]:
|
||||
"""Monthly CSI 300 snapshots; appends ``current`` (or today's list) if it changed."""
|
||||
path = _csi300_path()
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
snaps = data["snapshots"]
|
||||
if current is None:
|
||||
if current is None and refresh:
|
||||
try:
|
||||
from universe import get_csi300
|
||||
current = get_csi300()
|
||||
@@ -309,6 +309,16 @@ def load_csi300_history(current: list[str] | None = None) -> dict[str, list[str]
|
||||
return snaps
|
||||
|
||||
|
||||
def pit_membership(market: str, dates: pd.DatetimeIndex, *, refresh: bool = False) -> pd.DataFrame:
|
||||
if market == "us":
|
||||
intervals = load_sp500_history(refresh=refresh)
|
||||
return membership_mask(dates, intervals, sorted(intervals))
|
||||
if market == "cn":
|
||||
snapshots = load_csi300_history(refresh=refresh)
|
||||
return csi300_membership_mask(dates, all_csi300_tickers_ever(snapshots), snapshots)
|
||||
raise ValueError(f"No PIT membership for {market}")
|
||||
|
||||
|
||||
def csi300_membership_mask(dates: pd.DatetimeIndex, tickers: list[str],
|
||||
snapshots: dict[str, list[str]] | None = None) -> pd.DataFrame:
|
||||
"""Boolean mask (dates x tickers): True while the ticker was in the CSI 300."""
|
||||
|
||||
@@ -261,9 +261,12 @@ export function LivePage() {
|
||||
function emptyHint(book: LiveBook | undefined, market: StrategyMarket): string {
|
||||
if (!book?.startDate) return "暂无在线策略。运行 quant 的 trader.py auto 后再刷新。";
|
||||
const currency = currencyForMarket(market);
|
||||
const fee = book.feePerOrder ? ` · 每笔 ${formatMoney(book.feePerOrder[market], currency)}` : "";
|
||||
const schedule = book.feeSchedule?.[market];
|
||||
const fee = schedule
|
||||
? ` · 佣金最低 ${formatMoney(schedule.fixed_fee, currency)},比例成本 ${(schedule.tx_cost * 100).toFixed(2)}%`
|
||||
: book.feePerOrder ? ` · 每单佣金 ${formatMoney(book.feePerOrder[market], currency)}` : "";
|
||||
return `${book.label}:${book.strategies[market].length} 个策略,各 ${formatMoney(book.capital[market], currency)}${fee},`
|
||||
+ `${book.startDate[market]} 收盘后出现首日记录。`;
|
||||
+ `${book.startDate[market]} 起首个交易日收盘后出现记录。`;
|
||||
}
|
||||
|
||||
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
|
||||
|
||||
@@ -594,6 +594,15 @@ function EquityCurveChart({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{result.meta.warnings?.length ? (
|
||||
<details className="backtestAssumptions">
|
||||
<summary>回测数据与成交假设</summary>
|
||||
<ul>{result.meta.warnings.map((message) => <li key={message}>{message}</li>)}</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{result.meta.errors?.length ? (
|
||||
<p role="alert">未完成回测:{result.meta.errors.map((e) => `${e.strategy}: ${e.error}`).join(";")}</p>
|
||||
) : null}
|
||||
<div className="legendRow">
|
||||
{result.strategies.length === 0 && (
|
||||
<span className="legendEmpty">从左侧选取策略加入对比</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ function ensureCacheDir(): void {
|
||||
}
|
||||
|
||||
function configHash(config: BacktestConfig): string {
|
||||
const serialized = stableStringify(config);
|
||||
const serialized = stableStringify({ engine: "cash-ledger-pit-v2", config });
|
||||
return createHash("md5").update(serialized).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export interface InjectionEvent {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// Backtest metrics are time-weighted (computed on the no-injection curve).
|
||||
// Backtest metrics use unit NAV after executing contributions and their fees.
|
||||
// The bridge serializes NaN as null, so every field may be null.
|
||||
export interface StrategyMetrics {
|
||||
totalReturn: number | null;
|
||||
@@ -107,7 +107,7 @@ export interface StrategyResult {
|
||||
label: string;
|
||||
// Money curve (includes injections when a schedule exists).
|
||||
equityCurve: EquityPoint[];
|
||||
// Same strategy without injections; metrics are computed on this curve.
|
||||
// Same strategy without injections, supplied for comparison.
|
||||
baseEquityCurve: EquityPoint[] | null;
|
||||
metrics: StrategyMetrics;
|
||||
capitalFlows?: CapitalFlows;
|
||||
@@ -121,6 +121,11 @@ export interface StrategyResult {
|
||||
}
|
||||
|
||||
export interface BacktestMeta {
|
||||
requestedYears?: number;
|
||||
engineVersion?: string;
|
||||
warnings?: string[];
|
||||
coverage?: { minimum: number; median: number } | null;
|
||||
errors?: { strategy: string; error: string }[];
|
||||
market: StrategyMarket;
|
||||
years: number;
|
||||
initialCapital: number;
|
||||
@@ -163,6 +168,9 @@ export interface TradeLogEntry {
|
||||
// A live book: a cohort of strategies, each with its own account, started on
|
||||
// one date with one capital and fee schedule. id "" is the original book.
|
||||
export interface LiveBook {
|
||||
feeSchedule?: Partial<Record<StrategyMarket, {
|
||||
tx_cost: number; fixed_fee: number; fee_base: number; fee_per_share: number;
|
||||
}>> | null;
|
||||
id: string;
|
||||
label: string;
|
||||
startDate: Record<StrategyMarket, string> | null;
|
||||
|
||||
Reference in New Issue
Block a user