From a23b7ff575f3b06543ac17666dfd8789c8ac7442 Mon Sep 17 00:00:00 2001 From: Bader Ale Date: Thu, 25 Jun 2026 04:24:43 -0400 Subject: [PATCH] Account-aware multi-position engine, plain-language Report, anonymized council, market discovery Make the tool work for any account balance, not just the personal $1,000 setup, and add a beginner-friendly report plus reverse-search discovery. - Multi-position: core/portfolio.py allocates up to config.max_positions enterable names within deployable cash (identical to single-position at $1k); sidebar control + CLI --max-positions; report_service builds a PortfolioReport. - Plain-language Report tab: core/narrative.py explains each trade and the council's reasoning in plain English with jargon defined inline; new Report tab with per-trade tracker logging and a verdict legend (also surfaced on Scan). - Anonymized council: real-person personas replaced with archetypes (Value Investor, Risk Manager, Macro Strategist, Systematic Quant); rubric is now account-aware (no hardcoded "$1,000 / one position"). Guarded by test_personas. - Discovery / reverse search: core/data/screener.py uses Yahoo's live screener as an affordability prefilter; discovery_service scores the survivors; Report tab auto-suggests names that fit when the watchlist comes up empty. - Live-data provenance surfaced (source + fetched_at + live/estimated badges); reset button + input-change invalidation of stale results. - Fix .gitignore: anchor the runtime tracker-store rule to /data/ so it no longer swallows the core/data source package (which restores the previously-untracked bars.py / chains.py / earnings.py to version control). - Tests: 111 passing (portfolio, narrative, report, discovery, personas added). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +- CLAUDE.md | 79 ++++++++ README.md | 17 +- src/quantwheel/agents/council.py | 29 +-- src/quantwheel/agents/graph.py | 3 +- src/quantwheel/agents/nodes.py | 13 +- src/quantwheel/agents/personas/__init__.py | 7 +- src/quantwheel/agents/personas/base.py | 50 ++++- src/quantwheel/agents/personas/buffett.py | 25 +-- src/quantwheel/agents/personas/burry.py | 10 +- src/quantwheel/agents/personas/dalio.py | 26 +-- src/quantwheel/agents/personas/simons.py | 28 +-- .../agents/personas/statistician.py | 4 +- src/quantwheel/cli.py | 26 ++- src/quantwheel/core/data/__init__.py | 1 + src/quantwheel/core/data/bars.py | 41 ++++ src/quantwheel/core/data/chains.py | 176 +++++++++++++++++ src/quantwheel/core/data/earnings.py | 81 ++++++++ src/quantwheel/core/data/screener.py | 77 ++++++++ src/quantwheel/core/narrative.py | 180 ++++++++++++++++++ src/quantwheel/core/portfolio.py | 83 ++++++++ src/quantwheel/core/scoring.py | 5 +- src/quantwheel/core/types.py | 62 +++++- src/quantwheel/services/council_service.py | 7 +- src/quantwheel/services/discovery_service.py | 49 +++++ src/quantwheel/services/report_service.py | 116 +++++++++++ src/quantwheel/ui/streamlit_app/_format.py | 17 ++ src/quantwheel/ui/streamlit_app/app.py | 68 +++++-- .../ui/streamlit_app/tabs/approve.py | 21 +- .../ui/streamlit_app/tabs/council.py | 9 +- .../ui/streamlit_app/tabs/report.py | 118 ++++++++++++ src/quantwheel/ui/streamlit_app/tabs/scan.py | 14 +- .../ui/streamlit_app/tabs/universe.py | 10 +- tests/test_dashboard.py | 4 +- tests/test_discovery.py | 43 +++++ tests/test_narrative.py | 59 ++++++ tests/test_personas.py | 46 +++++ tests/test_portfolio.py | 57 ++++++ tests/test_report.py | 45 +++++ 39 files changed, 1591 insertions(+), 119 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/quantwheel/core/data/__init__.py create mode 100644 src/quantwheel/core/data/bars.py create mode 100644 src/quantwheel/core/data/chains.py create mode 100644 src/quantwheel/core/data/earnings.py create mode 100644 src/quantwheel/core/data/screener.py create mode 100644 src/quantwheel/core/narrative.py create mode 100644 src/quantwheel/core/portfolio.py create mode 100644 src/quantwheel/services/discovery_service.py create mode 100644 src/quantwheel/services/report_service.py create mode 100644 src/quantwheel/ui/streamlit_app/tabs/report.py create mode 100644 tests/test_discovery.py create mode 100644 tests/test_narrative.py create mode 100644 tests/test_personas.py create mode 100644 tests/test_portfolio.py create mode 100644 tests/test_report.py diff --git a/.gitignore b/.gitignore index bcb6893..75b004b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..693d749 --- /dev/null +++ b/CLAUDE.md @@ -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). diff --git a/README.md b/README.md index b898120..aea2fb7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/src/quantwheel/agents/council.py b/src/quantwheel/agents/council.py index aecbcf9..c6c3931 100644 --- a/src/quantwheel/agents/council.py +++ b/src/quantwheel/agents/council.py @@ -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 @@ -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), ] @@ -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 diff --git a/src/quantwheel/agents/graph.py b/src/quantwheel/agents/graph.py index 3e2c2a7..77f5d1e 100644 --- a/src/quantwheel/agents/graph.py +++ b/src/quantwheel/agents/graph.py @@ -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) diff --git a/src/quantwheel/agents/nodes.py b/src/quantwheel/agents/nodes.py index 4e28ce9..cf8b70b 100644 --- a/src/quantwheel/agents/nodes.py +++ b/src/quantwheel/agents/nodes.py @@ -58,7 +58,7 @@ 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. @@ -66,7 +66,8 @@ def council(state: WheelState, *, llm=None, provider: Optional[str] = None, 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} @@ -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."), ) diff --git a/src/quantwheel/agents/personas/__init__.py b/src/quantwheel/agents/personas/__init__.py index 4851595..2802fed 100644 --- a/src/quantwheel/agents/personas/__init__.py +++ b/src/quantwheel/agents/personas/__init__.py @@ -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. +""" diff --git a/src/quantwheel/agents/personas/base.py b/src/quantwheel/agents/personas/base.py index 3f882b1..b888234 100644 --- a/src/quantwheel/agents/personas/base.py +++ b/src/quantwheel/agents/personas/base.py @@ -22,16 +22,44 @@ 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 @@ -39,8 +67,7 @@ - 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." @@ -89,6 +116,8 @@ 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 @@ -96,13 +125,16 @@ def __init__( 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 diff --git a/src/quantwheel/agents/personas/buffett.py b/src/quantwheel/agents/personas/buffett.py index 9de8843..ac2ccf2 100644 --- a/src/quantwheel/agents/personas/buffett.py +++ b/src/quantwheel/agents/personas/buffett.py @@ -1,21 +1,22 @@ -"""Warren Buffett — quality-and-ownership lens.""" +"""The Value Investor — quality-and-ownership lens.""" from __future__ import annotations from quantwheel.agents.personas.base import PersonaAgent -NAME = "Warren Buffett" +NAME = "The Value Investor" -SYSTEM_PROMPT = """You are Warren Buffett. A cash-secured put is being paid to agree -to buy a business at a fair price — so your FIRST question is always: 'Would I be -happy to OWN 100 shares of this company for years if assigned?' You favor durable, -profitable businesses with a moat and a margin of safety, ideally with a dividend -that pays you to wait. You are deeply skeptical of speculative, unprofitable, or -hype-driven names (leveraged crypto miners, story stocks) — you would never want -to be assigned those. You think in decades, dislike leverage, and stay within your -circle of competence. Premium is nice, but never a reason to own a bad business.""" +SYSTEM_PROMPT = """You are the Value Investor, a long-term business owner at heart. A +cash-secured put is being paid to agree to buy a business at a fair price — so your +FIRST question is always: 'Would I be happy to OWN 100 shares of this company for +years if assigned?' You favor durable, profitable businesses with a moat and a +margin of safety, ideally with a dividend that pays you to wait. You are deeply +skeptical of speculative, unprofitable, or hype-driven names (leveraged crypto +miners, story stocks) — you would never want to be assigned those. You think in +decades, dislike leverage, and stay within your circle of competence. Premium is +nice, but never a reason to own a bad business.""" -def make(llm=None, provider=None, model=None) -> PersonaAgent: +def make(llm=None, provider=None, model=None, **kwargs) -> PersonaAgent: return PersonaAgent(NAME, SYSTEM_PROMPT, llm=llm, provider=provider, - model=model, temperature=0.4) + model=model, temperature=0.4, **kwargs) diff --git a/src/quantwheel/agents/personas/burry.py b/src/quantwheel/agents/personas/burry.py index f1c7420..e3f3f52 100644 --- a/src/quantwheel/agents/personas/burry.py +++ b/src/quantwheel/agents/personas/burry.py @@ -1,12 +1,12 @@ -"""Michael Burry — contrarian deep-value, tail-risk skeptic.""" +"""The Risk Manager — contrarian deep-value, tail-risk skeptic.""" from __future__ import annotations from quantwheel.agents.personas.base import PersonaAgent -NAME = "Michael Burry" +NAME = "The Risk Manager" -SYSTEM_PROMPT = """You are Michael Burry. You are a contrarian who obsesses over the +SYSTEM_PROMPT = """You are the Risk Manager, a contrarian who obsesses over the DOWNSIDE and the balance sheet. Before anything, you ask: 'What is the catastrophic loss here, and how likely is it?' You scrutinize leverage, dilution, cash burn, and going-concern risk, and you are deeply suspicious of crowded, hyped, or @@ -17,6 +17,6 @@ a deteriorating regime.""" -def make(llm=None, provider=None, model=None) -> PersonaAgent: +def make(llm=None, provider=None, model=None, **kwargs) -> PersonaAgent: return PersonaAgent(NAME, SYSTEM_PROMPT, llm=llm, provider=provider, - model=model, temperature=0.4) + model=model, temperature=0.4, **kwargs) diff --git a/src/quantwheel/agents/personas/dalio.py b/src/quantwheel/agents/personas/dalio.py index bf12cd1..ccedefb 100644 --- a/src/quantwheel/agents/personas/dalio.py +++ b/src/quantwheel/agents/personas/dalio.py @@ -1,22 +1,22 @@ -"""Ray Dalio — macro / regime / risk-balance lens.""" +"""The Macro Strategist — macro / regime / risk-balance lens.""" from __future__ import annotations from quantwheel.agents.personas.base import PersonaAgent -NAME = "Ray Dalio" +NAME = "The Macro Strategist" -SYSTEM_PROMPT = """You are Ray Dalio. You think top-down about the ENVIRONMENT before -the individual trade: the market regime, volatility, rates, and where we are in the -cycle. You weight the model's regime signal (the probability the regime stays -favorable) and the realized volatility heavily. You are acutely aware that this is a -ONE-POSITION account with zero diversification — a concentration that violates your -core principle of balancing uncorrelated risks — so you demand that the macro/regime -backdrop be genuinely supportive before risking the whole account on a single name. -You favor sitting out when the regime is unfavorable, and you respect the -distinction between a calm, range-bound tape and a volatile, trending-down one.""" +SYSTEM_PROMPT = """You are the Macro Strategist. You think top-down about the +ENVIRONMENT before the individual trade: the market regime, volatility, rates, and +where we are in the cycle. You weight the model's regime signal (the probability the +regime stays favorable) and the realized volatility heavily. You care about +concentration risk: balancing uncorrelated bets is a core principle, so you demand +that the macro/regime backdrop be genuinely supportive before committing capital to +a name, and you are warier the larger a single position looms in the account. You +favor sitting out when the regime is unfavorable, and you respect the distinction +between a calm, range-bound tape and a volatile, trending-down one.""" -def make(llm=None, provider=None, model=None) -> PersonaAgent: +def make(llm=None, provider=None, model=None, **kwargs) -> PersonaAgent: return PersonaAgent(NAME, SYSTEM_PROMPT, llm=llm, provider=provider, - model=model, temperature=0.4) + model=model, temperature=0.4, **kwargs) diff --git a/src/quantwheel/agents/personas/simons.py b/src/quantwheel/agents/personas/simons.py index d2f60a6..f40397e 100644 --- a/src/quantwheel/agents/personas/simons.py +++ b/src/quantwheel/agents/personas/simons.py @@ -1,23 +1,23 @@ -"""Jim Simons — pure systematic / statistical-edge lens.""" +"""The Systematic Quant — pure systematic / statistical-edge lens.""" from __future__ import annotations from quantwheel.agents.personas.base import PersonaAgent -NAME = "Jim Simons" +NAME = "The Systematic Quant" -SYSTEM_PROMPT = """You are Jim Simons of Renaissance Technologies. You are a pure -systematic quant: you believe in statistical edges measured over many trades, not -narratives about companies. You explicitly DISTRUST fundamental stories and -'this-time-is-different' reasoning — you want signal, not anecdote. You care about -whether the model's edge is real and persistent, whether the probabilities are -calibrated, and whether liquidity and transaction costs (spread, open interest) -would erode the edge. You think in terms of small, repeatable, positive-expectancy -bets executed with discipline. If the systematic evidence (EV, POP, calibration, -regime) is favorable and the trade is liquid, you are in; if the edge is thin or the -quote is unverifiable, you pass — regardless of how good the 'story' sounds.""" +SYSTEM_PROMPT = """You are the Systematic Quant. You believe in statistical edges +measured over many trades, not narratives about companies. You explicitly DISTRUST +fundamental stories and 'this-time-is-different' reasoning — you want signal, not +anecdote. You care about whether the model's edge is real and persistent, whether +the probabilities are calibrated, and whether liquidity and transaction costs +(spread, open interest) would erode the edge. You think in terms of small, +repeatable, positive-expectancy bets executed with discipline. If the systematic +evidence (EV, POP, calibration, regime) is favorable and the trade is liquid, you +are in; if the edge is thin or the quote is unverifiable, you pass — regardless of +how good the 'story' sounds.""" -def make(llm=None, provider=None, model=None) -> PersonaAgent: +def make(llm=None, provider=None, model=None, **kwargs) -> PersonaAgent: return PersonaAgent(NAME, SYSTEM_PROMPT, llm=llm, provider=provider, - model=model, temperature=0.2) + model=model, temperature=0.2, **kwargs) diff --git a/src/quantwheel/agents/personas/statistician.py b/src/quantwheel/agents/personas/statistician.py index a778dbd..2cc23bc 100644 --- a/src/quantwheel/agents/personas/statistician.py +++ b/src/quantwheel/agents/personas/statistician.py @@ -15,6 +15,6 @@ company's 'story'; you care whether the edge is real and statistically sound.""" -def make(llm=None, provider=None, model=None) -> PersonaAgent: +def make(llm=None, provider=None, model=None, **kwargs) -> PersonaAgent: return PersonaAgent(NAME, SYSTEM_PROMPT, llm=llm, provider=provider, - model=model, temperature=0.1) + model=model, temperature=0.1, **kwargs) diff --git a/src/quantwheel/cli.py b/src/quantwheel/cli.py index 1e798b2..97d57a0 100644 --- a/src/quantwheel/cli.py +++ b/src/quantwheel/cli.py @@ -12,7 +12,8 @@ import sys from dataclasses import replace -from quantwheel.core.config import DEFAULT +from quantwheel.core import portfolio +from quantwheel.core.config import DEFAULT, WheelConfig from quantwheel.core.types import ScanResult, Verdict from quantwheel.services.scan_service import run_weekly_scan from quantwheel.universe import DEFAULT_UNIVERSE, load_shortlist @@ -25,6 +26,23 @@ } +def _print_portfolio(result: ScanResult, config: WheelConfig) -> None: + """Print the sized, multi-position allocation (one line per fitted name).""" + positions = portfolio.allocate(result, config) + if not positions: + return + deployed = sum(p.collateral for p in positions) + credit = sum(p.credit for p in positions) + print(f"\nSized portfolio (up to {config.max_positions} position(s), " + f"${config.deployable_cash:,.0f} deployable):") + for p in positions: + q = p.candidate.quote + print(f" {p.candidate.ticker:5} {p.contracts}x ${q.strike:.2f}p " + f"exp {q.expiry} collateral ${p.collateral:,.0f} credit ${p.credit:,.0f}") + print(f" -> ${deployed:,.0f} collateral, ${credit:,.0f} premium, " + f"${config.account - deployed:,.0f} cash reserved.") + + def _print_report(result: ScanResult) -> None: print(f"\nWheel scan — {result.as_of} | account ${result.account:,.0f}") print(f"universe: {', '.join(result.universe)}\n") @@ -64,6 +82,8 @@ def scan_cli() -> None: ap.add_argument("--csv", help="Path to a shortlist CSV with a 'ticker' column.") ap.add_argument("--account", type=float, default=DEFAULT.account) ap.add_argument("--buffer", type=float, default=DEFAULT.cash_buffer) + ap.add_argument("--max-positions", type=int, default=DEFAULT.max_positions, + help="Concurrent positions to allow (sizing spreads across them).") args = ap.parse_args() if args.tickers: @@ -73,10 +93,12 @@ def scan_cli() -> None: else: universe = DEFAULT_UNIVERSE - config = replace(DEFAULT, account=args.account, cash_buffer=args.buffer) + config = replace(DEFAULT, account=args.account, cash_buffer=args.buffer, + max_positions=args.max_positions) print("Scanning (live market data)...") result = run_weekly_scan(universe, config) _print_report(result) + _print_portfolio(result, config) if __name__ == "__main__": diff --git a/src/quantwheel/core/data/__init__.py b/src/quantwheel/core/data/__init__.py new file mode 100644 index 0000000..0da44de --- /dev/null +++ b/src/quantwheel/core/data/__init__.py @@ -0,0 +1 @@ +"""core.data — market data adapters (yfinance): bars, option chains, earnings.""" diff --git a/src/quantwheel/core/data/bars.py b/src/quantwheel/core/data/bars.py new file mode 100644 index 0000000..45051a5 --- /dev/null +++ b/src/quantwheel/core/data/bars.py @@ -0,0 +1,41 @@ +"""Daily price bars from Yahoo Finance (via :mod:`yfinance`). + +Free, no API key. Returns a tidy lower-cased OHLCV frame indexed by date so the +rest of ``core`` never has to know where the data came from — swapping in a paid +feed later means rewriting only this file. +""" + +from __future__ import annotations + +from functools import lru_cache + +import pandas as pd +import yfinance as yf + + +@lru_cache(maxsize=128) +def _download(ticker: str, period: str, interval: str) -> pd.DataFrame: + """Cached raw download (per process run) to avoid hammering Yahoo.""" + raw = yf.Ticker(ticker).history(period=period, interval=interval, auto_adjust=True) + return raw + + +def fetch_bars(ticker: str, period: str = "2y", interval: str = "1d") -> pd.DataFrame: + """Return OHLCV bars for ``ticker`` as a lower-cased, date-indexed frame. + + Columns: ``open, high, low, close, volume``. Raises ``ValueError`` if Yahoo + returns nothing (bad symbol, delisted, or a transient outage). + """ + raw = _download(ticker.upper(), period, interval) + if raw is None or raw.empty: + raise ValueError(f"No price data returned for {ticker!r}.") + + df = raw.rename(columns=str.lower)[["open", "high", "low", "close", "volume"]].copy() + df.index = pd.to_datetime(df.index).tz_localize(None).normalize() + df.index.name = "date" + return df.dropna(subset=["close"]) + + +def latest_spot(ticker: str) -> float: + """Most recent close price for ``ticker``.""" + return float(fetch_bars(ticker, period="5d")["close"].iloc[-1]) diff --git a/src/quantwheel/core/data/chains.py b/src/quantwheel/core/data/chains.py new file mode 100644 index 0000000..c63a9ac --- /dev/null +++ b/src/quantwheel/core/data/chains.py @@ -0,0 +1,176 @@ +"""Live option chains from Yahoo Finance, with a graceful theoretical fallback. + +The goal is to price against the **real** chain — actual implied vol and bid/ask +— so the modelled credit/delta match Robinhood. But the free Yahoo feed is +unreliable outside regular trading hours: it returns zero bid/ask, zero open +interest, and a synthetic IV "ladder" (0.5, 0.25, 0.125 ...) that is not real. + +So this module does the honest thing: + +* **If the market is genuinely quoting** (any OTM put has a real ``bid``), use + the live chain: pick the strike nearest the target delta from each strike's + own implied vol, and model the credit at the bid/ask mid. +* **Otherwise** (after-hours / stale / synthetic), fall back to a Black-Scholes + theoretical quote on *realized* vol, **snapped to a real listed strike**, and + mark it ``is_estimated=True``. + +Either way the quote carries everything downstream needs, and an estimated quote +is clearly labelled so the UI can say "verify in Robinhood". +""" + +from __future__ import annotations + +from datetime import date, datetime +from functools import lru_cache +from typing import Optional + +import numpy as np +import pandas as pd +import yfinance as yf + +from quantwheel.core.config import DEFAULT, WheelConfig +from quantwheel.core.models import indicators as ind +from quantwheel.core.models.pricing import bs_put_greeks, bs_put_price, strike_for_delta +from quantwheel.core.types import OptionQuote + +# Plausible IV band — reject Yahoo's near-zero / absurd synthetic IVs. +_MIN_IV = 0.05 +_MAX_IV = 5.0 + + +@lru_cache(maxsize=64) +def list_expiries(ticker: str) -> tuple[date, ...]: + """All listed expiration dates for ``ticker`` (ascending).""" + opts = yf.Ticker(ticker.upper()).options or () + return tuple(datetime.strptime(o, "%Y-%m-%d").date() for o in opts) + + +def pick_expiry( + ticker: str, config: WheelConfig = DEFAULT, as_of: Optional[date] = None +) -> Optional[date]: + """Choose the listed expiry whose DTE best fits the weekly window. + + Prefers expiries inside ``[min_dte, max_dte]``; otherwise the nearest expiry + at least ``min_dte`` out (never something expiring tomorrow). ``None`` if the + name has no options. + """ + as_of = as_of or date.today() + expiries = list_expiries(ticker) + if not expiries: + return None + + target = (config.min_dte + config.max_dte) / 2.0 + in_window = [e for e in expiries if config.min_dte <= (e - as_of).days <= config.max_dte] + pool = in_window or [e for e in expiries if (e - as_of).days >= config.min_dte] + if not pool: + return None + return min(pool, key=lambda e: abs((e - as_of).days - target)) + + +@lru_cache(maxsize=128) +def _puts_for(ticker: str, expiry_iso: str) -> pd.DataFrame: + """Cached put chain for one expiry (keyed by iso string so it's hashable).""" + chain = yf.Ticker(ticker.upper()).option_chain(expiry_iso) + return chain.puts + + +def _otm_puts(ticker: str, expiry: date, spot: float) -> pd.DataFrame: + puts = _puts_for(ticker.upper(), expiry.strftime("%Y-%m-%d")) + if puts is None or puts.empty: + raise ValueError(f"No puts for {ticker} {expiry}.") + otm = puts[puts["strike"] < spot].copy() + if otm.empty: + raise ValueError(f"No OTM puts for {ticker} {expiry}.") + return otm + + +def _live_quote( + ticker: str, spot: float, expiry: date, otm: pd.DataFrame, dte: int, config: WheelConfig +) -> Optional[OptionQuote]: + """Build a quote from genuine market data, or ``None`` if none is available.""" + T = max(dte / 365.0, 1e-6) + iv = otm["impliedVolatility"].fillna(0.0) + has_quote = (otm["bid"].fillna(0) > 0) & (otm["ask"].fillna(0) > 0) + live = otm[has_quote & iv.between(_MIN_IV, _MAX_IV)] + if live.empty: + return None # market not quoting -> caller falls back to theoretical + + live = live.copy() + live["delta_mag"] = live.apply( + lambda r: abs( + bs_put_greeks(spot, float(r["strike"]), T, config.risk_free_rate, + float(r["impliedVolatility"])).delta + ), + axis=1, + ) + best = live.iloc[(live["delta_mag"] - config.target_delta).abs().argmin()] + bid, ask = float(best["bid"]), float(best["ask"]) + return OptionQuote( + ticker=ticker.upper(), expiry=expiry, dte=dte, strike=float(best["strike"]), + bid=bid, ask=ask, mid=(bid + ask) / 2.0, iv=float(best["impliedVolatility"]), + open_interest=int(best.get("openInterest", 0) or 0), + volume=int(best.get("volume", 0) or 0), + delta=-float(best["delta_mag"]), is_estimated=False, source="yfinance:live", + ) + + +def _theoretical_quote( + ticker: str, spot: float, expiry: date, otm: pd.DataFrame, dte: int, + close: pd.Series, config: WheelConfig, +) -> OptionQuote: + """Black-Scholes quote on realized vol, snapped to a real listed strike. + + Used when the live chain is unusable. Real *strikes* are still trustworthy + even when Yahoo's quotes are not, so we snap the delta-target strike to the + nearest listed OTM strike and price it ourselves. + """ + T = max(dte / 365.0, 1e-6) + r = config.risk_free_rate + sigma = ind.realized_vol(close, window=config.mc_return_window) + if not np.isfinite(sigma) or sigma <= 0: + sigma = 0.30 # last-resort default if history is too short + + raw_strike = strike_for_delta(spot, T, r, sigma, config.target_delta) + strikes = otm["strike"].to_numpy(dtype=float) + strike = float(strikes[np.abs(strikes - raw_strike).argmin()]) + + premium = bs_put_price(spot, strike, T, r, sigma) + delta = bs_put_greeks(spot, strike, T, r, sigma).delta + row = otm[otm["strike"] == strike] + return OptionQuote( + ticker=ticker.upper(), expiry=expiry, dte=dte, strike=strike, + bid=0.0, ask=0.0, mid=round(premium, 2), iv=sigma, + open_interest=int(row["openInterest"].iloc[0]) if not row.empty else 0, + volume=int(row["volume"].iloc[0]) if not row.empty else 0, + delta=delta, is_estimated=True, source="bs:realized_vol", + ) + + +def quote_put( + ticker: str, + spot: float, + close: pd.Series, + expiry: date, + config: WheelConfig = DEFAULT, + as_of: Optional[date] = None, +) -> OptionQuote: + """Best available put quote for ``expiry``: live if quoting, else theoretical.""" + as_of = as_of or date.today() + dte = (expiry - as_of).days + otm = _otm_puts(ticker, expiry, spot) + live = _live_quote(ticker, spot, expiry, otm, dte, config) + return live or _theoretical_quote(ticker, spot, expiry, otm, dte, close, config) + + +def fetch_put_quote( + ticker: str, + spot: float, + close: pd.Series, + config: WheelConfig = DEFAULT, + as_of: Optional[date] = None, +) -> Optional[OptionQuote]: + """Convenience: pick the expiry, then the best quote. ``None`` if no options.""" + expiry = pick_expiry(ticker, config, as_of) + if expiry is None: + return None + return quote_put(ticker, spot, close, expiry, config, as_of) diff --git a/src/quantwheel/core/data/earnings.py b/src/quantwheel/core/data/earnings.py new file mode 100644 index 0000000..479786c --- /dev/null +++ b/src/quantwheel/core/data/earnings.py @@ -0,0 +1,81 @@ +"""Next earnings date from Yahoo Finance. + +The single most important small-account rule is *never sell a weekly whose +expiry spans an earnings date*. One earnings gap can erase weeks of premium on a +one-position account. This module surfaces the next earnings date so the gate can +enforce that rule. + +Yahoo's earnings endpoints are occasionally flaky or empty; every lookup is +wrapped so a failure degrades to ``None`` (treated conservatively by the gate) +rather than crashing the scan. +""" + +from __future__ import annotations + +from datetime import date +from functools import lru_cache +from typing import Optional + +import pandas as pd +import yfinance as yf + + +@lru_cache(maxsize=64) +def historical_earnings_dates(ticker: str, limit: int = 24) -> tuple[date, ...]: + """All known earnings dates (past and future) for the backtest's gate. + + Prefers Yahoo's real earnings table. When that is empty (common on the free + feed), falls back to **synthesizing approximate quarterly dates** stepping + ~91 days out from the next known earnings date. This is an approximation — + real dates drift by days/weeks — but it lets the backtest's earnings gate be + active and the ablation meaningful, instead of a silent no-op. + """ + try: + df = yf.Ticker(ticker.upper()).get_earnings_dates(limit=limit) + if df is not None and not df.empty: + return tuple(sorted(pd.to_datetime(df.index).tz_localize(None).normalize().date)) + except Exception: + pass + + # Fallback: quarterly synthesis from the next known earnings date. + anchor = next_earnings_date(ticker) + if anchor is None: + return () + synth = [anchor + pd.Timedelta(days=91 * k) for k in range(-12, 5)] + return tuple(sorted(d.date() if hasattr(d, "date") else d for d in synth)) + + +@lru_cache(maxsize=128) +def next_earnings_date(ticker: str, as_of: Optional[date] = None) -> Optional[date]: + """Return the next earnings date on/after ``as_of`` (today), or ``None``. + + Tries ``get_earnings_dates`` first (richest source), then the ``calendar`` + fallback. Returns ``None`` if neither yields a future date. + """ + as_of = as_of or date.today() + tk = yf.Ticker(ticker.upper()) + + # Primary source: a table of recent + upcoming earnings timestamps. + try: + df = tk.get_earnings_dates(limit=12) + if df is not None and not df.empty: + dates = pd.to_datetime(df.index).tz_localize(None).normalize().date + future = sorted(d for d in dates if d >= as_of) + if future: + return future[0] + except Exception: + pass + + # Fallback: the calendar dict. + try: + cal = tk.calendar + ed = cal.get("Earnings Date") if isinstance(cal, dict) else None + if ed: + first = ed[0] if isinstance(ed, (list, tuple)) else ed + d = pd.to_datetime(first).date() + if d >= as_of: + return d + except Exception: + pass + + return None diff --git a/src/quantwheel/core/data/screener.py b/src/quantwheel/core/data/screener.py new file mode 100644 index 0000000..de8728e --- /dev/null +++ b/src/quantwheel/core/data/screener.py @@ -0,0 +1,77 @@ +"""Live equity screener — find affordable, liquid names market-wide (Yahoo). + +This powers *discovery* (reverse search): when nothing on the user's watchlist +fits their account, search the whole US market for names whose strikes their cash +can actually secure. We lean on Yahoo's own screener (via ``yfinance.screen``) as +a cheap *affordability prefilter* — bounding price and dollar-volume server-side — +then hand the survivors to the normal scan, which verifies optionability and runs +the full math. That keeps discovery fast: we only pay for a deep scan on names +that are already in range. + +Network-bound, like the rest of ``core/data``. Returns plain ticker strings and +degrades to ``[]`` on any screener error (it's a best-effort convenience). +""" + +from __future__ import annotations + +from quantwheel.core.config import DEFAULT, WheelConfig + +# A cash-secured put at the ~target-delta strike sits near 80% of spot, and must +# satisfy ``strike * 100 <= per-position budget``. So a name is affordable when +# its spot is roughly below ``budget / 80``. A floor avoids sub-$2 penny names. +_PRICE_FLOOR = 2.0 +_MIN_DAY_VOLUME = 1_000_000 # liquidity proxy; the option-liquidity gate is later + + +def affordable_max_price(config: WheelConfig) -> float: + """Highest spot price whose ~target-delta put could fit one position's budget.""" + budget = config.deployable_cash / max(1, config.max_positions) + return budget / 80.0 + + +def find_affordable_optionable( + config: WheelConfig = DEFAULT, + limit: int = 25, + exclude: tuple[str, ...] = (), +) -> list[str]: + """Return up to ``limit`` liquid US tickers priced to fit ``config``. + + Uses Yahoo's screener for a fast, server-side affordability + liquidity + filter. Optionability is intentionally *not* checked here — the scan that + follows skips names with no listed options. Returns ``[]`` on any error. + """ + max_price = affordable_max_price(config) + if max_price <= _PRICE_FLOOR: + return [] # account too small to secure any reasonable name + + try: + import yfinance as yf + from yfinance import EquityQuery as Q + + query = Q("and", [ + Q("eq", ["region", "us"]), + Q("gt", ["intradayprice", _PRICE_FLOOR]), + Q("lt", ["intradayprice", max_price]), + Q("gt", ["dayvolume", _MIN_DAY_VOLUME]), + ]) + # Over-fetch so exclusions and duplicates still leave a full batch. + res = yf.screen(query, size=max(limit * 2, 50), + sortField="dayvolume", sortAsc=False) + except Exception: + return [] + + quotes = res.get("quotes", []) if isinstance(res, dict) else (res or []) + excluded = {e.upper() for e in exclude} + out: list[str] = [] + seen: set[str] = set() + for q in quotes: + sym = str(q.get("symbol", "")).upper().strip() + if not sym or sym in seen or sym in excluded: + continue + if q.get("quoteType") not in (None, "EQUITY"): + continue + seen.add(sym) + out.append(sym) + if len(out) >= limit: + break + return out diff --git a/src/quantwheel/core/narrative.py b/src/quantwheel/core/narrative.py new file mode 100644 index 0000000..5259fbe --- /dev/null +++ b/src/quantwheel/core/narrative.py @@ -0,0 +1,180 @@ +"""Plain-language explanations of a trade and the council's reasoning. + +The quant core speaks in acronyms — POP, EV, P(assign), delta, DTE. A beginner or +intermediate investor shouldn't have to. These builders turn a sized +:class:`~quantwheel.core.types.Position` and its :class:`TradeCard` into short, +readable paragraphs that define each term inline the first time it appears. + +Pure, standard-library text generation — no UI, no LLM, no real-people names. +The dashboard Report tab renders whatever these return. +""" + +from __future__ import annotations + +from typing import Optional + +from quantwheel.core.config import DEFAULT, WheelConfig +from quantwheel.core.types import CouncilVerdict, Position, TradeCard + +# Beginner-friendly definitions, surfaced in a "what do these terms mean?" panel. +GLOSSARY: dict[str, str] = { + "Cash-secured put": "You agree to buy 100 shares at the strike price and set " + "aside the cash to do it. You're paid a premium up front for that promise.", + "Premium": "The cash you collect for selling the option — your maximum profit " + "on the trade.", + "Strike": "The price at which you'd buy the shares if the option is assigned.", + "Break-even": "The share price where you'd neither make nor lose money — the " + "strike minus the premium you collected per share.", + "Probability of profit (POP)": "The model's estimate of the chance the trade " + "finishes making money.", + "Assignment": "When the stock closes below the strike at expiry and you're " + "required to buy the 100 shares at the strike.", + "Expected value (EV)": "The average result if you made this same bet many " + "times. A positive number means the odds are in your favor.", + "Collateral": "The cash set aside to buy the shares if assigned — the strike " + "times 100 for each contract.", + "Days to expiry (DTE)": "How many days until the option expires.", + "Delta": "Roughly the chance the option ends in-the-money; a 0.20 delta means " + "about a 20% chance of assignment.", + "Covered call": "Once you own the shares, selling someone the right to buy " + "them from you at a higher price — collecting more premium while you wait.", +} + + +# Plain-English meaning of each machine Verdict, keyed by the enum's value. +# ENTER_* are tradeable; WATCH is on-the-radar; SKIP_* records *why* a name was +# rejected so the dashboard explains it instead of silently dropping the ticker. +VERDICT_LEGEND: dict[str, str] = { + "ENTER_STRONG": "Best setups — cleared every safety check with a strong " + "technical score. The top names to sell a put on.", + "ENTER": "A solid trade — cleared every safety check with a good score.", + "WATCH": "On the radar, not a buy yet — it passed the checks but the score is " + "only moderate, or the assignment risk is high enough to wait.", + "SKIP": "Passed the hard gates but the setup is weak — low score, or the " + "expected value isn't positive.", + "SKIP_REGIME": "Skipped — the market regime (trend/volatility backdrop) looks " + "unfavorable for selling puts on this name right now.", + "SKIP_EARNINGS": "Skipped — an earnings report lands on or before the option's " + "expiry, which can trigger a big surprise move.", + "SKIP_LIQUIDITY": "Skipped — the option market is too thin or the bid/ask spread " + "too wide to expect a fair fill.", + "SKIP_CAPITAL": "Skipped — the collateral it needs (strike × 100) is more than " + "your account can deploy, so it doesn't fit.", +} + + +def _expiry_str(card: TradeCard) -> str: + return card.expiry.strftime("%b %d, %Y") + + +def _lean_label(score: float) -> str: + """Council aggregate score → plain phrase (mirrors the quant labels).""" + if score >= 0.5: + return "strongly in favor" + if score >= 0.15: + return "cautiously in favor" + if score > -0.15: + return "split / neutral" + if score > -0.5: + return "leaning against" + return "strongly against" + + +def summary_line(position: Position, card: TradeCard) -> str: + """A one-line headline for the trade, no jargon.""" + n = position.contracts + s = "s" if n != 1 else "" + return ( + f"Sell {n} put{s} on {card.ticker} at ${card.strike:.2f} — collect " + f"about ${position.credit:,.0f}, with roughly a {card.pop:.0%} chance " + f"of keeping it all." + ) + + +def explain_trade(position: Position, card: TradeCard) -> str: + n = position.contracts + s = "s" if n != 1 else "" + shares = n * 100 + when = _expiry_str(card) + return ( + f"**Sell {n} cash-secured put{s} on {card.ticker} at the ${card.strike:.2f} " + f"strike, expiring {when} ({card.dte} days away).** You collect about " + f"**${position.credit:,.0f}** today — the *premium*, which is the most you " + f"can make on this trade. In exchange you set aside " + f"**${position.collateral:,.0f}** as *collateral* and agree to buy {shares} " + f"shares of {card.ticker} at ${card.strike:.2f} if it closes below that " + f"price on {when}." + ) + + +def explain_odds(position: Position, card: TradeCard) -> str: + p_assign = position.candidate.monte_carlo.p_assign + ev_total = card.expected_value * position.contracts + return ( + f"The model gives this about a **{card.pop:.0%} chance of finishing " + f"profitable** (its *probability of profit*) — that is, {card.ticker} " + f"staying above your *break-even* of ${card.breakeven:.2f}, which is the " + f"strike minus the premium you collected per share. It estimates roughly a " + f"**{p_assign:.0%}** chance you actually get *assigned* the shares. After " + f"accounting for that risk, the trade's *expected value* is about " + f"**${ev_total:,.0f}** — a positive number means the odds tilt your way " + f"over many repeats." + ) + + +def explain_exit(card: TradeCard, config: WheelConfig = DEFAULT) -> str: + take_each = config.profit_take_pct * card.target_credit + return ( + f"**When to take profit:** you don't have to wait until {_expiry_str(card)}. " + f"A common plan is to buy the option back once it has lost about " + f"{config.profit_take_pct:.0%} of its value (around ${take_each:.0f} per " + f"contract to close), pocket the rest, and free up the cash for the next " + f"trade." + ) + + +def explain_assignment(position: Position, card: TradeCard) -> str: + shares = position.contracts * 100 + return ( + f"**If it goes against you:** should {card.ticker} close below " + f"${card.strike:.2f}, you'll buy {shares} shares at ${card.strike:.2f}. " + f"Because you already kept the premium, your effective cost is about " + f"**${card.breakeven:.2f}** per share. From there the Wheel continues — you " + f"sell *covered calls* (collecting more premium for agreeing to sell those " + f"shares a little higher) until they're called away, then start over." + ) + + +def explain_council(verdict: Optional[CouncilVerdict]) -> str: + """Readable synthesis of the council vote, using the archetype member names.""" + if verdict is None or not verdict.votes: + return "" + votes = verdict.votes + n = len(votes) + for_votes = [v for v in votes if v.stance.score > 0] + against_votes = [v for v in votes if v.stance.score < 0] + parts = [ + f"The council was **{_lean_label(verdict.aggregate_score)}** on this trade — " + f"{len(for_votes)} of {n} members in favor." + ] + if for_votes: + top = max(for_votes, key=lambda v: v.conviction) + parts.append(f"The {top.persona} was most supportive: {top.rationale}") + if against_votes: + dissent = max(against_votes, key=lambda v: v.conviction) + parts.append(f"The main holdout was the {dissent.persona}: {dissent.rationale}") + elif not for_votes: + parts.append("Members were split, with no strong conviction either way.") + return " ".join(parts) + + +def build_explanation( + position: Position, card: TradeCard, config: WheelConfig = DEFAULT +) -> list[str]: + """The ordered plain-English paragraphs shown under a recommended trade.""" + return [ + explain_trade(position, card), + explain_odds(position, card), + explain_exit(card, config), + explain_assignment(position, card), + ] diff --git a/src/quantwheel/core/portfolio.py b/src/quantwheel/core/portfolio.py new file mode 100644 index 0000000..8516a29 --- /dev/null +++ b/src/quantwheel/core/portfolio.py @@ -0,0 +1,83 @@ +"""Portfolio allocation — turn a ranked scan into a sized, multi-position book. + +A $1,000 one-position account can only ever hold a single cash-secured put, so +the original tool sized one trade against all deployable cash. A larger account +should spread that cash across several names for diversification. This module is +the generalisation: given a scan (and optionally the council's verdicts), pick up +to ``config.max_positions`` enterable candidates and size each within a fair share +of deployable cash, never exceeding the total. + +Pure math — no UI, no LLM, no network. For ``config.max_positions == 1`` it is +identical to the original single-position sizing. +""" + +from __future__ import annotations + +import math +from typing import Optional + +from quantwheel.core.config import DEFAULT, WheelConfig +from quantwheel.core.types import ( + CouncilVerdict, + Position, + ScanResult, + ScoredCandidate, +) + + +def _ranked( + scan: ScanResult, verdicts: list[CouncilVerdict] +) -> list[ScoredCandidate]: + """Order enterable candidates: council-favorable first, else the scan order.""" + enterable = scan.enterable + if not verdicts: + return enterable # the scan already sorted these best-first + score_by_ticker = {v.ticker: v.aggregate_score for v in verdicts} + favorable = sorted( + (c for c in enterable if score_by_ticker.get(c.ticker, 0.0) > 0), + key=lambda c: -score_by_ticker.get(c.ticker, 0.0), + ) + fav_tickers = {c.ticker for c in favorable} + rest = [c for c in enterable if c.ticker not in fav_tickers] + return favorable + rest + + +def allocate( + scan: ScanResult, + config: WheelConfig = DEFAULT, + verdicts: Optional[list[CouncilVerdict]] = None, +) -> list[Position]: + """Greedily size up to ``config.max_positions`` enterable names. + + Each position is capped at an even share of deployable cash + (``deployable / max_positions``) so a single pricey name can't swallow the + whole account; any cash a name doesn't use cascades to the next. Candidates + that can't fit even one contract in their share are skipped. + """ + deployable = config.deployable_cash * config.max_pos_pct + n = max(1, int(config.max_positions)) + budget_each = deployable / n + remaining = deployable + + positions: list[Position] = [] + for c in _ranked(scan, verdicts or []): + if len(positions) >= n: + break + per_contract = c.quote.strike * 100.0 # cash-secured collateral / contract + if per_contract <= 0: + continue + budget = min(budget_each, remaining) + contracts = int(math.floor(budget / per_contract)) + if contracts < 1: + continue + collateral = contracts * per_contract + positions.append( + Position( + candidate=c, + contracts=contracts, + collateral=collateral, + credit=contracts * c.pricing.credit, + ) + ) + remaining -= collateral + return positions diff --git a/src/quantwheel/core/scoring.py b/src/quantwheel/core/scoring.py index f02e6cf..6ff5d4c 100644 --- a/src/quantwheel/core/scoring.py +++ b/src/quantwheel/core/scoring.py @@ -12,7 +12,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime from typing import Optional import numpy as np @@ -195,4 +195,5 @@ def score_universe( print(f" ! skipped {t}: {exc}") scored.sort(key=_rank_key) return ScanResult(as_of=as_of, account=config.account, - universe=[t.upper() for t in tickers], candidates=scored) + universe=[t.upper() for t in tickers], candidates=scored, + fetched_at=datetime.now()) diff --git a/src/quantwheel/core/types.py b/src/quantwheel/core/types.py index 8ce0714..edabef7 100644 --- a/src/quantwheel/core/types.py +++ b/src/quantwheel/core/types.py @@ -23,7 +23,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import date +from datetime import date, datetime from enum import Enum from typing import Optional @@ -240,6 +240,9 @@ class ScanResult: account: float universe: list[str] candidates: list[ScoredCandidate] # sorted best-first + # Wall-clock moment the live data was pulled. Lets the UI prove freshness + # ("fetched 14:32") rather than just showing the trading date. + fetched_at: Optional[datetime] = None @property def enterable(self) -> list[ScoredCandidate]: @@ -259,7 +262,7 @@ def enterable(self) -> list[ScoredCandidate]: class CouncilVote: """One persona's structured opinion on one candidate.""" - persona: str # e.g. "Warren Buffett" + persona: str # e.g. "The Value Investor" ticker: str stance: Stance conviction: float # 0..1 — how strongly the persona holds the stance @@ -311,6 +314,61 @@ class TradeCard: rationale: str +# --------------------------------------------------------------------------- +# Portfolio report (the plain-language, multi-position recommendation) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Position: + """One sized position chosen by the portfolio allocator. + + The multi-position generalisation of "size one trade": how many contracts of + a given candidate fit, and the cash/credit that implies. For a $1,000 / + one-position account there is at most one of these — identical to before. + """ + + candidate: ScoredCandidate + contracts: int + collateral: float # contracts * strike * 100 — cash set aside + credit: float # contracts * pricing.credit — premium collected + + +@dataclass(frozen=True) +class ReportItem: + """A single recommended trade rendered for a human: the numbers + the words.""" + + position: Position + card: TradeCard + summary: str # one-line plain-English headline for this trade + explanation: list[str] # ordered plain-English paragraphs (trade/odds/plans) + council_explanation: str = "" # readable synthesis of the council vote + is_estimated: bool = False # drives the live-vs-estimated badge + source: str = "yfinance" # provenance tag from the option quote + + +@dataclass(frozen=True) +class PortfolioReport: + """The week's account-aware recommendation: zero or more readable trades. + + This is what the dashboard Report tab renders. It is a *suggestion only* — + no code ever places an order. + """ + + as_of: date + account: float + deployable: float + max_positions: int + items: list[ReportItem] + cash_deployed: float + cash_reserved: float + total_credit: float + blended_pop: float # contract-weighted probability of profit + headline: str # plain-English top-line ("3 trades fit your $25,000…") + sit_out_reason: str = "" # populated (and items empty) when nothing qualifies + fetched_at: Optional[datetime] = None + + @dataclass class TradeRecord: """A row in the built-in trade tracker (mutable — it evolves over a cycle). diff --git a/src/quantwheel/services/council_service.py b/src/quantwheel/services/council_service.py index cfd9cd8..5c9d091 100644 --- a/src/quantwheel/services/council_service.py +++ b/src/quantwheel/services/council_service.py @@ -8,6 +8,7 @@ from typing import Optional from quantwheel.agents.council import run_council as _run +from quantwheel.core.config import DEFAULT, WheelConfig from quantwheel.core.types import CouncilVerdict, ScoredCandidate @@ -15,6 +16,7 @@ def run_council( candidates: list[ScoredCandidate], provider: Optional[str] = None, model: Optional[str] = None, + config: WheelConfig = DEFAULT, ) -> list[CouncilVerdict]: """Have the five personas vote on each candidate, then aggregate. @@ -29,10 +31,13 @@ def run_council( subset of a :class:`ScanResult`). provider, model: Override the configured LLM provider / model id. + config: + Strategy config — supplies account size / position count so each + persona's rubric reflects the user's real account. Returns ------- list[CouncilVerdict] One verdict per candidate, sorted by aggregate council score. """ - return _run(candidates, provider=provider, model=model) + return _run(candidates, provider=provider, model=model, config=config) diff --git a/src/quantwheel/services/discovery_service.py b/src/quantwheel/services/discovery_service.py new file mode 100644 index 0000000..b49d503 --- /dev/null +++ b/src/quantwheel/services/discovery_service.py @@ -0,0 +1,49 @@ +"""Discovery service — reverse search for names that fit the user's account. + +When the watchlist yields nothing tradeable, screen the market for affordable, +liquid names and score them with the *same* engine the watchlist uses. Returns a +normal :class:`ScanResult`, so the dashboard can render and report on discovered +names identically to a regular scan. +""" + +from __future__ import annotations + +from datetime import date +from typing import Optional + +from quantwheel.core import scoring +from quantwheel.core.config import DEFAULT, WheelConfig +from quantwheel.core.data import screener +from quantwheel.core.types import ScanResult + +DEFAULT_MAX_SCAN = 25 # cap the deep scan after the cheap screener prefilter + + +def discover( + config: WheelConfig = DEFAULT, + exclude: tuple[str, ...] = (), + max_scan: int = DEFAULT_MAX_SCAN, + as_of: Optional[date] = None, +) -> ScanResult: + """Screen for affordable names and score them. + + Parameters + ---------- + config: + Drives both the affordability prefilter and the scoring/sizing. + exclude: + Tickers to skip (typically the user's existing watchlist). + max_scan: + Hard cap on how many screened names get the full (slow) scan. + + Returns + ------- + ScanResult + Best-first, identical in shape to a watchlist scan. Empty candidates if + the screener finds nothing affordable (or is unavailable). + """ + symbols = screener.find_affordable_optionable(config, limit=max_scan, exclude=exclude) + if not symbols: + return ScanResult(as_of=as_of or date.today(), account=config.account, + universe=[], candidates=[]) + return scoring.score_universe(symbols, config, as_of) diff --git a/src/quantwheel/services/report_service.py b/src/quantwheel/services/report_service.py new file mode 100644 index 0000000..a1cc533 --- /dev/null +++ b/src/quantwheel/services/report_service.py @@ -0,0 +1,116 @@ +"""Report service — assemble the week's plain-language, multi-position recommendation. + +This is the facade the dashboard's **Report** tab calls. It runs the portfolio +allocator over a scan (optionally ordered by the council's verdicts), builds a +human-facing :class:`TradeCard` for each sized position, attaches the plain-English +narrative, and rolls everything up into a :class:`PortfolioReport`. + +UI-agnostic: it returns dataclasses only, so a different front-end (the Textual +TUI, a web client) could render the same report. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Optional + +from quantwheel.agents.nodes import make_trade_card +from quantwheel.core import narrative, portfolio +from quantwheel.core.config import DEFAULT, WheelConfig +from quantwheel.core.types import ( + CouncilVerdict, + PortfolioReport, + ReportItem, + ScanResult, +) + +# Plain-English reason per SKIP verdict, for the "sit out" explanation. +_SKIP_REASON = { + "SKIP_CAPITAL": "the strike needed more collateral than your account can deploy", + "SKIP_REGIME": "an unfavorable market regime (the trend/volatility backdrop)", + "SKIP_EARNINGS": "an earnings report landing on or before the option's expiry", + "SKIP_LIQUIDITY": "thin or wide option markets (poor liquidity)", + "SKIP": "a weak setup or non-positive expected value", +} + + +def _sit_out_reason(scan: ScanResult, config: WheelConfig) -> str: + """A friendly explanation of why nothing qualified this week.""" + skips = [c.verdict.value for c in scan.candidates if c.verdict.value.startswith("SKIP")] + if skips: + top = max(set(skips), key=skips.count) + why = _SKIP_REASON.get(top, "the safety checks") + blocker = f" The most common blocker was {why}." + else: + blocker = "" + return ( + f"No trade cleared every safety check for your ${config.account:,.0f} " + f"account this week.{blocker} Sitting out is a valid move — your cash " + f"simply waits for a better setup." + ) + + +def build_report( + scan: ScanResult, + council_verdicts: Optional[list[CouncilVerdict]] = None, + config: WheelConfig = DEFAULT, +) -> PortfolioReport: + """Turn a scan (+ optional council) into a readable, sized recommendation.""" + deployable = config.deployable_cash + positions = portfolio.allocate(scan, config, council_verdicts) + verdict_by_ticker = {v.ticker: v for v in (council_verdicts or [])} + + items: list[ReportItem] = [] + for pos in positions: + c = pos.candidate + verdict = verdict_by_ticker.get(c.ticker) + # Build the card, then reflect the *allocated* size (the allocator may size + # differently from the per-candidate standalone sizing in the scan). + card = make_trade_card(c, verdict, config) + card = replace(card, contracts=pos.contracts, collateral=pos.collateral) + items.append( + ReportItem( + position=pos, + card=card, + summary=narrative.summary_line(pos, card), + explanation=narrative.build_explanation(pos, card, config), + council_explanation=narrative.explain_council(verdict), + is_estimated=c.quote.is_estimated, + source=c.quote.source, + ) + ) + + cash_deployed = sum(i.position.collateral for i in items) + total_credit = sum(i.position.credit for i in items) + total_contracts = sum(i.position.contracts for i in items) + blended_pop = ( + sum(i.card.pop * i.position.contracts for i in items) / total_contracts + if total_contracts else 0.0 + ) + + if items: + n = len(items) + headline = ( + f"**{n} trade{'s' if n != 1 else ''}** fit your ${config.account:,.0f} " + f"account this week — setting aside ${cash_deployed:,.0f} of collateral " + f"and collecting about ${total_credit:,.0f} in premium." + ) + sit_out = "" + else: + headline = f"**Sit out this week.** Nothing fit your ${config.account:,.0f} account." + sit_out = _sit_out_reason(scan, config) + + return PortfolioReport( + as_of=scan.as_of, + account=config.account, + deployable=deployable, + max_positions=config.max_positions, + items=items, + cash_deployed=cash_deployed, + cash_reserved=config.account - cash_deployed, + total_credit=total_credit, + blended_pop=blended_pop, + headline=headline, + sit_out_reason=sit_out, + fetched_at=scan.fetched_at, + ) diff --git a/src/quantwheel/ui/streamlit_app/_format.py b/src/quantwheel/ui/streamlit_app/_format.py index 2d28d11..028b7a9 100644 --- a/src/quantwheel/ui/streamlit_app/_format.py +++ b/src/quantwheel/ui/streamlit_app/_format.py @@ -42,3 +42,20 @@ def scan_to_df(scan: ScanResult) -> pd.DataFrame: "POP": "{:.0%}", "P(assign)": "{:.0%}", "IV": "{:.0%}", "pFav": "{:.0%}", "Spot": "${:.2f}", "Strike": "${:.2f}", "Credit$": "${:.0f}", "EV$": "${:.1f}", } + + +def data_provenance(scan: ScanResult) -> str: + """A one-line proof-of-freshness banner: source, timestamp, and any estimates.""" + when = f" · fetched {scan.fetched_at:%H:%M:%S}" if scan.fetched_at else "" + n_est = sum(1 for c in scan.candidates if c.quote.is_estimated) + note = "" if n_est == 0 else f" · {n_est} quote(s) estimated (after-hours)" + return (f"📡 Stock & option data: **live from Yahoo Finance** " + f"(as of {scan.as_of}{when}){note}.") + + +def quote_badge(is_estimated: bool) -> str: + """Per-trade badge distinguishing a real live quote from a theoretical estimate.""" + if is_estimated: + return ("⚠️ **Estimated quote** (after-hours / synthetic Yahoo data) — " + "verify the live option chain in your broker before trading.") + return "✅ **Live option-chain quote** from Yahoo Finance." diff --git a/src/quantwheel/ui/streamlit_app/app.py b/src/quantwheel/ui/streamlit_app/app.py index ed78aca..0ee6cd7 100644 --- a/src/quantwheel/ui/streamlit_app/app.py +++ b/src/quantwheel/ui/streamlit_app/app.py @@ -22,6 +22,7 @@ backtest, candidate_detail, council, + report, scan, tracker, universe, @@ -29,46 +30,83 @@ st.set_page_config(page_title="quant-wheel", page_icon="🎯", layout="wide") +# Session keys holding *computed* results. They depend on the watchlist + account +# settings, so they're cleared whenever those inputs change (or on explicit reset). +_RESULT_KEYS = ("scan", "council", "report", "backtest", "discovery") + def _sidebar_config(): """Account parameters live in the sidebar and flow into every service call.""" st.sidebar.title("🎯 quant-wheel") - st.sidebar.caption("Small-account Wheel engine · educational, not advice") + st.sidebar.caption("Account-aware Wheel engine · educational, not advice") st.sidebar.subheader("Account") - account = st.sidebar.number_input("Account ($)", 100.0, 100_000.0, + account = st.sidebar.number_input("Account ($)", 100.0, 1_000_000.0, float(DEFAULT.account), step=100.0) - buffer = st.sidebar.number_input("Cash buffer ($)", 0.0, 50_000.0, + buffer = st.sidebar.number_input("Cash buffer ($)", 0.0, 500_000.0, float(DEFAULT.cash_buffer), step=50.0) - st.sidebar.metric("Deployable", f"${max(0.0, account - buffer):,.0f}") - st.sidebar.divider() - st.sidebar.caption( - "Flow: **Universe → Scan → Detail → Council → Approve → Tracker**, " - "plus **Backtest** for the evidence. No order is ever placed by software." - ) - return replace(DEFAULT, account=account, cash_buffer=buffer) + max_positions = int(st.sidebar.number_input( + "Max concurrent positions", 1, 20, int(DEFAULT.max_positions), step=1)) + + deployable = max(0.0, account - buffer) + st.sidebar.metric("Deployable", f"${deployable:,.0f}") + if max_positions > 1: + st.sidebar.caption(f"≈ ${deployable / max_positions:,.0f} budget per position, " + f"across up to {max_positions} names.") + return replace(DEFAULT, account=account, cash_buffer=buffer, + max_positions=max_positions) + + +def _invalidate_stale_results(config) -> None: + """Clear computed results when the watchlist or account settings change. + + This is what makes 'go back, add more tickers, re-run' behave: stale scan / + council / report / backtest are dropped so every dependent tab prompts a + fresh run instead of silently showing yesterday's numbers. + """ + universe = st.session_state.get("universe") + sig = (tuple(universe) if universe else None, + config.account, config.cash_buffer, config.max_positions) + if st.session_state.get("_inputs_sig") != sig: + if st.session_state.get("_inputs_sig") is not None: # not the first load + for key in _RESULT_KEYS: + st.session_state.pop(key, None) + st.session_state["_inputs_sig"] = sig def main() -> None: config = _sidebar_config() st.session_state["config"] = config + _invalidate_stale_results(config) + + st.sidebar.divider() + if st.sidebar.button("🔄 Reset results"): + for key in _RESULT_KEYS: + st.session_state.pop(key, None) + st.rerun() + st.sidebar.caption( + "Flow: **Universe → Scan → Detail → Council → Report → Approve → " + "Tracker**, plus **Backtest** for the evidence. No order is ever placed." + ) tabs = st.tabs([ "🗂 Universe", "🔎 Scan", "🔬 Candidate", "🏛 Council", - "✅ Approve", "📒 Tracker", "📈 Backtest", + "📋 Report", "✅ Approve", "📒 Tracker", "📈 Backtest", ]) with tabs[0]: - universe.render() + universe.render(config) with tabs[1]: scan.render(config) with tabs[2]: candidate_detail.render() with tabs[3]: - council.render() + council.render(config) with tabs[4]: - approve.render(config) + report.render(config) with tabs[5]: - tracker.render() + approve.render(config) with tabs[6]: + tracker.render() + with tabs[7]: backtest.render(config) diff --git a/src/quantwheel/ui/streamlit_app/tabs/approve.py b/src/quantwheel/ui/streamlit_app/tabs/approve.py index 5b8fa35..21e5e6b 100644 --- a/src/quantwheel/ui/streamlit_app/tabs/approve.py +++ b/src/quantwheel/ui/streamlit_app/tabs/approve.py @@ -10,6 +10,7 @@ from quantwheel.agents.nodes import make_trade_card from quantwheel.services import tracker_service +from quantwheel.ui.streamlit_app._format import quote_badge def _pick_candidate(scan, council): @@ -38,14 +39,22 @@ def render(config) -> None: card = make_trade_card(candidate, verdict, config) st.markdown(f"### {card.ticker} — {card.action}") + st.caption("The single best name. For the full plain-language write-up (and " + "multiple positions on a larger account), see the **Report** tab.") + st.markdown(quote_badge(candidate.quote.is_estimated)) cols = st.columns(4) - cols[0].metric("Strike", f"${card.strike:.2f}") - cols[1].metric("Expiry", str(card.expiry), f"{card.dte} DTE") - cols[2].metric("Credit", f"${card.target_credit:.0f}", f"×{card.contracts}") - cols[3].metric("POP / EV", f"{card.pop:.0%}", f"${card.expected_value:.1f}") + cols[0].metric("Strike", f"${card.strike:.2f}", + help="The price you'd buy the shares at if assigned.") + cols[1].metric("Expiry", str(card.expiry), f"{card.dte} days", + help="When the option expires (days to expiry).") + cols[2].metric("Credit", f"${card.target_credit:.0f}", f"×{card.contracts}", + help="Premium collected per contract — your maximum profit.") + cols[3].metric("POP / EV", f"{card.pop:.0%}", f"${card.expected_value:.1f}", + help="Probability of profit / expected value per contract.") - st.markdown(f"**Break-even:** ${card.breakeven:.2f} · " - f"**Collateral:** ${card.collateral:,.0f}") + st.markdown(f"**Break-even:** ${card.breakeven:.2f} (strike minus the premium " + f"per share) · **Collateral:** ${card.collateral:,.0f} (cash set " + f"aside to buy the shares)") st.markdown(f"**Exit plan:** {card.exit_plan}") st.markdown(f"**Assignment plan:** {card.assignment_plan}") st.markdown(f"**Why:** {card.rationale}") diff --git a/src/quantwheel/ui/streamlit_app/tabs/council.py b/src/quantwheel/ui/streamlit_app/tabs/council.py index 777c761..0079447 100644 --- a/src/quantwheel/ui/streamlit_app/tabs/council.py +++ b/src/quantwheel/ui/streamlit_app/tabs/council.py @@ -9,10 +9,10 @@ from quantwheel.services.council_service import run_council -def render() -> None: +def render(config=None) -> None: st.subheader("Investment council") - st.caption("Statistician · Buffett · Burry · Dalio · Simons — same evidence, " - "five judgements, one aggregate.") + st.caption("Statistician · Value Investor · Risk Manager · Macro Strategist · " + "Systematic Quant — same evidence, five judgements, one aggregate.") scan = st.session_state.get("scan") if scan is None: @@ -27,7 +27,8 @@ def render() -> None: if st.button("🏛 Convene council", type="primary", disabled=not ready): with st.spinner("Five investors deliberating…"): - st.session_state["council"] = run_council(candidates) + kwargs = {"config": config} if config is not None else {} + st.session_state["council"] = run_council(candidates, **kwargs) verdicts = st.session_state.get("council") if not verdicts: diff --git a/src/quantwheel/ui/streamlit_app/tabs/report.py b/src/quantwheel/ui/streamlit_app/tabs/report.py new file mode 100644 index 0000000..0d92d0b --- /dev/null +++ b/src/quantwheel/ui/streamlit_app/tabs/report.py @@ -0,0 +1,118 @@ +"""Report tab — the plain-language, account-aware recommendation. + +Reads the latest scan (and the council, if convened), builds a multi-position +:class:`PortfolioReport` via the report service, and renders it for a beginner or +intermediate investor: a portfolio summary, then each specific trade explained in +plain English with the council's reasoning. Per-trade 'Log to tracker' buttons +reuse the tracker service — nothing here places an order. + +When nothing on the watchlist fits, it offers **discovery** (reverse search): +screen the market for affordable, optionable names that do fit the account. +""" + +from __future__ import annotations + +import streamlit as st + +from quantwheel.core.narrative import GLOSSARY +from quantwheel.services import tracker_service +from quantwheel.services.discovery_service import discover +from quantwheel.services.report_service import build_report +from quantwheel.ui.streamlit_app._format import ( + SCAN_COL_FORMAT, + data_provenance, + quote_badge, + scan_to_df, +) + + +def _glossary() -> None: + with st.expander("📖 What do these terms mean?"): + for term, meaning in GLOSSARY.items(): + st.markdown(f"- **{term}** — {meaning}") + + +def _render_report(report, *, key_prefix: str) -> None: + """Render a PortfolioReport's summary + per-trade explanations.""" + cols = st.columns(4) + cols[0].metric("Trades", len(report.items)) + cols[1].metric("Collateral used", f"${report.cash_deployed:,.0f}") + cols[2].metric("Premium collected", f"${report.total_credit:,.0f}") + cols[3].metric("Avg. chance to keep it", f"{report.blended_pop:.0%}") + st.caption(f"Cash kept in reserve: ${report.cash_reserved:,.0f} of " + f"${report.account:,.0f}.") + st.divider() + + for i, item in enumerate(report.items): + card = item.card + st.markdown(f"### {card.ticker} — {item.summary}") + st.markdown(quote_badge(item.is_estimated)) + for para in item.explanation: + st.markdown(para) + if item.council_explanation: + st.info(f"**Why the council likes it:** {item.council_explanation}") + if st.button(f"✅ Log {card.ticker} to tracker", + key=f"log_{key_prefix}_{i}_{card.ticker}"): + rec = tracker_service.add_trade(tracker_service.record_from_card(card)) + st.success(f"Logged {rec.ticker} (id {rec.id}). See the Tracker tab.") + st.divider() + + st.caption("⚠️ Educational only — no order is placed by software. Verify the " + "live option chain in your broker before trading.") + + +def _discovery_section(config, watchlist) -> None: + """Reverse search: when the watchlist comes up empty, find names that fit.""" + st.divider() + st.markdown("#### 🔍 Find names that fit your account") + st.caption( + f"None of your watchlist cleared the checks for your ${config.account:,.0f} " + "account. Search the market for affordable, liquid, optionable names — " + "scored by the same engine." + ) + if st.button("🔍 Search the market for candidates", type="primary"): + with st.spinner("Screening the market and scoring candidates…"): + st.session_state["discovery"] = discover( + config, exclude=tuple(watchlist), max_scan=25) + + disc = st.session_state.get("discovery") + if disc is None: + return + if not disc.candidates: + st.info("The screener didn't return any optionable names that fit right " + "now — try adjusting your cash buffer, or check back during market " + "hours when more chains are quoting.") + return + + st.caption(data_provenance(disc)) + disc_report = build_report(disc, None, config) + st.markdown(disc_report.headline) + if disc_report.items: + _render_report(disc_report, key_prefix="disc") + else: + st.info("Found affordable names, but none cleared every safety check this " + "week. The closest matches the engine scored:") + st.dataframe( + scan_to_df(disc).head(10).style.format(SCAN_COL_FORMAT, na_rep="—"), + use_container_width=True, hide_index=True, + ) + + +def render(config) -> None: + st.subheader("Your weekly report") + scan = st.session_state.get("scan") + if scan is None: + st.info("Run a scan first (Scan tab) — the report is built from it.") + return + + report = build_report(scan, st.session_state.get("council"), config) + st.caption(data_provenance(scan)) + st.markdown(report.headline) + + if report.items: + _render_report(report, key_prefix="wl") + else: + st.warning(report.sit_out_reason) + _discovery_section(config, scan.universe) + + _glossary() diff --git a/src/quantwheel/ui/streamlit_app/tabs/scan.py b/src/quantwheel/ui/streamlit_app/tabs/scan.py index 19a9e89..501e172 100644 --- a/src/quantwheel/ui/streamlit_app/tabs/scan.py +++ b/src/quantwheel/ui/streamlit_app/tabs/scan.py @@ -4,8 +4,13 @@ import streamlit as st +from quantwheel.core.narrative import VERDICT_LEGEND from quantwheel.services.scan_service import run_weekly_scan -from quantwheel.ui.streamlit_app._format import SCAN_COL_FORMAT, scan_to_df +from quantwheel.ui.streamlit_app._format import ( + SCAN_COL_FORMAT, + data_provenance, + scan_to_df, +) from quantwheel.universe import DEFAULT_UNIVERSE @@ -23,12 +28,19 @@ def render(config) -> None: st.info("Run a scan to see candidates.") return + st.caption(data_provenance(scan)) df = scan_to_df(scan) st.dataframe( df.style.format(SCAN_COL_FORMAT, na_rep="—"), use_container_width=True, hide_index=True, ) + # Verdict legend — explain the codes in the table, surfacing those in view first. + present = list(dict.fromkeys(c.verdict.value for c in scan.candidates)) + with st.expander("🔑 What do the verdicts mean?", expanded=not scan.enterable): + for v in present + [k for k in VERDICT_LEGEND if k not in present]: + st.markdown(f"- **{v}** — {VERDICT_LEGEND.get(v, '—')}") + enter = scan.enterable c1, c2 = st.columns(2) c1.metric("Enterable", len(enter)) diff --git a/src/quantwheel/ui/streamlit_app/tabs/universe.py b/src/quantwheel/ui/streamlit_app/tabs/universe.py index ce2c7c0..ce7db8d 100644 --- a/src/quantwheel/ui/streamlit_app/tabs/universe.py +++ b/src/quantwheel/ui/streamlit_app/tabs/universe.py @@ -5,15 +5,21 @@ import pandas as pd import streamlit as st +from quantwheel.core.config import DEFAULT from quantwheel.universe import DEFAULT_UNIVERSE -def render() -> None: +def render(config=None) -> None: + config = config or DEFAULT st.subheader("Watchlist") + # Per-position deployable cash sets the largest strike a cash-secured put can + # afford (strike × 100 ≤ budget). Shown so the watchlist reflects the account. + budget = config.deployable_cash / max(1, config.max_positions) st.caption( "The names the scan considers. Mirrors the vault shortlist — Tier-A " "(F, SOFI, T, AAL) are core; Tier-B (NIO, MARA, CLSK) are higher-risk. " - "At $1,000 only sub-~$7 strikes fit a true cash-secured put." + f"With ${budget:,.0f} per position, strikes up to ~${budget / 100:,.2f} " + "fit a cash-secured put." ) current = st.session_state.get("universe", DEFAULT_UNIVERSE) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 51b81bc..4717579 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -18,5 +18,5 @@ def test_dashboard_renders_without_exception(): at = AppTest.from_file(str(_APP), default_timeout=60) at.run() assert not at.exception, f"dashboard raised: {at.exception}" - # Sanity: the seven tabs are present. - assert len(at.tabs) == 7 + # Sanity: the eight tabs are present (incl. the new Report tab). + assert len(at.tabs) == 8 diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..95c1dff --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,43 @@ +"""Discovery / reverse-search — screener math + service wiring (no network). + +The screener itself hits Yahoo, so we monkeypatch it; these tests pin the +affordability math and the service's score-the-survivors behaviour offline. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +from quantwheel.core.config import DEFAULT +from quantwheel.core.data import screener +from quantwheel.core.types import ScanResult +from quantwheel.services import discovery_service + + +def test_affordable_max_price_scales_with_account(): + # $1,000 account, $300 buffer, 1 position -> $700 deployable -> 700/80. + assert screener.affordable_max_price(DEFAULT) == 700.0 / 80.0 + big = replace(DEFAULT, account=25_000.0, max_positions=5) + # 24,700 deployable / 5 positions / 80 ≈ $61.75 per-name price ceiling. + assert round(screener.affordable_max_price(big), 2) == 61.75 + + +def test_discover_returns_empty_when_screener_finds_nothing(monkeypatch): + monkeypatch.setattr(screener, "find_affordable_optionable", + lambda *a, **k: []) + result = discovery_service.discover(DEFAULT, as_of=date(2026, 6, 25)) + assert isinstance(result, ScanResult) + assert result.candidates == [] + + +def test_discover_scores_screened_symbols(monkeypatch, scored_candidate): + monkeypatch.setattr(discovery_service.screener, "find_affordable_optionable", + lambda *a, **k: ["NIO"]) + sentinel = ScanResult(as_of=date(2026, 6, 25), account=1000.0, + universe=["NIO"], candidates=[scored_candidate]) + monkeypatch.setattr(discovery_service.scoring, "score_universe", + lambda *a, **k: sentinel) + result = discovery_service.discover(DEFAULT, exclude=("F",)) + assert result is sentinel + assert result.candidates[0].ticker == "NIO" diff --git a/tests/test_narrative.py b/tests/test_narrative.py new file mode 100644 index 0000000..c92c5a8 --- /dev/null +++ b/tests/test_narrative.py @@ -0,0 +1,59 @@ +"""Plain-language narrative builders — readable, jargon-defined, no real names.""" + +from __future__ import annotations + +from quantwheel.agents.nodes import make_trade_card +from quantwheel.core import narrative +from quantwheel.core.config import DEFAULT +from quantwheel.core.types import ( + CouncilVerdict, + CouncilVote, + Position, + Stance, +) + + +def _position(candidate): + p = candidate.pricing + return Position(candidate=candidate, contracts=1, + collateral=candidate.quote.strike * 100, credit=p.credit) + + +def test_trade_and_odds_are_readable(scored_candidate): + pos = _position(scored_candidate) + card = make_trade_card(scored_candidate, None, DEFAULT) + + trade = narrative.explain_trade(pos, card) + odds = narrative.explain_odds(pos, card) + assert scored_candidate.ticker in trade + assert "premium" in trade.lower() and "collateral" in trade.lower() + # Acronyms are expanded, not left bare. + assert "probability of profit" in odds.lower() + assert "expected value" in odds.lower() + + +def test_explanation_bundle_has_four_paragraphs(scored_candidate): + pos = _position(scored_candidate) + card = make_trade_card(scored_candidate, None, DEFAULT) + paras = narrative.build_explanation(pos, card, DEFAULT) + assert len(paras) == 4 + assert all(isinstance(p, str) and p for p in paras) + + +def test_council_explanation_uses_archetype_names(scored_candidate): + votes = [ + CouncilVote("The Value Investor", "NIO", Stance.FOR, 0.8, "happy to own it"), + CouncilVote("The Risk Manager", "NIO", Stance.AGAINST, 0.6, "balance sheet thin"), + ] + verdict = CouncilVerdict(ticker="NIO", votes=votes, aggregate_score=0.2, + consensus="x", quant_score=4.5) + text = narrative.explain_council(verdict) + assert "The Value Investor" in text + assert "The Risk Manager" in text + for bad in ("Buffett", "Burry", "Dalio", "Simons"): + assert bad not in text + + +def test_glossary_is_nonempty(): + assert narrative.GLOSSARY + assert "Premium" in narrative.GLOSSARY diff --git a/tests/test_personas.py b/tests/test_personas.py new file mode 100644 index 0000000..4bb5358 --- /dev/null +++ b/tests/test_personas.py @@ -0,0 +1,46 @@ +"""Guard: the council is anonymized — no real-person names anywhere. + +The five members are archetypes (Statistician, Value Investor, Risk Manager, +Macro Strategist, Systematic Quant). This test fails loudly if a real name ever +creeps back into a persona's display name or system prompt. +""" + +from __future__ import annotations + +from quantwheel.agents.personas import ( + buffett, + burry, + dalio, + simons, + statistician, +) + +_MODULES = [statistician, buffett, burry, dalio, simons] + +_FORBIDDEN = ("Buffett", "Burry", "Dalio", "Simons", "Renaissance", "Warren", "Michael") + +_EXPECTED_NAMES = { + "The Statistician", "The Value Investor", "The Risk Manager", + "The Macro Strategist", "The Systematic Quant", +} + + +def test_no_real_names_in_persona_modules(): + for mod in _MODULES: + blob = f"{mod.NAME}\n{mod.SYSTEM_PROMPT}" + for bad in _FORBIDDEN: + assert bad not in blob, f"{mod.__name__} still references {bad!r}" + + +def test_persona_names_are_archetypes(): + names = {mod.NAME for mod in _MODULES} + assert names == _EXPECTED_NAMES + + +def test_rubric_scales_with_account(): + from quantwheel.agents.personas.base import _rubric + + one = _rubric(1_000.0, 1) + many = _rubric(25_000.0, 3) + assert "$1,000" in one and "ONE position" in one + assert "$25,000" in many and "up to 3 positions" in many diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py new file mode 100644 index 0000000..fadf864 --- /dev/null +++ b/tests/test_portfolio.py @@ -0,0 +1,57 @@ +"""Portfolio allocator tests — pure math, no network.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +from quantwheel.core import portfolio +from quantwheel.core.config import DEFAULT +from quantwheel.core.types import ScanResult + + +def _mk(base, ticker, strike): + """Clone the fixture candidate with a new ticker/strike (still enterable).""" + q = replace(base.quote, ticker=ticker, strike=strike) + return replace(base, ticker=ticker, quote=q) + + +def _scan(candidates, account=1000.0): + return ScanResult(as_of=date(2026, 6, 25), account=account, + universe=[c.ticker for c in candidates], candidates=candidates) + + +def test_single_position_matches_legacy_sizing(scored_candidate): + # account 1000, buffer 300 -> 700 deployable; strike 4.5 -> 450/contract -> 1 fits. + scan = _scan([scored_candidate]) + positions = portfolio.allocate(scan, DEFAULT) + assert len(positions) == 1 + assert positions[0].contracts == 1 + assert positions[0].collateral == 450.0 + + +def test_multi_position_spreads_across_names(scored_candidate): + cands = [_mk(scored_candidate, t, 4.5) for t in ("F", "SOFI", "NIO")] + scan = _scan(cands, account=25_000.0) + config = replace(DEFAULT, account=25_000.0, max_positions=3) + positions = portfolio.allocate(scan, config) + + assert len(positions) == 3 + total = sum(p.collateral for p in positions) + assert total <= config.deployable_cash * config.max_pos_pct + 1e-6 + + +def test_respects_max_positions_cap(scored_candidate): + cands = [_mk(scored_candidate, f"T{i}", 4.5) for i in range(5)] + scan = _scan(cands, account=25_000.0) + config = replace(DEFAULT, account=25_000.0, max_positions=2) + positions = portfolio.allocate(scan, config) + assert len(positions) == 2 + + +def test_skips_names_that_do_not_fit(scored_candidate): + # A $50 strike needs $5,000 collateral — does not fit a $1,000 account. + pricey = _mk(scored_candidate, "BIG", 50.0) + scan = _scan([pricey]) + positions = portfolio.allocate(scan, DEFAULT) + assert positions == [] diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..eac19e4 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,45 @@ +"""Report service — assembles the plain-language, multi-position recommendation.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +from quantwheel.core.config import DEFAULT +from quantwheel.core.types import GateResult, ScanResult, Verdict +from quantwheel.services.report_service import build_report + + +def _scan(candidates, account=1000.0): + return ScanResult(as_of=date(2026, 6, 25), account=account, + universe=[c.ticker for c in candidates], candidates=candidates) + + +def test_report_recommends_a_specific_trade(scored_candidate): + report = build_report(_scan([scored_candidate]), None, DEFAULT) + assert len(report.items) == 1 + item = report.items[0] + assert item.card.ticker == "NIO" + assert item.card.contracts == item.position.contracts # card reflects allocation + assert item.summary and item.explanation + assert report.total_credit > 0 + assert "NIO" in item.summary + + +def test_report_caps_at_max_positions(scored_candidate): + cands = [replace(scored_candidate, ticker=t, + quote=replace(scored_candidate.quote, ticker=t, strike=4.5)) + for t in ("F", "SOFI", "NIO")] + config = replace(DEFAULT, account=25_000.0, max_positions=2) + report = build_report(_scan(cands, account=25_000.0), None, config) + assert len(report.items) == 2 + assert report.cash_deployed <= config.deployable_cash + 1e-6 + + +def test_report_sits_out_when_nothing_enterable(scored_candidate): + blocked = replace(scored_candidate, verdict=Verdict.SKIP_CAPITAL, + gates=GateResult(False, True, True, False, ["does not fit"])) + report = build_report(_scan([blocked]), None, DEFAULT) + assert report.items == [] + assert report.sit_out_reason + assert "sit" in report.headline.lower() or "sit" in report.sit_out_reason.lower()