Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ reports/*.md
*.parquet
data_cache/

# --- Local tracker store ---
data/
# --- Local tracker store (repo-root only; not the core/data source package) ---
/data/

# --- OS / editors ---
.DS_Store
Expand Down
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A quantitative + agentic engine for running the **Wheel options strategy** on a small ($1,000) account. It produces a ranked, gated trade sheet — it **never places orders**. Three honest constraints drive the design: one position at a time, real option-chain data (with an honest theoretical fallback), and a backtest that proves the rules add edge.

## Commands

```bash
# Install (editable). Pick extras: [all], or compose [ui]/[agents]/[tui]/[dev]/[research] + a provider.
pip install -e ".[all]"

# Quant-only weekly scan — no LLM, no API key, no UI:
quantwheel-scan --tickers F,SOFI,NIO --account 1000

# Streamlit dashboard (default UI, 7 tabs + tracker):
streamlit run src/quantwheel/ui/streamlit_app/app.py

# Textual TUI (same backend — proves the UI is swappable):
python -m quantwheel.ui.tui.app

# Tests (pyproject sets pythonpath=src, testpaths=tests):
pytest
pytest tests/test_pricing.py # one file
pytest tests/test_pricing.py::test_name # one test
pytest -k council # by keyword
```

## The one architectural rule (do not break it)

```
ui/ → services/ → core/ + agents/ (strictly one-way)
```

- The **UI only ever** imports from `services/` and reads the dataclasses in `core/types.py`. It never imports `core/` models or `agents/` directly.
- The **backend** (`core/`, `services/`, `agents/`) **never** imports a UI library. `tests/test_architecture.py` enforces this by scanning for `import streamlit/textual/plotly/...` and failing the build if any leak in. Both UIs (Streamlit, Textual) are thin adapters over the *same* `services/` facade.
- When adding backend behavior, expose it through a function in `services/` that returns/consumes `core/types.py` dataclasses — keep the math and the UI on opposite sides of that boundary.

## How a scan flows

`services/scan_service.run_weekly_scan` → `core/scoring.score_universe` → per ticker `score_candidate`:

```
bars → indicators → regime → chain quote → Monte Carlo → pricing
→ earnings/liquidity/capital gates → composite score → verdict
```

Key files: `core/scoring.py` is the orchestration heart that combines every model layer into one `Verdict`. Models live in `core/models/` (`indicators`, `regime` Markov, `montecarlo` GBM, `pricing` Black-Scholes). Data fetchers in `core/data/` (`bars`, `chains`, `earnings`) — all pull free Yahoo data via yfinance.

**Verdicts are deliberately honest:** hard gates (capital, earnings, regime, liquidity) and non-positive expected value override a pretty technical score. A name can look great and still be `SKIP_CAPITAL` because it doesn't fit the account. A high Monte-Carlo assignment probability caps the verdict at `WATCH`.

**Account-aware, multi-position output.** The engine is no longer hardwired to $1,000/one-position. `core/portfolio.py:allocate(scan, config, verdicts)` greedily sizes up to `config.max_positions` enterable names within deployable cash (for `max_positions == 1` it's identical to single-position sizing). `services/report_service.py:build_report(...)` turns a scan (+ optional council) into a `PortfolioReport` of specific trades, each paired with plain-English narrative from `core/narrative.py` (beginner-friendly, defines jargon inline). The Streamlit **Report tab** (`ui/.../tabs/report.py`) is the readable, headline surface; the **Approve tab** remains the single-best human-in-the-loop card. The backtest and LangGraph weekly agent stay single-position by design.

## Configuration

**Every tunable number lives in `core/config.py`** as a frozen `WheelConfig` dataclass (account size, DTE window, target delta, gate thresholds, MC paths, regime/scoring params). `DEFAULT` is the module-level instance everyone imports. To change behavior, construct a `WheelConfig` (or `dataclasses.replace(DEFAULT, ...)`) and thread it through the services layer — do not scatter magic numbers back into the models.

## The agentic layer (LLM-optional)

The quant core, backtest, scan, and tracker all work **with no LLM and no API key**. Only the council and weekly agent call an LLM.

- **Provider-agnostic LLM factory** in `agents/llm.py`. Pick the provider with env vars (`.env`): `QUANTWHEEL_PROVIDER` = `anthropic` | `ollama` | `openai` | `google`, plus `QUANTWHEEL_MODEL`, `QUANTWHEEL_TEMPERATURE`. Adding a provider = one branch in `make_chat_model`. The council uses **structured output**, so a local model must support tool-calling / JSON-schema (llama3.1/3.2, qwen2.5 work; tiny models don't).
- **Council** (`agents/council.py`, `agents/personas/`): five anonymized **archetype** personas (the Statistician, Value Investor, Risk Manager, Macro Strategist, Systematic Quant — no real-person names; `tests/test_personas.py` guards this) vote on the same evidence; aggregation is **deterministic** (conviction-weighted mean, no extra LLM call) so it's reproducible and testable. The per-persona rubric is account-aware (`personas/base.py:_rubric(account, max_positions)`), threaded from `WheelConfig`.
- **Weekly agent** (`agents/graph.py`): a LangGraph `StateGraph` with branching (sits out a week when nothing qualifies) and a **human-in-the-loop `interrupt()`** at the approval gate. Code never auto-trades — the graph resumes only after a human yes/no. Tests/cron pass `auto_approve` to answer the gate non-interactively. State lives in `agents/nodes.py:WheelState`.

## Persistence

The trade tracker (`services/tracker_service.py`) is a single local JSON file at `data/trades.json` (override with `QUANTWHEEL_TRACKER`). The LangGraph checkpointer is in-memory (`MemorySaver`) — a paused agent resumes within the same process by `thread_id`.

## Known limitations (keep in mind when changing model code)

- Free Yahoo option data is noisy: synthetic IV / zero bid-ask after hours. `chains.py` detects this and falls back to a Black-Scholes theoretical quote flagged `is_estimated` — gates treat estimated quotes and zero open-interest as "unknown," not failures.
- The backtest uses **realized** vol (no free historical implied vol) and synthesizes quarterly earnings dates when the feed lacks them. The *relative* rule ablation is robust; absolute premium income is understated.

## Learning project

This repo doubles as a LangChain/LangGraph course — see `LEARNING.md` and `notebooks/` (`01_learn_langchain` = the council, `02_learn_langgraph` = the weekly agent, `research.ipynb` = backtest plots).
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ whether the rules actually improve the odds.
The Wheel (sell cash-secured puts → if assigned, sell covered calls → repeat) is simple to state
and tedious to run well: read the chart, estimate assignment risk, price the premium, check
earnings, size it to your account — across a dozen tickers, every week. This automates the analysis
while keeping a human at the trigger, and it's built around three honest constraints of a **$1,000
Robinhood account**:
while keeping a human at the trigger. It was built for a **$1,000 Robinhood account** but is
**account-aware**: set your balance and how many concurrent positions you want, and it sizes a
diversified book accordingly (a $1,000 account holds one position; a larger one spreads across
several). It rests on three honest constraints:

1. **One position at a time** — collateral ties up the whole account, so *name selection and
earnings-avoidance are the risk management.*
1. **Sized to your account** — at $1,000 collateral ties up everything, so *name selection and
earnings-avoidance are the risk management*; at larger balances it allocates across several names.
2. **Real option data** — prices against the live chain's implied vol and bid/ask (with an honest
theoretical fallback when the feed is closed), so the numbers track what you'll actually get.
3. **Proof, not vibes** — a backtest with calibration and rule-ablation says whether the edge is
Expand All @@ -41,9 +43,10 @@ Robinhood account**:
| **Black-Scholes** | What's it worth, and the Greeks? | Priced with the chain's **real IV** |
| **Council** | Does it make qualitative sense? | 5 LLM personas vote, an aggregator ranks |

The council: **the Statistician** (trusts the math), **Warren Buffett** (would I be happy owning the
shares?), **Michael Burry** (tail-risk skeptic), **Ray Dalio** (macro/regime), and **Jim Simons**
(pure systematic — distrusts narrative).
The council is five anonymized **archetypes**, each a distinct investing lens: **the Statistician**
(trusts the math), **the Value Investor** (would I be happy owning the shares?), **the Risk Manager**
(tail-risk skeptic), **the Macro Strategist** (macro/regime), and **the Systematic Quant** (pure
systematic — distrusts narrative).

## What the backtest shows (rule ablation)

Expand Down
29 changes: 18 additions & 11 deletions src/quantwheel/agents/council.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""The investment council: five personas deliberate, then we aggregate.

Each persona (Statistician, Buffett, Burry, Dalio, Simons) votes on the *same*
evidence. We blend their stances — weighted by each persona's stated conviction —
into a single score in ``[-1, +1]`` and a short, deterministic consensus summary.
Each persona — the Statistician, the Value Investor, the Risk Manager, the Macro
Strategist and the Systematic Quant — votes on the *same* evidence. We blend their
stances, weighted by each persona's stated conviction, into a single score in
``[-1, +1]`` and a short, deterministic consensus summary.

Why deterministic aggregation (no extra LLM call)? It's free, reproducible, and
testable. The *diversity of judgement* comes from the five personas; the
Expand All @@ -16,24 +17,29 @@

from quantwheel.agents.personas import buffett, burry, dalio, simons, statistician
from quantwheel.agents.personas.base import PersonaAgent
from quantwheel.core.config import DEFAULT, WheelConfig
from quantwheel.core.types import CouncilVerdict, CouncilVote, ScoredCandidate, Stance


def build_council(
llm=None, provider: Optional[str] = None, model: Optional[str] = None
llm=None, provider: Optional[str] = None, model: Optional[str] = None,
config: WheelConfig = DEFAULT,
) -> list[PersonaAgent]:
"""Instantiate the five council members.

``provider``/``model`` choose the LLM (Ollama, Anthropic, OpenAI, …) — or
leave them ``None`` to read ``QUANTWHEEL_PROVIDER``/``QUANTWHEEL_MODEL`` from
the environment. Inject ``llm`` to run fully offline in tests.
the environment. Inject ``llm`` to run fully offline in tests. ``config``
supplies the account size / position count so each persona's rubric reflects
the user's real situation instead of a hardcoded small account.
"""
ctx = {"account": config.account, "max_positions": config.max_positions}
return [
statistician.make(llm=llm, provider=provider, model=model),
buffett.make(llm=llm, provider=provider, model=model),
burry.make(llm=llm, provider=provider, model=model),
dalio.make(llm=llm, provider=provider, model=model),
simons.make(llm=llm, provider=provider, model=model),
statistician.make(llm=llm, provider=provider, model=model, **ctx),
buffett.make(llm=llm, provider=provider, model=model, **ctx),
burry.make(llm=llm, provider=provider, model=model, **ctx),
dalio.make(llm=llm, provider=provider, model=model, **ctx),
simons.make(llm=llm, provider=provider, model=model, **ctx),
]


Expand Down Expand Up @@ -87,9 +93,10 @@ def deliberate(candidate: ScoredCandidate, council: list[PersonaAgent]) -> Counc
def run_council(
candidates: list[ScoredCandidate], llm=None,
provider: Optional[str] = None, model: Optional[str] = None,
config: WheelConfig = DEFAULT,
) -> list[CouncilVerdict]:
"""Deliberate over every candidate; return verdicts sorted best-first."""
council = build_council(llm=llm, provider=provider, model=model)
council = build_council(llm=llm, provider=provider, model=model, config=config)
verdicts = [deliberate(c, council) for c in candidates]
verdicts.sort(key=lambda v: -v.aggregate_score)
return verdicts
3 changes: 2 additions & 1 deletion src/quantwheel/agents/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ def build_weekly_agent(
g = StateGraph(WheelState)
g.add_node("load_universe", nodes.load_universe)
g.add_node("screen", partial(nodes.screen, wheel_config=config))
g.add_node("council", partial(nodes.council, llm=llm, provider=provider, model=model))
g.add_node("council", partial(nodes.council, llm=llm, provider=provider,
model=model, wheel_config=config))
g.add_node("rank_pick", nodes.rank_pick)
g.add_node("draft_card", partial(nodes.draft_card, wheel_config=config))
g.add_node("human_approval", _human_approval)
Expand Down
13 changes: 8 additions & 5 deletions src/quantwheel/agents/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,16 @@ def screen(state: WheelState, *, wheel_config: WheelConfig) -> WheelState:


def council(state: WheelState, *, llm=None, provider: Optional[str] = None,
model: Optional[str] = None) -> WheelState:
model: Optional[str] = None, wheel_config: WheelConfig = DEFAULT) -> WheelState:
"""Convene the council on the gated/enterable candidates only.

If nothing passed the gates, we skip the (possibly paid) LLM calls entirely.
"""
enterable = state["scan"].enterable
if not enterable:
return {"verdicts": [], "message": "No gated candidates — council skipped."}
verdicts = run_council(enterable, llm=llm, provider=provider, model=model)
verdicts = run_council(enterable, llm=llm, provider=provider, model=model,
config=wheel_config)
return {"verdicts": verdicts}


Expand Down Expand Up @@ -136,9 +137,11 @@ def make_trade_card(
collateral=c.sizing.collateral_used or q.strike * 100,
exit_plan=exit_plan, assignment_plan=assignment_plan,
council_consensus=(verdict.consensus if verdict else "n/a"),
rationale=(f"Verdict {c.verdict.value}, score {c.score:.1f}/6, "
f"EV ${p.expected_value:.1f}, POP {p.pop:.0%}, "
f"P(assign) {c.monte_carlo.p_assign:.0%}."),
rationale=(f"The model rates {c.ticker} a '{c.verdict.value}' setup "
f"(score {c.score:.1f}/6): about a {p.pop:.0%} chance of profit, "
f"expected value ~${p.expected_value:.1f} per contract, and "
f"roughly a {c.monte_carlo.p_assign:.0%} chance of being assigned "
f"the shares."),
)


Expand Down
7 changes: 6 additions & 1 deletion src/quantwheel/agents/personas/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
"""agents.personas — the five council members (one system prompt each)."""
"""agents.personas — the five council members (one system prompt each).

Each member is an anonymized *archetype* — the Statistician, the Value Investor,
the Risk Manager, the Macro Strategist and the Systematic Quant — so the council
debates distinct investing lenses without invoking any real person's name.
"""
50 changes: 41 additions & 9 deletions src/quantwheel/agents/personas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,52 @@

from pydantic import BaseModel, Field

from quantwheel.core.config import DEFAULT
from quantwheel.core.types import CouncilVote, ScoredCandidate, Stance

# Rubric appended to every persona's system prompt so all voters answer the same
# question in the same shape, differing only in *judgement*.
_RUBRIC = """

def _rubric(account: float, max_positions: int) -> str:
"""The shared scoring rubric, parameterised by the actual account context.

Every persona answers the same question in the same shape, differing only in
*judgement*. The account size and position count are injected so the council
reasons about the user's real situation — not a hardcoded $1,000 / one-name
account — which materially changes the diversification argument.
"""
if max_positions <= 1:
size_line = (
f"a ${account:,.0f} account that holds ONE position at a time "
f"(no diversification to fall back on)"
)
risk_line = (
"Remember: with a single concentrated position, assignment in a "
"falling stock can wipe out many weeks of premium and there is no "
"other holding to soften it."
)
else:
size_line = (
f"a ${account:,.0f} account that holds up to {max_positions} "
f"positions at a time"
)
risk_line = (
f"Remember: risk is spread across up to {max_positions} positions, "
f"but each name can still be assigned in a falling stock — judge "
f"THIS trade on its own merits and its weight in the portfolio."
)
return f"""

You are sitting on an investment council evaluating a single cash-secured PUT
trade (the Wheel strategy) for a SMALL $1,000 account that holds ONE position at
a time. Selling a cash-secured put means: collect premium now; if the stock
closes below the strike at expiry you are assigned 100 shares at the strike.
trade (the Wheel strategy) for {size_line}. Selling a cash-secured put means:
collect premium now; if the stock closes below the strike at expiry you are
assigned 100 shares at the strike.

Judge ONLY this trade, in character. Return:
- stance: STRONG_FOR / FOR / NEUTRAL / AGAINST / STRONG_AGAINST
- conviction: 0.0-1.0 (how strongly you hold this stance)
- rationale: 1-3 sentences, in your voice
- key_points: 2-4 short bullet phrases driving your view

Remember the small-account reality: assignment in a falling stock can wipe out
many weeks of premium, and there is no diversification to fall back on.
{risk_line}
"""

_HUMAN = "Evaluate this cash-secured put candidate:\n\n{evidence}\n\nGive your verdict."
Expand Down Expand Up @@ -89,20 +116,25 @@ def __init__(
provider: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.3,
account: float = DEFAULT.account,
max_positions: int = DEFAULT.max_positions,
) -> None:
self.name = name
self.system_prompt = system_prompt
self._llm = llm # injected runnable (tests) or None (live)
self._provider = provider # None -> from env (QUANTWHEEL_PROVIDER)
self._model = model # None -> provider default
self._temperature = temperature
self._account = account # injected so the rubric reflects reality
self._max_positions = max_positions

def _runnable(self):
"""Build the LCEL chain: prompt | structured chat model (or injected llm)."""
from langchain_core.prompts import ChatPromptTemplate

rubric = _rubric(self._account, self._max_positions)
prompt = ChatPromptTemplate.from_messages(
[("system", self.system_prompt + _RUBRIC), ("human", _HUMAN)]
[("system", self.system_prompt + rubric), ("human", _HUMAN)]
)
llm = self._llm
if llm is None: # live path — provider chosen by env / args
Expand Down
Loading