portfolio-research

English · 한국어

US equity factor research that charges for its own search — walk-forward only, Deflated Sharpe and PBO at the gate, and the rejections ship too.

Python 3.10+ License: MIT LinkedIn

Cumulative growth over the walk-forward validation window, 2002-12 to 2026-08: large-cap 5-factor with 200-day timing reaches 36x at 15bps slippage and 27x at 50bps, against 14x for SPY buy-and-hold. Log vertical axis.

Log scale — equal slopes mean equal returns. The flat stretches in 2008, 2012 and 2022 are the 200-day overlay holding cash; those three are where the drawdown parts company with SPY. (Chart labels are Korean; the alt text carries the reading.)

Three things that are unusual about this repo

Quick start

make install  # uv sync --extra dev — requires uv (https://docs.astral.sh/uv/)
make test     # pytest + coverage — sanity-check the install

No API key needed to explore the results that are already checked into the repo:

# Reproduce the strategy-search cost (DSR/PBO) — reads only results/oos/, no vendor data
uv run python scripts/strategy_search_cost.py

# Original VAA strategy — free data (yfinance), interactive menu
make run

Re-running the factor engine or TAA from scratch (ingesting fresh data, optimizing new configs) needs a Sharadar subscription — see Usage below. Everything reported in this README was measured on data through 2026-08-14, and the outputs in results/ are what ships, so the numbers can be checked without vendor data.

What is inside

Three subsystems answer three separate questions, each measured against the same overfitting gate (Deflated Sharpe + PBO).

  Factor engine (factor/) TAA allocation (taa/) Original VAA (strategies/)
Question Which stocks to buy Which asset class to rotate into (same — first attempt)
Scope US single stocks (20,931 tickers, 1997–2026) 18 ETFs 7–11 ETFs
Data Sharadar direct (point-in-time, delisted included) Sharadar funds bulk (closeadj) yfinance daily closes
Entry point opt-factor · opt-factor-tui scripts/run_taa.py make run · run.py
Outcome 1 adopted (large-cap) 0 adopted — all 9 failed the PBO gate Kept as the record of why it failed

None of the three imports another, with one exception: taa/ → factor.research.overfitting (DSR and PBO gate).

What it looks like when it works

Operating candidate · walk-forward validation window · 2002-12 – 2026-08 (23.6y)

Large-cap, 5 factors + 200-day moving-average timing overlay (configs/strategy_lean_timed.json)

Metric Slippage 15bps Slippage 50bps SPY (same window)
CAGR 16.34% 14.91% 11.66%
Max drawdown −24.3% −24.3% −55.2%
Volatility 15.7% 15.7% 18.6%
Sharpe 0.727 0.648 0.418
Calmar 0.67 0.61 0.21
Deflated Sharpe (72 parameter trials) 0.996 ✓ 0.988 ✓ —
Deflated Sharpe (35 strategy trials) 0.982 ✓ 0.957 ✓ —
PBO (CSCV over 35 configs · monthly · S=16) 0.303 ✓ — —

This strategy is measured with the guards switched on — $5 minimum price, $1M minimum dollar volume, and slippage. The universe is the historical S&P 500, so there is no capacity limit. Raising slippage to 50bps leaves drawdown and volatility unchanged and costs 1.4pp of return.

The last two rows charge for the search outside the walk-forward — “35 strategies were tried and one was picked”. The reasoning behind the gate’s settings (monthly, S=16) is below. Reproduce with uv run python scripts/strategy_search_cost.py; it reads only results/oos/ and needs no vendor data.

Why it is built this way

Quant backtests fail in a small number of well-known ways. Each one is blocked structurally here, under one design principle: never fail silently.

Common failure How it is prevented
Survivorship bias — only today’s survivors are in the sample Delisted names retained (Enron, old American Airlines, Ambac verified present)
Look-ahead — using numbers before they were public Expressions cannot touch raw tables; everything passes through PanelContext, which enforces datekey alignment
Restatement contamination — using revised figures First print wins — only the number the market originally saw is stored
Silent truncation — partial data reported as success Pagination raises TruncatedDataError when the expected range isn’t reached
Overfitting — run hundreds of variants, report the best Deflated Sharpe Ratio + PBO charge for the number of trials
In-sample performance reporting Official performance is walk-forward only; single backtests are labelled reference-only

What was retired, and what it cost

Cumulative growth of the micro-cap strategy: 153x with the guards off, 0.040x with them on — a 96% loss of principal. Log vertical axis.

Same strategy, same window; the only difference is slippage, minimum price and minimum dollar volume. The blue line was this README’s headline until 2026-08-16.

A micro-cap, 8-factor strategy at CAGR 23.78% stood here until the three guards the design document calls mandatory were switched on. They collapse it — Sharpe 1.047 → −0.224 — because 98% of the universe disappears with them on. The headline moved to the large-cap strategy, which had always been measured with its guards on and so had never been compared on equal terms.

Over twenty candidates were rejected at the Deflated Sharpe gate, and the PBO number was published wrong twice before it was measured properly. The whole trail is written down: docs/journal/.

Tactical asset allocation — nine pre-registered, nine rejected

Monthly rotation across 18 ETFs. Nine pre-registered configurations, all nine rejected: PBO = 0.770. The gate was not relaxed.

That does not mean nothing works. All six BAA variants (Keller 2022) beat 60/40 on Calmar without exception, 0.535–0.812 against 0.354 — but PBO across just those six is 0.861, so which one is best cannot be determined from this data. Both sentences have to stand together.

The nine configurations, the eight defects caught along the way, and why VAA measured 6.07% here against the papers’ 16–17% are in docs/taa/01-results.md (Korean) and docs/journal/.

Architecture

flowchart LR
    subgraph Factor engine
        SH[(Sharadar<br/>sf1 · sep · daily · actions)] -->|"opt-factor ingest"| STORE[(PITStore<br/>us.duckdb)]
        STORE --> CTX["PanelContext<br/>(datekey-aligned, no look-ahead)"]
        CTX --> DSL["Factor DSL<br/>158 factors, factor/library"]
        DSL --> UNIV["Universe filters<br/>price · dollar-volume · sector"]
        UNIV --> PIPE["FactorPipeline.run<br/>factor/pipeline.py"]
        PIPE --> BT["Backtest engine<br/>factor/backtest"]
        BT --> WF["walk-forward optimize<br/>opt-factor optimize"]
        WF --> DSRF["DSR + PBO gate<br/>factor/research/overfitting.py"]
        DSRF -->|"passes"| HOLD["opt-factor holdings<br/>trade plan"]
    end

    subgraph TAA allocation
        FUNDS[(Sharadar funds bulk<br/>closeadj, 18 ETFs)] -->|"taa/data.py"| SIG["Signals<br/>13612w · sma13"]
        SIG --> STRAT["StrategySpec registry<br/>9 pre-registered configs"]
        STRAT --> TBT["Monthly backtest<br/>taa/backtest.py"]
        TBT --> EVAL["evaluate_all + verdict<br/>taa/evaluate.py"]
        EVAL --> DSRF
    end

    subgraph Original VAA
        YF[(yfinance<br/>daily closes)] --> VAA["VAAStrategy<br/>strategies/vaa.py"]
        VAA --> RUNPY["run.py / make run<br/>backtest + report"]
    end

Usage

Reproducing the ingest/optimize commands below needs a Sharadar subscription — the only retail-priced source with point-in-time fundamentals and delisted coverage. The adapter sits behind a neutral Provider protocol, so swapping sources means rewriting one file.

# Ingest data (Sharadar subscription required)
export SHARADAR_API_KEY=...
opt-factor ingest --store us.duckdb --provider sharadar \
  --tables sf1,sep,daily,actions,sp500,tickers

# Screen factor predictive power — decile spread, IC, turnover
uv run python scripts/factor_lab.py --store us.duckdb --factors GP_A,PER,SIZE

# Official performance (walk-forward + Deflated Sharpe)
opt-factor optimize --store us.duckdb --config configs/strategy_lean_timed.json \
  --space configs/space.json --objective calmar

# What to buy today (pass current holdings to get a trade plan)
opt-factor holdings --store us.duckdb \
  --config configs/strategy_lean_timed.json --current my_holdings.csv

# TAA: 9 configurations · PBO · verdict table
uv run python scripts/run_taa.py

Develop

make lint       # ruff check + format --check
make typecheck  # mypy src/

Dependencies are managed with uv (uv.lock). Do not use pip install. Code lives in src/opt_portfolio/: factor/, taa/, strategies/, with analysis/ and core/ shared.

Documentation

Design documents are Korean, in docs/factor-system/ — specs, the data contract (store schema · PIT rules · vendor measurements), the walk-forward mathematics, and the experiment log with the full rejection list. In English: docs/journal/ and docs/factor-library.md.

Limitations

⚠️ All backtests are historical and do not guarantee future returns.

License

MIT

Contributing & Issues

Part of an open-source stack spanning Korean equities, US equities and crypto. Each repository stands on its own.

Market Project What it is
🇰🇷 Korean equities kiwoom-client Kiwoom Securities REST API client — full domestic-equity endpoint coverage, real-time WebSocket, sync + async (pip install kiwoom-client)
🇰🇷 Korean equities krx-fundamentals-client Korean corporate fundamentals Python client library — financial statements, valuation, dividends, screening (DART + KRX + Naver)
🇰🇷 Korean equities krx-news-client Korean market news & disclosures as one schema — Python client library (DART + Toss Securities)
🇰🇷 Korean equities krx-quant-core Shared Python core for Korean-equity quant systems — KRX tick, price-limit and session rules, date-aware transaction-tax cost model, Kiwoom order guards, DART material-disclosure risk classification, fill simulation, Deflated Sharpe and purged CV stats
🇰🇷 Korean equities fin-checkup Telegram alerts for risk disclosures + a DART/SEC financial health checkup — reports measurements and facts, never a recommendation
🇰🇷 Korean equities quant-airflow Airflow pipeline collecting Korean market data into TimescaleDB — delisted names included, so downstream backtests aren’t survivorship-biased
🇰🇷 Korean equities swing-it KOSPI/KOSDAQ alpha validation framework — judged on the per-trade distribution, with random null controls, purged CV and Deflated Sharpe enforced as CI guardrails
🇺🇸 US equities macro-sector-agent Top-down US sector-cycle research — asks which industry has been forgotten, not what to buy; the machine excludes, never picks
🇺🇸 US equities automated-stock-trading-systems Backtester for Bensdorp’s seven non-correlated trading systems (educational reimplementation)
₿ Crypto binance-quant-engine Crypto futures backtest & execution engine — zero lookahead, backtest↔live parity
₿ Crypto crypto-quant-insights Chart-backed research notes from validating a live futures system — crash stress tests, Kelly leverage, live/backtest execution gaps

Author

Younghwan Chae (채영환) · GitHub @younghwan91 · LinkedIn

The full open-source quant stack is listed on the profile.