# 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 existing `TiingoDailyClient`.
- **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`) from `extra`,
- **marks** the held book to today's prices → daily book return (minus borrow cost on shorts for `ls`),
- **rebalances monthly** (`today.month != last_rebal.month` or first run): recompute targets, update positions,
- returns `([(today_iso, book_return)], updated_extra)`; the `ForwardTracker` books 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 `@asset`s (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 through `ForwardTracker` → round-trip via `ForwardStateReader` (days-list summary); assert monthly-rebalance triggers + `extra` positions; 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)
1. Ports: `FundamentalsClient` + `UniverseSource`.
2. `TiingoFundamentalsClient` (daily + statements) + fake.
3. `TiingoUniverseSource` (supported-tickers + dollar-volume rank) + fake.
4. Domain `equity_factor.py` (z-score/composite/winsor/quantile + 3 constructions + book return) — heavy unit tests.
5. Three `ForwardStrategy` services (long/ls/tilt) sharing factor compute.
6. Registry (3 entries) + 3 Dagster assets + cockpit_forward deps + Definitions.
7. Full gate + deploy + verify all 3 in cockpit.
