Skip to content

mdolton/ls-analysis

Repository files navigation

Meridian Capital Partners

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.

Layers at a glance

# 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

Setup

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env   # add ANTHROPIC_API_KEY for Layers 3 / 7

With 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 YFRateLimitError for some tickers — these are caught per-ticker, logged, counted in the run summary, and skipped (the run still completes). Re-run later, or set POLYGON_API_KEY. SEC EDGAR calls use a shared 8 req/s limiter and are not affected.


Running the system day-to-day

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.

Daily, before any rebalance

.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 breakers

run_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.

Layer 3 (Claude) — periodic, not per-rebalance

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.

Rebalance + execute — choose one mode

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 → reconcile

Use --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)

After trading

.venv/bin/python run_reporting.py --daily      # Layer 7 — attribution CSV + tear sheet + LP letter

Building 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.


Layer 1 — Data ingestion

.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 gate

Refresh 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)

Layer 2 — Scoring engine

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.csv

Outputs:

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.


Layer 3 — Claude qualitative analysis

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 cache

Default 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_control is set on every call but cache_read_input_tokens will usually be 0 on Sonnet — expected, not a bug. The dominant cost is the per-ticker user content, which is unique per ticker.


Layer 4 — Portfolio construction

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.csvcombined_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.csv

Books 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 via scipy.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.


Layer 5 — Risk management

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 switch

Subsystems:

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).


Layer 6 — Alpaca paper-trading execution

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.csv

Components:

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):

  1. risk.pre_trade.check_trade — any rejection ⇒ record failed fill, return vetoed.
  2. short_check.is_shortable — only when opening/increasing a short; skip on False.
  3. signal_price = latest_close(conn, ticker); limit = signal × (1 ± 0.001) (buy pays up; sell marks down).
  4. Chunk on 2% ADV: n_chunks = ⌈trade_notional / (0.02 × adv_dollar)⌉, integer-share split.
  5. Each chunk: submit LimitOrderRequest(TIF=DAY), poll every 5s, cancel at 120s wall-clock.
  6. Partial fills at timeout are kept; residual dropped (no fractional-share retry loops).
  7. Zero-fill timeouts retry up to 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).


Layer 7 — Reporting engine

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 commentary

Modules:

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.


Layer 8 — Streamlit dashboard

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:8502

Pages:

# 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.


Reset / bootstrap

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.pyrun_scoring.py → (optional run_analysis.py) → run_portfolio.py --rebalancerun_risk_check.py → tear sheet / letter via run_reporting.py.


Tests

.venv/bin/pytest -q

Currently 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.json aggregator.
  • 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.py dry-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 Streamlit AppTest.from_file("streamlit_app.py") with the URL page param set to the page id.
  • End-to-end smokerun_data.py, run_portfolio.py, run_risk_check.py, run_reporting.py.

Config

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

About

Long/Short analysis

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages