
Trading Skills Navigator
- 437 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
Use trading-skills-navigator for development tasks
About
trading-skills-navigator: A skill for development. This provides functionality for development workflows.
- trading-skills-navigator
Trading Skills Navigator by the numbers
- 437 all-time installs (skills.sh)
- +39 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #959 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill trading-skills-navigatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 437 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
What it does
Use trading-skills-navigator for development tasks
Files
Trading Skills Navigator
The interactive on-ramp for this repository. It turns a user's goal into a concrete recommendation: which workflow to run, which skillset (skills-index category) it belongs to, the API requirement, and the setup path for Claude Web App or Claude Code.
A new user faces 54 skills + 5 workflows with no router. This skill is that router. It is deterministic — a Python recommender (scripts/recommend.py) consumes the repo metadata; this SKILL.md narrates the result conversationally.
When to Use
- The user expresses a trading/investing goal and asks where to start or which
skill/workflow to use ("どれを使えばいい", "where do I start").
- The user asks what works without paid API keys.
- The user wants the no-API vs API path separated, or a beginner path.
- The user describes a persona ("part-time swing trader", "dividend investor",
"I want to short", "I want to backtest ideas") and needs routing.
Do not use this skill to execute trades, place orders, or auto-run other skills. It recommends and explains only.
Workflow
Step 1 — Capture the goal and constraints
From the user's message, extract:
- The natural-language goal (verbatim is fine).
- Optional constraints: no-API only? a daily time budget
(15m/30m/60m/90m)? experience level (beginner/intermediate/advanced)?
Ask at most one brief clarifying question only if the goal is empty or has no discernible intent. Otherwise proceed — the recommender degrades gracefully.
Step 2 — Run the recommender
python3 skills/trading-skills-navigator/scripts/recommend.py \
--query "<the user's goal, verbatim>" \
--format json
# optional: --no-api --time-budget 15m|30m|60m|90m|any
# --experience beginner|intermediate|advanced- In Claude Code the script reads the repo-root SSoT
(skills-index.yaml + workflows/*.yaml) automatically.
- In the Claude Web App there is no repo root; the script transparently
falls back to the bundled assets/metadata_snapshot.json. The recommendation is byte-identical in both environments — no behavior change for the user.
Step 3 — Narrate the result conversationally
Parse the JSON and explain, in the user's language:
- Primary workflow —
display_name,cadence,~estimated_minutes,
api_profile. State plainly what it does and when to run it.
- Secondary workflows — if any, how they relate (e.g. "run the regime
check first, then this when it allows risk").
- Skillset — the
skillset.id(skills-index category).
manifest_status: active means a curated skillsets/<id>.yaml bundle ships for this category (market-regime, core-portfolio, swing-opportunity, trade-memory) — mention it as the install bundle for the recommended workflow. manifest_status: deferred means no manifest yet (e.g. honest-gap categories); the recommendation is workflow-based only.
- No-API vs API — read
no_api_path:true→ the entire recommended path
works without paid API keys (state this plainly); false → tell the user which paid key(s) the path needs; null → honest gap, no path. (no_api is the request flag — whether no-API mode was active — not whether the path is free; always narrate no_api_path.) If a workflow was excluded under --no-api, surface the rationale entry naming the paid integration (e.g. "swing-opportunity-daily needs FMP").
- Honest gap — if
honest_gapis true there is no shipped workflow for
this intent. Say so directly, then present suggested_skills from the relevant category and relay the note. Never invent a workflow.
- Always read the
rationalearray and explain why this was recommended.
Step 4 — Explain the setup path
Read references/setup_paths.md and walk the user through installing `setup_bundle` — the recommender's deterministic install union over the primary skillset and every secondary workflow (so nothing is dropped for a multi-workflow recommendation). Enumerate setup_bundle.required → recommended → optional, cite setup_bundle.sources to explain why each skill is needed, and name skillset.manifest.related_workflows for how the bundle is run. Narrate skillset.manifest (when present) as "what the recommended skillset is". On an honest gap install suggested_skills. Do this for whichever environment the user is in (Claude Web App .skill upload, or Claude Code folder copy); call out any paid API keys those skills need.
Step 5 — Point to the learning loop
Close by pointing the user at trader-memory-core and the trade-memory-loop / monthly-performance-review workflows so every recommended path feeds the Plan → Trade → Record → Review → Improve loop.
Output Format
The JSON the recommender emits (stable, idempotent, sort_keys):
| Field | Meaning |
|---|---|
primary_workflow | Recommended workflow object, or null on an honest gap |
secondary_workflows | Supporting workflows (ordered, time-budget filtered) |
skillset | {id, source: skills-index.category, manifest_status, manifest}. manifest_status is active when skillsets/<id>.yaml ships, else deferred. manifest is the 5-key view {display_name, required_skills, recommended_skills, optional_skills, related_workflows} when active, else null. Describes the primary skillset only — not the install list |
setup_bundle | {required, recommended, optional, sources} — the actionable install union over the primary skillset and every secondary workflow (deterministic, tier-deduped). This is what to install. All-empty on an honest gap (use suggested_skills) |
suggested_skills | Skills to use when no workflow shipped (honest gap); else [] |
no_api | Request-side: was no-API constraint mode active (flag or persona) |
no_api_path | Path-side: does the whole recommendation (primary + every secondary) work without paid API keys? true/false; null on an honest gap. This is the DoD's API-vs-no-API separation — narrate it explicitly |
honest_gap | true when no workflow exists for the intent |
note | Plain-language explanation for gaps / unmapped input |
rationale | Ordered list of why-this-was-recommended strings |
setup_path_ref | Pointer to the setup-path reference |
Resources
scripts/recommend.py— the deterministic recommender (single source of
truth for routing).
scripts/build_snapshot.py— regeneratesassets/metadata_snapshot.json
from the SSoT; --check guards drift (pre-commit + CI).
references/intent_routing.md— the persona table, the 10-question contract,
the --no-api credential rule, and scoring tie-breaks.
references/setup_paths.md— Claude Web App vs Claude Code setup steps.assets/metadata_snapshot.json— generated SSoT digest for the Web App
fallback. Never edit by hand; run build_snapshot.py.
{
"schema_version": 1,
"skills": [
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Backtest Expert",
"id": "backtest-expert",
"integrations": [
{
"id": "user_input",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Expert guidance for systematic backtesting of trading strategies.",
"timeframe": "research"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Breadth Chart Analyst",
"id": "breadth-chart-analyst",
"integrations": [
{
"id": "chart_image",
"requirement": "required",
"type": "image"
}
],
"status": "production",
"summary": "This skill should be used when analyzing market breadth charts, specifically the S&P 500 Breadth Index (200-Day MA based) and the US Stock Market Uptrend Stock Ratio charts.",
"timeframe": "daily"
},
{
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "Breakout Trade Planner",
"id": "breakout-trade-planner",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Generate Minervini-style breakout trade plans from VCP screener output with worst-case risk calculation, portfolio heat management, and Alpaca-compatible order templates (stop-limit bracket for pre-placement, limit bracket for post-confi...",
"timeframe": "event-driven"
},
{
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "CANSLIM Screener",
"id": "canslim-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Screen US stocks using William O'Neil's CANSLIM growth stock methodology.",
"timeframe": "weekly"
},
{
"category": "meta",
"difficulty": "beginner",
"display_name": "Data Quality Checker",
"id": "data-quality-checker",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Validate data quality in market analysis documents and blog articles before publication.",
"timeframe": "event-driven"
},
{
"category": "core-portfolio",
"difficulty": "intermediate",
"display_name": "Dividend Growth Pullback Screener",
"id": "dividend-growth-pullback-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
},
{
"id": "finviz",
"requirement": "recommended",
"type": "screener"
}
],
"status": "production",
"summary": "Use this skill to find high-quality dividend growth stocks (12%+ annual dividend growth, 1.5%+ yield) that are experiencing temporary pullbacks, identified by RSI oversold conditions (RSI ≤40).",
"timeframe": "weekly"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Downtrend Duration Analyzer",
"id": "downtrend-duration-analyzer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Analyze historical downtrend durations and generate interactive HTML histograms showing typical correction lengths by sector and market cap.",
"timeframe": "research"
},
{
"category": "meta",
"difficulty": "intermediate",
"display_name": "Dual Axis Skill Reviewer",
"id": "dual-axis-skill-reviewer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Review skills in any project using a dual-axis method: (1) deterministic code-based checks (structure, scripts, tests, execution safety) and (2) LLM deep review findings.",
"timeframe": "research"
},
{
"category": "meta",
"difficulty": "beginner",
"display_name": "Earnings Calendar",
"id": "earnings-calendar",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "This skill retrieves upcoming earnings announcements for US stocks using the Financial Modeling Prep (FMP) API.",
"timeframe": "event-driven"
},
{
"category": "advanced-satellite",
"difficulty": "intermediate",
"display_name": "Earnings Trade Analyzer",
"id": "earnings-trade-analyzer",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Analyze recent post-earnings stocks using a 5-factor scoring system (Gap Size, Pre-Earnings Trend, Volume Trend, MA200 Position, MA50 Position).",
"timeframe": "event-driven"
},
{
"category": "meta",
"difficulty": "beginner",
"display_name": "Economic Calendar Fetcher",
"id": "economic-calendar-fetcher",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Fetch upcoming economic events and data releases using FMP API.",
"timeframe": "event-driven"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Candidate Agent",
"id": "edge-candidate-agent",
"integrations": [
{
"id": "fmp",
"requirement": "optional",
"type": "market_data"
}
],
"status": "production",
"summary": "Generate and prioritize US equity long-side edge research tickets from EOD observations, then export pipeline-ready candidate specs for trade-strategy-pipeline Phase I.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Concept Synthesizer",
"id": "edge-concept-synthesizer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Abstract detector tickets and hints into reusable edge concepts with thesis, invalidation signals, and strategy playbooks before strategy design/export.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "intermediate",
"display_name": "Edge Hint Extractor",
"id": "edge-hint-extractor",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Extract edge hints from daily market observations and news reactions, with optional LLM ideation, and output canonical hints.yaml for downstream concept synthesis and auto detection.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Pipeline Orchestrator",
"id": "edge-pipeline-orchestrator",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Orchestrate the full edge research pipeline from candidate detection through strategy design, review, revision, and export.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Signal Aggregator",
"id": "edge-signal-aggregator",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Aggregate and rank signals from multiple edge-finding skills (edge-candidate-agent, theme-detector, sector-analyst, institutional-flow-tracker) into a prioritized conviction dashboard with weighted scoring, deduplication, and contradicti...",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Strategy Designer",
"id": "edge-strategy-designer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Convert abstract edge concepts into strategy draft variants and optional exportable ticket YAMLs for edge-candidate-agent export/validation.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Edge Strategy Reviewer",
"id": "edge-strategy-reviewer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Critically review strategy drafts from edge-strategy-designer for edge plausibility, overfitting risk, sample size adequacy, and execution realism.",
"timeframe": "research"
},
{
"category": "market-regime",
"difficulty": "beginner",
"display_name": "Exposure Coach",
"id": "exposure-coach",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Generate a one-page Market Posture summary with net exposure ceiling, growth-vs-value bias, participation breadth, and new-entry-allowed vs cash-priority recommendation by integrating signals from breadth, regime, and flow analysis skills.",
"timeframe": "daily"
},
{
"category": "swing-opportunity",
"difficulty": "beginner",
"display_name": "Finviz Screener",
"id": "finviz-screener",
"integrations": [
{
"id": "finviz",
"requirement": "optional",
"type": "screener"
}
],
"status": "production",
"summary": "Build and open FinViz screener URLs from natural language requests.",
"timeframe": "event-driven"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "FTD Detector",
"id": "ftd-detector",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Detects Follow-Through Day (FTD) signals for market bottom confirmation using William O'Neil's methodology.",
"timeframe": "daily"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "IBD Distribution Day Monitor",
"id": "ibd-distribution-day-monitor",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Detect IBD-style Distribution Days for QQQ/SPY (close down at least 0.2% on higher volume), track 25-session expiration and 5% invalidation, count d5/d15/d25 clusters, classify market risk (NORMAL/CAUTION/HIGH/SEVERE), and emit TQQQ/QQQ...",
"timeframe": "daily"
},
{
"category": "advanced-satellite",
"difficulty": "intermediate",
"display_name": "Institutional Flow Tracker",
"id": "institutional-flow-tracker",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Use this skill to track institutional investor ownership changes and portfolio flows using 13F filings data.",
"timeframe": "research"
},
{
"category": "core-portfolio",
"difficulty": "beginner",
"display_name": "Kanchi Dividend Review Monitor",
"id": "kanchi-dividend-review-monitor",
"integrations": [
{
"id": "fmp",
"requirement": "recommended",
"type": "market_data"
}
],
"status": "production",
"summary": "Monitor dividend portfolios with Kanchi-style forced-review triggers (T1-T5) and convert anomalies into OK/WARN/REVIEW states without auto-selling.",
"timeframe": "weekly"
},
{
"category": "core-portfolio",
"difficulty": "intermediate",
"display_name": "Kanchi Dividend SOP",
"id": "kanchi-dividend-sop",
"integrations": [
{
"id": "fmp",
"requirement": "recommended",
"type": "market_data"
}
],
"status": "production",
"summary": "Convert Kanchi-style dividend investing into a repeatable US-stock operating procedure.",
"timeframe": "weekly"
},
{
"category": "core-portfolio",
"difficulty": "intermediate",
"display_name": "Kanchi Dividend US Tax Accounting",
"id": "kanchi-dividend-us-tax-accounting",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Provide US dividend tax and account-location workflow for Kanchi-style income portfolios.",
"timeframe": "event-driven"
},
{
"category": "market-regime",
"difficulty": "advanced",
"display_name": "Macro Regime Detector",
"id": "macro-regime-detector",
"integrations": [
{
"id": "yfinance_or_csv",
"requirement": "recommended",
"type": "market_data"
}
],
"status": "production",
"summary": "Detect structural macro regime transitions (1-2 year horizon) using cross-asset ratio analysis.",
"timeframe": "weekly"
},
{
"category": "market-regime",
"difficulty": "beginner",
"display_name": "Market Breadth Analyzer",
"id": "market-breadth-analyzer",
"integrations": [
{
"id": "public_csv",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Quantifies market breadth health using TraderMonty's public CSV data.",
"timeframe": "daily"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Market Environment Analysis",
"id": "market-environment-analysis",
"integrations": [
{
"id": "websearch",
"requirement": "required",
"type": "web"
},
{
"id": "chart_image",
"requirement": "optional",
"type": "image"
}
],
"status": "production",
"summary": "Comprehensive market environment analysis and reporting tool.",
"timeframe": "daily"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Market News Analyst",
"id": "market-news-analyst",
"integrations": [
{
"id": "websearch",
"requirement": "required",
"type": "web"
}
],
"status": "production",
"summary": "This skill should be used when analyzing recent market-moving news events and their impact on equity markets and commodities.",
"timeframe": "daily"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Market Top Detector",
"id": "market-top-detector",
"integrations": [
{
"id": "public_csv",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Detects market top probability using O'Neil Distribution Days, Minervini Leading Stock Deterioration, and Monty Defensive Sector Rotation.",
"timeframe": "daily"
},
{
"category": "advanced-satellite",
"difficulty": "advanced",
"display_name": "Options Strategy Advisor",
"id": "options-strategy-advisor",
"integrations": [
{
"id": "fmp",
"requirement": "optional",
"type": "market_data"
}
],
"status": "production",
"summary": "Options trading strategy analysis and simulation tool.",
"timeframe": "event-driven"
},
{
"category": "advanced-satellite",
"difficulty": "advanced",
"display_name": "Pair Trade Screener",
"id": "pair-trade-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Statistical arbitrage tool for identifying and analyzing pair trading opportunities.",
"timeframe": "research"
},
{
"category": "advanced-satellite",
"difficulty": "advanced",
"display_name": "Parabolic Short Trade Planner",
"id": "parabolic-short-trade-planner",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
},
{
"id": "alpaca",
"requirement": "optional",
"type": "broker"
}
],
"status": "production",
"summary": "Screen US equities for parabolic exhaustion patterns and generate conditional pre-market short plans, then evaluate intraday trigger fires from live 5-min bars.",
"timeframe": "event-driven"
},
{
"category": "advanced-satellite",
"difficulty": "intermediate",
"display_name": "PEAD Screener",
"id": "pead-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Screen post-earnings gap-up stocks for PEAD (Post-Earnings Announcement Drift) patterns.",
"timeframe": "event-driven"
},
{
"category": "core-portfolio",
"difficulty": "intermediate",
"display_name": "Portfolio Manager",
"id": "portfolio-manager",
"integrations": [
{
"id": "alpaca",
"requirement": "required",
"type": "broker"
}
],
"status": "production",
"summary": "Comprehensive portfolio analysis using Alpaca MCP Server integration to fetch holdings and positions, then analyze asset allocation, risk metrics, individual stock positions, diversification, and generate rebalancing recommendations.",
"timeframe": "weekly"
},
{
"category": "trade-planning",
"difficulty": "beginner",
"display_name": "Position Sizer",
"id": "position-sizer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Calculate risk-based position sizes for long stock trades.",
"timeframe": "event-driven"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Scenario Analyzer",
"id": "scenario-analyzer",
"integrations": [
{
"id": "websearch",
"requirement": "required",
"type": "web"
}
],
"status": "production",
"summary": "Analyze 18-month scenarios from news headlines via scenario-analyst agent with strategy-reviewer second opinion; outputs primary/secondary/tertiary impact analysis and stock picks.",
"timeframe": "event-driven"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "Sector Analyst",
"id": "sector-analyst",
"integrations": [
{
"id": "chart_image",
"requirement": "required",
"type": "image"
}
],
"status": "production",
"summary": "This skill should be used when analyzing sector rotation patterns and market cycle positioning.",
"timeframe": "weekly"
},
{
"category": "trade-memory",
"difficulty": "beginner",
"display_name": "Signal Postmortem",
"id": "signal-postmortem",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Record and analyze post-trade outcomes for signals generated by edge pipeline and other skills.",
"timeframe": "event-driven"
},
{
"category": "meta",
"difficulty": "intermediate",
"display_name": "Skill Designer",
"id": "skill-designer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Design new Claude skills from structured idea specifications.",
"timeframe": "research"
},
{
"category": "meta",
"difficulty": "intermediate",
"display_name": "Skill Idea Miner",
"id": "skill-idea-miner",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Mine Claude Code session logs for skill idea candidates.",
"timeframe": "research"
},
{
"category": "meta",
"difficulty": "intermediate",
"display_name": "Skill Integration Tester",
"id": "skill-integration-tester",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Validate multi-skill workflows defined in CLAUDE.md by checking skill existence, inter-skill data contracts (JSON schema compatibility), file naming conventions, and handoff integrity.",
"timeframe": "research"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Stanley Druckenmiller Investment",
"id": "stanley-druckenmiller-investment",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Druckenmiller Strategy Synthesizer - Integrates 8 upstream skill outputs (Market Breadth, Uptrend Analysis, Market Top, Macro Regime, FTD Detector, VCP Screener, Theme Detector, CANSLIM Screener) into a unified conviction score (0-100),...",
"timeframe": "weekly"
},
{
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "Stockbee Momentum Burst Screener",
"id": "stockbee-momentum-burst-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
},
{
"id": "prices_json",
"requirement": "optional",
"type": "local_file"
},
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "beta",
"summary": "Screen US stocks for Stockbee-style 3-5 day momentum burst candidates using 4% breakout, dollar breakout, range expansion, volume expansion, setup quality, and risk-distance filters.",
"timeframe": "daily"
},
{
"category": "strategy-research",
"difficulty": "advanced",
"display_name": "Strategy Pivot Designer",
"id": "strategy-pivot-designer",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Detect backtest iteration stagnation and generate structurally different strategy pivot proposals when parameter tuning reaches a local optimum.",
"timeframe": "research"
},
{
"category": "trade-planning",
"difficulty": "intermediate",
"display_name": "Technical Analyst",
"id": "technical-analyst",
"integrations": [
{
"id": "chart_image",
"requirement": "required",
"type": "image"
}
],
"status": "production",
"summary": "This skill should be used when analyzing weekly price charts for stocks, stock indices, cryptocurrencies, or forex pairs.",
"timeframe": "event-driven"
},
{
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "Theme Detector",
"id": "theme-detector",
"integrations": [
{
"id": "fmp",
"requirement": "optional",
"type": "market_data"
},
{
"id": "finviz",
"requirement": "recommended",
"type": "screener"
}
],
"status": "production",
"summary": "Detect and analyze trending market themes across sectors.",
"timeframe": "weekly"
},
{
"category": "trade-memory",
"difficulty": "advanced",
"display_name": "Trade Hypothesis Ideator",
"id": "trade-hypothesis-ideator",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Generate falsifiable trade strategy hypotheses from market data, trade logs, and journal snippets with ranked hypothesis cards and optional strategy.yaml export.",
"timeframe": "research"
},
{
"category": "trade-memory",
"difficulty": "intermediate",
"display_name": "Trade Performance Coach",
"id": "trade-performance-coach",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "beta",
"summary": "Review closed trades, partial exits, and monthly aggregates for process adherence, risk discipline, execution quality, and evidence-based trading behavior patterns, then produce next-session operating rules.",
"timeframe": "event-driven"
},
{
"category": "trade-memory",
"difficulty": "beginner",
"display_name": "Trader Memory Core",
"id": "trader-memory-core",
"integrations": [
{
"id": "fmp",
"requirement": "optional",
"type": "market_data"
}
],
"status": "production",
"summary": "Track investment theses across their lifecycle — from screening idea to closed position with postmortem.",
"timeframe": "event-driven"
},
{
"category": "meta",
"difficulty": "beginner",
"display_name": "Trading Skills Navigator",
"id": "trading-skills-navigator",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Recommend the right workflow, skillset, API profile, and setup path from a natural-language trading goal.",
"timeframe": "research"
},
{
"category": "market-regime",
"difficulty": "beginner",
"display_name": "Uptrend Analyzer",
"id": "uptrend-analyzer",
"integrations": [
{
"id": "public_csv",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Analyzes market breadth using Monty's Uptrend Ratio Dashboard data to diagnose the current market environment.",
"timeframe": "daily"
},
{
"category": "market-regime",
"difficulty": "intermediate",
"display_name": "US Market Bubble Detector",
"id": "us-market-bubble-detector",
"integrations": [
{
"id": "user_input",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Evaluates market bubble risk through quantitative data-driven analysis using the revised Minsky/Kindleberger framework v2.1.",
"timeframe": "weekly"
},
{
"category": "trade-planning",
"difficulty": "intermediate",
"display_name": "US Stock Analysis",
"id": "us-stock-analysis",
"integrations": [
{
"id": "user_input",
"requirement": "required",
"type": "local_file"
}
],
"status": "production",
"summary": "Comprehensive US stock analysis including fundamental analysis (financial metrics, business quality, valuation), technical analysis (indicators, chart patterns, support/resistance), stock comparisons, and investment report generation.",
"timeframe": "event-driven"
},
{
"category": "core-portfolio",
"difficulty": "intermediate",
"display_name": "Value Dividend Screener",
"id": "value-dividend-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
},
{
"id": "finviz",
"requirement": "recommended",
"type": "screener"
}
],
"status": "production",
"summary": "Screen US stocks for high-quality dividend opportunities combining value characteristics (P/E ratio under 20, P/B ratio under 2), attractive yields (3% or higher), and consistent growth (dividend/revenue/EPS trending up over 3 years).",
"timeframe": "weekly"
},
{
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "VCP Screener",
"id": "vcp-screener",
"integrations": [
{
"id": "fmp",
"requirement": "required",
"type": "market_data"
}
],
"status": "production",
"summary": "Screen S&P 500 stocks for Mark Minervini's Volatility Contraction Pattern (VCP).",
"timeframe": "daily"
},
{
"category": "trade-memory",
"difficulty": "beginner",
"display_name": "Weekly Performance Digest",
"id": "weekly-performance-digest",
"integrations": [
{
"id": "local_calculation",
"requirement": "not_required",
"type": "calculation"
}
],
"status": "production",
"summary": "Generate a weekly performance summary from closed trades with win rate, expectancy, and pattern analysis.",
"timeframe": "weekly"
}
],
"skillsets": [
{
"api_profile": "mixed",
"category": "core-portfolio",
"difficulty": "beginner",
"display_name": "Core Portfolio",
"id": "core-portfolio",
"optional_skills": [
"dividend-growth-pullback-screener",
"kanchi-dividend-sop"
],
"recommended_skills": [
"kanchi-dividend-review-monitor",
"value-dividend-screener",
"kanchi-dividend-us-tax-accounting"
],
"related_workflows": [
"core-portfolio-weekly"
],
"required_skills": [
"portfolio-manager",
"trader-memory-core"
],
"target_users": [
"long-term-investor",
"dividend-investor"
],
"timeframe": "weekly"
},
{
"api_profile": "no-api-basic",
"category": "market-regime",
"difficulty": "beginner",
"display_name": "Market Regime",
"id": "market-regime",
"optional_skills": [
"breadth-chart-analyst",
"sector-analyst",
"market-environment-analysis",
"market-news-analyst",
"downtrend-duration-analyzer",
"us-market-bubble-detector"
],
"recommended_skills": [
"market-top-detector",
"macro-regime-detector"
],
"related_workflows": [
"market-regime-daily"
],
"required_skills": [
"market-breadth-analyzer",
"uptrend-analyzer",
"exposure-coach"
],
"target_users": [
"part-time-swing-trader",
"growth-investor"
],
"timeframe": "daily"
},
{
"api_profile": "fmp-required",
"category": "swing-opportunity",
"difficulty": "intermediate",
"display_name": "Swing Opportunity",
"id": "swing-opportunity",
"optional_skills": [
"stockbee-momentum-burst-screener",
"finviz-screener"
],
"recommended_skills": [
"canslim-screener",
"breakout-trade-planner",
"theme-detector"
],
"related_workflows": [
"swing-opportunity-daily"
],
"required_skills": [
"vcp-screener",
"technical-analyst",
"position-sizer",
"trader-memory-core"
],
"target_users": [
"part-time-swing-trader"
],
"timeframe": "daily"
},
{
"api_profile": "no-api-basic",
"category": "trade-memory",
"difficulty": "beginner",
"display_name": "Trade Memory",
"id": "trade-memory",
"optional_skills": [
"trade-hypothesis-ideator"
],
"recommended_skills": [
"backtest-expert",
"trade-performance-coach"
],
"related_workflows": [
"trade-memory-loop",
"monthly-performance-review"
],
"required_skills": [
"trader-memory-core",
"signal-postmortem"
],
"target_users": [
"part-time-swing-trader",
"long-term-investor",
"growth-investor"
],
"timeframe": "event-driven"
}
],
"workflows": [
{
"api_profile": "mixed",
"cadence": "weekly",
"difficulty": "beginner",
"display_name": "Core Portfolio Weekly",
"estimated_minutes": 60,
"id": "core-portfolio-weekly",
"optional_skills": [
"kanchi-dividend-review-monitor",
"value-dividend-screener",
"kanchi-dividend-us-tax-accounting"
],
"prerequisite_workflows": [],
"required_skills": [
"portfolio-manager",
"trader-memory-core"
],
"target_users": [
"long-term-investor",
"dividend-investor"
]
},
{
"api_profile": "no-api-basic",
"cadence": "daily",
"difficulty": "beginner",
"display_name": "Market Regime Daily",
"estimated_minutes": 15,
"id": "market-regime-daily",
"optional_skills": [
"market-top-detector",
"macro-regime-detector"
],
"prerequisite_workflows": [],
"required_skills": [
"market-breadth-analyzer",
"uptrend-analyzer",
"exposure-coach"
],
"target_users": [
"part-time-swing-trader",
"growth-investor"
]
},
{
"api_profile": "no-api-basic",
"cadence": "monthly",
"difficulty": "intermediate",
"display_name": "Monthly Performance Review",
"estimated_minutes": 90,
"id": "monthly-performance-review",
"optional_skills": [
"trade-performance-coach",
"backtest-expert",
"dual-axis-skill-reviewer"
],
"prerequisite_workflows": [],
"required_skills": [
"trader-memory-core",
"signal-postmortem"
],
"target_users": [
"part-time-swing-trader",
"long-term-investor",
"growth-investor"
]
},
{
"api_profile": "mixed",
"cadence": "daily",
"difficulty": "intermediate",
"display_name": "Multi-Asset Opportunity Daily",
"estimated_minutes": 45,
"id": "multi-asset-opportunity-daily",
"optional_skills": [
"market-news-analyst",
"market-environment-analysis",
"sector-analyst",
"scenario-analyzer",
"stanley-druckenmiller-investment"
],
"prerequisite_workflows": [
"market-regime-daily"
],
"required_skills": [
"macro-regime-detector",
"theme-detector",
"trade-hypothesis-ideator",
"position-sizer",
"trader-memory-core"
],
"target_users": [
"part-time-swing-trader",
"growth-investor",
"macro-discretionary-trader"
]
},
{
"api_profile": "fmp-required",
"cadence": "daily",
"difficulty": "intermediate",
"display_name": "Swing Opportunity Daily",
"estimated_minutes": 35,
"id": "swing-opportunity-daily",
"optional_skills": [
"stockbee-momentum-burst-screener",
"canslim-screener",
"breakout-trade-planner",
"theme-detector"
],
"prerequisite_workflows": [
"market-regime-daily"
],
"required_skills": [
"vcp-screener",
"technical-analyst",
"position-sizer",
"trader-memory-core"
],
"target_users": [
"part-time-swing-trader"
]
},
{
"api_profile": "no-api-basic",
"cadence": "ad-hoc",
"difficulty": "beginner",
"display_name": "Trade Memory Loop",
"estimated_minutes": 30,
"id": "trade-memory-loop",
"optional_skills": [
"trade-performance-coach",
"backtest-expert"
],
"prerequisite_workflows": [],
"required_skills": [
"trader-memory-core",
"signal-postmortem"
],
"target_users": [
"part-time-swing-trader",
"long-term-investor",
"growth-investor"
]
}
]
}
Intent Routing Rubric
scripts/recommend.py is the single source of truth for routing. This document explains the engine; it does not re-implement it (no drift test in Phase 1 — the script's golden suite is the contract).
Engine overview
1. Normalize the query (lowercase, collapse whitespace). Bilingual — every persona carries both English and Japanese trigger terms, so the recommender routes a JA goal (e.g. 「配当株を探したい」「APIキー無しで使えるもの」「スイング トレードをしたい」) the same as its English equivalent. (.lower() leaves Japanese unchanged; mixed-case "API" folds to "api", which the JA no-API terms account for.) 2. Walk the ordered persona table. The first matching persona wins. 3. A persona either names a primary workflow (+ optional secondary), or an gap_category (honest gap — no workflow shipped). 4. Apply constraint filters (--no-api, --time-budget, --experience). 5. Emit a stable JSON recommendation, including no_api_path (see below).
If no persona matches, the input is treated as unmapped → a graceful beginner default (market-regime-daily, honest_gap: false) with a note asking the user to rephrase. This is distinct from an honest gap (personas #7/#10), which is a recognized intent with no shipped workflow.
Persona table (order matters)
Evaluated top-to-bottom; first match wins. Order encodes precedence:
| # | Persona | Trigger gist | Result |
|---|---|---|---|
| 1 | short-strategy-trader | "short strategies / shorting / parabolic short" | honest gap → advanced-satellite |
| 2 | strategy-researcher | "backtest / research a strategy / strategy ideas" | honest gap → strategy-research |
| 3 | no-api-path | "without API / no API keys / no subscription" | market-regime-daily + {trade-memory-loop, monthly-performance-review}, force no_api |
| 4 | part-time-swing-trader-regime-gated | "swing" AND regime-conditional ("only when", "favorable", "when the market") | market-regime-daily + swing-opportunity-daily |
| 5 | morning-risk-check | "15 min each morning / can I take risk today" | market-regime-daily |
| 6 | separate-core-satellite | "separate / split long-term from short-term risk" (not "dividend") | market-regime-daily + core-portfolio-weekly |
| 7 | dividend-long-term-investor | "dividend / holdings / rebalance / long-term investor" | core-portfolio-weekly |
| 8 | swing-trader | "swing / breakout" (no regime gate) | swing-opportunity-daily |
| 9 | beginner-onramp | "beginner / where do I start / getting started" | market-regime-daily |
| 10 | trade-journaler | "journal / postmortem / closed trade / lessons learned" | trade-memory-loop |
| 11 | monthly-reviewer | "monthly review / end of month / performance review" | monthly-performance-review |
Critical orderings
- #1/#2 (honest gaps) before everything so "short strategies" / "backtest" are
not swallowed by accidental keyword overlap.
- #1 triggers only on short-selling phrases ("short strateg", "shorting",
"go short", "short position"…) — it deliberately does not match "short-term", so #6 ("separate long-term holdings from short-term risk") still routes to the regime layer.
- #4 (swing and regime-conditional) before #8 (generic swing): Q1
("swing trade only when the market is favorable") → regime first; Q5 ("do swing trading") → swing directly.
- #6 before #7 and excludes "dividend": "separate long-term holdings from
short-term risk" → regime/core split, not the dividend bucket.
The 10-Question Contract
PROJECT_VISION.md §12 lists 9 example questions and a DoD of "10". Q1–Q9 are verbatim; Q10 is authored to complete the executable contract. Each row is a golden test in tests/test_recommend.py (the hard Phase-1 gate).
| # | Question | Primary | Secondary | Skillset | no-API | honest-gap |
|---|---|---|---|---|---|---|
| 1 | invest long term but swing trade only when market favorable | market-regime-daily | swing-opportunity-daily | market-regime | no | no |
| 2 | 15 min each morning, can I take risk today | market-regime-daily | — | market-regime | yes | no |
| 3 | separate long-term holdings from short-term risk | market-regime-daily | core-portfolio-weekly | market-regime | no | no |
| 4 | review holdings and dividend candidates this week | core-portfolio-weekly | — | core-portfolio | no | no |
| 5 | do swing trading | swing-opportunity-daily | — | swing-opportunity | no | no |
| 6 | find dividend stocks | core-portfolio-weekly | — | core-portfolio | no | no |
| 7 | use short strategies | null | — | advanced-satellite | — | yes |
| 8 | what works without API keys | market-regime-daily | trade-memory-loop, monthly-performance-review | market-regime | yes | no |
| 9 | beginner-friendly starting path | market-regime-daily | — | market-regime | yes | no |
| 10 | research and backtest new strategy ideas (authored) | null | — | strategy-research | — | yes |
Q8 honors the single-primary_workflow schema: primary is market-regime-daily; the rest of the no-API set are secondary_workflows; skillset is the primary's category (market-regime).
Skillset rule
skillset.id = the skills-index category of the workflow's first required skill (manifest order). Not "most common category": e.g. swing-opportunity-daily's required skills span swing-opportunity / trade-planning / trade-memory; only the first (vcp-screener → swing-opportunity) yields the contract-correct skillset. source is always skills-index.category.
manifest_status (PR-N2): active iff a skillsets/<skillset.id>.yaml manifest ships (the manifest id == the skills-index category, so the lookup is a direct match — carried as the skillsets list in the SSoT / bundled snapshot). Today the shipped set is market-regime, core-portfolio, swing-opportunity, trade-memory → those report active. Categories with no manifest — including every honest-gap category (advanced-satellite for #7, strategy-research for #10) — report deferred. The skillset object shape is unchanged ({id, source, manifest_status}); only the status value reflects manifest presence.
skillset.manifest (PR-N3): always present in the skillset object — a 5-key view {display_name, required_skills, recommended_skills, optional_skills, related_workflows} when manifest_status == active, else null. It describes the primary (dominant-category) skillset only — it is not the install list.
setup_bundle (PR-N3, top-level): the actionable install union over the primary skillset/workflow plus every secondary workflow's required/optional, deterministically ordered (primary first, then secondaries in secondary_workflows order) and tier-deduped (required > recommended > optional). sources records each contributor (skillset:<id> / workflow:<id>). This exists because a single skillset cannot cover a multi-workflow recommendation — e.g. Q1's market-regime skillset alone would drop swing-opportunity-daily's vcp-screener. Honest gap → all-empty (suggested_skills is the install list there). setup_bundle is what setup_paths.md / SKILL.md Step 4 instruct the user to install.
The --no-api credential rule
A workflow needs a paid key if either:
api_profile ∈ {fmp-required, finviz-required, alpaca-required}, or- any entry in its
required_skillshas an integration with
id ∈ {fmp, finviz, alpaca} and requirement == required.
api_profile: mixed is never trusted on its own — the required-skill credentials are always inspected. Consequences:
core-portfolio-weeklyismixedbut its requiredportfolio-manager
needs Alpaca (required) → excluded under --no-api.
swing-opportunity-dailyisfmp-required→ excluded.market-regime-dailyisno-api-basic; its required skills use
public_csv at required — not a paid key → kept.
trade-memory-loop/monthly-performance-reviewareno-api-basic;
trader-memory-core has FMP at optional (not required) → kept.
When --no-api removes the persona's primary, the engine falls back to the universal no-API on-ramp (market-regime-daily) and records the exclusion reason in rationale. Honest gaps are unaffected (they have no primary).
no_api vs no_api_path (the DoD's API-vs-no-API separation)
Two distinct booleans — narrate no_api_path, not no_api:
- `no_api` — request-side: was no-API constraint mode active (the
--no-api flag, or the no-api-path persona forcing it). It says nothing about whether the result actually needs keys.
- `no_api_path` — path-side: does the entire recommendation (primary
and every secondary) work without paid API keys? workflow_paid_api_reason(primary) is None and all(... for secondary). null on an honest gap (no path). This is the 10-Question Contract's "no-API" column.
This is why Q1 and Q2 both recommend market-regime-daily yet differ: Q2 has no secondary → no_api_path: true; Q1 also pulls in fmp-required swing-opportunity-daily → no_api_path: false. A bare market-regime-daily/trade-memory-loop/monthly-performance-review path is true even without --no-api, which is exactly the DoD's "separate API-key and no-API paths".
Tie-breaks
- `--time-budget` (
15m/30m/60m/90m/any): secondary workflows whose
estimated_minutes exceed the budget are dropped (primary is never dropped — it stays as the best intent match, with a rationale note if it is over budget).
- `--experience`:
beginnersorts beginner-difficulty secondaries first. - Secondary order is otherwise deterministic: `(beginner_first,
estimated_minutes, id)`.
Honest gap output
For personas #1/#7 (advanced-satellite) and #2/#10 (strategy-research): primary_workflow: null, secondary_workflows: [], skillset.id = the gap category, suggested_skills = that category's non-deprecated skills (id-sorted, {id, display_name, category}), honest_gap: true, and a note stating the workflow manifest is deferred. Exit code is still 0 — an honest gap is a successful, honest recommendation.
Setup Paths
After the recommender returns a result, walk the user through installing the `setup_bundle` — the recommender already computed the exact install set for you. Pick the path for the user's environment.
Which skills to install — read setup_bundle
The recommender JSON has a top-level `setup_bundle` object — the deterministic install union over the primary skillset and every secondary workflow (so a multi-workflow recommendation never drops a secondary workflow's skills). Use it directly; do not re-derive from primary_workflow.
setup_bundle.required— must install all of these.setup_bundle.recommended— install for the full value of the recommended
skillset.
setup_bundle.optional— nice-to-have; install if the user wants the
optional steps.
setup_bundle.sources— explains where each part came from (e.g.
skillset:market-regime, workflow:swing-opportunity-daily); use it to tell the user why each skill is in the list.
- A skill never appears in two tiers (required wins over recommended over
optional), so install top-down without de-duping.
skillset.manifest (when manifest_status: active) is the description of the recommended skillset — its display_name and curated required/recommended/optional + related_workflows (how the bundle is run). Narrate it as "what this sleeve is"; it is not the install list — that is setup_bundle (which also covers the secondary workflows).
On an honest gap (primary_workflow: null, skillset.manifest: null, setup_bundle all empty), there is no workflow/skillset to set up; install the individual suggested_skills instead.
Always tell the user which of those skills need a paid API key (check each skill's row in the repo's API Requirements matrix / CLAUDE.md). If the recommendation was made with --no-api, none of the required skills need paid keys by construction.
Path A — Claude Web App (.skill upload)
The Web App cannot clone the repo. Each skill is uploaded as a .skill package.
1. Get the packaged skills from skill-packages/ in this repository (e.g. skill-packages/<skill-name>.skill). For the Navigator itself: skill-packages/trading-skills-navigator.skill. 2. In the Claude Web App, open Settings → Capabilities → Skills (or the skill upload entry point) and upload each required .skill file. 3. Upload trading-skills-navigator.skill too if the user wants the router itself in the Web App — it ships with assets/metadata_snapshot.json, so recommendations work without the repo present. 4. For skills that need API keys, set them as instructed in that skill's SKILL.md (environment variable or in-conversation argument). 5. Invoke by describing the goal in natural language; the skill triggers from its description.
The Navigator's recommender uses the bundled snapshot in this environment,
so its output is identical to Claude Code.
Path B — Claude Code (folder copy / clone)
1. Clone or pull this repository. 2. The skills live under skills/<skill-name>/. In Claude Code they are discovered from the repo automatically; to use one globally, symlink it:
ln -sfn "$(pwd)/skills/<skill-name>" ~/.claude/skills/<skill-name>3. Set any required API keys as environment variables (see CLAUDE.md → API Key Management), e.g.:
export FMP_API_KEY=... # FMP-backed skills
export FINVIZ_API_KEY=... # FINVIZ Elite (optional accelerator)
export ALPACA_API_KEY=... # portfolio-manager (Alpaca)
export ALPACA_SECRET_KEY=...4. Run the recommended workflow's skills in the order the workflow manifest (workflows/<id>.yaml) lists them. The Navigator's recommender reads the repo-root SSoT directly here (no snapshot needed).
No-API starting path (recommended for new users)
If the user has no paid keys, the safe starting path is `market-regime-daily` (api_profile: no-api-basic): market-breadth-analyzer → uptrend-analyzer → exposure-coach. Add the journaling loop (trade-memory-loop, monthly-performance-review, both no-API) to close the Plan → Trade → Record → Review → Improve loop. Upgrade to FMP/Alpaca-backed workflows (swing-opportunity-daily, core-portfolio-weekly) only when the user is ready to add paid data.
#!/usr/bin/env python3
"""Regenerate assets/metadata_snapshot.json from the repo-root SSoT.
`.skill` packages ship only the skill folder — not repo-root
skills-index.yaml / workflows/. The Claude Web App therefore needs a
bundled snapshot. This script builds it from the SSoT using the SAME
normalize_* functions recommend.py uses, so the repo-root and snapshot
code paths produce byte-identical recommendations (the parity invariant).
Usage:
build_snapshot.py [--project-root PATH] [--check]
`--check` exits non-zero if the committed snapshot drifts from the SSoT
(wired into pre-commit + CI, mirroring generate_catalog_from_index.py).
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
# Import the SSoT loader + normalizers from the sibling recommender so the
# snapshot can never diverge from how recommend.py interprets the SSoT.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import json # noqa: E402
from recommend import ( # noqa: E402
BUNDLED_SNAPSHOT,
load_ssot,
)
def build_snapshot_text(project_root: Path) -> str:
"""Return the canonical snapshot JSON text for the given SSoT root."""
metadata = load_ssot(project_root)
# Same canonical encoding as recommend.dumps(): sorted keys, 2-space
# indent, trailing newline (end-of-file-fixer parity).
return json.dumps(metadata, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Regenerate assets/metadata_snapshot.json from the SSoT."
)
parser.add_argument(
"--project-root",
type=Path,
default=Path.cwd(),
help="Repository root holding skills-index.yaml + workflows/ (default: cwd)",
)
parser.add_argument(
"--check",
action="store_true",
help="Exit non-zero if the committed snapshot would change.",
)
parser.add_argument(
"--snapshot-path",
type=Path,
default=BUNDLED_SNAPSHOT,
help=argparse.SUPPRESS,
)
args = parser.parse_args(argv)
index_path = args.project_root / "skills-index.yaml"
workflows_dir = args.project_root / "workflows"
if not index_path.is_file() or not workflows_dir.is_dir():
print(
f"ERROR: SSoT not found under {args.project_root} "
"(need skills-index.yaml + workflows/)",
file=sys.stderr,
)
return 1
try:
regenerated = build_snapshot_text(args.project_root)
except Exception as exc: # noqa: BLE001 — surface load/parse errors cleanly
print(f"ERROR: failed to build snapshot: {exc}", file=sys.stderr)
return 1
snap_path: Path = args.snapshot_path
current = snap_path.read_text(encoding="utf-8") if snap_path.is_file() else None
if args.check:
if current != regenerated:
print(
f"DRIFT: {snap_path} is out of sync with the SSoT. "
"Run: python3 skills/trading-skills-navigator/scripts/"
"build_snapshot.py",
file=sys.stderr,
)
return 1
print(f"OK: {snap_path} matches the SSoT", file=sys.stderr)
return 0
if current == regenerated:
print(f"Unchanged: {snap_path}", file=sys.stderr)
return 0
snap_path.parent.mkdir(parents=True, exist_ok=True)
snap_path.write_text(regenerated, encoding="utf-8")
print(f"Wrote {snap_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Trading Skills Navigator — deterministic workflow / skillset recommender.
Turns a natural-language trading goal into a concrete recommendation:
which workflow to run, which skillset (skills-index category) it belongs to,
the API requirement, and a pointer to the setup path.
Design contract: see ../references/intent_routing.md and the 10-question
golden suite in tests/test_recommend.py. The routing logic here is the
single source of truth; the reference doc explains it.
Metadata resolution order (same deterministic engine in both):
1. repo-root SSoT — skills-index.yaml + workflows/*.yaml (Claude Code)
2. bundled snapshot — assets/metadata_snapshot.json (Claude Web App)
Output JSON is stable and idempotent (json.dumps sort_keys=True). The
snapshot is generated from the SSoT by build_snapshot.py using the SAME
normalize_* functions, so repo-root and snapshot paths are byte-identical
(the parity invariant — golden-tested).
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Paid-API integration ids. A workflow is "needs a paid key" if its
# api_profile says so OR any required skill has one of these integrations
# at requirement == "required". (public_csv "required" is NOT paid.)
PAID_INTEGRATION_IDS = frozenset({"fmp", "finviz", "alpaca"})
API_REQUIRED_PROFILES = frozenset({"fmp-required", "finviz-required", "alpaca-required"})
SKILL_ROOT = Path(__file__).resolve().parents[1]
BUNDLED_SNAPSHOT = SKILL_ROOT / "assets" / "metadata_snapshot.json"
SETUP_PATH_REF = "references/setup_paths.md"
SCHEMA_VERSION = 1
# ---------------------------------------------------------------------------
# Normalization — the parity contract.
#
# Both the SSoT loader and build_snapshot.py call these. Whatever fields the
# JSON output can surface MUST be carried here so repo-root and snapshot
# produce byte-identical recommendations.
# ---------------------------------------------------------------------------
def normalize_skill(raw: dict[str, Any]) -> dict[str, Any]:
integrations = []
for ig in raw.get("integrations") or []:
if not isinstance(ig, dict):
continue
integrations.append(
{
"id": str(ig.get("id") or "unknown"),
"type": str(ig.get("type") or "unknown"),
"requirement": str(ig.get("requirement") or "unknown"),
}
)
return {
"id": str(raw.get("id") or "").strip(),
"display_name": str(raw.get("display_name") or raw.get("id") or "").strip(),
"category": str(raw.get("category") or "meta"),
"status": str(raw.get("status") or "unknown"),
"summary": str(raw.get("summary") or "").strip(),
"timeframe": str(raw.get("timeframe") or "unknown"),
"difficulty": str(raw.get("difficulty") or "unknown"),
"integrations": integrations,
}
def _normalize_prerequisites(raw: Any) -> list[str]:
out: list[str] = []
for item in raw or []:
if isinstance(item, dict):
pid = item.get("id")
if pid:
out.append(str(pid))
elif item:
out.append(str(item))
return out
def normalize_workflow(raw: dict[str, Any]) -> dict[str, Any]:
est = raw.get("estimated_minutes")
try:
est_val: int | None = int(est) if est is not None else None
except (TypeError, ValueError):
est_val = None
return {
"id": str(raw.get("id") or "").strip(),
"display_name": str(raw.get("display_name") or raw.get("id") or "").strip(),
"cadence": str(raw.get("cadence") or "unknown"),
"estimated_minutes": est_val,
"api_profile": str(raw.get("api_profile") or "unknown"),
"difficulty": str(raw.get("difficulty") or "unknown"),
"target_users": [str(u) for u in (raw.get("target_users") or [])],
"required_skills": [str(s) for s in (raw.get("required_skills") or [])],
"optional_skills": [str(s) for s in (raw.get("optional_skills") or [])],
"prerequisite_workflows": _normalize_prerequisites(raw.get("prerequisite_workflows")),
}
def normalize_skillset(raw: dict[str, Any]) -> dict[str, Any]:
"""Minimal skillset digest (mirrors normalize_workflow). Carries every
field the snapshot needs so SSoT↔snapshot stays byte-identical."""
return {
"id": str(raw.get("id") or "").strip(),
"display_name": str(raw.get("display_name") or raw.get("id") or "").strip(),
"category": str(raw.get("category") or raw.get("id") or "").strip(),
"timeframe": str(raw.get("timeframe") or "unknown"),
"difficulty": str(raw.get("difficulty") or "unknown"),
"api_profile": str(raw.get("api_profile") or "unknown"),
"target_users": [str(u) for u in (raw.get("target_users") or [])],
"required_skills": [str(s) for s in (raw.get("required_skills") or [])],
"recommended_skills": [str(s) for s in (raw.get("recommended_skills") or [])],
"optional_skills": [str(s) for s in (raw.get("optional_skills") or [])],
"related_workflows": [str(w) for w in (raw.get("related_workflows") or [])],
}
# ---------------------------------------------------------------------------
# Metadata loading — SSoT first, bundled snapshot fallback.
# ---------------------------------------------------------------------------
class MetadataError(RuntimeError):
"""Raised when neither the SSoT nor the bundled snapshot can be loaded."""
def load_ssot(project_root: Path) -> dict[str, Any]:
"""Load + normalize skills-index.yaml and workflows/*.yaml from a repo root."""
import yaml # lazy: pure-snapshot mode (Web App) never needs pyyaml
index_path = project_root / "skills-index.yaml"
workflows_dir = project_root / "workflows"
with index_path.open("r", encoding="utf-8") as f:
index = yaml.safe_load(f)
skills = [
normalize_skill(s)
for s in (index.get("skills") or [])
if isinstance(s, dict) and s.get("id")
]
workflows = []
for wf_path in sorted(workflows_dir.glob("*.yaml")):
with wf_path.open("r", encoding="utf-8") as f:
wf = yaml.safe_load(f)
if isinstance(wf, dict) and wf.get("id"):
workflows.append(normalize_workflow(wf))
skillsets = []
skillsets_dir = project_root / "skillsets"
if skillsets_dir.is_dir():
for ss_path in sorted(skillsets_dir.glob("*.yaml")):
with ss_path.open("r", encoding="utf-8") as f:
ss = yaml.safe_load(f)
if isinstance(ss, dict) and ss.get("id"):
skillsets.append(normalize_skillset(ss))
return _finalize_metadata(skills, workflows, skillsets)
def load_snapshot(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as f:
snap = json.load(f)
# Snapshot is already normalized; re-finalize to guarantee identical
# ordering regardless of how it was written. `skillsets` is tolerated
# absent for backward compatibility with a pre-PR-N2 snapshot.
return _finalize_metadata(
list(snap.get("skills") or []),
list(snap.get("workflows") or []),
list(snap.get("skillsets") or []),
)
def _finalize_metadata(
skills: list[dict[str, Any]],
workflows: list[dict[str, Any]],
skillsets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
skills_sorted = sorted(skills, key=lambda s: s["id"])
workflows_sorted = sorted(workflows, key=lambda w: w["id"])
skillsets_sorted = sorted(skillsets or [], key=lambda s: s["id"])
return {
"schema_version": SCHEMA_VERSION,
"skills": skills_sorted,
"workflows": workflows_sorted,
"skillsets": skillsets_sorted,
}
def resolve_metadata(
project_root: Path, *, snapshot_path: Path | None = None
) -> tuple[dict[str, Any], str]:
"""Return (metadata, source) where source is 'ssot' or 'snapshot'."""
index_path = project_root / "skills-index.yaml"
workflows_dir = project_root / "workflows"
if index_path.is_file() and workflows_dir.is_dir():
try:
return load_ssot(project_root), "ssot"
except Exception as exc: # noqa: BLE001 — fall back, then surface below
ssot_error: Exception | None = exc
else:
ssot_error = None
else:
ssot_error = None
snap = snapshot_path or BUNDLED_SNAPSHOT
if snap.is_file():
return load_snapshot(snap), "snapshot"
if ssot_error is not None:
raise MetadataError(f"SSoT load failed and no snapshot available: {ssot_error}")
raise MetadataError(
f"No metadata found: missing skills-index.yaml/workflows/ and no bundled snapshot at {snap}"
)
# ---------------------------------------------------------------------------
# Personas — ordered; first match wins. Encodes the 10-question contract
# (test_recommend.py) + PROJECT_VISION.md §7 target users.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Persona:
name: str
any_terms: tuple[str, ...]
primary: str | None = None
secondary: tuple[str, ...] = ()
gap_category: str | None = None
no_api: bool = False
rationale: str = ""
require_groups: tuple[tuple[str, ...], ...] = ()
exclude_terms: tuple[str, ...] = ()
def matches(self, q: str) -> bool:
if not any(term in q for term in self.any_terms):
return False
for group in self.require_groups:
if not any(term in q for term in group):
return False
if any(term in q for term in self.exclude_terms):
return False
return True
PERSONAS: tuple[Persona, ...] = (
# Q7 — honest gap: short / advanced-satellite strategies.
Persona(
name="short-strategy-trader",
any_terms=(
"short strateg",
"short selling",
"short-selling",
"shorting",
"go short",
"short the ",
"short position",
"short candidate",
"short setup",
"bearish short",
"short squeeze",
"parabolic short",
# JA
"ショート戦略",
"ショートポジション",
"空売り",
"売り戦略",
"売り建て",
"ショートしたい",
"ショート狙い",
),
gap_category="advanced-satellite",
rationale=(
"short-strategy trader — short/event strategies are Advanced "
"Satellite; no dedicated workflow shipped yet"
),
),
# Q10 — honest gap: strategy research / backtesting.
Persona(
name="strategy-researcher",
any_terms=(
"backtest",
"back-test",
"back test",
"hypothes",
"research strateg",
"research and backtest",
"strategy idea",
"develop strateg",
"develop a strateg",
"new strateg",
"edge research",
"strategy research",
"validate strateg",
"test a strateg",
"test my strateg",
"build a strateg",
# JA
"バックテスト",
"戦略を検証",
"戦略の検証",
"戦略アイデア",
"戦略研究",
"新しい戦略",
"仮説を検証",
"仮説検証",
"戦略を開発",
"リサーチして",
),
gap_category="strategy-research",
rationale=(
"strategy researcher/developer — research & backtesting is the "
"Strategy Research area; no dedicated workflow shipped yet"
),
),
# Q8 — no-API path.
Persona(
name="no-api-path",
any_terms=(
"without api",
"without an api",
"without any api",
"no api",
"no-api",
"api key",
"api keys",
"without paid",
"without a subscription",
"no subscription",
"free only",
"no paid",
# JA — note: normalize_query lowercases, so "API" -> "api"
"api キー",
"apiキー",
"api無し",
"api 無し",
"apiなし",
"api なし",
"api不要",
"api 不要",
"キー無し",
"キーなし",
"課金なし",
"課金無し",
"無料で使える",
"無料のもの",
"サブスクなし",
"サブスク無し",
"サブスクリプション無し",
),
primary="market-regime-daily",
secondary=("trade-memory-loop", "monthly-performance-review"),
no_api=True,
rationale="no-API path — only workflows that work without paid API keys",
),
# Q1 — swing trade gated on market regime (swing AND regime-conditional).
Persona(
name="part-time-swing-trader-regime-gated",
any_terms=("swing", "スイング"),
require_groups=(
(
"only when",
"favorable",
"when the market",
"when conditions",
"if the market",
"market allows",
"market is good",
"market permitting",
"market environment",
"when it's safe",
"when its safe",
"market is favorable",
# JA regime-conditional
"相場が良い",
"相場が良い時",
"相場次第",
"地合いが良い",
"市場が良い",
"市場環境が良い",
"環境が良い時",
"良い時だけ",
"条件が良い",
),
),
primary="market-regime-daily",
secondary=("swing-opportunity-daily",),
rationale=(
"part-time swing trader who only takes swing risk when the market "
"regime is favorable — regime check first, then swing candidates"
),
),
# Q2 — time-boxed morning risk check.
Persona(
name="morning-risk-check",
any_terms=(
"each morning",
"every morning",
"this morning",
"in the morning",
"15 min",
"minutes each",
"minutes every",
"minutes before",
"quick check",
"take risk today",
"risk today",
"can i take risk",
"is it safe to trade",
"should i take risk",
"take on risk today",
"risk on today",
# JA
"毎朝",
"朝の15分",
"15分",
"今日リスク",
"今日はリスク",
"リスクを取れる",
"リスクを取って",
"リスクオン",
"今日エントリー",
"今日トレードして",
"相場に入れる",
),
primary="market-regime-daily",
rationale=(
"time-constrained daily risk check — market regime / exposure posture for today"
),
),
# Q3 — separate long-term holdings from short-term risk.
Persona(
name="separate-core-satellite",
any_terms=(
"separate",
"core and satellite",
"core vs satellite",
"core-satellite",
"long-term holdings from",
"long term holdings from",
"offense from defense",
"keep long-term separate",
"split",
"isolate short-term",
"short-term risk from",
"short term risk from",
# JA
"分けたい",
"分離",
"切り分け",
"区別したい",
"コアとサテライト",
"長期と短期を分け",
"長期保有と短期",
),
require_groups=(
(
"hold",
"risk",
"invest",
"portfolio",
"long-term",
"long term",
"trad",
# JA
"保有",
"リスク",
"投資",
"ポートフォリオ",
"長期",
"短期",
"トレード",
),
),
exclude_terms=("dividend", "配当"),
primary="market-regime-daily",
secondary=("core-portfolio-weekly",),
rationale=(
"growth investor separating long-term core holdings from "
"short-term trading risk — regime layer governs the satellite sleeve"
),
),
# Q4 + Q6 — dividend / long-term core portfolio.
Persona(
name="dividend-long-term-investor",
any_terms=(
"dividend",
"holdings",
"my portfolio",
"review my holdings",
"rebalance",
"allocation",
"portfolio review",
"portfolio structure",
"income stock",
"long-term investor",
"long term investor",
"buy and hold",
"yield",
# JA
"配当",
"配当株",
"高配当",
"増配",
"インカム",
"利回り",
"保有銘柄",
"保有を見直",
"ポートフォリオを見直",
"長期投資",
"長期保有",
"リバランス",
),
primary="core-portfolio-weekly",
rationale=(
"dividend / long-term investor — weekly core-portfolio review and dividend candidates"
),
),
# Q5 — generic swing trading (no regime gate; AFTER Q1 persona).
Persona(
name="swing-trader",
any_terms=(
"swing",
"breakout",
"momentum trade",
"trade candidate",
"trade setups",
# JA
"スイング",
"スイングトレード",
"ブレイクアウト",
"押し目",
"モメンタム",
"短期売買",
),
primary="swing-opportunity-daily",
rationale=(
"part-time swing trader — daily swing candidate generation and "
"trade planning (run the market-regime check first)"
),
),
# Q9 — beginner / starting path.
Persona(
name="beginner-onramp",
any_terms=(
"beginner",
"starting path",
"where do i start",
"where should i start",
"get started",
"getting started",
"new to",
"just starting",
"first time",
"first skill",
"onboard",
"what should i start",
"i'm new",
"im new",
"not sure where to start",
"help me start",
# JA
"初心者",
"初心者向け",
"どこから始め",
"何から始め",
"始め方",
"入門",
"初めて",
"最初に何",
"使い方がわからない",
"おすすめのスキル",
"どれを使えば",
),
primary="market-regime-daily",
rationale=("beginner-friendly on-ramp — start with the no-API daily market-regime routine"),
),
# Trade journaling / postmortem loop (PROJECT_VISION §7 shared layer).
Persona(
name="trade-journaler",
any_terms=(
"journal",
"postmortem",
"post-mortem",
"record my trade",
"closed trade",
"review a closed",
"lessons learned",
"what went wrong with",
"log my trade",
"trade review after",
# Post-trade coaching entry terms (added 2026-05-25 alongside the
# trade-performance-coach skill integration in PR-G); without these,
# natural-language queries like "post-trade coaching" fall back to
# the beginner persona instead of trade-memory-loop.
"post-trade coach",
"post-trade coaching",
"trade coach",
"trade coaching",
"performance coach",
"trade-performance-coach",
# JA
"ジャーナル",
"トレード記録",
"売買記録",
"振り返り",
"ポストモーテム",
"決済済み",
"決済したトレード",
"損切り後",
"反省",
"教訓",
"トレードコーチ",
"売買コーチ",
"取引後レビュー",
"トレード後レビュー",
),
primary="trade-memory-loop",
rationale="trade journaling / postmortem / post-trade coaching loop after a closed position",
),
# Monthly performance review.
Persona(
name="monthly-reviewer",
any_terms=(
"monthly review",
"monthly performance",
"review the month",
"end of month",
"month-end review",
"monthly retrospective",
"review last month",
"performance review",
# JA
"月次レビュー",
"月次",
"月末レビュー",
"月間パフォーマンス",
"今月の振り返り",
"先月の振り返り",
"月次の振り返り",
),
primary="monthly-performance-review",
rationale=(
"monthly performance review — close the Plan->Trade->Record->Review->Improve loop"
),
),
)
# ---------------------------------------------------------------------------
# Engine
# ---------------------------------------------------------------------------
@dataclass
class Recommendation:
query: str
rationale: list[str] = field(default_factory=list)
def normalize_query(query: str) -> str:
return " ".join(query.lower().split())
def workflow_paid_api_reason(
workflow: dict[str, Any], skills_by_id: dict[str, dict[str, Any]]
) -> str | None:
"""Return a human reason if the workflow needs a paid API, else None.
'mixed' api_profile is NEVER trusted on its own — required-skill
credentials are always inspected.
"""
if workflow.get("api_profile") in API_REQUIRED_PROFILES:
return f"api_profile is {workflow['api_profile']}"
for sid in workflow.get("required_skills") or []:
skill = skills_by_id.get(sid)
if not skill:
continue
for ig in skill.get("integrations") or []:
if ig.get("id") in PAID_INTEGRATION_IDS and ig.get("requirement") == "required":
return f"required skill '{sid}' needs {ig['id']} (required)"
return None
def dominant_category(workflow: dict[str, Any], skills_by_id: dict[str, dict[str, Any]]) -> str:
"""Skillset = the skills-index category of the workflow's FIRST required skill.
First-required-skill (not most-common) is the contract rule: e.g.
swing-opportunity-daily's required skills span swing-opportunity /
trade-planning / trade-memory; only the first (vcp-screener →
swing-opportunity) yields the contract-correct skillset.
"""
for sid in workflow.get("required_skills") or []:
skill = skills_by_id.get(sid)
if skill:
return skill.get("category", "meta")
return "meta"
def _workflow_public_view(workflow: dict[str, Any]) -> dict[str, Any]:
return {
"id": workflow["id"],
"display_name": workflow["display_name"],
"cadence": workflow["cadence"],
"estimated_minutes": workflow["estimated_minutes"],
"api_profile": workflow["api_profile"],
"difficulty": workflow["difficulty"],
"required_skills": list(workflow["required_skills"]),
"optional_skills": list(workflow["optional_skills"]),
"prerequisite_workflows": list(workflow["prerequisite_workflows"]),
}
def _skillset_manifest_view(ss: dict[str, Any]) -> dict[str, Any]:
# PR-N3: 5-key public view of a skillset manifest (no `id` — the id lives
# on the outer skillset object). Mirrors _workflow_public_view. List order
# is preserved from the manifest YAML (meaningful authored order).
return {
"display_name": ss["display_name"],
"required_skills": list(ss["required_skills"]),
"recommended_skills": list(ss["recommended_skills"]),
"optional_skills": list(ss["optional_skills"]),
"related_workflows": list(ss["related_workflows"]),
}
def _skillset(
category: str,
skillset_ids: frozenset[str],
skillsets_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
# PR-N2: "active" iff a skillsets/<category>.yaml manifest exists (its id
# == the skills-index category). Honest-gap categories have no manifest
# → "deferred". PR-N3: `manifest` is ALWAYS present (symmetric schema) —
# the 5-key view when active, else None.
active = category in skillset_ids
return {
"id": category,
"source": "skills-index.category",
"manifest_status": "active" if active else "deferred",
"manifest": (
_skillset_manifest_view(skillsets_by_id[category])
if active and category in skillsets_by_id
else None
),
}
def _ordered_unique(items: list[str]) -> list[str]:
return list(dict.fromkeys(items))
def _setup_bundle(
skillset_id: str,
skillset_manifest: dict[str, Any] | None,
primary_wf: dict[str, Any] | None,
secondary_wfs: list[dict[str, Any]],
) -> dict[str, Any]:
"""Deterministic install union over the primary skillset/workflow + every
secondary workflow. `skillset_id` is threaded explicitly because the 5-key
manifest view has no `id`. Tier precedence: required > recommended >
optional (a skill never appears in two tiers). Order: primary first, then
secondaries in order. Pure function of metadata → parity-safe."""
required: list[str] = []
recommended: list[str] = []
optional: list[str] = []
sources: list[str] = []
if skillset_manifest is not None:
required += skillset_manifest["required_skills"]
recommended += skillset_manifest["recommended_skills"]
optional += skillset_manifest["optional_skills"]
sources.append(f"skillset:{skillset_id}")
elif primary_wf is not None:
required += primary_wf["required_skills"]
optional += primary_wf["optional_skills"]
sources.append(f"workflow:{primary_wf['id']}")
for wf in secondary_wfs:
required += wf["required_skills"]
optional += wf["optional_skills"]
sources.append(f"workflow:{wf['id']}")
required = _ordered_unique(required)
req_set = set(required)
recommended = [s for s in _ordered_unique(recommended) if s not in req_set]
rec_set = set(recommended)
optional = [s for s in _ordered_unique(optional) if s not in req_set and s not in rec_set]
return {
"required": required,
"recommended": recommended,
"optional": optional,
"sources": sources,
}
def _parse_time_budget(value: str | None) -> int | None:
if not value or value == "any":
return None
digits = value.rstrip("m").strip()
try:
return int(digits)
except ValueError:
return None
def _order_secondary(
ids: list[str],
workflows_by_id: dict[str, dict[str, Any]],
*,
experience: str | None,
time_budget: int | None,
) -> list[str]:
"""Deterministic secondary ordering + soft time-budget filter."""
def sort_key(wid: str) -> tuple[Any, ...]:
wf = workflows_by_id.get(wid, {})
est = wf.get("estimated_minutes")
est_key = est if isinstance(est, int) else 9999
beginner_first = 0
if experience == "beginner":
beginner_first = 0 if wf.get("difficulty") == "beginner" else 1
return (beginner_first, est_key, wid)
ordered = sorted(dict.fromkeys(ids), key=sort_key)
if time_budget is not None:
ordered = [
wid
for wid in ordered
if not isinstance(workflows_by_id.get(wid, {}).get("estimated_minutes"), int)
or workflows_by_id[wid]["estimated_minutes"] <= time_budget
]
return ordered
def recommend(
query: str,
metadata: dict[str, Any],
*,
no_api: bool = False,
time_budget: str | None = None,
experience: str | None = None,
) -> dict[str, Any]:
"""Pure function: (query, metadata, constraints) -> stable result dict."""
norm = normalize_query(query)
skills_by_id = {s["id"]: s for s in metadata["skills"]}
workflows_by_id = {w["id"]: w for w in metadata["workflows"]}
skillsets_by_id = {s["id"]: s for s in (metadata.get("skillsets") or [])}
skillset_ids = frozenset(skillsets_by_id)
rationale: list[str] = []
note: str | None = None
honest_gap = False
gap_category: str | None = None
primary_id: str | None = None
secondary_ids: list[str] = []
matched = next((p for p in PERSONAS if p.matches(norm)), None)
if matched is not None:
rationale.append(f"matched persona: {matched.name} — {matched.rationale}")
if matched.no_api:
no_api = True
if matched.gap_category is not None:
honest_gap = True
gap_category = matched.gap_category
else:
primary_id = matched.primary
secondary_ids = list(matched.secondary)
else:
# Truly unmapped input: graceful beginner default (NOT honest-gap).
primary_id = "market-regime-daily"
rationale.append(
"no specific intent matched — defaulting to the beginner-friendly market-regime on-ramp"
)
note = (
"Query did not match a known persona; showing the universal "
"beginner starting point. Rephrase with your goal (e.g. 'swing "
"trading', 'dividend stocks', 'no API path') for a targeted "
"recommendation."
)
time_budget_min = _parse_time_budget(time_budget)
# ---- honest-gap branch -------------------------------------------------
if honest_gap and gap_category is not None:
suggested = sorted(
(
{
"id": s["id"],
"display_name": s["display_name"],
"category": s["category"],
}
for s in metadata["skills"]
if s["category"] == gap_category and s["status"] != "deprecated"
),
key=lambda s: s["id"],
)
note = (
f"No dedicated workflow shipped yet for this intent. Suggested "
f"individual skills from the '{gap_category}' category; a workflow "
f"manifest is deferred to a later phase."
)
gap_skillset = _skillset(gap_category, skillset_ids, skillsets_by_id)
return _finalize_result(
query=query,
primary=None,
secondary=[],
skillset=gap_skillset,
# Honest gap: no manifest, no primary, no secondary → empty bundle.
# The actionable install list for a gap is `suggested_skills`.
setup_bundle=_setup_bundle(gap_skillset["id"], gap_skillset["manifest"], None, []),
suggested_skills=suggested,
no_api=no_api,
no_api_path=None, # honest gap has no path — contract column "—"
honest_gap=True,
note=note,
rationale=rationale,
)
# ---- workflow branch ---------------------------------------------------
assert primary_id is not None # set in every non-gap path above
if no_api:
reason = workflow_paid_api_reason(workflows_by_id.get(primary_id, {}), skills_by_id)
if reason is not None:
rationale.append(
f"--no-api: '{primary_id}' excluded ({reason}); defaulting to "
f"the no-API on-ramp 'market-regime-daily'"
)
primary_id = "market-regime-daily"
secondary_ids = [
wid
for wid in secondary_ids
if workflow_paid_api_reason(workflows_by_id.get(wid, {}), skills_by_id) is None
]
primary_wf = workflows_by_id.get(primary_id)
if primary_wf is None:
raise MetadataError(f"recommended workflow '{primary_id}' not in metadata")
secondary_ids = [wid for wid in secondary_ids if wid != primary_id]
secondary_ids = _order_secondary(
secondary_ids,
workflows_by_id,
experience=experience,
time_budget=time_budget_min,
)
skillset = _skillset(dominant_category(primary_wf, skills_by_id), skillset_ids, skillsets_by_id)
rationale.append(
f"skillset '{skillset['id']}' = category of "
f"'{primary_wf['required_skills'][0]}' "
f"(first required skill of {primary_wf['id']})"
if primary_wf["required_skills"]
else f"skillset '{skillset['id']}'"
)
if (
time_budget_min is not None
and isinstance(primary_wf["estimated_minutes"], int)
and primary_wf["estimated_minutes"] > time_budget_min
):
rationale.append(
f"note: primary needs ~{primary_wf['estimated_minutes']}m, over "
f"your {time_budget_min}m budget — it stays primary as the best "
f"intent match"
)
# no_api_path = the WHOLE recommended path (primary + every secondary)
# works without paid API keys. This is the contract's "no-API" column
# (distinct from `no_api`, which is the request-side constraint flag):
# e.g. Q1/Q2 both recommend market-regime-daily, but Q1 also pulls in
# fmp-required swing-opportunity-daily so its path is NOT no-API.
no_api_path = workflow_paid_api_reason(primary_wf, skills_by_id) is None and all(
workflow_paid_api_reason(workflows_by_id[wid], skills_by_id) is None
for wid in secondary_ids
)
primary_view = _workflow_public_view(primary_wf)
secondary_views = [_workflow_public_view(workflows_by_id[wid]) for wid in secondary_ids]
return _finalize_result(
query=query,
primary=primary_view,
secondary=secondary_views,
skillset=skillset,
# Actionable install union: primary skillset (or primary workflow if no
# manifest) + EVERY secondary workflow — so a multi-workflow rec never
# drops a secondary's skills (e.g. Q1 keeps vcp-screener).
setup_bundle=_setup_bundle(
skillset["id"], skillset["manifest"], primary_view, secondary_views
),
suggested_skills=[],
no_api=no_api,
no_api_path=no_api_path,
honest_gap=False,
note=note,
rationale=rationale,
)
def _finalize_result(
*,
query: str,
primary: dict[str, Any] | None,
secondary: list[dict[str, Any]],
skillset: dict[str, Any],
setup_bundle: dict[str, Any],
suggested_skills: list[dict[str, str]],
no_api: bool,
no_api_path: bool | None,
honest_gap: bool,
note: str | None,
rationale: list[str],
) -> dict[str, Any]:
return {
"query": query,
"primary_workflow": primary,
"secondary_workflows": secondary,
"skillset": skillset,
"setup_bundle": setup_bundle,
"suggested_skills": suggested_skills,
"no_api": no_api,
"no_api_path": no_api_path,
"honest_gap": honest_gap,
"note": note,
"rationale": rationale,
"setup_path_ref": SETUP_PATH_REF,
}
def dumps(result: dict[str, Any]) -> str:
"""Canonical, idempotent JSON string (stable bytes for golden tests)."""
return json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
# ---------------------------------------------------------------------------
# Text rendering (for SKILL.md narration; tests use --format json)
# ---------------------------------------------------------------------------
def render_text(result: dict[str, Any]) -> str:
lines: list[str] = []
lines.append(f"Query: {result['query']}")
if result["honest_gap"]:
lines.append(f"Recommended workflow: (none yet — {result['skillset']['id']})")
lines.append("Suggested skills:")
for s in result["suggested_skills"]:
lines.append(f" - {s['display_name']} ({s['id']})")
else:
pw = result["primary_workflow"]
lines.append(
f"Primary workflow: {pw['display_name']} ({pw['id']}) — "
f"{pw['cadence']}, ~{pw['estimated_minutes']}m, "
f"api_profile={pw['api_profile']}"
)
for sw in result["secondary_workflows"]:
lines.append(f"Secondary: {sw['display_name']} ({sw['id']})")
lines.append(
f"Skillset: {result['skillset']['id']} "
f"(manifest_status={result['skillset']['manifest_status']})"
)
man = result["skillset"]["manifest"]
if man is not None:
lines.append(f" Skillset manifest: {man['display_name']}")
lines.append(f" Required: {', '.join(man['required_skills']) or '—'}")
lines.append(f" Recommended: {', '.join(man['recommended_skills']) or '—'}")
lines.append(f" Optional: {', '.join(man['optional_skills']) or '—'}")
lines.append(f" Related workflows: {', '.join(man['related_workflows']) or '—'}")
sb = result["setup_bundle"]
if result["honest_gap"]:
lines.append("Setup bundle: (use suggested skills above)")
else:
lines.append("Setup bundle:")
lines.append(f" Required: {', '.join(sb['required']) or '—'}")
lines.append(f" Recommended: {', '.join(sb['recommended']) or '—'}")
lines.append(f" Optional: {', '.join(sb['optional']) or '—'}")
lines.append(f" Sources: {', '.join(sb['sources']) or '—'}")
if result["no_api_path"] is None:
lines.append("No-API path: n/a (no workflow shipped)")
else:
lines.append(
f"No-API path: {'yes' if result['no_api_path'] else 'no'} (works without paid API keys)"
)
lines.append(f"No-API mode requested: {result['no_api']}")
if result["note"]:
lines.append(f"Note: {result['note']}")
lines.append("Why:")
for r in result["rationale"]:
lines.append(f" - {r}")
lines.append(f"Setup path: {result['setup_path_ref']}")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Recommend a trading workflow / skillset / setup path from a natural-language goal."
)
)
parser.add_argument("--query", required=True, help="Natural-language trading goal")
parser.add_argument(
"--no-api",
action="store_true",
help="Only recommend workflows that work without paid API keys",
)
parser.add_argument(
"--time-budget",
choices=["15m", "30m", "60m", "90m", "any"],
default="any",
help="Daily time budget (soft tie-break / secondary filter)",
)
parser.add_argument(
"--experience",
choices=["beginner", "intermediate", "advanced"],
default=None,
help="Experience level (soft tie-break)",
)
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format")
parser.add_argument(
"--project-root",
type=Path,
default=Path.cwd(),
help="Repository root for the SSoT (default: cwd). Falls back to the "
"bundled snapshot when the SSoT is absent (Claude Web App).",
)
args = parser.parse_args(argv)
if not args.query or not args.query.strip():
print("ERROR: --query must not be empty", file=sys.stderr)
return 1
try:
metadata, source = resolve_metadata(args.project_root)
except MetadataError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
result = recommend(
args.query,
metadata,
no_api=args.no_api,
time_budget=args.time_budget,
experience=args.experience,
)
# Source is environment info, NOT part of the recommendation — keep it off
# stdout so SSoT vs snapshot output stays byte-identical (parity invariant).
print(f"metadata source: {source}", file=sys.stderr)
if args.format == "json":
sys.stdout.write(dumps(result))
else:
sys.stdout.write(render_text(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Shared fixtures for trading-skills-navigator tests.
Minimal `write_index` / `write_workflow` helpers mirror
scripts/tests/test_validate_skills_index.py (duplicated here, not imported —
the repo's per-skill conftest isolation makes cross-skill imports unclean).
The 10-question golden suite runs against the REAL repo SSoT (the executable
Phase-1 DoD); the synthetic helpers are for isolating the credential-aware
--no-api rule.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Callable
import pytest
import yaml
# Make recommend.py / build_snapshot.py importable (module names are unique;
# the root conftest only evicts known-conflicting basenames).
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_SCRIPTS_DIR))
def _find_repo_root(start: Path) -> Path:
for parent in [start, *start.parents]:
if (parent / "skills-index.yaml").is_file() and (parent / "workflows").is_dir():
return parent
raise RuntimeError("could not locate repo root (skills-index.yaml + workflows/)")
@pytest.fixture(scope="session")
def repo_root() -> Path:
return _find_repo_root(Path(__file__).resolve())
@pytest.fixture(scope="session")
def repo_metadata(repo_root: Path) -> dict[str, Any]:
"""Normalized metadata loaded from the real repo SSoT."""
from recommend import load_ssot
return load_ssot(repo_root)
@pytest.fixture(scope="session")
def bundled_metadata() -> dict[str, Any]:
"""Normalized metadata loaded from the committed bundled snapshot."""
from recommend import BUNDLED_SNAPSHOT, load_snapshot
return load_snapshot(BUNDLED_SNAPSHOT)
@pytest.fixture()
def write_index(tmp_path: Path) -> Callable[..., Path]:
"""Write a minimal skills-index.yaml into tmp_path; return its dir."""
def _write(skills: list[dict[str, Any]]) -> Path:
payload = {
"schema_version": 1,
"categories": [
"market-regime",
"core-portfolio",
"swing-opportunity",
"trade-planning",
"trade-memory",
"strategy-research",
"advanced-satellite",
"meta",
],
"skills": skills,
}
(tmp_path / "skills-index.yaml").write_text(
yaml.safe_dump(payload, sort_keys=False), encoding="utf-8"
)
return tmp_path
return _write
@pytest.fixture()
def write_workflow(tmp_path: Path) -> Callable[..., Path]:
"""Write workflows/<id>.yaml into tmp_path/workflows; return the dir."""
def _write(workflow: dict[str, Any]) -> Path:
wf_dir = tmp_path / "workflows"
wf_dir.mkdir(exist_ok=True)
(wf_dir / f"{workflow['id']}.yaml").write_text(
yaml.safe_dump(workflow, sort_keys=False), encoding="utf-8"
)
return wf_dir
return _write
@pytest.fixture()
def write_skillset(tmp_path: Path) -> Callable[..., Path]:
"""Write skillsets/<id>.yaml into tmp_path/skillsets; return the dir."""
def _write(skillset: dict[str, Any]) -> Path:
ss_dir = tmp_path / "skillsets"
ss_dir.mkdir(exist_ok=True)
(ss_dir / f"{skillset['id']}.yaml").write_text(
yaml.safe_dump(skillset, sort_keys=False), encoding="utf-8"
)
return ss_dir
return _write