Cross-sectional multi-factor book on broad liquid US universe (Tiingo fundamentals + 30yr EOD); long-only / long-short / long-tilt as 3 ForwardStrategy paper-tracks through the gate. Deterministic domain+fake-port tests. Backtest/DSR layer = separate increment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.7 KiB
Equity-Factor Sleeve — Design (B2)
Status: design (approved in substance 2026-06-16)
Predecessor: B1 (6 paper-track strategies + Tiingo equity data). Reuses the ForwardStrategy/ForwardTracker port, the cockpit STRATEGY_REGISTRY + cockpit_forward ingest, and TiingoDailyClient.
Goal
A systematic cross-sectional multi-factor equity book on a broad liquid US universe, run as three construction variants — long-only, long-short market-neutral, and long-tilt — each a separate ForwardStrategy paper-forward track through the existing gate. The point is platform-native: build all three, let the forward-gate + DSR decide which construction holds up out-of-sample. Diversifies the (crypto-heavy) book into a new asset class using data we already pay for (Tiingo fundamentals + 30yr EOD).
What this is NOT (scope boundary)
- NOT a backtest — this is the forward-track sleeve. A 30yr backtest/DSR pre-screen layer over the same factor logic is a separate increment (strongly recommended to pair, so candidates are screened before months of blind forward). Out of scope here.
- NOT live execution — paper-forward only (no broker orders).
- NOT analyst/estimate data — Benzinga (earnings estimates/ratings) is not entitled; we use Tiingo actuals + price.
Data (verified live on our Tiingo Power key)
- EOD (
/tiingo/daily/{t}/prices) — adjusted OHLCV, 30yr; via existingTiingoDailyClient. - Fundamentals daily (
/tiingo/fundamentals/{t}/daily) —peRatio,pbRatio,marketCap,enterpriseVal,trailingPEG1Y. - Fundamentals statements (
/tiingo/fundamentals/{t}/statements) —piotroskiFScore,roe,roa,debtEquity,grossMargin,profitMargin,revenueQoQ, … (85 fields via/definitions). - Supported tickers (Tiingo
supported_tickers.zip) — the candidate list for the universe screener (assetType=Stock, US exchanges, active).
Architecture (hexagonal, fits the platform)
domain/strategies/equity_factor.py pure: z-scores, composite, quantile/rank weights, book return (no I/O)
ports/market_data.py (extend) FundamentalsClient + UniverseSource Protocols
adapters/data/tiingo_fundamentals.py TiingoFundamentalsClient (daily metrics + statements)
adapters/data/tiingo_universe.py TiingoUniverseSource (supported-tickers → filter → dollar-volume rank → top-N)
application/equity_factor_strategy.py 3 ForwardStrategy services (long / ls / tilt) sharing the universe+factor compute
registry.py (extend) 3 entries: eqfactor_long, eqfactor_ls, eqfactor_tilt
adapters/orchestration/assets.py 3 assets → cockpit_forward deps
Universe screener (TiingoUniverseSource)
top_n(n=300) -> list[str]: load Tiingo supported-tickers (cache the zip), filter to US common stock + active, rank by trailing ~30d dollar-volume (close×volume from EOD over the candidate set), return the top-N. Refreshed at each monthly rebalance. (Default N=300; configurable 100–500.) To bound API cost, the volume rank uses a coarse pre-filter (e.g. the exchange-listed common-stock subset) before pulling EOD.
Factor computation (domain/strategies/equity_factor.py, pure)
Per rebalance, for the universe cross-section:
- Value = z(−peRatio) + z(−pbRatio) (cheap = high score; guard non-positive/None).
- Quality = z(piotroskiFScore) + z(roe) + z(−debtEquity) + z(grossMargin).
- Momentum = z(12-1 month price return) (skip most recent month).
- Each raw factor is winsorized (e.g. ±3σ) then cross-sectional z-scored; missing inputs → neutral 0 for that factor (a name needs ≥2 of 3 families present to be scored).
- Composite = equal-weight mean of the three family z-scores.
Pure functions:
zscore(values),winsorize(values, k),composite(value_z, quality_z, momentum_z),quantile_weights(scores, construction).
Three construction variants (the construction param)
eqfactor_long— top-quintile, equal-weight long, weights sum to 1.0.eqfactor_ls— long top-quintile / short bottom-quintile, equal-weight each leg → market-neutral (Σw=0, gross=2.0). Realized return nets a borrow cost on the short leg (default 1%/yr, applied per-day).eqfactor_tilt— long-only budget, rank-weighted overweight/underweight (weights ∝ max(0, rank-centered score), normalized to sum 1.0). No shorts.
Live-booking ForwardStrategy services
Three services (one per construction) share a common _factor_targets(as_of) that calls the universe + fundamentals + price clients and returns target weights via the domain. Each advance(last_date, extra) (the live-booking pattern, like funding/poc):
- reads prior
positions(+last_rebal) fromextra, - marks the held book to today's prices → daily book return (minus borrow cost on shorts for
ls), - rebalances monthly (
today.month != last_rebal.monthor first run): recompute targets, update positions, - returns
([(today_iso, book_return)], updated_extra); theForwardTrackerbooks forward-only (inception freeze on first run), emits the days-list state the cockpit ingests.
Registry + orchestration
Three STRATEGY_REGISTRY entries (state_file basenames eqfactor_long_state / eqfactor_ls_state / eqfactor_tilt_state; gate_spec e.g. min_days=60, min_total_return=0, min_sharpe=0.3). Three Dagster @assets (no from __future__ import annotations) mirroring the B1 paper assets, added to cockpit_forward deps + Definitions/job. They fetch live HTTP (no DuckDB) → run in parallel.
Testing (deterministic; this is the "test correctly" answer)
- Domain (no network): fixed factor-input cross-section → assert exact z-scores (winsorized), composite, quantile membership, and each construction's weights (long Σ=1; ls Σ=0 & gross=2; tilt Σ=1, monotone in score); book-return = Σ(weight×asset-return); borrow-cost applied to ls shorts.
- Universe screener: fake supported-tickers + volumes → assert filter + top-N by dollar-volume.
- Services: fake
FundamentalsClient/DailyBarClient/UniverseSource(deterministic fixtures, no network) → drive each construction throughForwardTracker→ round-trip viaForwardStateReader(days-list summary); assert monthly-rebalance triggers +extrapositions; ls books a borrow cost. - No lookahead: forward-only (we only use data known as-of now) — inherent; documented.
- strict mypy, full suite green, two-stage review per task (spec then code-quality), faithful to B1's discipline.
Discipline / caveats
- Factor premia are partly crowded/decayed — no edge is claimed; the forward-gate + DSR decide. Realistic small weighting expected.
- Long-short realism: borrow cost modeled (1%/yr default); assumes shortability of liquid large-caps (reasonable for top-N).
- Point-in-time: forward-only, so a reporting-lag lookahead cannot occur live; the future backtest layer MUST handle fundamentals reporting lag (flagged for that increment).
- Data cost: Tiingo Power limits (100k req/day, 108k symbols/mo) comfortably cover a 300-name monthly rebalance.
Defaults (adjust if desired)
universe top-300 by dollar-volume · monthly rebalance · short borrow 1%/yr · winsor ±3σ · quintile cut-offs.
Build order (subagent-driven, next via writing-plans)
- Ports:
FundamentalsClient+UniverseSource. TiingoFundamentalsClient(daily + statements) + fake.TiingoUniverseSource(supported-tickers + dollar-volume rank) + fake.- Domain
equity_factor.py(z-score/composite/winsor/quantile + 3 constructions + book return) — heavy unit tests. - Three
ForwardStrategyservices (long/ls/tilt) sharing factor compute. - Registry (3 entries) + 3 Dagster assets + cockpit_forward deps + Definitions.
- Full gate + deploy + verify all 3 in cockpit.