
Edge Hint Extractor
- 906 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
edge-hint-extractor is a quantitative trading skill that converts raw daily market observations, anomalies, and news reactions into structured edge hints saved as hints.yaml for downstream concept synthesis.
About
edge-hint-extractor is a Claude Code skill in tradermonty/claude-trading-skills that transforms raw observation signals—market_summary entries, anomalies, and news reactions—into canonical edge hint objects written to hints.yaml. It is the first stage in a split workflow: observe, abstract, design, pipeline. Optional LLM ideation generates ideas constrained by current anomaly and news context so hints stay grounded in live market data. Quantitative developers and systematic traders use edge-hint-extractor when daily journals or feeds need to become reusable structured inputs for concept synthesis and automated edge detection pipelines rather than ad-hoc notes.
- Converts market_summary, anomalies, and news_reactions into canonical hints.yaml
- Supports deterministic extraction plus optional LLM-augmented ideation
- Two LLM modes: --llm-ideas-cmd (subprocess) and --llm-ideas-file (Claude Code workflow)
- Outputs structured hints list with generation metadata and rule/LLM hint counts
- First stage in the observe → abstract → design → pipeline workflow
Edge Hint Extractor by the numbers
- 906 all-time installs (skills.sh)
- +73 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,210 of 16,546 AI & Agent Building 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 edge-hint-extractorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 906 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you turn daily market observations into trading hints?
Convert raw daily market observations, anomalies, and news reactions into structured, reusable edge hints saved as hints.yaml.
Who is it for?
Quantitative developers building systematic trading pipelines who capture daily market observations and need structured hint inputs.
Skip if: Developers without a trading research workflow or those who only need one-off market commentary without structured YAML outputs.
When should I use this skill?
User wants to convert daily market observations, anomalies, or news reactions into reusable edge hints or hints.yaml for concept synthesis.
What you get
A canonical hints.yaml file containing structured edge hint objects derived from market observations and news reactions.
- hints.yaml edge hint file
Files
Edge Hint Extractor
Overview
Convert raw observation signals (market_summary, anomalies, news reactions) into structured edge hints. This skill is the first stage in the split workflow: observe -> abstract -> design -> pipeline.
When to Use
- You want to turn daily market observations into reusable hint objects.
- You want LLM-generated ideas constrained by current anomalies/news context.
- You need a clean
hints.yamlinput for concept synthesis or auto detection.
Prerequisites
- Python 3.9+
PyYAML- Optional inputs from detector run:
market_summary.jsonanomalies.jsonnews_reactions.csvornews_reactions.json
Output
hints.yamlcontaining:hintslist- generation metadata
- rule/LLM hint counts
Workflow
1. Gather observation files (market_summary, anomalies, optional news reactions). 2. Run scripts/build_hints.py to generate deterministic hints. 3. Optionally augment hints with LLM ideas via one of two methods:
- a.
--llm-ideas-cmd— pipe data to an external LLM CLI (subprocess). - b.
--llm-ideas-file PATH— load pre-written hints from a YAML file (for Claude Code workflows where Claude generates hints itself).
4. Pass hints.yaml into concept synthesis or auto detection.
Note: --llm-ideas-cmd and --llm-ideas-file are mutually exclusive.
Quick Commands
Rule-based only (default output to reports/edge_hint_extractor/hints.yaml):
python3 skills/edge-hint-extractor/scripts/build_hints.py \
--market-summary /tmp/edge-auto/market_summary.json \
--anomalies /tmp/edge-auto/anomalies.json \
--news-reactions /tmp/news_reactions.csv \
--as-of 2026-02-20 \
--output-dir reports/Rule + LLM augmentation (external CLI):
python3 skills/edge-hint-extractor/scripts/build_hints.py \
--market-summary /tmp/edge-auto/market_summary.json \
--anomalies /tmp/edge-auto/anomalies.json \
--llm-ideas-cmd "python3 /path/to/llm_ideas_cli.py" \
--output-dir reports/Rule + LLM augmentation (pre-written file, for Claude Code):
python3 skills/edge-hint-extractor/scripts/build_hints.py \
--market-summary /tmp/edge-auto/market_summary.json \
--anomalies /tmp/edge-auto/anomalies.json \
--llm-ideas-file /tmp/llm_hints.yaml \
--output-dir reports/Resources
skills/edge-hint-extractor/scripts/build_hints.pyreferences/hints_schema.md
interface:
display_name: "Edge Hint Extractor"
short_description: "Convert observations/news into canonical hints.yaml"
default_prompt: "Extract structured edge hints from daily market observations and optional LLM ideation output."
Hints Schema
Use this schema as input for downstream concept synthesis and auto detection.
generated_at_utc: "2026-02-22T12:00:00+00:00"
as_of: "2026-02-20"
meta:
rule_hints: 6
llm_hints: 3
total_hints: 9
regime: RiskOn
hints:
- title: "Breadth-supported breakout regime"
observation: "Risk-on regime with pct_above_ma50=0.65"
hypothesis_type: "breakout" # optional
preferred_entry_family: "pivot_breakout" # optional
symbols: ["NVDA", "AVGO"] # optional
regime_bias: "RiskOn" # optional
mechanism_tag: "behavior" # optionalField Notes
hypothesis_type: optional; if present should be one of: breakout, earnings_drift, news_reaction, futures_trigger, calendar_anomaly, panic_reversal, regime_shift, sector_x_stock. Unrecognized values are first checked against keyword inference from the hint's title and observation; if no match is found, they fall back toresearch_hypothesis. Used for clustering when--promote-hintsis enabled.preferred_entry_family: optional; if present must bepivot_breakoutorgap_up_continuation.symbols: optional focus list. Empty means broad market hint.regime_bias: optional regime gate (RiskOn,Neutral,RiskOff).mechanism_tag: optional mechanism label (behavior,flow,structure, etc.).
LLM CLI Contract
build_hints.py --llm-ideas-cmd "<command>" sends JSON to stdin:
{
"as_of": "YYYY-MM-DD",
"market_summary": {...},
"anomalies": [...],
"news_reactions": [...],
"instruction": "Generate concise edge hints ..."
}The command must print either:
[{...}, {...}]{"hints": [{...}, {...}]}
#!/usr/bin/env python3
"""Build edge hints from market observations, news reactions, and optional LLM ideas."""
from __future__ import annotations
import argparse
import csv
import json
import shlex
import subprocess
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any
import yaml
SUPPORTED_ENTRY_FAMILIES = {
"pivot_breakout",
"gap_up_continuation",
"panic_reversal",
"news_reaction",
}
class HintExtractionError(Exception):
"""Raised when hint extraction fails."""
def parse_as_of(raw: str | None) -> date | None:
"""Parse YYYY-MM-DD into a date object."""
if not raw:
return None
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError as exc:
raise HintExtractionError(f"invalid --as-of format: {raw}") from exc
def safe_float(value: Any, default: float = 0.0) -> float:
"""Best-effort float conversion."""
try:
return float(value)
except (TypeError, ValueError):
return default
def read_json(path: Path) -> Any:
"""Read JSON file."""
try:
return json.loads(path.read_text())
except json.JSONDecodeError as exc:
raise HintExtractionError(f"invalid JSON: {path}") from exc
def read_market_summary(path: Path | None) -> dict[str, Any]:
"""Load market summary JSON."""
if path is None:
return {}
payload = read_json(path)
if not isinstance(payload, dict):
raise HintExtractionError(f"market summary must be an object: {path}")
return payload
def read_anomalies(path: Path | None) -> list[dict[str, Any]]:
"""Load anomalies JSON list."""
if path is None:
return []
payload = read_json(path)
if not isinstance(payload, list):
raise HintExtractionError(f"anomalies must be a JSON list: {path}")
return [item for item in payload if isinstance(item, dict)]
def parse_timestamp_to_date(value: str | None) -> date | None:
"""Parse ISO timestamp into date."""
if not value or not isinstance(value, str):
return None
fixed = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(fixed).date()
except ValueError:
return None
def normalize_news_row(row: dict[str, Any]) -> dict[str, Any] | None:
"""Normalize one news reaction row."""
symbol = str(row.get("symbol", "")).strip().upper()
if not symbol:
return None
return {
"symbol": symbol,
"timestamp": str(row.get("timestamp", "")).strip(),
"reaction_1d": safe_float(row.get("reaction_1d")),
"headline": str(row.get("headline", "")).strip(),
}
def read_news_reactions(path: Path | None, as_of: date | None) -> list[dict[str, Any]]:
"""Load news reactions from CSV or JSON."""
if path is None:
return []
rows: list[dict[str, Any]] = []
suffix = path.suffix.lower()
if suffix == ".csv":
with path.open(newline="") as fh:
reader = csv.DictReader(fh)
for raw in reader:
if not isinstance(raw, dict):
continue
normalized = normalize_news_row(raw)
if normalized is not None:
rows.append(normalized)
elif suffix == ".json":
payload = read_json(path)
if isinstance(payload, dict):
data = payload.get("rows") or payload.get("data") or payload.get("news") or []
elif isinstance(payload, list):
data = payload
else:
raise HintExtractionError(f"unsupported JSON news format: {path}")
for raw in data:
if not isinstance(raw, dict):
continue
normalized = normalize_news_row(raw)
if normalized is not None:
rows.append(normalized)
else:
raise HintExtractionError(f"unsupported news reactions format: {path}")
filtered: list[dict[str, Any]] = []
for row in rows:
if as_of is not None:
row_date = parse_timestamp_to_date(row.get("timestamp"))
if row_date is not None and row_date != as_of:
continue
filtered.append(row)
filtered.sort(key=lambda item: abs(safe_float(item.get("reaction_1d"))), reverse=True)
return filtered
def infer_regime_label(market_summary: dict[str, Any]) -> str:
"""Infer regime label when not explicitly provided."""
raw = market_summary.get("regime_label")
if isinstance(raw, str) and raw.strip():
return raw.strip()
risk_on = safe_float(market_summary.get("risk_on_score"))
risk_off = safe_float(market_summary.get("risk_off_score"))
if risk_on >= risk_off + 10:
return "RiskOn"
if risk_off >= risk_on + 10:
return "RiskOff"
return "Neutral"
def normalize_hint(raw_hint: dict[str, Any]) -> dict[str, Any]:
"""Normalize user/LLM/rule hint into canonical schema."""
title = str(raw_hint.get("title") or raw_hint.get("observation") or "untitled_hint").strip()
observation = str(raw_hint.get("observation") or title).strip()
raw_family = raw_hint.get("preferred_entry_family")
if isinstance(raw_family, str) and raw_family in SUPPORTED_ENTRY_FAMILIES:
preferred_entry_family: str | None = raw_family
else:
preferred_entry_family = None
symbols_input = raw_hint.get("symbols", [])
symbols: list[str] = []
if isinstance(symbols_input, list):
seen: set[str] = set()
for symbol in symbols_input:
if not isinstance(symbol, str):
continue
normalized = symbol.strip().upper()
if normalized and normalized not in seen:
seen.add(normalized)
symbols.append(normalized)
normalized_hint = {
"title": title,
"observation": observation,
"symbols": symbols,
"regime_bias": str(raw_hint.get("regime_bias", "")).strip(),
"mechanism_tag": str(raw_hint.get("mechanism_tag", "")).strip() or "behavior",
}
if preferred_entry_family is not None:
normalized_hint["preferred_entry_family"] = preferred_entry_family
raw_hypothesis = raw_hint.get("hypothesis_type")
if isinstance(raw_hypothesis, str) and raw_hypothesis.strip():
normalized_hint["hypothesis_type"] = raw_hypothesis.strip()
return normalized_hint
def build_rule_hints(
market_summary: dict[str, Any],
anomalies: list[dict[str, Any]],
news_rows: list[dict[str, Any]],
max_anomaly_hints: int,
news_threshold: float,
) -> list[dict[str, Any]]:
"""Generate deterministic hints from inputs."""
regime = infer_regime_label(market_summary)
hints: list[dict[str, Any]] = []
breadth = safe_float(market_summary.get("pct_above_ma50"))
vol_trend = safe_float(market_summary.get("vol_trend"), default=1.0)
if regime == "RiskOn":
hints.append(
normalize_hint(
{
"title": "Breadth-supported breakout regime",
"observation": (
f"Risk-on regime with pct_above_ma50={breadth:.3f} and vol_trend={vol_trend:.3f}."
),
"hypothesis_type": "breakout",
"preferred_entry_family": "pivot_breakout",
"regime_bias": regime,
"mechanism_tag": "behavior",
}
)
)
elif regime == "RiskOff":
hints.append(
normalize_hint(
{
"title": "Risk-off selectivity",
"observation": "Risk-off conditions suggest defensive and confirmation-based entries.",
"hypothesis_type": "regime_shift",
"regime_bias": regime,
"mechanism_tag": "risk_premium",
}
)
)
sorted_anomalies = sorted(
[item for item in anomalies if isinstance(item, dict)],
key=lambda item: abs(safe_float(item.get("z"), safe_float(item.get("abs_z")))),
reverse=True,
)
for anomaly in sorted_anomalies[: max(max_anomaly_hints, 0)]:
symbol = str(anomaly.get("symbol", "")).upper().strip()
metric = str(anomaly.get("metric", "")).strip().lower()
z_score = safe_float(anomaly.get("z"), safe_float(anomaly.get("abs_z")))
if not symbol:
continue
if metric == "gap" and z_score >= 2.5:
hints.append(
normalize_hint(
{
"title": f"Positive gap shock in {symbol}",
"observation": f"{symbol} printed a large positive gap anomaly (z={z_score:.2f}).",
"hypothesis_type": "breakout",
"preferred_entry_family": "gap_up_continuation",
"symbols": [symbol],
"regime_bias": regime,
"mechanism_tag": "behavior",
}
)
)
elif metric == "gap" and z_score <= -2.5:
hints.append(
normalize_hint(
{
"title": f"Downside overreaction watch in {symbol}",
"observation": f"{symbol} showed a negative gap anomaly (z={z_score:.2f}).",
"hypothesis_type": "panic_reversal",
"preferred_entry_family": "panic_reversal",
"symbols": [symbol],
"regime_bias": regime,
"mechanism_tag": "behavior",
}
)
)
elif metric == "rel_volume" and abs(z_score) >= 2.5:
hints.append(
normalize_hint(
{
"title": f"Participation spike in {symbol}",
"observation": f"Relative volume anomaly detected (z={z_score:.2f}).",
"hypothesis_type": "breakout",
"preferred_entry_family": "pivot_breakout",
"symbols": [symbol],
"regime_bias": regime,
"mechanism_tag": "flow",
}
)
)
for row in news_rows:
reaction = safe_float(row.get("reaction_1d"))
if abs(reaction) < news_threshold:
continue
symbol = str(row.get("symbol", "")).upper().strip()
if not symbol:
continue
if reaction >= 0:
raw_hint = {
"title": f"News drift continuation in {symbol}",
"observation": f"{symbol} reacted +{reaction:.3f} on event day; monitor continuation.",
"hypothesis_type": "news_reaction",
"preferred_entry_family": "news_reaction",
"symbols": [symbol],
"regime_bias": regime,
"mechanism_tag": "behavior",
}
else:
raw_hint = {
"title": f"News shock reversal in {symbol}",
"observation": f"{symbol} reacted {reaction:.3f} on event day; monitor overshoot reversal.",
"hypothesis_type": "news_reaction",
"preferred_entry_family": "news_reaction",
"symbols": [symbol],
"regime_bias": regime,
"mechanism_tag": "behavior",
}
hints.append(normalize_hint(raw_hint))
return hints
def parse_hints_payload(raw_payload: Any) -> list[dict[str, Any]]:
"""Parse hint payload from YAML/JSON output."""
if raw_payload is None:
return []
if isinstance(raw_payload, list):
raw_hints = raw_payload
elif isinstance(raw_payload, dict):
maybe_hints = raw_payload.get("hints", [])
raw_hints = maybe_hints if isinstance(maybe_hints, list) else []
else:
raise HintExtractionError("LLM output must be list or {hints: [...]}")
normalized: list[dict[str, Any]] = []
for raw in raw_hints:
if not isinstance(raw, dict):
continue
normalized.append(normalize_hint(raw))
return normalized
def generate_llm_hints(llm_command: str | None, payload: dict[str, Any]) -> list[dict[str, Any]]:
"""Generate hints from external LLM command."""
if not llm_command:
return []
command_parts = shlex.split(llm_command)
if not command_parts:
raise HintExtractionError("--llm-ideas-cmd is empty")
result = subprocess.run(
command_parts,
input=json.dumps(payload),
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise HintExtractionError(f"LLM ideas command failed: {detail}")
stdout = result.stdout.strip()
if not stdout:
return []
parsed = yaml.safe_load(stdout)
return parse_hints_payload(parsed)
def load_llm_hints_from_file(path: Path) -> list[dict[str, Any]]:
"""Load LLM-generated hints from a pre-written YAML file."""
text = path.read_text().strip()
if not text:
return []
try:
parsed = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise HintExtractionError(f"invalid YAML in --llm-ideas-file {path}: {exc}") from exc
return parse_hints_payload(parsed)
def dedupe_hints(hints: list[dict[str, Any]], max_total: int) -> list[dict[str, Any]]:
"""Deduplicate hints by semantic identity."""
deduped: list[dict[str, Any]] = []
seen: set[tuple[str, str, str, tuple[str, ...], str, str]] = set()
for hint in hints:
title = str(hint.get("title", "")).strip().lower()
hypothesis = str(hint.get("hypothesis_type", "")).strip().lower()
family = str(hint.get("preferred_entry_family", "")).strip().lower()
symbols = tuple(str(item).upper() for item in hint.get("symbols", []))
regime = str(hint.get("regime_bias", "")).strip().lower()
mechanism = str(hint.get("mechanism_tag", "")).strip().lower()
key = (title, hypothesis, family, symbols, regime, mechanism)
if key in seen:
continue
seen.add(key)
deduped.append(hint)
if len(deduped) >= max(max_total, 0):
break
return deduped
def parse_args() -> argparse.Namespace:
"""Parse CLI args."""
parser = argparse.ArgumentParser(
description="Build hints.yaml from market observations/news with optional LLM augmentation.",
)
parser.add_argument("--market-summary", default=None, help="Optional market_summary.json path")
parser.add_argument("--anomalies", default=None, help="Optional anomalies.json path")
parser.add_argument(
"--news-reactions",
default=None,
help="Optional news reactions CSV/JSON path with symbol,timestamp,reaction_1d",
)
parser.add_argument(
"--as-of", default=None, help="Target date YYYY-MM-DD for filtering news rows"
)
llm_group = parser.add_mutually_exclusive_group()
llm_group.add_argument("--llm-ideas-cmd", default=None, help="Optional external LLM command")
llm_group.add_argument(
"--llm-ideas-file",
default=None,
metavar="PATH",
help="Pre-written YAML file of LLM hints (use from Claude Code)",
)
parser.add_argument(
"--max-anomaly-hints", type=int, default=8, help="Max anomaly-derived hints"
)
parser.add_argument(
"--news-threshold", type=float, default=0.06, help="Min abs(reaction_1d) for hints"
)
parser.add_argument("--max-total-hints", type=int, default=25, help="Max total hints to output")
parser.add_argument(
"--output-dir",
default="reports/",
help="Output directory (default: reports/)",
)
parser.add_argument(
"--output",
default=None,
help="Output hints YAML path (overrides --output-dir if specified)",
)
return parser.parse_args()
def main() -> int:
"""CLI entrypoint."""
args = parse_args()
market_summary_path = Path(args.market_summary).resolve() if args.market_summary else None
anomalies_path = Path(args.anomalies).resolve() if args.anomalies else None
news_path = Path(args.news_reactions).resolve() if args.news_reactions else None
as_of = parse_as_of(args.as_of)
if args.output:
output_path = Path(args.output).resolve()
else:
output_dir = Path(args.output_dir).resolve()
output_path = output_dir / "edge_hint_extractor" / "hints.yaml"
for path in [market_summary_path, anomalies_path, news_path]:
if path is not None and not path.exists():
print(f"[ERROR] file not found: {path}")
return 1
try:
market_summary = read_market_summary(market_summary_path)
anomalies = read_anomalies(anomalies_path)
news_rows = read_news_reactions(news_path, as_of=as_of)
rule_hints = build_rule_hints(
market_summary=market_summary,
anomalies=anomalies,
news_rows=news_rows,
max_anomaly_hints=max(args.max_anomaly_hints, 0),
news_threshold=max(args.news_threshold, 0.0),
)
llm_payload = {
"as_of": as_of.isoformat() if as_of else None,
"market_summary": market_summary,
"anomalies": anomalies[:20],
"news_reactions": news_rows[:20],
"instruction": (
"Generate concise edge hints with fields: title, observation, "
"hypothesis_type(optional: breakout|earnings_drift|news_reaction|"
"futures_trigger|calendar_anomaly|panic_reversal|regime_shift|sector_x_stock), "
"preferred_entry_family(optional), symbols(optional), "
"regime_bias(optional), mechanism_tag(optional)."
),
}
if args.llm_ideas_file:
llm_hints_path = Path(args.llm_ideas_file).resolve()
if not llm_hints_path.exists():
print(f"[ERROR] --llm-ideas-file not found: {llm_hints_path}")
return 1
llm_hints = load_llm_hints_from_file(llm_hints_path)
else:
llm_hints = generate_llm_hints(args.llm_ideas_cmd, llm_payload)
hints = dedupe_hints(rule_hints + llm_hints, max_total=max(args.max_total_hints, 0))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_payload = {
"generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"as_of": as_of.isoformat() if as_of else None,
"hints": hints,
"meta": {
"rule_hints": len(rule_hints),
"llm_hints": len(llm_hints),
"total_hints": len(hints),
"regime": infer_regime_label(market_summary),
},
}
output_path.write_text(yaml.safe_dump(output_payload, sort_keys=False))
except HintExtractionError as exc:
print(f"[ERROR] {exc}")
return 1
print(f"[OK] hints={len(hints)} output={output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Test configuration for edge-hint-extractor scripts."""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
"""Unit tests for build_hints.py."""
from datetime import date
from pathlib import Path
from subprocess import CompletedProcess
import build_hints as bh
import pytest
def test_build_rule_hints_generates_market_and_news_hints() -> None:
hints = bh.build_rule_hints(
market_summary={"regime_label": "RiskOn", "pct_above_ma50": 0.66, "vol_trend": 1.12},
anomalies=[
{"symbol": "CPRT", "metric": "gap", "z": -3.2},
{"symbol": "NVDA", "metric": "rel_volume", "z": 3.1},
],
news_rows=[
{"symbol": "TSLA", "timestamp": "2026-02-20T21:00:00Z", "reaction_1d": -0.12},
],
max_anomaly_hints=5,
news_threshold=0.06,
)
titles = [hint["title"] for hint in hints]
assert any("Breadth-supported breakout regime" in title for title in titles)
assert any("Participation spike in NVDA" in title for title in titles)
assert any("News shock reversal in TSLA" in title for title in titles)
def test_generate_llm_hints_parses_hints_dict(monkeypatch) -> None:
stdout = """
hints:
- title: LLM momentum idea
observation: strong leaders pushing highs
preferred_entry_family: pivot_breakout
symbols: [NVDA]
"""
def fake_run(*args, **kwargs):
return CompletedProcess(args=args[0], returncode=0, stdout=stdout, stderr="")
monkeypatch.setattr(bh.subprocess, "run", fake_run)
hints = bh.generate_llm_hints(
llm_command="fake-llm-cli",
payload={"as_of": date(2026, 2, 20).isoformat()},
)
assert len(hints) == 1
assert hints[0]["preferred_entry_family"] == "pivot_breakout"
assert hints[0]["symbols"] == ["NVDA"]
def test_infer_regime_label_from_explicit_value() -> None:
"""Test that explicit regime_label takes precedence."""
assert bh.infer_regime_label({"regime_label": "RiskOff"}) == "RiskOff"
assert bh.infer_regime_label({"regime_label": " Neutral "}) == "Neutral"
def test_infer_regime_label_from_scores() -> None:
"""Test regime inference from risk_on/risk_off scores."""
assert bh.infer_regime_label({"risk_on_score": 70, "risk_off_score": 30}) == "RiskOn"
assert bh.infer_regime_label({"risk_on_score": 30, "risk_off_score": 70}) == "RiskOff"
assert bh.infer_regime_label({"risk_on_score": 50, "risk_off_score": 50}) == "Neutral"
assert bh.infer_regime_label({}) == "Neutral"
def test_normalize_hint_handles_missing_fields() -> None:
"""Test that normalize_hint provides defaults for missing fields."""
hint = bh.normalize_hint({"title": "Test"})
assert hint["title"] == "Test"
assert hint["observation"] == "Test"
assert hint["symbols"] == []
assert hint["regime_bias"] == ""
assert hint["mechanism_tag"] == "behavior"
assert "preferred_entry_family" not in hint
def test_normalize_hint_dedupes_symbols() -> None:
"""Test that duplicate symbols are removed."""
hint = bh.normalize_hint({"title": "Test", "symbols": ["AAPL", "aapl", "AAPL", "MSFT"]})
assert hint["symbols"] == ["AAPL", "MSFT"]
def test_normalize_hint_validates_entry_family() -> None:
"""Test that only valid entry families are accepted."""
hint1 = bh.normalize_hint({"title": "T", "preferred_entry_family": "pivot_breakout"})
assert hint1["preferred_entry_family"] == "pivot_breakout"
hint2 = bh.normalize_hint({"title": "T", "preferred_entry_family": "invalid_family"})
assert "preferred_entry_family" not in hint2
def test_dedupe_hints_removes_duplicates() -> None:
"""Test that semantically identical hints are deduplicated."""
hints = [
{"title": "Test", "symbols": ["AAPL"], "regime_bias": "RiskOn", "mechanism_tag": "flow"},
{"title": "test", "symbols": ["AAPL"], "regime_bias": "RiskOn", "mechanism_tag": "flow"},
{
"title": "Different",
"symbols": ["MSFT"],
"regime_bias": "RiskOn",
"mechanism_tag": "flow",
},
]
result = bh.dedupe_hints(hints, max_total=10)
assert len(result) == 2
def test_dedupe_hints_respects_max_total() -> None:
"""Test that max_total limit is respected."""
hints = [{"title": f"Hint {i}", "symbols": []} for i in range(10)]
result = bh.dedupe_hints(hints, max_total=3)
assert len(result) == 3
def test_parse_as_of_valid_date() -> None:
"""Test valid date parsing."""
assert bh.parse_as_of("2026-02-20") == date(2026, 2, 20)
assert bh.parse_as_of(None) is None
assert bh.parse_as_of("") is None
def test_parse_as_of_invalid_date() -> None:
"""Test that invalid date format raises HintExtractionError."""
with pytest.raises(bh.HintExtractionError, match="invalid --as-of format"):
bh.parse_as_of("2026/02/20")
def test_safe_float_handles_edge_cases() -> None:
"""Test safe_float conversion with various inputs."""
assert bh.safe_float(3.14) == 3.14
assert bh.safe_float("2.5") == 2.5
assert bh.safe_float(None) == 0.0
assert bh.safe_float("invalid") == 0.0
assert bh.safe_float(None, default=-1.0) == -1.0
def test_build_rule_hints_risk_off_regime() -> None:
"""Test hint generation in RiskOff regime."""
hints = bh.build_rule_hints(
market_summary={"regime_label": "RiskOff"},
anomalies=[],
news_rows=[],
max_anomaly_hints=5,
news_threshold=0.06,
)
titles = [h["title"] for h in hints]
assert any("Risk-off selectivity" in t for t in titles)
def test_build_rule_hints_positive_gap_anomaly() -> None:
"""Test positive gap anomaly generates correct hint."""
hints = bh.build_rule_hints(
market_summary={},
anomalies=[{"symbol": "NVDA", "metric": "gap", "z": 3.5}],
news_rows=[],
max_anomaly_hints=5,
news_threshold=0.06,
)
titles = [h["title"] for h in hints]
assert any("Positive gap shock in NVDA" in t for t in titles)
def test_build_rule_hints_positive_news_reaction() -> None:
"""Test positive news reaction generates continuation hint."""
hints = bh.build_rule_hints(
market_summary={},
anomalies=[],
news_rows=[{"symbol": "AAPL", "reaction_1d": 0.10}],
max_anomaly_hints=5,
news_threshold=0.06,
)
titles = [h["title"] for h in hints]
assert any("News drift continuation in AAPL" in t for t in titles)
def test_parse_hints_payload_handles_various_formats() -> None:
"""Test that parse_hints_payload handles list and dict formats."""
# List format
result1 = bh.parse_hints_payload([{"title": "Test"}])
assert len(result1) == 1
# Dict format with hints key
result2 = bh.parse_hints_payload({"hints": [{"title": "Test"}]})
assert len(result2) == 1
# None
result3 = bh.parse_hints_payload(None)
assert result3 == []
def test_parse_hints_payload_rejects_invalid_format() -> None:
"""Test that invalid formats raise HintExtractionError."""
with pytest.raises(bh.HintExtractionError, match="must be list or"):
bh.parse_hints_payload("invalid string")
def test_normalize_news_row_handles_empty_symbol() -> None:
"""Test that rows with empty symbols are filtered out."""
result = bh.normalize_news_row({"symbol": "", "timestamp": "2026-02-20"})
assert result is None
result2 = bh.normalize_news_row({"symbol": "AAPL", "timestamp": "2026-02-20"})
assert result2 is not None
assert result2["symbol"] == "AAPL"
def test_normalize_hint_preserves_hypothesis_type() -> None:
"""Test that hypothesis_type is passed through when present."""
hint = bh.normalize_hint({"title": "Test", "hypothesis_type": "breakout"})
assert hint["hypothesis_type"] == "breakout"
def test_normalize_hint_omits_empty_hypothesis_type() -> None:
"""Test that empty or whitespace-only hypothesis_type is excluded."""
hint1 = bh.normalize_hint({"title": "Test", "hypothesis_type": ""})
assert "hypothesis_type" not in hint1
hint2 = bh.normalize_hint({"title": "Test", "hypothesis_type": " "})
assert "hypothesis_type" not in hint2
hint3 = bh.normalize_hint({"title": "Test"})
assert "hypothesis_type" not in hint3
def test_build_rule_hints_include_hypothesis_type() -> None:
"""Test that rule-generated hints include hypothesis_type."""
hints = bh.build_rule_hints(
market_summary={"regime_label": "RiskOn", "pct_above_ma50": 0.66, "vol_trend": 1.12},
anomalies=[
{"symbol": "NVDA", "metric": "gap", "z": 3.5},
{"symbol": "CPRT", "metric": "gap", "z": -3.2},
{"symbol": "AMD", "metric": "rel_volume", "z": 3.1},
],
news_rows=[
{"symbol": "AAPL", "reaction_1d": 0.10},
{"symbol": "TSLA", "reaction_1d": -0.12},
],
max_anomaly_hints=5,
news_threshold=0.06,
)
type_map = {h["title"]: h.get("hypothesis_type") for h in hints}
assert type_map["Breadth-supported breakout regime"] == "breakout"
assert type_map["Positive gap shock in NVDA"] == "breakout"
assert type_map["Downside overreaction watch in CPRT"] == "panic_reversal"
assert type_map["Participation spike in AMD"] == "breakout"
assert type_map["News drift continuation in AAPL"] == "news_reaction"
assert type_map["News shock reversal in TSLA"] == "news_reaction"
def test_dedupe_hints_distinguishes_hypothesis_type() -> None:
"""Test that same title with different hypothesis_type are kept as separate hints."""
hints = [
{
"title": "Test",
"hypothesis_type": "breakout",
"symbols": [],
"regime_bias": "",
"mechanism_tag": "behavior",
},
{
"title": "Test",
"hypothesis_type": "panic_reversal",
"symbols": [],
"regime_bias": "",
"mechanism_tag": "behavior",
},
]
result = bh.dedupe_hints(hints, max_total=10)
assert len(result) == 2
def test_load_llm_hints_from_file_bare_list(tmp_path: Path) -> None:
"""Test loading a bare YAML list from file."""
yaml_content = (
"- title: Sector rotation into industrials\n"
" observation: Tech underperforming\n"
" symbols: [CAT, DE]\n"
" regime_bias: Neutral\n"
" mechanism_tag: flow\n"
)
f = tmp_path / "hints.yaml"
f.write_text(yaml_content)
result = bh.load_llm_hints_from_file(f)
assert len(result) == 1
assert result[0]["title"] == "Sector rotation into industrials"
assert result[0]["symbols"] == ["CAT", "DE"]
def test_load_llm_hints_from_file_dict_wrapper(tmp_path: Path) -> None:
"""Test loading hints wrapped in {hints: [...]} format."""
yaml_content = (
"hints:\n"
" - title: Momentum breakout\n"
" observation: Leaders pushing highs\n"
" symbols: [NVDA]\n"
)
f = tmp_path / "hints.yaml"
f.write_text(yaml_content)
result = bh.load_llm_hints_from_file(f)
assert len(result) == 1
assert result[0]["symbols"] == ["NVDA"]
def test_load_llm_hints_from_file_empty(tmp_path: Path) -> None:
"""Test that empty file returns empty list."""
f = tmp_path / "empty.yaml"
f.write_text("")
result = bh.load_llm_hints_from_file(f)
assert result == []
def test_load_llm_hints_from_file_invalid_yaml(tmp_path: Path) -> None:
"""Test that invalid YAML raises HintExtractionError."""
f = tmp_path / "bad.yaml"
f.write_text(":\n - [invalid\n")
with pytest.raises(bh.HintExtractionError, match="invalid YAML"):
bh.load_llm_hints_from_file(f)
def test_main_llm_ideas_file_not_found(tmp_path: Path, monkeypatch) -> None:
"""Test that main() returns 1 when --llm-ideas-file does not exist."""
monkeypatch.setattr(
"sys.argv",
["build_hints.py", "--llm-ideas-file", str(tmp_path / "missing.yaml")],
)
assert bh.main() == 1
def test_llm_ideas_file_and_cmd_mutual_exclusion(monkeypatch) -> None:
"""Test that --llm-ideas-file and --llm-ideas-cmd cannot be used together."""
monkeypatch.setattr(
"sys.argv",
[
"build_hints.py",
"--llm-ideas-cmd",
"echo hi",
"--llm-ideas-file",
"/tmp/hints.yaml",
],
)
with pytest.raises(SystemExit):
bh.parse_args()
def test_parse_timestamp_to_date_handles_formats() -> None:
"""Test timestamp parsing with various formats."""
# ISO format with Z
assert bh.parse_timestamp_to_date("2026-02-20T12:00:00Z") == date(2026, 2, 20)
# ISO format with offset
assert bh.parse_timestamp_to_date("2026-02-20T12:00:00+00:00") == date(2026, 2, 20)
# Invalid format
assert bh.parse_timestamp_to_date("invalid") is None
# Empty/None
assert bh.parse_timestamp_to_date(None) is None
assert bh.parse_timestamp_to_date("") is None
Related skills
FAQ
What file does edge-hint-extractor produce?
edge-hint-extractor outputs a canonical hints.yaml file containing structured edge hint objects. Downstream skills in the trading pipeline consume hints.yaml for concept synthesis and automated edge detection.
What inputs does edge-hint-extractor accept?
edge-hint-extractor processes raw observation signals including market_summary data, detected anomalies, and news reactions. Optional LLM ideation can generate additional hints constrained by that context.