A 7-layer long/short equity system for the S&P 500. Data ingestion through Claude qualitative analysis, portfolio construction, Barra-style risk, Alpaca paper-trading execution, and an institutional reporting engine — all backed by a local SQLite database.
| # | Layer | Folder | Entry point | Purpose |
|---|---|---|---|---|
| 1 | Data ingestion | data/ |
run_data.py |
Pulls market data, fundamentals, SEC filings, 13-F, short interest, estimates, calendar, transcripts into cache/meridian.db |
| 2 | Scoring engine | factors/ |
run_scoring.py |
Within-sector 0-100 scores for 8 parent factors (37 sub-factors) + weighted composite, flags top/bottom quintiles |
| 3 | Claude analysis | analysis/ |
run_analysis.py |
LLM-driven qualitative analysis (earnings calls, 10-K, fundamentals, insiders) blended into the composite |
| 4 | Portfolio | portfolio/ |
run_portfolio.py |
MVO + conviction-tilt optimizers, transaction costs, persistent SQLite state |
| 5 | Risk | risk/ |
run_risk_check.py |
Barra-style factor risk model + 8 pre-trade checks + 5 circuit breakers + 4 monitors + 6 stress scenarios |
| 6 | Execution | execution/ |
run_execution.py |
Alpaca paper-trading executor (veto → short check → chunk → poll → fill) |
| 7 | Reporting | reporting/ |
run_reporting.py |
P&L attribution, win/loss, sector-relative alpha, tear sheet, weekly commentary, daily LP letter |
| 8 | Dashboard | dashboard/ |
streamlit_app.py |
Streamlit dashboard at :8502 — 6 pages (Portfolio cover + ULTRON chat, Research, Risk, Performance, Execution, Letter), dark + indigo theme, auto-refresh during market hours |
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # add ANTHROPIC_API_KEY for Layers 3 / 7With no .env keys set, Layers 1–2 run entirely on yfinance + SEC EDGAR. Layer 3 (and the Layer-7
Claude documents) require ANTHROPIC_API_KEY. Optional providers (auto-detected from .env):
| Env var | Provides |
|---|---|
POLYGON_API_KEY |
Licensed price data (replaces yfinance) |
FMP_API_KEY |
Earnings transcripts + structured financials |
FRED_API_KEY |
Yield curve, credit spread, fed funds |
SEC_USER_AGENT_EMAIL |
Contact email for SEC EDGAR (recommended) |
ALPACA_API_KEY / ALPACA_SECRET_KEY |
Layer 6 paper-trading execution |
The active provider is logged on every run (Using Polygon for prices / Falling back to yfinance for prices, etc.).
Note on yfinance: Yahoo Finance rate-limits aggressively. Repeated full-pipeline runs in a short window will surface
YFRateLimitErrorfor some tickers — these are caught per-ticker, logged, counted in the run summary, and skipped (the run still completes). Re-run later, or setPOLYGON_API_KEY. SEC EDGAR calls use a shared 8 req/s limiter and are not affected.
The layers split into a daily data/scoring refresh, an occasional Claude pass, and a rebalance-and-execute step you run on whatever cadence you trade. Pick one of the two operating modes below and don't mix them within a cycle.
.venv/bin/python run_data.py --no-filings # Layer 1 — refresh prices/estimates/calendar (fast; skips SEC)
.venv/bin/python run_scoring.py # Layer 2 — recompute factor scores + composite
.venv/bin/python run_risk_check.py # Layer 5 — refresh risk state + circuit breakersrun_data.py is what updates latest_close, which Layer 4 uses to size positions and Layer 6 uses
as the slippage benchmark and limit fallback. Refresh the data before you rebalance — that, not
re-running the analysis, is what keeps sizing and limits current. A full run_data.py (the default
includes SEC filings) is only needed when you want fresh 10-K / 13-F / short-interest — weekly is
plenty.
run_analysis.py only changes the scores that pick names (0.6 quant + 0.4 Claude); it sets no
prices and is the one paid step ($25/run ceiling). You do not need to re-run it before every
rebalance. Run it on your research cadence (e.g. weekly, or after earnings) with --force to
bypass the 30-day cache; between runs the cached analysis keeps blending into the composite.
Skipping it entirely just leaves the book 100% quant — no penalty.
Mode A — paper/live trading through Alpaca (Layer 6 owns the book). Alpaca is the source of
truth; run_execution.py builds its own trade list and reconciles portfolio_positions to the
broker.
.venv/bin/python run_portfolio.py --whatif # Layer 4 — preview the target book; commits NOTHING
.venv/bin/python run_execution.py --dry-run # Layer 6 — preview orders; no Alpaca calls
.venv/bin/python run_execution.py --execute # Layer 6 — sync Alpaca → build trade list → route orders → reconcileUse --whatif, not --rebalance, in this mode. run_execution.py --execute reconciles with
Alpaca first, then diffs the scored-CSV target against your actual broker holdings, so it opens
exactly the positions you don't yet hold. Running --rebalance here is redundant — it pre-commits
the target into portfolio_positions as if held, and the startup sync discards that book before
building the trade list anyway.
Mode B — simulated portfolio (no broker). run_portfolio.py --rebalance is the terminal step:
it commits the target straight into portfolio_positions as if filled and marks the approvals
executed. Good for tracking a paper strategy without Alpaca. Don't follow it with
run_execution.py — the two would be maintaining the same table from different sources of truth.
.venv/bin/python run_portfolio.py --rebalance # Layer 4 — propose + commit the book (assumes fills).venv/bin/python run_reporting.py --daily # Layer 7 — attribution CSV + tear sheet + LP letterBuilding from cash ramps over several rebalances. Each rebalance is bounded by a 30% turnover budget (largest score changes first), so a full ~40-name book takes a few cycles to establish — you won't see every target name traded in a single
--execute.
.venv/bin/python run_data.py # full refresh (all S&P 500 + benchmarks)
.venv/bin/python run_data.py --no-filings # skip SEC filings (fast daily run)
.venv/bin/python run_data.py --no-13f # skip 13-F holdings
.venv/bin/python run_data.py --forms 10-K,4 # selective SEC form pull
.venv/bin/python run_data.py --limit 5 # cap per-ticker loops (quick test)
.venv/bin/python run_data.py --force # bypass the weekly universe-refresh gateRefresh order: universe → prices → fundamentals → short interest → estimates → earnings
calendar → transcripts → SEC filings → 13-F. Everything logged to output/run.log; one row per run
in the run_log table.
Database tables (in cache/meridian.db):
| Table | Contents |
|---|---|
universe |
S&P 500 constituents + benchmark tickers (is_benchmark=1) |
daily_prices |
Daily OHLCV + adj_close, 3-yr lookback, incremental updates |
fundamentals_raw |
Quarterly + annual income/balance/cashflow line items |
fundamental_ratios |
24 derived ratios per period (ROE, ROA, margins, growth, D/E, FCF yield, accruals, …) |
sec_filings |
Latest 10-K & 10-Q (in cache/edgar/<ticker>/), recent 8-K + Form 4 metadata |
insider_transactions |
Parsed Form 4 transactions; P=open-market buy, S=sale, A/M/F=grant/exercise/tax; is_ceo_cfo_buy flag |
insider_cluster_flags |
Tickers with 3+ distinct insiders buying within a 30-day window |
institutional_holdings |
13-F holdings for 9 tracked hedge funds |
institutional_summary |
Per-ticker fund-count, net share change, new-position cluster flag |
short_interest |
Daily snapshots: shares short, short ratio, short % of float |
analyst_estimates |
Daily snapshots: forward EPS, price-target consensus |
earnings_calendar |
Upcoming earnings dates within the next 30 days |
earnings_transcripts |
Latest earnings-call transcripts (FMP; only fetched for Layer-4 candidates) |
macro_series |
FRED series (yield curve, credit spread, fed funds) — only when FRED_API_KEY set |
run_log |
One row per run_data.py invocation with summary counts |
Run summary at the end:
=== Meridian data refresh complete ===
Tickers updated: ...
Price bars added: ...
Filings cached: ...
Insider txns parsed: ...
Errors: ... (see output/run.log)
Reads cache/meridian.db and computes, within each GICS sector, a 0-100 percentile score for 8
parent factors (37 sub-factors total) — momentum, value, quality (incl. Piotroski F-Score + Altman
Z-Score with colour bands), growth, estimate revisions, short interest, insider activity,
institutional flow — then a weighted composite re-ranked within sector. Top-quintile names are
flagged LONG, bottom-quintile SHORT. No network calls.
.venv/bin/python run_scoring.py # score the whole S&P 500, write the CSV
.venv/bin/python run_scoring.py --ticker AAPL # also print a detailed single-stock factor card
.venv/bin/python run_scoring.py --regime-weights # VIX-conditional composite weights for this run
.venv/bin/python run_scoring.py --output path.csvOutputs:
| Path / table | Contents |
|---|---|
output/scored_universe_latest.csv |
One row per constituent: 37 sub-factor scores, 8 parent scores, composite, LONG/SHORT flags, Piotroski/Altman bands, cluster flags |
scoring_runs table |
Metadata per run |
factor_scores table |
All scores keyed by (run_id, ticker) |
factor_returns table |
60-day long/short factor return series used for crowding |
crowding_correlations table |
Pairwise factor-return correlations vs academic baselines, flagged on deviation |
Degenerate factors (source tables too sparse — estimate revisions needs ~30 days of snapshots, short interest / 13-F / fundamentals need a full Layer-1 run) come back with all scores pinned to 50 and are listed in the run summary. Regime-conditional weights are off by default.
run_analysis.py is the AI analyst. Reads primary sources Layer 1 cached — earnings-call
transcripts, 10-K risk factors, derived financial metrics, Form 4 trades — and turns the Anthropic
responses into structured JSON that blends with the Layer-2 quant composite. Requires
ANTHROPIC_API_KEY in .env.
.venv/bin/python run_analysis.py --estimate-cost # preview API cost (no API calls)
.venv/bin/python run_analysis.py --ticker AAPL # one ticker (all four analyzers + report)
.venv/bin/python run_analysis.py --sector "Information Technology"
.venv/bin/python run_analysis.py # full run: top-N LONG + bottom-N SHORT
.venv/bin/python run_analysis.py --force # ignore the analysis cacheDefault model: claude-sonnet-4-6 (override analysis.model). cache_control: ephemeral is
set on every system prompt. Calls retry on 429/5xx with exponential backoff. JSON is extracted
whether raw, in a ```json fence, or wrapped in prose.
Cost control (analysis/cost_tracker.py): reads response.usage after every call (priced from
analysis.pricing) and enforces a hard ceiling — analysis.cost_ceiling_usd ($25/run default).
Aborts cleanly when reached. A full run of ~20 long + ~20 short candidates ≈ $2-5 on Sonnet.
Caching (analysis/cache.py): every analyzer output stored in analysis_results keyed by
(analyzer, ticker, artifact_id). Re-running the same artifact is a free hit. Entries older than
analysis.cache_ttl_days (default 30) are evicted on startup.
Five analyzers (each returns None if its input isn't cached — no downstream penalty):
| Analyzer | Input | Output (key fields) |
|---|---|---|
earnings_analyzer |
Latest transcript (≤ 120K chars; FMP fetched for candidates first if key set) | 1-10 scores: Management Confidence, Revenue Guidance, Margin Trajectory, Competitive Position, Risk Factors, Capital Allocation; bull/bear case; key quotes; one-line summary |
filing_analyzer |
8 quarters of fundamental_ratios (forensic review: CFO vs NI, AR vs revenue, accruals) |
earnings_quality_score, revenue_quality_score, balance_sheet_score, accruals_assessment, red/green flags, risk_level |
risk_analyzer |
"Item 1A. Risk Factors" from cached 10-K (≤ 80K chars; prior 10-K diffed if cached) | material_risks, new_risks, boilerplate_percentage, risk_severity, reasoning |
insider_analyzer |
Form 4 transactions, last 90 days | signal_strength (STRONG_BUY…STRONG_SELL), confidence, cluster_detected, reasoning |
sector_analysis |
All available Claude results + Layer-2 scores for one sector | rankings, top_long_idea, top_short_idea, sector_outlook |
Combined score (analysis/combined_score.py): 0.6 × quant composite + 0.4 × Claude
fundamental (avg of available analyzer subscores, normalised 0-100). Names without Claude analysis
stay 100% quant — no penalty. Re-ranked within sector, re-flagged on the Layer-2 quintile
threshold. Written to output/combined_scores_latest.csv.
Reports (analysis/report_generator.py): one markdown file per candidate under
output/reports_{timestamp}/{TICKER}.md (plus INDEX.md) — all scores, analyzer summaries,
upcoming catalysts, consolidated risk flags.
Prompt caching note. Prompt caching only materialises a cache entry when the cached prefix clears the model's minimum (~2K tokens for Sonnet, ~4K for Opus). The Layer-3 system prompts are detailed but below that, so
cache_controlis set on every call butcache_read_input_tokenswill usually be 0 on Sonnet — expected, not a bug. The dominant cost is the per-ticker user content, which is unique per ticker.
Turns the Layer-2 (or Layer-3 combined) scores into an actual long/short book. Reads
cache/meridian.db + output/scored_universe_latest.csv (or combined_scores_latest.csv —
combined_score / combined_long_flag take priority). No network calls.
.venv/bin/python run_portfolio.py --current # show current book + exposures + betas
.venv/bin/python run_portfolio.py --whatif # propose a rebalance, commit nothing
.venv/bin/python run_portfolio.py --rebalance # propose + commit
.venv/bin/python run_portfolio.py --rebalance --optimize-method mvo
.venv/bin/python run_portfolio.py --whatif --scored-csv output/combined_scores_latest.csvBooks are 20 LONG / 20 SHORT — long_flag==1 / short_flag==1 names ranked by composite, topped
up from highest/lowest remaining when fewer are flagged. Each rebalance is bounded by a 30%
turnover budget, so building from cash ramps over several rebalances (biggest score changes first).
Two optimizers (config portfolio.optimize_method, or --optimize-method):
-
MVO (
portfolio/mvo_optimizer.py) — Markowitz mean-variance viascipy.optimize.minimize(SLSQP). Expected returns: composite score linearly mapped (100 → +15%/yr, 0 → −15%/yr). Covariance: 120-day historical (replaced by Layer-5 factor cov when it estimates cleanly). Costs subtracted from gross E[return]. Objectiveμ·w − λ·wᵀΣw. Constraints: long sum to 0.80, short sum to 0.70 (gross 150%, net +10%), per-position [0.5%, 5%],|w·β| ≤ 0.15,|sector_net| ≤ 5%, single-side sector< 25%. On non-convergence: warning + fall back to conviction-tilt. -
Conviction-tilt (
portfolio/optimizer.py) — equal weight within each book; top-5%-by-score get 1.5×, top-10% get 1.25× (re-normalised to gross). Sector-neutralised. Caps: 5% NAV, 5% of 20-day ADV ($), half-sized within 5 days of earnings. Short book scaled to neutralise net beta only when|net β| > 0.15(so the gross/net targets are preserved when already balanced).
Components:
| Module | What it does |
|---|---|
portfolio/transaction_costs.py |
3-component cost (bps of notional): commission ($0 Alpaca) + spread (5% of avg HL range) + impact (0.10 · √(trade/ADV) · vol_bps) |
portfolio/rebalance.py |
Diffs target vs current weights, applies turnover budget (largest score changes first; crossing trade partially filled to land on budget), estimates costs, commits to DB unless --whatif |
portfolio/rebalance_schedule.py |
Advisory only — warns within 2d of earnings, 5d of FOMC (2026 dates hardcoded), 3d of monthly opex. Never blocks. |
portfolio/beta.py |
Rolling 60-day beta vs SPY (cov/var); long-book / short-book / net portfolio beta |
portfolio/factor_exposure.py |
Weighted-avg factor score per book + spread; flags spreads > 1σ from history (needs ≥3 prior rebalances) |
portfolio/state.py |
4 SQLite tables: portfolio_positions, portfolio_history, position_approvals, portfolio_factor_exposures. Corporate actions: splits, ticker changes, mergers/delistings |
Config (portfolio: block): num_longs/num_shorts = 20/20, max_position_pct = 5%,
max_sector_side_pct = 25%, gross_exposure = 150%, net_exposure ∈ [0%, +10%], max_beta = 0.15,
turnover_budget = 30%, mvo_risk_aversion = 1.0, capital_base = $10 M, plus cost coefficients,
lookbacks, and 2026 FOMC dates.
A Barra-style factor risk model plus six absolute-veto / monitor systems. Reads
cache/meridian.db, output/scored_universe_latest.csv, and the Layer-4 portfolio state. Writes
cache/risk_state.json and — on kill switch — cache/risk_halt.lock. No network calls except
the stress tester's three historical scenarios (yfinance, cached to cache/stress/*.parquet).
.venv/bin/python run_risk_check.py # full check → risk_state.json
.venv/bin/python run_risk_check.py --stress # + 6-scenario stress test
.venv/bin/python run_risk_check.py --tail-only # just VIX / credit-spread check
.venv/bin/python run_risk_check.py --clear-halt # clear the kill switchSubsystems:
| Subsystem | File | Behavior |
|---|---|---|
| Factor risk model | factor_risk_model.py |
Barra cross-sectional regression over 120d: daily factor returns → annualised factor cov F + per-stock specific variance → predicted stock cov X·F·Xᵀ + diag(specific) fed to Layer-4 MVO. Portfolio decomp: factor_var = expᵀ·F·exp, specific_var = Σwᵢ²·specᵢ. MCTRᵢ = wᵢ·(Σ·w)ᵢ/σ_p (Euler — sums to σ_p); positions with MCTR% > 1.5× weight% flagged. |
| Pre-trade veto | pre_trade.py |
8 checks, any failure ⇒ REJECT, no override: (1) halt lock; (2) earnings blackout (within 5d ⇒ size ≤ 50% of 5% cap); (3) liquidity ≤ 5% of 20-day ADV; (4) position ≤ 5% AUM; (5) single-side sector ≤ 25%; (6) gross ≤ 165% AND net ∈ [−10%, +15%]; (7) ` |
| Circuit breakers | circuit_breakers.py |
Daily loss > 1.5% ⇒ SIZE_DOWN 30%; daily loss > 2.5% ⇒ CLOSE_ALL_TODAY (day-scoped halt); 5-day loss > 4% ⇒ SIZE_DOWN 30%; drawdown > 8% ⇒ KILL_SWITCH (persistent lock, --clear-halt only); single position > 3% of NAV ⇒ force-close (overrides Layer-4 5% build cap). |
| Factor monitor | factor_monitor.py |
Z-scores each factor's long-minus-short exposure vs universe σ; alerts when ` |
| Correlation monitor | correlation_monitor.py |
60-day rolling pairwise within each book; alerts when avg > 0.60. Reports the effective number of bets — exp(entropy(eigenvalues of corr matrix)). |
| Tail-risk monitor | tail_risk.py |
Mandatory, no override. VIX ≥ 25 ⇒ reduce gross 20%, VIX ≥ 35 ⇒ reduce gross 50%. HY credit OAS (BAMLH0A0HYM2 via FRED or macro_series) widening ≥ 1σ vs 252-day mean ⇒ reduce gross 20%. |
| Stress tester | stress_test.py |
6 scenarios with $ + % of NAV split by book — historical (2008 Crisis, 2020 Covid, 2022 Rate Hikes) + synthetic (Sector Shock −30% on most-concentrated sector; Momentum Reversal: top-q-momentum −20%, bottom-q +20%; Short Squeeze: every short +30%) |
| Risk state aggregator | risk_state.py |
Writes cache/risk_state.json with daily/weekly P&L, drawdown, halt status, breaker log (50 entries), exposures, decomposition, factor contributions, top MCTR positions, alerts |
Config (risk: block): factor-model lookback (120d) + factor list + feed_to_mvo toggle; 8
pre-trade limits; 5 circuit-breaker thresholds; factor/correlation alert levels; VIX +
credit-spread thresholds; 6 stress scenarios. capital_base inherits from portfolio.capital_base.
Dependency: pyarrow (stress-test cache — CSV fallback if not installed).
Takes the Layer-4 trade list (what-if only — never commits through this path) and routes each trade
through pre-trade veto → short-availability check → chunked limit orders ≤ 2% ADV → 120s
time-in-force with cancel + 3-retry → fill recording. Paper trading by default. Live trading
requires BOTH execution.mode: live in config.yaml AND typing YES I UNDERSTAND THE RISKS at
the runtime prompt.
.venv/bin/python run_execution.py --dry-run # log planned chunks/limits, no Alpaca calls
.venv/bin/python run_execution.py --execute # place orders (paper by default)
.venv/bin/python run_execution.py --execute --scored-csv output/combined_scores_latest.csvComponents:
| Module | What it does |
|---|---|
execution/broker.py |
BrokerClient wraps alpaca-py's TradingClient. with_backoff(fn, …) retries ConnectionError/TimeoutError/OSError with capped exponential delay. sync_positions(conn) reconciles portfolio_positions with Alpaca on startup — inserts missing, overwrites mismatches with a WARNING, removes local-only positions. |
execution/short_check.py |
Caches Alpaca's shortable + easy_to_borrow flags per ticker for 7 days in a short_availability table. Short trade skipped (and a failed row written to execution_fills) if either is false. |
execution/costs.py |
Every chunk outcome (filled/partial/cancelled/veto-fail) is written to execution_fills. Slippage in bps is side-adjusted so positive ⇒ adverse: long +(fill − signal)/signal × 1e4, short −(fill − signal)/signal × 1e4. costs.summary(window_days=30) returns rolling avg/median/p95 + total $ + worst-5 fills. |
execution/executor.py |
The state machine — see below. |
execution/order_manager.py |
In-memory state per Alpaca order id (PENDING → PARTIAL → FILLED | CANCELLED | FAILED). install_sigint_handler(manager, broker) cancels every pending order best-effort on Ctrl-C, leaves positions intact. |
Per-trade execution flow (executor.execute_trade):
risk.pre_trade.check_trade— any rejection ⇒ recordfailedfill, returnvetoed.short_check.is_shortable— only when opening/increasing a short; skip on False.signal_price = latest_close(conn, ticker);limit = signal × (1 ± 0.001)(buy pays up; sell marks down).- Chunk on 2% ADV:
n_chunks = ⌈trade_notional / (0.02 × adv_dollar)⌉, integer-share split. - Each chunk: submit
LimitOrderRequest(TIF=DAY), poll every 5s, cancel at 120s wall-clock. - Partial fills at timeout are kept; residual dropped (no fractional-share retry loops).
- Zero-fill timeouts retry up to 3× with a refreshed signal price.
Config (execution: block): mode (paper|live), sync_on_startup,
retries.{max_attempts, backoff_base_seconds, backoff_max_seconds}, short_check.cache_days (7),
executor.{limit_offset_pct=0.001, chunk_max_adv_pct=0.02, time_in_force_seconds=120, poll_seconds=5, max_retries=3}, slippage.{rolling_window_days=30, worst_n_fills=5}.
New dependency: alpaca-py==0.34.0. New env vars: ALPACA_API_KEY, ALPACA_SECRET_KEY.
New SQLite tables: short_availability (ticker, shortable, easy_to_borrow, fetched_at) and
execution_fills (ticker, side, action, shares, signal_price, limit_price, fill_price,
slippage_bps, status, alpaca_order_id, submitted_at, finished_at, notes).
Eight modules that turn the Layer 1-6 state into institutional CSVs, a Markdown tear sheet, and two Claude-authored documents in a locked-in ULTRON voice.
.venv/bin/python run_reporting.py --daily # attribution CSV + tear sheet + LP letter
.venv/bin/python run_reporting.py --weekly # ULTRON weekly commentary + tear sheet
.venv/bin/python run_reporting.py --tearsheet # just output/tear_sheet_latest.md
.venv/bin/python run_reporting.py --letter --force # regenerate today's LP letter
.venv/bin/python run_reporting.py --commentary # just this week's ULTRON commentaryModules:
| Module | Output | What it does |
|---|---|---|
pnl_attribution.py |
output/daily_attribution.csv (upsert by date) |
daily_return = beta + sector + factor + alpha. Beta = net_beta × spy_return. Sector = Σ_sector (long_w − short_w) × (sector_etf_return − spy_return). Factor = OLS betas on the last 60d of factor returns × today's. Alpha = residual. |
position_attribution.py |
(in-memory) | MTM every open position; pair position_approvals closes with most-recent prior opens (LIFO) for realized P&L. predictive_power = Spearman(entry composite score, realized return) across open + closed (≥ 5 points). |
win_loss.py |
(in-memory) | Win rate / P/L ratio sliced 5 ways: side, holding bucket (≤5d / 5-20d / 20-60d / >60d), sector, VIX regime at entry (LOW <15 / NORMAL / HIGH >25), entry composite quintile (1-5). Plus streaks. |
sector_relative.py |
(in-memory) | Per sector with positions: picks_return (weighted side-adjusted; shorts negated) vs etf_return over 90d ⇒ stock-selection alpha. total_alpha weighted by gross sector exposure. |
turnover.py |
(in-memory) | 30/90d turnover from executed position_approvals, annualised, vs portfolio.turnover_budget. FIFO tax: per-ticker lot queue, classifies each closed lot short-term (< 365d, 37%) or long-term (20%). |
tear_sheet.py |
output/tear_sheet_latest.md |
11 sections: Header, Top-line, Returns vs SPY, Monthly Returns grid, Drawdown, Rolling 12mo Sharpe, Factor Exposures, Sector Exposures, Turnover & Tax, Win/Loss, Predictive Power. |
claude_commentary.py |
output/commentary/weekly_<date>.md |
Fires on configured weekday (default Friday). ≤4KB JSON context → single paragraph, 3-6 sentences. Reuses Layer-3 Anthropic client + cost tracker ($5/run ceiling). |
lp_letter.py |
output/letters/letter_<date>.md (cached) |
Formal letterhead (fund name, domicile, inception, AUM, doc id MCP-IM-{YYYY}-{MMDD}) + "CONFIDENTIAL • LIMITED PARTNERS ONLY" stamp + "Dear Limited Partners," + 3-4 paragraph body from Claude + signature block + compliance footer. --force regenerates. |
persona.py |
(shared) | Single ULTRON SYSTEM_PROMPT used by both commentary and LP letter. Voice anchors: "the network", "executions"; no first-person plural; no we believe / in our view; no !; no emoji. voice_check(body) returns offenders — generators log flags as WARNINGs but do not block (the human revises). |
Config (reporting: block): output paths, sector ETF map, win/loss bucket boundaries + VIX
thresholds, turnover tax rates (37% / 20%) + annualisation factor (12), tear-sheet rolling window +
risk-free rate, commentary weekday + model + budget, LP letter fund identity + doc-id prefix.
streamlit_app.py is the 6-page UI for everything Layers 1-7 produce. No new computation —
every page is a thin renderer over cache/meridian.db + the CSVs/JSON/markdown that the reporting
engine writes. ULTRON lives on Page I and chats with Claude using a 19KB snapshot of the network
state as cached context.
.venv/bin/streamlit run streamlit_app.py --server.port 8502 --server.headless true
# http://localhost:8502Pages:
| # | Page | Reads | Renders |
|---|---|---|---|
| I | Portfolio | universe, portfolio_positions, ^VIX close, scored CSV, insider tables, earnings_calendar |
ULTRON cover (92px headline + dark gradient image slot) + Ask ULTRON chat (6-turn memory) + 10 KPI tiles + status strip (VIX regime + data source) |
| II | Research | scored CSV, advisories from portfolio.rebalance_schedule, optional analysis_results |
Schedule banner, MVO/conviction toggle, top-30 long + bottom-30 short × 8-factor heatmap, 10+10 candidate cards (Approve/Reject/Reset), Execute button that fires run_execution.py --execute as a subprocess |
| III | Risk | cache/risk_state.json |
Daily/weekly/drawdown breaker bars (FIRED pill on breach), variance decomposition donut, MCTR table with >1.5× flag, last-72h alerts, optional 6-scenario stress table |
| IV | Performance | NAV via risk.pnl, output/daily_attribution.csv, reporting outputs |
Equity curve vs SPY (rebased to 100), daily attribution bars (Beta/Sector/Factor/Alpha), sector-relative alpha bars, turnover + tax panel, win/loss top-line, best/worst 5 contributors, latest ULTRON weekly commentary card |
| V | Execution | execution_fills, short_availability, live Alpaca poll for open orders |
KPI row (filled 30d / avg slip / total slip $ / open orders), open orders table (graceful muted notice when broker not configured), recent fills (200), worst-5 fills, short availability |
| VI | Letter | output/letters/letter_<today>.md |
Renders the LP letter (letterhead + body + signature + footer composed by reporting.lp_letter). Regenerate button calls lp_letter.generate_letter(force=True) in-process. |
Theme (dashboard/theme.py): one global_css(cfg) string + a plotly_template(cfg) dict.
Palette: #0b0e17 bg, #131827 → #1a2035 card gradient, #6366f1 indigo accent, #10b981 long
green, #f43f5e short red. Fonts: Plus Jakarta Sans + JetBrains Mono (loaded via Google Fonts).
Streamlit chrome (MainMenu, footer, header, toolbar, decoration) hidden by CSS.
ULTRON chat (dashboard/ultron.py): reuses reporting/persona.SYSTEM_PROMPT — the same voice
that authors the weekly commentary and LP letter. Default model is claude-haiku-4-5 (cheap + fast
for chat; override via dashboard.chat.model). Per-session cost ceiling $2 via the Layer-3
CostTracker. 6-turn memory in st.session_state.ultron_history. The 19KB snapshot is built by
build_snapshot(conn, cfg) and includes universe stats, all open positions, exposures (gross /
net / long / short / sector breakdown), win/loss top-line, sector_relative totals, last 20 fills,
last 5 daily attribution rows, and VIX.
Auto-refresh (dashboard/auto_refresh.py): wraps streamlit-autorefresh. Active only on
weekdays 09:30-16:00 ET (NYSE hours; window configurable). Outside that window the dashboard runs
without refresh — manual interaction reruns Streamlit normally.
Config (dashboard: block): port (8502), auto_refresh_seconds (300), market window,
assets_dir (for ultron.png), chat (model, max_tokens, cost_ceiling_usd, memory_turns,
snapshot_max_kb), and the full theme palette.
New dependencies: streamlit==1.40.0, streamlit-autorefresh==1.0.1, plotly==5.24.0.
run_reset.py exposes four reset depths in one CLI for when you want to start fresh without
manually figuring out which files belong where. Destructive tiers (--state / --db / --all)
prompt for yes to confirm; pass --yes to skip the prompt in scripts. --dry-run previews
the action without mutating anything.
.venv/bin/python run_reset.py --halt # tier 1: clear the kill-switch / halt lock only
.venv/bin/python run_reset.py --state # tier 2: drop portfolio/risk/exec state, keep market data
.venv/bin/python run_reset.py --db # tier 3: full DB wipe + nuke output/, keep cache/edgar/
.venv/bin/python run_reset.py --all --yes # tier 4: rm -rf cache/ + output/
.venv/bin/python run_reset.py --state --dry-run # preview without mutating| Tier | What it removes | What it keeps |
|---|---|---|
--halt |
cache/risk_halt.lock |
everything else |
--state |
6 state tables (portfolio_positions, portfolio_history, position_approvals, portfolio_factor_exposures, execution_fills, short_availability); cache/risk_state.json; cache/risk_halt.lock; output/letters/, output/commentary/, output/reports_*/, output/daily_attribution.csv, output/tear_sheet_latest.md |
market data, scoring tables, analysis_results, cache/edgar/, cache/stress/ |
--db |
cache/meridian.db, cache/risk_state.json, cache/risk_halt.lock; every file under output/ except output/run.log |
cache/edgar/ (SEC HTML cache — saves bandwidth on re-bootstrap), cache/stress/ (yfinance parquet cache), output/run.log (forensic record) |
--all |
cache/, output/ |
nothing |
After --state or --db, the natural re-bootstrap order is run_data.py → run_scoring.py →
(optional run_analysis.py) → run_portfolio.py --rebalance → run_risk_check.py → tear sheet /
letter via run_reporting.py.
.venv/bin/pytest -qCurrently 431 passing, zero failures. No test makes a live API call — every Anthropic and Alpaca call site is monkeypatched.
Coverage by layer:
- Layer 1 — 24 ratio calculations, Form 4 XML parsing, insider-cluster detection, 13-F diff/summary, incremental-fetch date logic.
- Layer 2 — Scoring smoke (output / ticker / regime-weights flags), percentile-rank math, VIX-conditional regime weights, crowding detection + factor-return correlations.
- Layer 3 — JSON extraction, retry/backoff, cost-ceiling enforcement, analysis cache + TTL eviction, combined-score blend, every analyzer against an in-memory DB with a fake Anthropic client.
- Layer 4 — Portfolio state + corporate actions, rolling betas, transaction-cost model, MVO optimizer constraints, conviction-tilt steps, rebalance-schedule date math, factor-exposure anomaly flagging, rebalance generator's turnover budget.
- Layer 5 — Halt-lock lifecycle, NAV / drawdown math, Barra cross-sectional regression + MCTR Euler decomposition, 8 pre-trade checks, all 5 circuit-breaker triggers, factor & correlation alerting, VIX / credit-spread tail-risk, 6 stress scenarios,
risk_state.jsonaggregator. - Layer 6 — Broker backoff + position reconciliation, short-availability cache + staleness eviction, fill recording + slippage-bps sign convention, executor chunking + timeout + retry, order-manager state transitions + SIGINT handler, slippage summary stats,
run_execution.pydry-run smoke. - Layer 7 — ULTRON persona voice_check, P&L attribution decomposition + factor regression alignment + CSV upsert, MTM / round-trip / Spearman predictive power, win-loss slicings + streaks, sector-relative alpha, ASC turnover + FIFO tax, tear-sheet metric assembly + Markdown rendering, weekly commentary + LP letter with fake Anthropic client + date caching.
- Layer 8 (dashboard) — Pure helpers in
dashboard/state.py,dashboard/auto_refresh.py,dashboard/ultron.py,dashboard/nav.py, and each page's pure helpers (_kpi_rows,_factor_heatmap_data,_breaker_bar,_rebase_to_100,_attribution_bar_rows,_kpi_row_values,_letter_path_for_today, etc.). Each page is also headless-smoke-tested via StreamlitAppTest.from_file("streamlit_app.py")with the URLpageparam set to the page id. - End-to-end smoke —
run_data.py,run_portfolio.py,run_risk_check.py,run_reporting.py.
All tunables live in config.yaml. Secrets in .env (gitignored) — see .env.example.
| Block | Layer | Owns |
|---|---|---|
| (top-level) | 1 | Paths, price lookback, SEC rate limit (8 req/s) + lookback windows, 9 tracked 13-F funds + CIKs, retry policy |
scoring: |
2 | Composite weights, regime-conditional overrides, per-factor windows, sector ETF map, crowding baselines |
analysis: |
3 | Model, token budgets, cost ceiling, cache TTL, candidate counts, per-model pricing |
portfolio: |
4 | Book sizes, exposure / beta / sector limits, turnover budget, optimizer choice + risk aversion, cost coefficients, capital base, 2026 FOMC dates |
risk: |
5 | Factor-model lookback + factors + feed_to_mvo, 8 pre-trade limits, 5 circuit-breaker thresholds, factor / correlation / tail-risk alert levels, 6 stress scenarios |
execution: |
6 | Mode, retries, short-check cache, executor knobs, slippage window |
reporting: |
7 | Output paths, sector ETF map, win/loss buckets + VIX thresholds, tax rates, commentary weekday + model + budget, LP letter identity |