Monthly CIO letter prep pack for fund managers: period attribution via daily contribution analysis, top contributors / detractors with evidence-backed drivers, macro snapshot, news themes, and a conditional forward-outlook. Output is Word .docx for the CIO to edit and send. Provide current_portfolio, prior_portfolio, trade_log, period_start, period_end. Symbols in RIC format. NOT for individual stock analysis (use /parallax-deep-dive), not for daily fund-manager check-ins (use /parallax-morni...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add bencharoenwong/parallax-workflows --skill parallax-cio-letter-prep --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Parallax Cio Letter Prep?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/bencharoenwong-parallax-cio-letter-prep)More formats (shields.io, HTML) on the badges page.
---
name: parallax-cio-letter-prep
description: "Monthly CIO letter prep pack for fund managers: period attribution via daily contribution analysis, top contributors / detractors with evidence-backed drivers, macro snapshot, news themes, and a conditional forward-outlook. Output is Word .docx for the CIO to edit and send. Provide current_portfolio, prior_portfolio, trade_log, period_start, period_end. Symbols in RIC format. NOT for individual stock analysis (use /parallax-deep-dive), not for daily fund-manager check-ins (use /parallax-morning-brief), not for retail/RIA reviews (use /parallax-client-review)."
---
<!-- white-label: integration-pattern.md -->
# CIO Letter Prep Pack
## When not to use
- Single stock analysis → use /parallax-should-i-buy or /parallax-deep-dive
- Daily fund manager brief → use /parallax-morning-brief
- RIA / wealth-advisor client review → use /parallax-client-review
- Reactive drawdown attribution after a single event → use /parallax-explain-portfolio
- Backtesting a strategy → use /backtest
- Period start more than 365 days before today → export_price_series trailing-window cannot reach it; split into quarterly windows or adjust start date
## Gotchas
- JIT-load `_parallax/parallax-conventions.md` for RIC, symbol cross-validation, parallel exec, fallbacks, news async, macro reasoning, and disclaimer
- JIT-load `_parallax/house-view/loader.md` ONLY if `house_view` is supplied AND forward-outlook will render. House view is scoped to the forward-outlook section ONLY — retrospective sections (header, attribution, contributors, detractors, trade narrative, macro, news) are always view-agnostic. No view → omit forward-outlook entirely; never retrofit view language onto retrospective prose.
- Attribution method is "daily contribution analysis" (NOT "time-weighted" / TWR — that's a different industry term for return calculation, and conflating them confuses practitioners). Math: daily weights from `prior_portfolio + trade_log`, sum (daily_weight × daily_return) per holding. Naive `weight × period_return` is silently wrong when positions change mid-period.
- **`company_contribution[].contribution_pct` is a share of total portfolio P&L, NOT a return contribution.** The rows sum to 1.0. A value of 0.64 means "this holding produced 64% of the period's total P&L" — never "this holding contributed 64% of return". Rendering it as bps directly overstates by a factor of `1 / total_return`. Return contribution in bps is `contribution_pct × portfolio_summary.total_return × 10000`.
- **Sign comes from the converted value, never from raw `contribution_pct`.** In a losing period the portfolio's total P&L is negative, so every losing holding has a positive `contribution_pct` (negative ÷ negative). Ranking or splitting contributors from detractors on the raw field puts the biggest loser at the top of the contributors table and suppresses the detractors table entirely. The conversion above restores the correct sign in every case, because `total_return` carries the period's sign.
- **Server-side numbers are canonical.** `analyze_portfolio.company_contribution` produces the LP-facing numbers; `scripts/contribution.py` is the local reconciliation audit. If `|local − server| > 25 bps` on total return, halt rendering with an audit-failed error. The 25-bp tolerance accommodates known rebalance-date convention skew between Parallax server-side and local math; tighter agreement would require aligning conventions in v2.
- `scripts/contribution.py` enforces a 1-bp inner reconciliation gate (`|sum(contributions) − portfolio_total_return| > 1bp` → `ReconciliationError`). On gate fire, surface the diff and abort. Never catch-and-discard.
- `prior_portfolio` and `trade_log` are HARD-REQUIRED. Reject incomplete invocations with the accepted-shape examples in Inputs. No silent degradation to position-only contribution.
- `daily_prices` MUST be total-return prices (dividends reinvested). `export_price_series` returns TR-adjusted closes; do not mix in raw closes from any other source.
- Per-mover fan-out cap: 5 contributors + 5 detractors = 10 names max. Do not exceed.
- Soft cap on holdings: 40. Auto-truncate to top 40 by current weight; surface "Holdings 41-N (X% weight) excluded from per-position analysis". Daily contribution math runs over the FULL set; only per-mover fan-out is truncated.
- `macro_analyst` parameter is `market` (not `country`). Cap macro coverage at 3 markets.
- Period start must be within the trailing 365-day window from today (\`export_price_series\` limit — trailing from today, no start/end anchor). For periods starting >365 days ago, reject with a v2-roadmap note.
- Output is Word .docx ONLY via the `docx` skill chain. Markdown is an intermediate artifact, never the deliverable.
- Materiality tiers for excluded holdings: ≤5% total → in-line "Coverage gap" note above contributors table; >5% → high-visibility WARNING banner at top; any single holding >10% → reject the attribution section entirely.
- Driver-field fallback hierarchy: (1) news event with date from `get_news_synthesis`, (2) factor score change from `get_score_analysis`, (3) sector movement from `analyze_portfolio` `sector_contribution` / `sector_allocation` (this skill never calls `get_peer_snapshot`, and `analyze_portfolio` returns no peer data), (4) default phrase ("Price appreciation in line with [sector / market]" or "Multiple expansion / contraction"). Never render an empty driver slot.
- Empty contributor / detractor side: if all holdings have positive (negative) converted return contribution, render the populated table only; suppress the empty side rather than rendering "No detractors" prose.
- Single-holding portfolio: top-5 collapses to top-1 (or top-N for N < 5); render valid for N ∈ [1, 5].
- Duplicate symbol in input portfolio: reject at validation with "Duplicate symbol {sym}" — no auto-dedup.
- Mid-period delisting (price series ends before period_end): treat as a coverage gap (drop from rankings + surface per the materiality tiers), not a hard ValueError.
- This skill is private-beta gated; excluded from default `build-skills.sh` builds. Confirm enablement before running for new customers.
- JIT-load `_parallax/white-label/integration-pattern.md` in Step 0; that doc carries the loader call (`load_visual_branding`), error-state contract, docx substitution table (§6), and About This Report template (§7). Step 5 applies it; the substitution table lives in §6.
- Every host interaction is a host primitive from `parallax-conventions.md` §14 (bindings §14.2, fail-open §14.3). The `docx` skill chain is `invoke-skill`; `scripts/contribution.py` is `run-shell`.
- Voice and auto-jurisdiction disclaimers remain explicitly out of scope (see "Not in scope"). This skill uses `load_visual_branding()` rather than `load_client_branding()` — the 7-key wrapper excludes `branding["voice"]` at code level, so accidental access raises `KeyError`. The CIO writes the prose; the standard wording in the **Disclaimer** section stays. Visual branding only.
Generate a structured Word document that a fund-manager CIO can edit and send to LPs as the period letter. The pack covers the period dates, gross return / drawdown / vol, attribution snapshot, top 5 contributors and bottom 5 detractors with evidence-backed drivers, trade-log narrative, macro snapshot, news themes, a conditional forward-outlook (only if a house view is active), coverage gaps, and the standard disclaimer.
## Usage
```
/parallax-cio-letter-prep <inputs.json>
```
Where `inputs.json` provides the structured inputs in the shapes below. The CIO reviews, edits in Word, and sends to LPs. The skill never sends or stores anything externally.
## Inputs
| Field | Required? | Shape | Notes |
|---|---|---|---|
| `current_portfolio` | yes | `[{"symbol":"AAPL.O","weight":0.25}, ...]` | RIC format. Weights sum to ~1.0. |
| `prior_portfolio` | **yes** | `[{"symbol":"AAPL.O","weight":0.20}, ...]` | Period-start snapshot. Hard-required — anchor for trades. |
| `trade_log` | **yes** | `[{"symbol":"NVDA.O","action":"enter","date":"2026-04-15","weight_delta":0.05}, ...]` | Chronologically ordered. Actions ∈ {`add`, `trim`, `enter`, `exit`}. Same-date weight_deltas should sum to zero across symbols. Hard-required — without it, the math is silently wrong when positions change mid-period. |
| `period_start` | yes | ISO `YYYY-MM-DD` | Inclusive. |
| `period_end` | yes | ISO `YYYY-MM-DD` | Inclusive. Period length must be ≤ 365 days. |
| `benchmark` | optional | `"SPY.N"` or RIC | If supplied, attribution snapshot includes excess return. |
| `length_target` | optional | `"short"` (≤ 600 words) \| `"standard"` (≤ 1000) \| `"long"` (≤ 1500) | Default: `"standard"`. Affects narrative density, not table content. |
| `house_view` | optional | view manifest per `_parallax/house-view/loader.md` | Forward-outlook section ONLY. |
If `prior_portfolio` or `trade_log` is missing, reject with a clear error and an accepted-shape example.
## Workflow
Report-consumer shape: the spine headings below. The dependent per-mover fan-out needs the Step 4 ranking, so it fires inside Step 4 as Batch B rather than in Step 2.
### Step 0 — Pre-flight
1. Resolve every `_parallax/...` path named in this file to the canonical copy (conventions §0.0 item 1).
2. `load-reference` `_parallax/parallax-conventions.md` (execution mode, RIC resolution, symbol cross-validation, fallbacks, news async, macro reasoning). `load-reference` `_parallax/house-view/loader.md` ONLY if `house_view` is supplied AND the forward-outlook section will render.
3. `discover-tools`: bind every tool named in Steps 2 and 4 (`get_telemetry`, `analyze_portfolio`, `export_price_series`, `get_company_info`, `check_portfolio_redundancy`, `etf_daily_price`, `get_assessment`, `get_score_analysis`, `get_news_synthesis`, `macro_analyst`) to the exact callables and schemas exposed now (conventions §0.0–§0.1).
<!-- host-note -->
Claude Code: `ToolSearch` with query `"+Parallax"` before the first Parallax call; `run-shell` = `Bash`; `invoke-skill` = the `docx` skill.
<!-- /host-note -->
4. `load-reference` `_parallax/white-label/integration-pattern.md` and apply §2 (Loading the branding) verbatim. The loader call is `load_visual_branding()` (NOT `load_client_branding()`); the wrapper returns the 7-key visual subset and structurally excludes voice. Compute `white_label_active` per §2.
### Step 1 — Resolve inputs
1. Validate inputs: weights sum to 1.0 (within 1e-3), period ≤ 365 days, trade_log chronologically ordered, weight_delta sums per date balance to zero, no duplicate symbols. Missing `prior_portfolio` or `trade_log`: reject with the accepted-shape example.
2. If portfolio has > 40 holdings, truncate to top 40 by current weight and record the truncated tail's combined weight for the excluded-holdings note. Daily contribution math still runs over the FULL set; only per-mover fan-out is truncated.
3. If `house_view` is supplied, load it per loader.md §1-§2. If validation fails, treat as no view (omit forward-outlook). If no `house_view`, skip the forward-outlook section entirely.
### Step 2 — Fetch (parallel batches)
#### Batch A — Period analytics fan-out
`call-tool` all rows below together. Every row is independent.
| Tool | Parameters | Notes |
|---|---|---|
| `get_telemetry` | fields: regime_tag, signals, commentary.headline, commentary.mechanism, divergences | Market regime context for the period header. |
| `analyze_portfolio` | Construct the `portfolio` array as one entry at `period_start` carrying `prior_portfolio` weights, plus one entry per distinct trade date in `trade_log` carrying the cumulative-post-trade weights as of that date. The final entry's weights MUST equal `current_portfolio`. `start_date=period_start`, `end_date=period_end`, `benchmark=<input or "ACWI.OQ">`, `fields=["portfolio_summary","performance_metrics","drawdown_analysis","portfolio_scores","concentration_metrics","company_contribution","sector_contribution","sector_allocation","time_period_returns","latest_holdings"]`. For the common single-rebalance case (one trade date `D`), the array has 2 entries: `[{date: period_start, ...prior}, {date: D, ...current}]`. | **Single multi-date call.** Server-side `company_contribution` is canonical; current+prior factor exposures via `latest_holdings` + `sector_allocation` over time. `scripts/contribution.py` runs as the reconciliation audit (Step 3). |
| `export_price_series` | `symbol=<each holding>`, `days=<min((today - period_start).days + 5, 365)>` | One call per holding (parallel). Returns TR-adjusted closes. Trailing window anchored to period_start (today must be within 365 days of period_start); slice fetched prices to [period_start, period_end] before passing to contribution.py. Fires for the FULL holdings set (input to local audit) — Batch B fan-out is the truncated set, not this. |
| `get_company_info` | `symbol=<each holding>` — **one call per holding, all fanned out in parallel within this batch** | Ground-truth name oracle for the Step 3 identity gate. FREE, instant. Per-holding only — do NOT consolidate into a comma-joined call: comma-joined calls fail-empty on partial coverage, so a single unresolved RIC silently zeroes the whole batch, leaving the identity gate with nothing to compare and passing every holding. Per-holding calls also mean the single-symbol `GET_COMPANY_INFO_SCHEMA` and its mock model the response this skill actually receives. |
| `check_portfolio_redundancy` | `holdings=current_portfolio` | Surfaced under coverage gaps if low coverage; otherwise informs trade-narrative quality. |
Asset-class scope: this skill assumes equity legs only (single-stock fund-manager portfolios). `export_price_series` is the correct call for every holding. If a holding resolves to ETF, branch via `etf_daily_price` per the equity-branch convention; equity is the default Pre-classification gate.
If `export_price_series` fails for a holding, mark that holding as price-unavailable and apply materiality-tier handling in Step 3.
### Step 3 — Verify
**Identity gate.** Cross-validate each holding's identity per the `analyze_portfolio` row of the conventions §2 identity table: compare the per-row `name` in `latest_holdings[]` and `company_contribution[]` against `get_company_info.name` for the same `ric`, normalizing both sides first per §2 step 2 — `Apple Inc` and `Apple Inc.` are the same company and must not flag. Flag any mismatch that survives normalization and exclude mismatched holdings from aggregate factor calculations. A holding with a row in neither `latest_holdings[]` nor `company_contribution[]`, or whose `get_company_info` call fails or returns empty, has no name pair to compare and therefore cannot be identity-checked — record it as UNCHECKED and list it under coverage gaps rather than treating a missing comparison as a pass.
**Reconciliation audit.** Server-side `analyze_portfolio.company_contribution` provides the canonical LP-facing numbers. `run-shell` `scripts/contribution.py` locally as a cross-check:
```bash
# Illustrative — template-fill from JSON inputs at runtime, do not copy {...} literally
python -c "
import json, sys
sys.path.insert(0, 'skills/parallax-cio-letter-prep/scripts')
from contribution import daily_contribution, ReconciliationError
result = daily_contribution(
prior_portfolio=PRIOR_PORTFOLIO, # dict from prior_portfolio input
current_portfolio=CURRENT_PORTFOLIO, # dict from current_portfolio input
trade_log=TRADES, # list from trade_log input
daily_prices=DAILY_PRICES, # built from Batch A export_price_series results
period_start=PERIOD_START,
period_end=PERIOD_END,
)
print(json.dumps(result))
"
```
Compare local `result['portfolio_total_return']` against server-side `analyze_portfolio.portfolio_summary.total_return`:
- `|local − server| ≤ 25 bps` → render server-side numbers in the pack; note "local audit passed (diff {N} bps)" in About This Report.
- `|local − server| > 25 bps` → halt rendering with "Reconciliation audit failed: local {X} bps vs server {Y} bps (diff {Z} bps exceeds 25-bp tolerance). Investigate before sending to LPs."
The 25-bp tolerance accommodates known rebalance-date convention skew (Parallax server-side rebalances ON date D; `contribution.py` applies trades AFTER D's close). For monthly periods the typical divergence is ~10-25 bps; values outside this band signal a real bug in inputs, prices, or trade log.
The script's own 1-bp inner gate (`ReconciliationError` on `|sum(contributions) − portfolio_total_return| > 1bp`) still fires as the inner safety net.
**Materiality tiers** based on holdings excluded from contribution due to missing prices:
| Total excluded weight | Action |
|---|---|
| 0% < total ≤ 5% | Inline "Coverage gap" note above contributors table listing each excluded `{symbol} ({weight}%)`. |
| Total > 5% | High-visibility **WARNING banner** at the top of the doc: "WARNING: [X.X%] of the portfolio was excluded from contribution analysis due to missing data. Key performance drivers may be missing from this report." |
| Any single holding > 10% weight | Reject the attribution section entirely with an error pointing to the missing symbol. |
### Step 4 — Compute
Convert each server-side `company_contribution` row to return contribution first: `contrib_bps = contribution_pct × portfolio_summary.total_return × 10000`. Rank on that converted value, not on raw `contribution_pct` — see the basis and sign gotchas above. Select the top 5 contributors (positive `contrib_bps`) and bottom 5 detractors (negative `contrib_bps`). If a side has fewer than 5 same-signed holdings, render only what exists (suppress empty side).
#### Batch B — Per-mover and macro fan-out (dependent on the ranking)
`call-tool` together:
| Tool | Calls | Notes |
|---|---|---|
| `get_assessment` | × ≤ 10 | Async (~30-90s). AI synthesis used for "why" prose. |
| `get_score_analysis` | × ≤ 10 | Weekly factor score history — input to driver fallback step 2. |
| `get_news_synthesis` | × ≤ 10 | Async (~30-90s). Primary input to driver fallback step 1. |
| `macro_analyst` | × ≤ 3 markets | Parameter is `market` (not `country`). Pick relevant markets per conventions §6. Component default — call once per market; the summary call returns all components inline. |
Per conventions §5, the async tools should not block render assembly; if they have not resolved by render time, leave a `[news pending]` placeholder and complete on resolution.
### Step 5 — Compose
If `white_label_active` is True, the render applies the docx substitution table at integration-pattern.md §6. Logo path (when present) is inserted at the cover-page header per §6 (left-aligned, ≤1.5 inch height). Semantic colors (`cg-green-700`, `cg-red-700`, `cg-amber-*`) are NEVER overridden per §1.
If `white_label_active` is False, the **Default brand palette** table in Output Format below applies; no cover-page logo is inserted. On `logo_missing` (partial-success path per integration-pattern.md §4), palette and fonts still apply; only the cover-page logo is skipped.
About This Report line wording follows integration-pattern.md §7 (docx column). For this skill specifically: on `logo_missing`, the qualifier is `(logo unavailable, omitted from cover)`.
**Synthesis (sequential).** Compose the structured pack content with these sections in order. Hand the resulting structured content to the `docx` skill chain to render to Word format.
1. **Period header** — `period_start` to `period_end`, gross return (server-side), max drawdown (`drawdown_analysis.portfolio.max_drawdown`), realized vol (`performance_metrics.portfolio.annualized_volatility`). If `benchmark` was provided, include benchmark return and excess return.
2. **WARNING banner** (only if the Step 3 materiality tier 2 fired — total excluded weight > 5%).
3. **Attribution snapshot** — factor and sector deltas from `analyze_portfolio` start-state vs end-state (via `latest_holdings` and `sector_allocation` time series). Two short paragraphs: factor-tilt change, sector-weight change. Reference any redundancy alerts from `check_portfolio_redundancy`.
4. **Top contributors table** — top 5 by converted return contribution (Step 4). Row template:
`{symbol} | {contrib_bps} bps | Driver: {driver_field}`
`{contrib_bps}` is `contribution_pct × portfolio_summary.total_return × 10000`, NOT `contribution_pct × 10000`. The conversion is self-verifying from the blocks Step 2 already requests, with no addition to its `fields=` list: for every row, `contribution_pct` must equal that row's `company_contribution[].total_pl` over `portfolio_summary.total_pl`, so multiplying by `total_return × 10000` restates the row's P&L in bps of opening capital. **Check the identity, not a worked number.** Recompute it against whatever `_parallax/scripts/mcp_mocks/analyze_portfolio.json` currently holds: take any `company_contribution[]` row, use that row's own `ric`, and confirm the two sides agree to within rounding. No symbol or figure is quoted here on purpose — that fixture is generated, so every identifier and value in it moves whenever `_parallax/scripts/gen_mock_fixtures.py` or its pinned seed changes, and a hardcoded example goes stale silently while the identity never does. Never hand-edit or paste a response over that fixture: it is `MANAGED`, and the provenance gate accepts only what the generator reproduces byte-exact. The unconverted reading is the converted one divided by `total_return`, so it is wrong by exactly that factor, and over a losing period it also carries the wrong sign.
Fill `{driver_field}` per the fallback hierarchy:
1. Notable news event with date (from `get_news_synthesis`) — e.g., `Beat Q1 EPS by 12%, raised guidance (2026-04-22)`.
2. Significant factor score change (from `get_score_analysis`) — e.g., `MOMENTUM 5.2 → 7.8 over period`.
3. Sector movement (from `analyze_portfolio` `sector_contribution` / `sector_allocation`; no peer data is available in this skill's call set) — e.g., `Technology sector contributed +8.4% of period P&L`.
4. Default phrase: `Price appreciation in line with [sector / market]` or `Multiple expansion`.
Never leave the driver slot blank.
5. **Top detractors table** — bottom 5 by converted return contribution. Same row template and same conversion; default-phrase fallback for negative cases is `Multiple contraction` or `Price weakness in line with [sector / market]`. Suppress this section only if no holding has a negative `contrib_bps` after conversion.
6. **Trade log integration narrative** — one paragraph weaving the period's trades into the attribution. For each trade, one sentence: which mover the trade enabled or limited (cross-reference contribution_bps for the entered/exited symbol). If trades are minor (< 5% combined weight_delta), summarize as "Limited rebalancing during the period" rather than enumerating.
7. **Macro snapshot** — one bullet per macro market (≤ 3 bullets). Pull headline from each `macro_analyst` summary; ground in the period's regime call from `get_telemetry`.
8. **News themes** — cluster news from `get_news_synthesis` calls by `sector × directional move` (positive vs negative). Max 5 buckets. Each bucket cites ≥ 1 ticker by name. Do NOT repeat the per-mover driver text verbatim; this section is sector-themed.
9. **Forward-outlook** — render ONLY if `house_view` is active and validated. 1 bullet per top-5 holding by current weight, framed in view-language (regime call, tilt direction, conviction notes per loader.md §3-§5). If no active view, OMIT this section entirely.
10. **Coverage gaps** — list any holdings excluded from contribution due to missing data with their weights. List any holdings excluded from per-position analysis due to the 40-holding soft cap. List any holdings recorded as UNCHECKED by the Step 3 identity gate — whether because no `analyze_portfolio` row carried a name for them or because their `get_company_info` call failed or returned empty. List any tools that returned "data unavailable" per conventions §4.
11. **About This Report** — small footer block (smaller font, italic): generation date, tools used (with versions if available), reconciliation-audit result formatted as `Reconciliation audit: PASS — local total {X} bps vs server {Y} bps; diff {Z} bps; tolerance 25 bps` (or `FAIL` with halt-and-report wording). Skill version + private-beta tag. **Branding line** — per integration-pattern.md §7 docx column. Default-Parallax path: `Branding: default Parallax`. White-label clean load: `Branding: white-label (source: <branding["source"]["reference"]>)`. `logo_missing`: append `(logo unavailable, omitted from cover)`.
12. **Disclaimer** — per conventions §9.1. If active view: use the view-aware disclaimer per loader.md §5 rule 5; otherwise the standard wording (see Disclaimer section below).
### Step 6 — Render (docx chain)
`invoke-skill` the `docx` skill with the Step 5 structured content per the render chain in Output Format. No render-gate script applies: the `.docx` is the deliverable and the reconciliation audit (Step 3) is the halting gate. Never ship the Markdown intermediate.
## Failure modes
- `prior_portfolio` or `trade_log` missing, duplicate symbol, period > 365 days, or period start > 365 days ago: reject before any Parallax call.
- Reconciliation audit `|local − server| > 25 bps`, or the script's 1-bp `ReconciliationError`: halt rendering with the diff; never catch-and-discard.
- `export_price_series` fails for a holding: price-unavailable → materiality tiers (Step 3); any single holding > 10% rejects the attribution section.
- `get_company_info` fails or returns empty: the holding is UNCHECKED and listed under coverage gaps; never a pass.
- Async tools pending at render: `[news pending]` placeholder per conventions §5; complete on resolution.
- `invoke-skill` (docx) unavailable: stop with the structured content on disk and say the `.docx` could not be produced on this host; do not hand over Markdown as the deliverable.
## Done when
- The `.docx` exists and opens; every Step 5 section rendered or omitted per its rule (forward-outlook only with an active view; empty mover side suppressed).
- About This Report carries the reconciliation line, the branding line, and the private-beta tag; §9.2 disclosure sits immediately above the disclaimer.
- Coverage gaps list every excluded, truncated, or UNCHECKED holding.
## Output Format
Word .docx ONLY via the `docx` skill chain. The deliverable is a .docx file the CIO can open and edit in Word.
**Render chain:**
1. Step 5 composes the structured content (period header, tables as row-arrays, prose paragraphs as strings, conditional banner / forward-outlook flags).
2. Hand the structured content to the `docx` skill: tables become Word tables (branded header row, alternating-row shading), section headers become Word Heading styles (1 / 2), prose becomes Body Text, the WARNING banner (if any) is rendered with a top-of-doc highlight (amber background + dark text).
3. The single deliverable is `.docx`. An internal Markdown intermediate may be produced during development for diffing or review-tool friendliness, but it is NEVER handed to the CIO and NEVER counted as the skill's output. If you find yourself shipping markdown, you've broken the output contract — re-render to `.docx`.
**Default brand palette** (placeholder; the fund's own brand can override):
| Token | Hex | Use |
|---|---|---|
| `cg-navy-900` | `#0C2746` | H1/H2 headings, table header fill |
| `cg-navy-700` | `#154175` | H3/H4 headings, interactive accents |
| `cg-neutral-900` | `#1F2937` | Body text |
| `cg-neutral-500` | `#6B7280` | Muted text (subtitle, provenance, disclaimer) |
| `cg-neutral-100` | `#EAEDF3` | Alternating-row table shading |
| `cg-green-700` | `#15803D` | Positive contributions, "PASS" markers |
| `cg-red-700` | `#B91C1C` | Negative contributions, "FAIL" markers |
| `cg-amber-700` | `#B45309` | Warning banner text |
| `cg-amber-50` | `#F9F1EB` | Warning banner fill |
The render synthesis applies these tokens to: title (navy-900), body (neutral-900), section headings (navy-900 H1/H2 → navy-700 H3/H4 → navy-400 H5/H6), table headers (navy-900 fill + white text), even rows (neutral-100 shading), contributor "+ bps" (green-700), detractor "− bps" (red-700), footer/disclaimer (neutral-500 + italic + 8-9pt). Funds publishing under their own brand should swap the palette in their config; the default palette is a sensible institutional-finance baseline.
**White-label substitution.** When `white_label_active` (per Step 0), the renderer applies the docx substitution table at `_parallax/white-label/integration-pattern.md` §6. That table is the canonical mapping from `cg-*` tokens to `branding["colors"]` / `branding["fonts"]` / `branding["logos"]["primary"]`, and it specifies the never-overridden semantic tokens.
Cover-page client name follows integration-pattern.md §6: when `white_label_active` AND `client_name != ""`, the cover-page header gains the client name at H1 in the brand `branding["fonts"]["header"]`. When `client_name == ""` (legacy configs), the client name is omitted but the cover-page logo (if present) still renders.
**Golden fixture:** Reference output at `skills/parallax-cio-letter-prep/fixtures/golden_pack_2026-04.docx` is the pre-retrofit baseline rendered with the default Parallax palette (no client config active). Post-retrofit verification: see `scripts/compare_docx.py` and the **Retrofit gate procedure** below.
**Retrofit gate procedure (one-shot, best-effort).**
The `golden_pack_2026-04.docx` fixture was rendered against live Parallax MCP data in 2026-04. That data is time-varying and not reproducible after the fact, so a fully byte-identical regeneration is not generally achievable. The gate is therefore **structural**: it asserts that the post-retrofit output uses the same fonts, palette tokens, and section / table shape as the golden — properties that depend on the SKILL.md substitution rules and the `docx` skill chain, NOT on the per-period Parallax data.
1. Confirm `~/.parallax/client-branding/config.yaml` does NOT exist (default-Parallax path).
2. Run `/parallax-cio-letter-prep` with any valid inputs (the simplest is the three-holding 30-day fixture documented in **Worked Numerical Example** below — synthetic prices that exercise all sections). Save output as `post-retrofit.docx`.
3. Run `python skills/parallax-cio-letter-prep/scripts/compare_docx.py post-retrofit.docx fixtures/golden_pack_2026-04.docx`.
4. Expected: PASS on fonts, palette, and section / table-row counts (table contents may differ because Parallax data differs across runs; the script does not assert content equality). Investigate any FAIL on the structural properties before proceeding.
The comparison script checks: (a) `document.styles` font names for Heading 1/2/3 + Body Text, (b) palette hex values in `theme1.xml`, (c) cover-page header image presence + width-EMU within ±5% tolerance, (d) section count + table row counts.
**Note on the logo check:** the current golden fixture was generated on the default-Parallax path (no client config), which produces no cover-page logo. The script's logo check therefore asserts `logo_count == 0` on both sides — meaningful only when one side has a logo. When the golden is regenerated against a white-label-active config, verify that the logo lands in a Word header part (`word/header*.xml`) rather than the document body; the script currently inspects only header parts.
## Worked Numerical Example
Hand-verifiable 3-holding 30-day fixture matching `test_held_entire_period_no_trades`. Full derivation in [`references/math-worked-example.md`](references/math-worked-example.md). Summary: AAPL +878.4 bps, JPM −1181.8 bps, MSFT 0, total −303.4 bps. Reconciliation gate clears < 1e-12.
## Math Reference: scripts/contribution.py
Function: `daily_contribution(prior_portfolio, current_portfolio, trade_log, daily_prices, period_start, period_end, reconciliation_tolerance=1e-4)`.
- **Required-input contract:** all six positional arguments are required. The function rejects empty / mismatched / out-of-order / out-of-period inputs with a `ValueError`, including a cross-check that `prior_portfolio + trade_log` reconstructs `current_portfolio` within 10 bps of weight per symbol.
- **Total-return-prices assumption:** `daily_prices` MUST be total-return prices (dividends reinvested). Pass `export_price_series` outputs directly. Passing raw closes breaks reconciliation by the dividend amount; see `test_non_total_return_prices_break_math_negative_control` for the negative control.
- **Reconciliation gate:** the function computes `diff = sum(contributions) − portfolio_total_return` and raises `ReconciliationError(diff, tolerance)` if `|diff| > 1e-4` (1 bp). The skill MUST NOT catch and discard this error — surface it with the diff value and abort rendering.
- **Returns:** `{contributions: {symbol: decimal_contribution}, portfolio_total_return: arithmetic_sum_of_daily_portfolio_returns, reconciliation_diff: signed_diff}`. `contribution_bps = decimal_contribution * 10000`.
- **Arithmetic vs geometric:** the math sums daily contributions arithmetically, not geometrically. The reported `portfolio_total_return` is therefore the arithmetic sum of daily portfolio returns, NOT the geometrically compounded period return. For monthly letter periods the gap is small but non-zero; document it in the pack's footnote if asked.
## MCP Endpoint Contracts: shared `_parallax/scripts/`
Contract tests for this skill live at `scripts/test_mcp_contracts.py` and import the shared validator + schemas from `_parallax/scripts/contract_validator.py` and `_parallax/scripts/contract_schemas.py`. Mock fixtures live at `_parallax/scripts/mcp_mocks/*.json` (shared across all parallax-* skills consuming the same endpoints). The tests assert that each mock conforms to a per-endpoint schema listing the fields the skill is known to read, with required / optional markers and types. Refresh workflow is documented in `_parallax/scripts/mcp_mocks/README.md`. **When this skill begins reading a new field from any endpoint, update the schema in `_parallax/scripts/contract_schemas.py` and the corresponding mock JSON in the same PR.** A red contract test in CI surfaces upstream schema drift before a customer hits it.
## Disclaimer
**AI-interaction disclosure (required regardless of view state):** Render `parallax-conventions.md §9.2` immediately above the disclaimer below.
If active view: use the view-aware disclaimer per `_parallax/house-view/loader.md` §5 rule 5. Otherwise: render the standard disclaimer verbatim from `parallax-conventions.md` §9.1.
## Not in scope (v2 candidates)
- **Period > 365 days** — split into quarterly windows manually; v2 will automate.
- **Batch mode for multi-fund firms** — one invocation per fund in v1.
- **Custodian PDF / CSV ingestion** to auto-derive `prior_portfolio` and `trade_log`.
- **Voice synthesis / prose paragraph generation** — v1 produces structured prose; v2 may bake CIO voice templates.
- **Auto-jurisdiction regulatory disclaimers.**
- **House view integration in retrospective sections** — explicitly out of scope to avoid anachronism risk.
- **LP-tier customization** of the same pack.
## Design Lineage
The skill's design choices, summarized inline so the rationale is self-contained:
| Choice | Rationale |
|---|---|
| Daily contribution analysis (not naive `weight × period_return`) | Naive math is silently wrong when positions change mid-period. Daily reconstruction from prior + trade_log is the only correct approach for rebalanced portfolios. |
| Hard-require `prior_portfolio` + `trade_log` | Without both, the math falls back to wrong-by-default. Better to reject than silently degrade. |
| House view scoped to forward-outlook section ONLY | Letter is retrospective; view is forward-looking. Mixing them creates anachronism risk in the LP narrative. |
| Tight composition templates with required evidence slots | Free-form prose drifts into generic platitudes. Templates force evidence-anchored drivers. |
| Materiality tiers for excluded holdings | Drop-with-note is insufficient when the dropped holding is a top contributor. Tiers ensure user-visible warnings scale with materiality. |
| Word .docx output via the `docx` skill chain | Fund managers write LP letters in Word. Native format reduces friction and matches CIO workflow. |
| White-label visual substitution (palette + fonts + logo); voice and disclaimers stay v2 | Visual branding is independent of prose; replacing the default Parallax-CG palette with the fund's own brand makes the .docx look like the fund's collateral without touching the killed-premise voice-generation territory. Voice and auto-disclaimers remain explicitly out of scope (see "Not in scope") because the CIO writes the prose and reviews the disclaimer; pulling either from a config silently would violate the design's compliance posture. |
| Server-side `company_contribution` as canonical numbers | Parallax's server-side math is what CIO will defend in LP meetings. Local `contribution.py` is the audit gate. The endpoint returns P&L share rather than return contribution, so the letter converts it once at Step 4 rather than substituting the local audit's numbers into LP-facing cells. |
| 5+5 contributor/detractor cap with 40-holding soft cap | Async per-mover MCP calls are expensive (~30-90s each). Caps balance cost against narrative depth. |
| Driver fallback hierarchy (news → factor → sector → default phrase) | Real news is best evidence; default phrases prevent broken row rendering when no signal exists. |
| Reconciliation gate + 25-bp audit tolerance | Inner 1-bp gate catches math bugs in `contribution.py`; outer 25-bp gate catches local-vs-server divergence beyond known convention skew. |
| Private-beta gate | Pilot with named customer first; promote after one full letter cycle of feedback. |
| MCP contract tests | Catch upstream Parallax schema drift in CI before customers hit it. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!