diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46c9a74..d76ed5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: required_modules = { "nano/library/__init__.py", + "nano/library/catalog.py", "nano/library/contribution.py", } missing_modules = sorted(required_modules - packaged) @@ -101,8 +102,13 @@ jobs: "from nano.library.contribution import " "baseline_control_frames, module_control_frame, " "source_provenance_issues; " + "from nano.library.catalog import " + "catalog_diagnostics, load_catalog; " "assert baseline_control_frames.__module__ == " - "'nano.library.contribution'" + "'nano.library.contribution'; " + "catalog = load_catalog(); " + "assert catalog['strategyCount'] == 53; " + "assert catalog_diagnostics() == ()" ), ], cwd=outside_checkout, diff --git a/nano/__init__.py b/nano/__init__.py index 780c569..57b4482 100644 --- a/nano/__init__.py +++ b/nano/__init__.py @@ -26,4 +26,4 @@ artifact cannot change behavior because a transitive dependency did. """ -__version__ = "1.0.4" +__version__ = "1.0.5" diff --git a/nano/cli/commands.py b/nano/cli/commands.py index 083d8ab..653431e 100644 --- a/nano/cli/commands.py +++ b/nano/cli/commands.py @@ -41,6 +41,13 @@ from ..data import FeedError, load_frame, parse_date from ..indicators.registry import INDICATORS, names as indicator_names from ..ir.schema import SUPPORTED_IR_VERSIONS, IRValidationError +from ..library.catalog import ( + CatalogValidationError, + catalog_diagnostics, + load_catalog, + searchable_text, + strategy_rows, +) from ..runtime.interpreter import RuntimeError_ from ..runtime.receipt import ReceiptError, build_receipt, canonical_bytes, differences from ..runtime.risk import RiskGate @@ -524,6 +531,123 @@ def command_indicators(args: Any, console: Console) -> int: return EXIT_OK +# --------------------------------------------------------------------------- +# nano library +# --------------------------------------------------------------------------- + + +def _library_document(console: Console) -> Optional[dict[str, Any]]: + try: + return load_catalog() + except CatalogValidationError as error: + for diagnostic in error.diagnostics: + console.warn(f"error: {diagnostic.render()}") + return None + + +def _print_library_rows(rows: Sequence[dict[str, Any]], console: Console) -> None: + console.say("ID\tIR\tHOST SIGNALS") + for row in rows: + signals = ",".join(row.get("requiredHostSignals", [])) + console.say(f"{row['id']}\t{row['irVersion']}\t{signals}") + + +def command_library(args: Any, console: Console) -> int: + """Browse or verify the generated strategy-library catalog.""" + + action = args.library_action + if action == "check": + diagnostics = catalog_diagnostics() + if diagnostics: + for diagnostic in diagnostics: + console.warn(f"error: {diagnostic.render()}") + return EXIT_DIAGNOSTICS + document = _library_document(console) + if document is None: + return EXIT_DIAGNOSTICS + counts = document["irMaturityCounts"] + console.say( + f"catalog ok — {document['strategyCount']} strategies, " + f"{len(document['categoryCounts'])} categories, " + f"{counts.get('baseline', 0)} baseline + {counts.get('v1', 0)} v1" + ) + return EXIT_OK + + document = _library_document(console) + if document is None: + return EXIT_DIAGNOSTICS + rows = strategy_rows(document) + + if action == "show": + needle = args.strategy.casefold() + match = next( + ( + row + for row in rows + if row.get("id", "").casefold() == needle + or row.get("slug", "").casefold() == needle + ), + None, + ) + if match is None: + console.warn(f"error: unknown library strategy {args.strategy!r}") + return EXIT_USAGE + console.say(json.dumps(match, indent=2, ensure_ascii=False)) + return EXIT_OK + + if action == "search": + terms = tuple(term.casefold() for term in args.query.split() if term) + if not terms: + console.warn("error: library search needs a non-empty query") + return EXIT_USAGE + matched = [ + row + for row in rows + if all(term in searchable_text(row) for term in terms) + ] + _print_library_rows(matched, console) + return EXIT_OK + + if action == "filter": + if args.category is None and args.regime is None and args.input is None: + console.warn( + "error: library filter needs --category, --regime, or --input" + ) + return EXIT_USAGE + if any( + value is not None and not value.strip() + for value in (args.category, args.regime, args.input) + ): + console.warn("error: library filter values must not be empty") + return EXIT_USAGE + matched = rows + if args.category is not None: + category = args.category.strip().casefold() + matched = [ + row for row in matched if row.get("category", "").casefold() == category + ] + if args.regime is not None: + regime = args.regime.strip().casefold() + matched = [ + row for row in matched if regime in row.get("regime", "").casefold() + ] + if args.input is not None: + input_name = args.input.strip().casefold() + matched = [ + row + for row in matched + if any( + input_name in str(signal).casefold() + for signal in row.get("requiredHostSignals", []) + ) + ] + _print_library_rows(matched, console) + return EXIT_OK + + _print_library_rows(rows, console) + return EXIT_OK + + def command_version(args: Any, console: Console) -> int: """Print component versions — useful when a host reports a mismatch.""" from .. import __version__ @@ -546,6 +670,7 @@ def command_version(args: Any, console: Console) -> int: "command_check", "command_compile", "command_indicators", + "command_library", "command_replay", "command_version", "command_visualize", diff --git a/nano/cli/main.py b/nano/cli/main.py index 05536ac..7a998f4 100644 --- a/nano/cli/main.py +++ b/nano/cli/main.py @@ -37,6 +37,7 @@ command_check, command_compile, command_indicators, + command_library, command_replay, command_version, command_visualize, @@ -56,6 +57,8 @@ nano compile strategy.nano --emit types nano replay strategy.nano --data bars.csv --date 2026-01-15 --verify nano visualize strategy.nano --format mermaid + nano library search trend + nano library show ema_pullback_continuation """ @@ -158,6 +161,43 @@ def build_parser() -> argparse.ArgumentParser: indicators.add_argument("name", nargs="?", metavar="NAME") indicators.set_defaults(handler=command_indicators) + library = subcommands.add_parser( + "library", help="browse and verify the packaged strategy catalog" + ) + library_actions = library.add_subparsers( + dest="library_action", metavar="ACTION", required=True + ) + + library_list = library_actions.add_parser( + "list", help="list every strategy in stable ID order" + ) + library_list.set_defaults(handler=command_library) + + library_show = library_actions.add_parser( + "show", help="show one strategy's complete metadata as JSON" + ) + library_show.add_argument("strategy", metavar="ID_OR_SLUG") + library_show.set_defaults(handler=command_library) + + library_search = library_actions.add_parser( + "search", help="search all authored and derived metadata" + ) + library_search.add_argument("query", metavar="QUERY") + library_search.set_defaults(handler=command_library) + + library_filter = library_actions.add_parser( + "filter", help="filter by category, regime text, or host input" + ) + library_filter.add_argument("--category", metavar="CATEGORY") + library_filter.add_argument("--regime", metavar="TEXT") + library_filter.add_argument("--input", metavar="SIGNAL") + library_filter.set_defaults(handler=command_library) + + library_check = library_actions.add_parser( + "check", help="verify catalogability and byte-identical regeneration" + ) + library_check.set_defaults(handler=command_library) + version = subcommands.add_parser("version", help="print component versions") version.set_defaults(handler=command_version) diff --git a/nano/library/catalog.py b/nano/library/catalog.py new file mode 100644 index 0000000..de954cf --- /dev/null +++ b/nano/library/catalog.py @@ -0,0 +1,1001 @@ +"""Deterministic metadata catalog for the packaged strategy library. + +The canonical inputs are each strategy's leading ``//`` header and pinned IR +partner. The JSON catalog is a generated projection for the CLI, documentation, +and hosted consumers; contributors never maintain a second metadata record. +""" + +from __future__ import annotations + +import json +import re +from collections import Counter +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any, Mapping, Optional, Sequence + +from nano.ir.schema import ( + NANO_IR_VERSION_1_0, + NANO_IR_VERSION_BASELINE, + SUPPORTED_IR_VERSIONS, +) +from nano.library.contribution import source_provenance_issues + + +CATALOG_SCHEMA_VERSION = 1 +METADATA_VERSION = "StrategyMetadataV1" +CATALOG_PARTS = ("catalog", "strategy_metadata_v1.json") + +_SLUG_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") +_CONFUSED_SLUG = r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*" +_FIELD_RE = re.compile( + r"(?m)^(REGIME|CONDITIONS|INVALIDATION|SHAPE|CALIBRATED ON|SOURCE):[ \t]*" +) +_CONFUSED_RE = re.compile( + rf"(?m)(?:^|(?<=[.;] ))NOT[ \t]+({_CONFUSED_SLUG}):[ \t]*" +) +_REQUIRED_FIELDS = ( + "REGIME", + "CONDITIONS", + "INVALIDATION", + "SHAPE", + "CALIBRATED ON", +) +_CATALOG_KEYS = frozenset( + { + "type", + "schemaVersion", + "metadataVersion", + "strategyCount", + "categoryCounts", + "irMaturityCounts", + "strategies", + } +) +_STRATEGY_KEYS = frozenset( + { + "metadataVersion", + "id", + "slug", + "name", + "category", + "irMaturity", + "irVersion", + "regime", + "conditions", + "invalidation", + "shape", + "calibratedOn", + "nearestConfused", + "provenance", + "requiredHostSignals", + "sourcePath", + "irPath", + } +) +_CONFUSED_KEYS = frozenset({"slug", "id", "distinction"}) + + +@dataclass(frozen=True) +class CatalogDiagnostic: + """One catalogability failure with a source-shaped location.""" + + path: str + message: str + line: Optional[int] = None + + def render(self) -> str: + location = f"{self.path}:{self.line}" if self.line is not None else self.path + return f"{location}: {self.message}" + + +class CatalogValidationError(ValueError): + """Raised when source/IR cannot produce trustworthy catalog metadata.""" + + def __init__(self, diagnostics: Sequence[CatalogDiagnostic]) -> None: + self.diagnostics = tuple(diagnostics) + super().__init__("\n".join(item.render() for item in diagnostics)) + + +@dataclass(frozen=True) +class ConfusedStrategyV1: + """A nearby strategy and the distinction recorded by the source header.""" + + slug: str + distinction: str + + +@dataclass(frozen=True) +class StrategyMetadataV1: + """The complete metadata projection for one strategy source/IR pair.""" + + id: str + slug: str + name: str + category: str + ir_maturity: str + ir_version: str + regime: str + conditions: str + invalidation: str + shape: str + calibrated_on: str + nearest_confused: tuple[ConfusedStrategyV1, ...] + provenance: Optional[str] + required_host_signals: tuple[str, ...] + source_path: str + ir_path: str + + def to_dict(self, slug_ids: Mapping[str, str]) -> dict[str, Any]: + return { + "metadataVersion": METADATA_VERSION, + "id": self.id, + "slug": self.slug, + "name": self.name, + "category": self.category, + "irMaturity": self.ir_maturity, + "irVersion": self.ir_version, + "regime": self.regime, + "conditions": self.conditions, + "invalidation": self.invalidation, + "shape": self.shape, + "calibratedOn": self.calibrated_on, + "nearestConfused": [ + { + "slug": item.slug, + "id": slug_ids.get(item.slug), + "distinction": item.distinction, + } + for item in self.nearest_confused + ], + "provenance": self.provenance, + "requiredHostSignals": list(self.required_host_signals), + "sourcePath": self.source_path, + "irPath": self.ir_path, + } + + +@dataclass(frozen=True) +class _Marker: + kind: str + target: Optional[str] + start: int + end: int + line: int + + +def library_resource_root() -> Any: + """Return the package resource containing source, IR, and generated catalog.""" + + return resources.files("nano.library") + + +def default_library_path() -> Path: + """Return the filesystem library root used by repository generators.""" + + return Path(__file__).resolve().parent + + +def catalog_resource(library_root: Optional[Any] = None) -> Any: + resource = library_root or library_resource_root() + return resource.joinpath(*CATALOG_PARTS) + + +def catalog_path(library_root: Optional[Path] = None) -> Path: + return (library_root or default_library_path()).joinpath(*CATALOG_PARTS) + + +def _leading_header(source: str) -> tuple[str, list[int], list[str]]: + """Return header text, per-character source lines, and original comment lines.""" + + pieces: list[str] = [] + line_map: list[int] = [] + header_lines: list[str] = [] + saw_comment = False + for number, raw_line in enumerate(source.splitlines(), start=1): + stripped = raw_line.strip() + if not stripped: + if saw_comment: + pieces.append("\n") + line_map.append(number) + continue + if not stripped.startswith("//"): + break + saw_comment = True + header_lines.append(stripped) + # Metadata fields use the contribution header's exact ``// FIELD:`` + # spelling. A comment like ``//SOURCE:`` remains ordinary prose and + # cannot bypass contribution.py's optional provenance policy. + text = stripped[3:] if stripped.startswith("// ") else stripped + pieces.append(text) + line_map.extend([number] * len(text)) + pieces.append("\n") + line_map.append(number) + return "".join(pieces), line_map, header_lines + + +def _normalise(value: str) -> str: + return " ".join(value.split()) + + +def _line_at(line_map: Sequence[int], offset: int) -> int: + if not line_map: + return 1 + return line_map[min(offset, len(line_map) - 1)] + + +def _parse_header( + source: str, *, source_path: str, slug: str +) -> tuple[dict[str, str], tuple[ConfusedStrategyV1, ...]]: + blob, line_map, header_lines = _leading_header(source) + if not blob: + raise CatalogValidationError( + ( + CatalogDiagnostic( + source_path, + "no leading `//` metadata header; expected REGIME, CONDITIONS, " + "INVALIDATION, SHAPE, CALIBRATED ON, and NOT fields", + 1, + ), + ) + ) + + markers: list[_Marker] = [] + for match in _FIELD_RE.finditer(blob): + markers.append( + _Marker( + match.group(1), + None, + match.start(), + match.end(), + _line_at(line_map, match.start()), + ) + ) + for match in _CONFUSED_RE.finditer(blob): + markers.append( + _Marker( + "NOT", + match.group(1), + match.start(), + match.end(), + _line_at(line_map, match.start()), + ) + ) + markers.sort(key=lambda item: item.start) + + diagnostics = [ + CatalogDiagnostic(source_path, issue) + for issue in source_provenance_issues(header_lines) + ] + fields: dict[str, str] = {} + confused: list[ConfusedStrategyV1] = [] + confused_occurrences: Counter[str] = Counter() + occurrences: Counter[str] = Counter() + + for index, marker in enumerate(markers): + end = markers[index + 1].start if index + 1 < len(markers) else len(blob) + value = _normalise(blob[marker.end:end]) + if marker.kind == "NOT": + assert marker.target is not None + confused_occurrences[marker.target] += 1 + if confused_occurrences[marker.target] > 1: + diagnostics.append( + CatalogDiagnostic( + source_path, + f"duplicate `NOT {marker.target}:` nearest-confused field", + marker.line, + ) + ) + if marker.target == slug: + diagnostics.append( + CatalogDiagnostic( + source_path, + f"`NOT {marker.target}:` points back to the same strategy", + marker.line, + ) + ) + if not value: + diagnostics.append( + CatalogDiagnostic( + source_path, + f"`NOT {marker.target}:` needs a concrete distinction", + marker.line, + ) + ) + confused.append(ConfusedStrategyV1(marker.target, value)) + continue + + occurrences[marker.kind] += 1 + if occurrences[marker.kind] > 1: + # contribution.py owns the optional SOURCE policy so its wording + # remains one precise contract instead of two competing errors. + if marker.kind != "SOURCE": + diagnostics.append( + CatalogDiagnostic( + source_path, + f"duplicate `{marker.kind}:` metadata field", + marker.line, + ) + ) + continue + if not value: + if marker.kind != "SOURCE": + diagnostics.append( + CatalogDiagnostic( + source_path, + f"`{marker.kind}:` metadata field is empty", + marker.line, + ) + ) + fields[marker.kind] = value + + for field in _REQUIRED_FIELDS: + if occurrences[field] == 0: + diagnostics.append( + CatalogDiagnostic( + source_path, + f"missing `// {field}:` metadata field", + 1, + ) + ) + if not confused: + diagnostics.append( + CatalogDiagnostic( + source_path, + "missing `NOT :` nearest-confused strategy field", + 1, + ) + ) + + if diagnostics: + raise CatalogValidationError(diagnostics) + return fields, tuple(confused) + + +def _required_host_signals( + document: Mapping[str, Any], *, source_path: str +) -> tuple[str, ...]: + version = document.get("nanoIrVersion") + if version == NANO_IR_VERSION_BASELINE: + values = [ + node.get("signal") + for node in document.get("nodes", []) + if isinstance(node, Mapping) and node.get("type") == "Condition" + ] + elif version == NANO_IR_VERSION_1_0: + values = [ + declaration.get("name") + for declaration in document.get("inputs", []) + if isinstance(declaration, Mapping) + ] + else: + raise CatalogValidationError( + ( + CatalogDiagnostic( + source_path, + f"unsupported nanoIrVersion {version!r}; expected one of " + f"{SUPPORTED_IR_VERSIONS!r}", + ), + ) + ) + + if any(not isinstance(value, str) or not value for value in values): + raise CatalogValidationError( + ( + CatalogDiagnostic( + source_path, + "IR contains a host input or condition without a non-empty name", + ), + ) + ) + return tuple(sorted(set(values))) + + +def parse_strategy_metadata( + source: str, + document: Mapping[str, Any], + *, + category: str, + slug: str, + source_path: Optional[str] = None, +) -> StrategyMetadataV1: + """Parse one canonical source header and enrich it from its pinned IR.""" + + display_path = source_path or f"library/{category}/{slug}.nano" + diagnostics: list[CatalogDiagnostic] = [] + for label, value in (("category", category), ("slug", slug)): + if not _SLUG_RE.fullmatch(value): + diagnostics.append( + CatalogDiagnostic( + display_path, + f"{label} {value!r} is not a stable lowercase snake_case identifier", + ) + ) + name = document.get("name") + if not isinstance(name, str) or not name: + diagnostics.append( + CatalogDiagnostic(display_path, "IR is missing a non-empty strategy `name`") + ) + if diagnostics: + raise CatalogValidationError(diagnostics) + + fields, confused = _parse_header(source, source_path=display_path, slug=slug) + version = document.get("nanoIrVersion") + required_host_signals = _required_host_signals( + document, source_path=display_path + ) + maturity = { + NANO_IR_VERSION_BASELINE: "baseline", + NANO_IR_VERSION_1_0: "v1", + }[version] + + strategy_id = f"{category}/{slug}" + return StrategyMetadataV1( + id=strategy_id, + slug=slug, + name=name, + category=category, + ir_maturity=maturity, + ir_version=version, + regime=fields["REGIME"], + conditions=fields["CONDITIONS"], + invalidation=fields["INVALIDATION"], + shape=fields["SHAPE"], + calibrated_on=fields["CALIBRATED ON"], + nearest_confused=confused, + provenance=fields.get("SOURCE"), + required_host_signals=required_host_signals, + source_path=f"library/{strategy_id}.nano", + ir_path=f"library/{strategy_id}_ir.json", + ) + + +def _walk_sources(root: Any) -> list[tuple[tuple[str, ...], Any]]: + found: list[tuple[tuple[str, ...], Any]] = [] + + def walk(directory: Any, prefix: tuple[str, ...]) -> None: + for child in sorted(directory.iterdir(), key=lambda item: item.name): + relative = prefix + (child.name,) + if child.is_dir(): + walk(child, relative) + elif child.is_file() and child.name.endswith(".nano"): + found.append((relative, child)) + + walk(root, ()) + return found + + +def _load_entry(relative: tuple[str, ...], nano_resource: Any) -> StrategyMetadataV1: + display_path = f"library/{'/'.join(relative)}" + if len(relative) != 2: + raise CatalogValidationError( + ( + CatalogDiagnostic( + display_path, + "strategy must live exactly at library//.nano", + ), + ) + ) + category, filename = relative + slug = filename[: -len(".nano")] + partner = nano_resource.parent.joinpath(f"{slug}_ir.json") + if not partner.is_file(): + raise CatalogValidationError( + ( + CatalogDiagnostic( + display_path, + f"missing pinned IR partner `{slug}_ir.json`", + ), + ) + ) + try: + source = nano_resource.read_text(encoding="utf-8-sig") + except (OSError, UnicodeDecodeError) as error: + raise CatalogValidationError( + (CatalogDiagnostic(display_path, f"cannot read UTF-8 source: {error}"),) + ) from error + ir_display = f"library/{category}/{slug}_ir.json" + try: + document = json.loads(partner.read_text(encoding="utf-8")) + except OSError as error: + raise CatalogValidationError( + (CatalogDiagnostic(ir_display, f"cannot read pinned IR: {error}"),) + ) from error + except json.JSONDecodeError as error: + raise CatalogValidationError( + ( + CatalogDiagnostic( + ir_display, + f"pinned IR is not valid JSON: {error.msg}", + error.lineno, + ), + ) + ) from error + if not isinstance(document, Mapping): + raise CatalogValidationError( + (CatalogDiagnostic(ir_display, "pinned IR must be a JSON object"),) + ) + return parse_strategy_metadata( + source, + document, + category=category, + slug=slug, + source_path=display_path, + ) + + +def build_catalog(library_root: Optional[Any] = None) -> dict[str, Any]: + """Build the complete catalog document in stable ID order.""" + + root = library_root or library_resource_root() + metadata: list[StrategyMetadataV1] = [] + diagnostics: list[CatalogDiagnostic] = [] + for relative, resource in _walk_sources(root): + try: + metadata.append(_load_entry(relative, resource)) + except CatalogValidationError as error: + diagnostics.extend(error.diagnostics) + + ids = [item.id for item in metadata] + slugs = [item.slug for item in metadata] + for label, values in (("stable ID", ids), ("slug", slugs)): + duplicates = sorted( + value for value, count in Counter(values).items() if count > 1 + ) + for duplicate in duplicates: + diagnostics.append( + CatalogDiagnostic( + "library", + f"duplicate {label} {duplicate!r}; lookup would be ambiguous", + ) + ) + if not metadata and not diagnostics: + diagnostics.append(CatalogDiagnostic("library", "no strategy sources found")) + if diagnostics: + raise CatalogValidationError(diagnostics) + + metadata.sort(key=lambda item: item.id) + slug_ids = {item.slug: item.id for item in metadata} + category_counts = Counter(item.category for item in metadata) + maturity_counts = Counter(item.ir_maturity for item in metadata) + return { + "type": "NanoStrategyCatalog", + "schemaVersion": CATALOG_SCHEMA_VERSION, + "metadataVersion": METADATA_VERSION, + "strategyCount": len(metadata), + "categoryCounts": { + category: category_counts[category] for category in sorted(category_counts) + }, + "irMaturityCounts": { + maturity: maturity_counts[maturity] + for maturity in ("baseline", "v1") + if maturity_counts[maturity] + }, + "strategies": [item.to_dict(slug_ids) for item in metadata], + } + + +def render_catalog(document: Mapping[str, Any]) -> str: + """Render a catalog with one canonical byte representation.""" + + return json.dumps(document, ensure_ascii=False, indent=2) + "\n" + + +def generate_catalog_text(library_root: Optional[Any] = None) -> str: + return render_catalog(build_catalog(library_root)) + + +def write_catalog(library_root: Optional[Path] = None) -> Path: + """Regenerate the checked-in artifact from source/IR and return its path.""" + + root = library_root or default_library_path() + output = catalog_path(root) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(generate_catalog_text(root), encoding="utf-8", newline="\n") + return output + + +def _document_diagnostics( + document: Any, *, artifact_path: str +) -> tuple[CatalogDiagnostic, ...]: + diagnostics: list[CatalogDiagnostic] = [] + if not isinstance(document, Mapping): + return (CatalogDiagnostic(artifact_path, "catalog must be a JSON object"),) + if set(document) != _CATALOG_KEYS: + missing = sorted(_CATALOG_KEYS - set(document)) + unknown = sorted(set(document) - _CATALOG_KEYS) + diagnostics.append( + CatalogDiagnostic( + artifact_path, + f"catalog keys must match StrategyMetadataV1 exactly; " + f"missing={missing!r}, unknown={unknown!r}", + ) + ) + if document.get("type") != "NanoStrategyCatalog": + diagnostics.append( + CatalogDiagnostic(artifact_path, "catalog `type` must be NanoStrategyCatalog") + ) + if ( + type(document.get("schemaVersion")) is not int + or document.get("schemaVersion") != CATALOG_SCHEMA_VERSION + ): + diagnostics.append( + CatalogDiagnostic( + artifact_path, + f"unsupported schemaVersion {document.get('schemaVersion')!r}", + ) + ) + if document.get("metadataVersion") != METADATA_VERSION: + diagnostics.append( + CatalogDiagnostic( + artifact_path, + f"unsupported metadataVersion {document.get('metadataVersion')!r}", + ) + ) + strategies = document.get("strategies") + if not isinstance(strategies, list): + diagnostics.append( + CatalogDiagnostic(artifact_path, "`strategies` must be an array") + ) + return tuple(diagnostics) + if ( + type(document.get("strategyCount")) is not int + or document.get("strategyCount") != len(strategies) + ): + diagnostics.append( + CatalogDiagnostic( + artifact_path, + "strategyCount does not match the strategies array", + ) + ) + ids: list[str] = [] + slugs: list[str] = [] + categories: list[str] = [] + maturities: list[str] = [] + required_strings = ( + "id", + "slug", + "name", + "category", + "irMaturity", + "irVersion", + "regime", + "conditions", + "invalidation", + "shape", + "calibratedOn", + "sourcePath", + "irPath", + ) + for index, entry in enumerate(strategies): + location = f"{artifact_path}#strategies[{index}]" + if not isinstance(entry, Mapping): + diagnostics.append(CatalogDiagnostic(location, "entry must be an object")) + continue + if set(entry) != _STRATEGY_KEYS: + missing = sorted(_STRATEGY_KEYS - set(entry)) + unknown = sorted(set(entry) - _STRATEGY_KEYS) + diagnostics.append( + CatalogDiagnostic( + location, + f"strategy keys must match StrategyMetadataV1 exactly; " + f"missing={missing!r}, unknown={unknown!r}", + ) + ) + if entry.get("metadataVersion") != METADATA_VERSION: + diagnostics.append( + CatalogDiagnostic(location, "entry has an unsupported metadataVersion") + ) + for field in required_strings: + if not isinstance(entry.get(field), str) or not entry.get(field): + diagnostics.append( + CatalogDiagnostic(location, f"`{field}` must be a non-empty string") + ) + strategy_id = entry.get("id") + slug = entry.get("slug") + category = entry.get("category") + maturity = entry.get("irMaturity") + version = entry.get("irVersion") + if isinstance(strategy_id, str): + ids.append(strategy_id) + if isinstance(slug, str): + slugs.append(slug) + if isinstance(category, str): + categories.append(category) + if isinstance(maturity, str): + maturities.append(maturity) + if all(isinstance(value, str) for value in (strategy_id, slug, category)): + if not _SLUG_RE.fullmatch(slug) or not _SLUG_RE.fullmatch(category): + diagnostics.append( + CatalogDiagnostic( + location, + "category and slug must be stable lowercase snake_case identifiers", + ) + ) + expected_id = f"{category}/{slug}" + if strategy_id != expected_id: + diagnostics.append( + CatalogDiagnostic(location, f"stable ID must be {expected_id!r}") + ) + if entry.get("sourcePath") != f"library/{expected_id}.nano": + diagnostics.append( + CatalogDiagnostic(location, "sourcePath does not match the stable ID") + ) + if entry.get("irPath") != f"library/{expected_id}_ir.json": + diagnostics.append( + CatalogDiagnostic(location, "irPath does not match the stable ID") + ) + expected_maturity = { + NANO_IR_VERSION_BASELINE: "baseline", + NANO_IR_VERSION_1_0: "v1", + }.get(version) + if expected_maturity is None or maturity != expected_maturity: + diagnostics.append( + CatalogDiagnostic(location, "IR maturity and version do not agree") + ) + signals = entry.get("requiredHostSignals") + if ( + not isinstance(signals, list) + or any(not isinstance(value, str) or not value for value in signals) + or signals != sorted(set(signals)) + ): + diagnostics.append( + CatalogDiagnostic( + location, + "requiredHostSignals must be unique non-empty strings in stable order", + ) + ) + confused = entry.get("nearestConfused") + if not isinstance(confused, list) or not confused: + diagnostics.append( + CatalogDiagnostic(location, "nearestConfused must be a non-empty array") + ) + else: + confused_slugs: list[str] = [] + for item in confused: + if not isinstance(item, Mapping): + diagnostics.append( + CatalogDiagnostic( + location, "nearestConfused contains a malformed entry" + ) + ) + continue + if set(item) != _CONFUSED_KEYS: + missing = sorted(_CONFUSED_KEYS - set(item)) + unknown = sorted(set(item) - _CONFUSED_KEYS) + diagnostics.append( + CatalogDiagnostic( + location, + f"nearestConfused keys must match StrategyMetadataV1 " + f"exactly; missing={missing!r}, unknown={unknown!r}", + ) + ) + confused_slug = item.get("slug") + if isinstance(confused_slug, str): + confused_slugs.append(confused_slug) + if ( + not isinstance(confused_slug, str) + or not confused_slug + or _SLUG_RE.fullmatch(confused_slug) is None + or not isinstance(item.get("distinction"), str) + or not item.get("distinction") + or ( + item.get("id") is not None + and not isinstance(item.get("id"), str) + ) + ): + diagnostics.append( + CatalogDiagnostic( + location, "nearestConfused contains a malformed entry" + ) + ) + if len(confused_slugs) != len(set(confused_slugs)): + diagnostics.append( + CatalogDiagnostic( + location, "nearestConfused slugs must be unique" + ) + ) + provenance = entry.get("provenance") + if provenance is not None and ( + not isinstance(provenance, str) or not provenance + ): + diagnostics.append( + CatalogDiagnostic(location, "provenance must be null or a non-empty string") + ) + + if ids != sorted(ids) or len(ids) != len(strategies): + diagnostics.append( + CatalogDiagnostic( + artifact_path, + "strategy entries are malformed or not in stable ID order", + ) + ) + if len(ids) != len(set(ids)) or len(slugs) != len(set(slugs)): + diagnostics.append( + CatalogDiagnostic(artifact_path, "strategy IDs and slugs must be unique") + ) + slug_ids = { + entry.get("slug"): entry.get("id") + for entry in strategies + if isinstance(entry, Mapping) + and isinstance(entry.get("slug"), str) + and isinstance(entry.get("id"), str) + } + for index, entry in enumerate(strategies): + if not isinstance(entry, Mapping) or not isinstance( + entry.get("nearestConfused"), list + ): + continue + for item in entry["nearestConfused"]: + if not isinstance(item, Mapping) or not isinstance(item.get("slug"), str): + continue + if item.get("id") != slug_ids.get(item["slug"]): + diagnostics.append( + CatalogDiagnostic( + f"{artifact_path}#strategies[{index}]", + "nearestConfused ID does not resolve from its slug", + ) + ) + counts = Counter(categories) + expected_counts = {name: counts[name] for name in sorted(counts)} + actual_category_counts = document.get("categoryCounts") + if ( + not isinstance(actual_category_counts, Mapping) + or set(actual_category_counts) != set(expected_counts) + or any( + type(actual_category_counts[name]) is not int + or actual_category_counts[name] != count + for name, count in expected_counts.items() + ) + ): + diagnostics.append( + CatalogDiagnostic( + artifact_path, + "categoryCounts does not match the strategy entries", + ) + ) + maturity_counts = Counter(maturities) + expected_maturity_counts = { + maturity: maturity_counts[maturity] + for maturity in ("baseline", "v1") + if maturity_counts[maturity] + } + actual_maturity_counts = document.get("irMaturityCounts") + if ( + not isinstance(actual_maturity_counts, Mapping) + or set(actual_maturity_counts) != set(expected_maturity_counts) + or any( + type(actual_maturity_counts[name]) is not int + or actual_maturity_counts[name] != count + for name, count in expected_maturity_counts.items() + ) + ): + diagnostics.append( + CatalogDiagnostic( + artifact_path, + "irMaturityCounts does not match the strategy entries", + ) + ) + return tuple(diagnostics) + + +def catalog_diagnostics( + library_root: Optional[Any] = None, +) -> tuple[CatalogDiagnostic, ...]: + """Return catalogability or generated-artifact drift diagnostics.""" + + root = library_root or library_resource_root() + try: + expected = generate_catalog_text(root) + except CatalogValidationError as error: + return error.diagnostics + artifact = catalog_resource(root) + artifact_path = f"library/{'/'.join(CATALOG_PARTS)}" + if not artifact.is_file(): + return ( + CatalogDiagnostic( + artifact_path, + "generated catalog is missing; run scripts/generate_catalog.py", + ), + ) + try: + actual_bytes = artifact.read_bytes() + except OSError as error: + return ( + CatalogDiagnostic( + artifact_path, f"cannot read generated catalog: {error}" + ), + ) + if actual_bytes != expected.encode("utf-8"): + return ( + CatalogDiagnostic( + artifact_path, + "generated catalog is stale or non-canonical; run " + "scripts/generate_catalog.py", + ), + ) + try: + actual = actual_bytes.decode("utf-8") + document = json.loads(actual) + except (UnicodeDecodeError, json.JSONDecodeError) as error: # pragma: no cover + return ( + CatalogDiagnostic( + artifact_path, f"catalog is not valid UTF-8 JSON: {error}" + ), + ) + return _document_diagnostics(document, artifact_path=artifact_path) + + +def load_catalog(library_root: Optional[Any] = None) -> dict[str, Any]: + """Load and validate the shipped generated artifact.""" + + artifact = catalog_resource(library_root) + artifact_path = f"library/{'/'.join(CATALOG_PARTS)}" + try: + document = json.loads(artifact.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise CatalogValidationError( + (CatalogDiagnostic(artifact_path, f"cannot load catalog: {error}"),) + ) from error + diagnostics = _document_diagnostics(document, artifact_path=artifact_path) + if diagnostics: + raise CatalogValidationError(diagnostics) + return dict(document) + + +def strategy_rows(document: Mapping[str, Any]) -> list[dict[str, Any]]: + return [ + dict(entry) + for entry in document.get("strategies", []) + if isinstance(entry, Mapping) + ] + + +def searchable_text(strategy: Mapping[str, Any]) -> str: + """Flatten authored and derived metadata for case-insensitive search.""" + + values: list[Any] = [ + strategy.get("id"), + strategy.get("slug"), + strategy.get("name"), + strategy.get("category"), + strategy.get("irMaturity"), + strategy.get("irVersion"), + strategy.get("regime"), + strategy.get("conditions"), + strategy.get("invalidation"), + strategy.get("shape"), + strategy.get("calibratedOn"), + strategy.get("provenance"), + *strategy.get("requiredHostSignals", []), + ] + confused = strategy.get("nearestConfused", []) + if isinstance(confused, list): + for item in confused: + if isinstance(item, Mapping): + values.extend( + (item.get("slug"), item.get("id"), item.get("distinction")) + ) + return " ".join(str(value) for value in values if value is not None).casefold() + + +__all__ = ( + "CATALOG_PARTS", + "CATALOG_SCHEMA_VERSION", + "METADATA_VERSION", + "CatalogDiagnostic", + "CatalogValidationError", + "ConfusedStrategyV1", + "StrategyMetadataV1", + "build_catalog", + "catalog_diagnostics", + "catalog_path", + "generate_catalog_text", + "load_catalog", + "parse_strategy_metadata", + "render_catalog", + "searchable_text", + "strategy_rows", + "write_catalog", +) diff --git a/nano/library/catalog/strategy_metadata_v1.json b/nano/library/catalog/strategy_metadata_v1.json new file mode 100644 index 0000000..7b51c0b --- /dev/null +++ b/nano/library/catalog/strategy_metadata_v1.json @@ -0,0 +1,1586 @@ +{ + "type": "NanoStrategyCatalog", + "schemaVersion": 1, + "metadataVersion": "StrategyMetadataV1", + "strategyCount": 53, + "categoryCounts": { + "event_volatility": 11, + "mean_reversion": 6, + "momentum": 6, + "risk": 7, + "trend": 7, + "volatility": 4, + "volume": 4, + "watchdog": 8 + }, + "irMaturityCounts": { + "baseline": 41, + "v1": 12 + }, + "strategies": [ + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/cpi_impulse_pullback_long", + "slug": "cpi_impulse_pullback_long", + "name": "CpiImpulsePullbackLong", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "CPI releases where the print is genuinely cool AND the tape agrees. Do NOT fire on a cool print alone - the impulse and retrace terms must confirm - and do NOT fire when the release packet is missing, provisional, or conflicted: RELEASE_CONFIRMED stays 0 and this branch honestly abstains.", + "conditions": "armed event, open entry window, confirmed release, cool CPI, upward impulse at least 0.8 pre-event ATRs, held retracement, cross-market agreement, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and the one-position-per-event lock.", + "shape": "5s cadence after the observe-only impulse window; cool print, leg up, shallow pullback that holds, continuation.", + "calibratedOn": "MES around US CPI, 5s cadence. The 0.72 release threshold is a versioned fixed scale until enough forecast-error history exists; never silently retune it between events.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_long", + "id": "event_volatility/event_impulse_pullback_long", + "distinction": "same tape shape, but this variant demands execution-grade release provenance and the fundamental sign to match the move. When both qualify, the proposal board still selects only one." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BULL_CROSS_CONFIRM", + "CPI_COOL_SCORE", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "RELEASE_CONFIRMED", + "RETRACE_HOLD_SCORE", + "UPSIDE_IMPULSE_ATR" + ], + "sourcePath": "library/event_volatility/cpi_impulse_pullback_long.nano", + "irPath": "library/event_volatility/cpi_impulse_pullback_long_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/cpi_impulse_pullback_short", + "slug": "cpi_impulse_pullback_short", + "name": "CpiImpulsePullbackShort", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "CPI releases where the print is genuinely hot AND the tape agrees. Do NOT fire on a hot print alone, and do NOT fire when the release packet is missing, provisional, or conflicted - this branch abstains.", + "conditions": "armed event, open entry window, confirmed release, hot CPI, downward impulse at least 0.8 pre-event ATRs, held retracement, cross-market agreement, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and the one-position-per-event lock.", + "shape": "5s cadence after the observe-only impulse window; hot print, leg down, weak bounce that fails, continuation.", + "calibratedOn": "MES around US CPI, 5s cadence. The 0.72 release threshold is a versioned fixed scale until enough forecast-error history exists; never silently retune it between events.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_short", + "id": "event_volatility/event_impulse_pullback_short", + "distinction": "same tape shape, but this variant demands execution-grade release provenance and the fundamental sign to match." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BEAR_CROSS_CONFIRM", + "CPI_HOT_SCORE", + "DOWNSIDE_IMPULSE_ATR", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "RELEASE_CONFIRMED", + "RETRACE_HOLD_SCORE" + ], + "sourcePath": "library/event_volatility/cpi_impulse_pullback_short.nano", + "irPath": "library/event_volatility/cpi_impulse_pullback_short_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_false_first_move_long", + "slug": "event_false_first_move_long", + "name": "EventFalseFirstMoveLong", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "red-folder releases where the first move traps breakout sellers - common on headline/core disagreement. Do NOT fire on a clean downside continuation; BREAK_FAILURE_SCORE and EVENT_ANCHOR_RECLAIM must both prove the failure first.", + "conditions": "armed event, open entry window, downside range break, proven break failure, anchor reclaim, bullish flow, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and re-entry lock. Losing the reclaimed anchor is the natural stop reference.", + "shape": "5s cadence; spike down through the pre-event low, sharp rejection, reclaim of anchor, then reversal higher.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. Break/reclaim scores are range-relative and travel across index futures; re-verify before promotion.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_long", + "id": "event_volatility/event_impulse_pullback_long", + "distinction": "that rule needs an upward first impulse; this one requires the first impulse to have been DOWN and to have failed. They cannot both qualify on the same event path." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BREAK_FAILURE_SCORE", + "BULL_FLOW_CONFIRM", + "ENTRY_WINDOW_OPEN", + "EVENT_ANCHOR_RECLAIM", + "EVENT_READY", + "LIQUIDITY_OK", + "PRE_EVENT_RANGE_BREAK_DOWN" + ], + "sourcePath": "library/event_volatility/event_false_first_move_long.nano", + "irPath": "library/event_volatility/event_false_first_move_long_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_false_first_move_short", + "slug": "event_false_first_move_short", + "name": "EventFalseFirstMoveShort", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "red-folder releases where the first move traps breakout buyers. Do NOT fire on a clean upside continuation; BREAK_FAILURE_SCORE and EVENT_ANCHOR_REJECT must both prove the failure first.", + "conditions": "armed event, open entry window, upside range break, proven break failure, anchor rejection, bearish flow, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and re-entry lock. Reclaiming the rejected anchor is the natural stop reference.", + "shape": "5s cadence; spike up through the pre-event high, sharp rejection, loss of anchor, then reversal lower.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. Break/reclaim scores are range-relative; re-verify before promotion.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_short", + "id": "event_volatility/event_impulse_pullback_short", + "distinction": "that rule needs a downward first impulse; this one requires the first impulse to have been UP and to have failed." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BEAR_FLOW_CONFIRM", + "BREAK_FAILURE_SCORE", + "ENTRY_WINDOW_OPEN", + "EVENT_ANCHOR_REJECT", + "EVENT_READY", + "LIQUIDITY_OK", + "PRE_EVENT_RANGE_BREAK_UP" + ], + "sourcePath": "library/event_volatility/event_false_first_move_short.nano", + "irPath": "library/event_volatility/event_false_first_move_short_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_impulse_pullback_long", + "slug": "event_impulse_pullback_long", + "name": "EventImpulsePullbackLong", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "scheduled macro releases (CPI, PPI, NFP, FOMC stage A) with a decisive first impulse that holds its retracement. Do NOT fire outside an armed event window - EVENT_READY and ENTRY_WINDOW_OPEN are host gates, not suggestions.", + "conditions": "armed event, open entry window, upward impulse at least 0.8 pre-event ATRs, at least 60 percent of the impulse held through the first pullback, cross-market agreement, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat at T+10m, and the one-position-per-event lock. This rule only proposes.", + "shape": "5s decision cadence after the observe-only impulse window; a strong leg up, a shallow pullback that holds, then continuation.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. Impulse and retrace thresholds are event-scaled (ATR-relative) and travel better than absolute points, but re-verify per event type before promotion.", + "nearestConfused": [ + { + "slug": "cpi_impulse_pullback_long", + "id": "event_volatility/cpi_impulse_pullback_long", + "distinction": "that variant additionally requires a CONFIRMED release packet and a cool CPI print. This one trades the tape reaction alone so it can run while the release adapter is unavailable." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BULL_CROSS_CONFIRM", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "RETRACE_HOLD_SCORE", + "UPSIDE_IMPULSE_ATR" + ], + "sourcePath": "library/event_volatility/event_impulse_pullback_long.nano", + "irPath": "library/event_volatility/event_impulse_pullback_long_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_impulse_pullback_short", + "slug": "event_impulse_pullback_short", + "name": "EventImpulsePullbackShort", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "scheduled macro releases with a decisive first impulse lower that holds its retracement. Do NOT fire outside an armed event window.", + "conditions": "armed event, open entry window, downward impulse at least 0.8 pre-event ATRs, at least 60 percent of the impulse held through the first pullback, bearish cross-market agreement, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat at T+10m, and the one-position-per-event lock.", + "shape": "5s decision cadence after the observe-only impulse window; a strong leg down, a shallow bounce that fails, then continuation.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. ATR-relative thresholds; re-verify per event type before promotion.", + "nearestConfused": [ + { + "slug": "event_false_first_move_short", + "id": "event_volatility/event_false_first_move_short", + "distinction": "that rule sells a FAILED upside breakout; this one sells confirmed downside continuation. The host's mutually exclusive impulse terms prevent both arming on the same tape." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BEAR_CROSS_CONFIRM", + "DOWNSIDE_IMPULSE_ATR", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "RETRACE_HOLD_SCORE" + ], + "sourcePath": "library/event_volatility/event_impulse_pullback_short.nano", + "irPath": "library/event_volatility/event_impulse_pullback_short_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_liquidity_halt", + "slug": "event_liquidity_halt", + "name": "EventLiquidityHalt", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "any armed macro event window. This is a control, not a directional hypothesis - it emits PAUSE and must never be promoted into the execution slot or counted as a setup. The host enforces the same liquidity gate directly; this published rule is the auditable explanation.", + "conditions": "none beyond the stress reading. It is armed whenever the event window is live.", + "invalidation": "none - a breaker is not a trade. It releases when SPREAD_STRESS falls back under the threshold.", + "shape": "1s, because spread blowouts around a release happen and clear inside single seconds; a 5s control would react after the damage.", + "calibratedOn": "MES/MNQ around US red-folder releases. 0.8 means the spread is near its stressed ceiling relative to the pre-event baseline; the baseline normalization travels across instruments, the ceiling does not - re-verify per instrument class.", + "nearestConfused": [ + { + "slug": "event_whipsaw_halt", + "id": "event_volatility/event_whipsaw_halt", + "distinction": "that halts on direction churn of the tape; this halts on the cost of trading it. Both should be armed during every event." + } + ], + "provenance": null, + "requiredHostSignals": [ + "SPREAD_STRESS" + ], + "sourcePath": "library/event_volatility/event_liquidity_halt.nano", + "irPath": "library/event_volatility/event_liquidity_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_release_integrity_halt", + "slug": "event_release_integrity_halt", + "name": "EventReleaseIntegrityHalt", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "any armed macro event window. Control only - emits PAUSE, never promoted, never counted as a setup. The host's fail-closed gates enforce the same rule directly; this published rule is the auditable explanation.", + "conditions": "none beyond the conflict flag.", + "invalidation": "none - releases only if the host downgrades the conflict, which in v1 it does not do during the event window.", + "shape": "1s, so a conflicted release halts proposals before the first 5s qualify tick can act on poisoned numbers.", + "calibratedOn": "not calibrated - the flag is binary by contract and travels unchanged across all event types and instruments.", + "nearestConfused": [ + { + "slug": "event_liquidity_halt", + "id": "event_volatility/event_liquidity_halt", + "distinction": "that halts on market conditions; this halts on data trust. A perfectly liquid tape with a conflicted release must still halt every release-aware branch." + } + ], + "provenance": null, + "requiredHostSignals": [ + "RELEASE_CONFLICT" + ], + "sourcePath": "library/event_volatility/event_release_integrity_halt.nano", + "irPath": "library/event_volatility/event_release_integrity_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_second_leg_long", + "slug": "event_second_leg_long", + "name": "EventSecondLegLong", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "CPI, NFP, and FOMC statement legs where the real move develops after the first shakeout. Do NOT fire on the first impulse itself - SECOND_LEG_SCORE stays 0 until the compression-then-break sequence is complete.", + "conditions": "armed event, open entry window, decisive first impulse up, completed second-leg sequence, bullish flow, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and re-entry lock. Falling back inside the compression is the natural stop reference.", + "shape": "5s cadence; impulse up, 30-90s of compression holding most of the move, then a break of the first impulse high.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. Sequence-based score travels across event types; re-verify thresholds.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_long", + "id": "event_volatility/event_impulse_pullback_long", + "distinction": "that rule enters on the first held pullback; this one requires the later break of the first-move extreme and therefore enters deeper into the entry window at a better-proven price." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BULL_FLOW_CONFIRM", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "SECOND_LEG_SCORE", + "UPSIDE_IMPULSE_ATR" + ], + "sourcePath": "library/event_volatility/event_second_leg_long.nano", + "irPath": "library/event_volatility/event_second_leg_long_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_second_leg_short", + "slug": "event_second_leg_short", + "name": "EventSecondLegShort", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "CPI, NFP, and FOMC statement legs where the real move lower develops after the first shakeout. Do NOT fire on the first impulse itself.", + "conditions": "armed event, open entry window, decisive first impulse down, completed second-leg sequence, bearish flow, normalized liquidity.", + "invalidation": "host event risk model owns stop, hard flat, and re-entry lock. Reclaiming the compression is the natural stop reference.", + "shape": "5s cadence; impulse down, 30-90s compression holding most of the move, then a break of the first impulse low.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. Sequence-based score travels across event types; re-verify thresholds.", + "nearestConfused": [ + { + "slug": "event_false_first_move_short", + "id": "event_volatility/event_false_first_move_short", + "distinction": "that rule sells a failed UPSIDE breakout; this one sells continuation of a proven DOWNSIDE impulse after compression." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BEAR_FLOW_CONFIRM", + "DOWNSIDE_IMPULSE_ATR", + "ENTRY_WINDOW_OPEN", + "EVENT_READY", + "LIQUIDITY_OK", + "SECOND_LEG_SCORE" + ], + "sourcePath": "library/event_volatility/event_second_leg_short.nano", + "irPath": "library/event_volatility/event_second_leg_short_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "event_volatility/event_whipsaw_halt", + "slug": "event_whipsaw_halt", + "name": "EventWhipsawHalt", + "category": "event_volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "any armed macro event window. Control only - emits PAUSE, never promoted, never counted as a setup. The host enforces the same churn gate directly; this published rule is the auditable explanation.", + "conditions": "none beyond the churn reading.", + "invalidation": "none - releases when the tape picks a side and WHIPSAW_SCORE decays under the threshold.", + "shape": "5s, matching the qualify cadence - whipsaw is a property of the window, not of a single second.", + "calibratedOn": "MES/MNQ around US red-folder releases, 5s cadence. 0.7 tolerates the ordinary post-release shakeout but halts sustained chop; the anchor-crossing normalization travels across index futures.", + "nearestConfused": [ + { + "slug": "event_liquidity_halt", + "id": "event_volatility/event_liquidity_halt", + "distinction": "spreads can be perfectly normal while the tape chops; churn and cost are independent reasons to stand down. Both armed." + } + ], + "provenance": null, + "requiredHostSignals": [ + "WHIPSAW_SCORE" + ], + "sourcePath": "library/event_volatility/event_whipsaw_halt.nano", + "irPath": "library/event_volatility/event_whipsaw_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/bollinger_band_touch", + "slug": "bollinger_band_touch", + "name": "BollingerBandTouch", + "category": "mean_reversion", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "range-bound with stable band width. Do NOT fire in a downtrend or a volatility expansion - %B sits below 0 for long runs in a trend, and every touch there is a knife-catch.", + "conditions": "band width flat or contracting, and the prior swing low still holding on the timeframe above.", + "invalidation": "a second consecutive close below the lower band, or band width expanding while price falls. Both mean continuation, not reversion.", + "shape": "1h; a wick piercing the lower band that closes back inside it.", + "calibratedOn": "US large-cap equity ETF, 1h. %B itself is scale-free, but \"band width is stable\" is not - re-derive it for a new instrument class.", + "nearestConfused": [ + { + "slug": "bb_squeeze_breakout", + "id": "volatility/bb_squeeze_breakout", + "distinction": "same indicator family, opposite intent. That one enters on expansion out of compression; this one needs the range to hold." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BB_PCT_B" + ], + "sourcePath": "library/mean_reversion/bollinger_band_touch.nano", + "irPath": "library/mean_reversion/bollinger_band_touch_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/bollinger_lower_reclaim", + "slug": "bollinger_lower_reclaim", + "name": "BollingerLowerReclaim", + "category": "mean_reversion", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "a range, or a well-behaved uptrend that overshoots. Do NOT fire in a volatility expansion: the bands widen to track the move, so price can close outside and reclaim repeatedly on the way down while every reclaim fails. INPUTS: close (series). No other host signal.", + "conditions": "prior close at or below the prior lower band, current close above the current lower band. Nothing about trend - this rule deliberately carries no regime filter, which is why it belongs beside one that does.", + "invalidation": "a second close back under the band. The reclaim thesis is that the excursion is finished; a repeat says it was not.", + "shape": "1d; a spike through the band that closes back inside the next bar.", + "calibratedOn": "liquid US equity index proxies, daily. 20 bars and 2.0 standard deviations are Bollinger's published convention. The multiplier is scale-free so it travels; what does not travel is the assumption that returns are symmetric enough for a two-sigma excursion to be rare, which is false on instruments with a fat left tail.", + "nearestConfused": [ + { + "slug": "bollinger_band_touch", + "id": "mean_reversion/bollinger_band_touch", + "distinction": "that is the level test on baseline IR - it fires while price is outside the band, so it enters into the excursion. This waits for the excursion to end, which trades earliness for confirmation and fires far less often." + }, + { + "slug": "zscore_fade_trend_filtered", + "id": "mean_reversion/zscore_fade_trend_filtered", + "distinction": "same statistical idea (distance from a rolling mean in units of rolling dispersion) with a trend gate attached; that one abstains in a downtrend, this one does not." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/mean_reversion/bollinger_lower_reclaim.nano", + "irPath": "library/mean_reversion/bollinger_lower_reclaim_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/cci_extreme", + "slug": "cci_extreme", + "name": "CciExtreme", + "category": "mean_reversion", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "range, or a pullback inside a standing uptrend. Do NOT fire in a sustained downtrend - CCI pins beyond -100 and stays there for many bars.", + "conditions": "a definable range floor, and the higher timeframe not printing new lows.", + "invalidation": "CCI_NEG continuing past roughly 200 (the move is accelerating, not exhausting), or a close beneath the range floor.", + "shape": "1h; a sharp excursion below recent congestion that snaps back.", + "calibratedOn": "US tech equity ETF, 1h. The +/-100 convention is standard; how often it is actually reached is entirely instrument-dependent.", + "nearestConfused": [ + { + "slug": "zscore_reversion", + "id": "mean_reversion/zscore_reversion", + "distinction": "both measure distance from a mean, but CCI normalises by mean absolute deviation rather than standard deviation, so it reacts sooner and fires considerably more often in chop." + } + ], + "provenance": null, + "requiredHostSignals": [ + "CCI_NEG" + ], + "sourcePath": "library/mean_reversion/cci_extreme.nano", + "irPath": "library/mean_reversion/cci_extreme_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/opening_gap_fade", + "slug": "opening_gap_fade", + "name": "OpeningGapFade", + "category": "mean_reversion", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "a session-bound instrument in a range, where an overnight gap is an imbalance rather than news. Do NOT fire on a breakaway gap: a gap that opens a new trend is the same shape on the open and the opposite trade, and no bar-level rule can tell the two apart. That is this rule's central risk and it is not solved here. INPUTS: open, high, low, close (series). Requires a genuine session open, so it is meaningless on a continuously traded instrument where the open is just the previous close.", + "conditions": "gap at least 1.5 prior-bar ATRs, and a close on the wrong side of the open. Symmetric: the else arm is the down-gap mirror, and the two can never both be true because a gap cannot be up and down at once.", + "invalidation": "a close beyond the gap extreme, which says the gap is being extended rather than filled. The host owns the stop; this rule proposes only.", + "shape": "1d; an outsized open that gives the whole move back by the close.", + "calibratedOn": "session-bound US equities, daily bars. 1.5 ATRs is a convention; because it is ATR-relative it travels across instruments, but it does not travel across cadences - on a 5m bar the \"gap\" is a single tick of slippage. The rule also assumes the open is executable, which is false in the first seconds of an auction.", + "nearestConfused": [ + { + "slug": "event_impulse_pullback_short", + "id": "event_volatility/event_impulse_pullback_short", + "distinction": "that is a macro-event rule on a five-second cadence, gated by a host event engine that has verified a scheduled release. This is a daily-bar rule with no event knowledge at all - if a red-folder release caused the gap, that rule should own it and this one should not fire." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "high", + "low", + "open" + ], + "sourcePath": "library/mean_reversion/opening_gap_fade.nano", + "irPath": "library/mean_reversion/opening_gap_fade_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/zscore_fade_trend_filtered", + "slug": "zscore_fade_trend_filtered", + "name": "ZscoreFadeTrendFiltered", + "category": "mean_reversion", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "a pullback inside an uptrend. Do NOT fire in a downtrend - that is exactly what the two-hundred-bar filter exists to prevent, because an unfiltered z-score fade in a bear leg buys every step down and each one is cheaper than the last. INPUTS: close (series). No other host signal.", + "conditions": "close above the long average, the z-score at or beyond two standard deviations below the short mean, and the z-score higher than one bar ago. The final term waits for the flush to start reclaiming instead of buying each deeper reading.", + "invalidation": "a close below the two-hundred-bar average, which disarms the rule outright, or a z-score that keeps falling - dispersion-relative cheapness that gets cheaper is a regime change, not an opportunity.", + "shape": "1d; a sharp two-sigma flush that starts reclaiming inside an intact uptrend.", + "calibratedOn": "liquid US equity index proxies, daily. 20/200 and 2.0 sigma are conventions. The z-score is unit-free so the threshold travels; the 200-bar filter does not travel to intraday cadences, where 200 bars can be a single session and the \"trend\" it measures is noise.", + "nearestConfused": [ + { + "slug": "zscore_reversion", + "id": "mean_reversion/zscore_reversion", + "distinction": "that is the same statistic on baseline IR, using a host-negated ZSCORE_NEG signal and no regime filter. The whole difference is the filter, and it is the difference between a mean-reversion rule and a falling-knife rule." + }, + { + "slug": "bollinger_lower_reclaim", + "id": "mean_reversion/bollinger_lower_reclaim", + "distinction": "same distance-from-mean idea, but that one waits for the excursion to end and this one enters into it. If both fire, this one fired first." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/mean_reversion/zscore_fade_trend_filtered.nano", + "irPath": "library/mean_reversion/zscore_fade_trend_filtered_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "mean_reversion/zscore_reversion", + "slug": "zscore_reversion", + "name": "ZscoreReversion", + "category": "mean_reversion", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "statistically stationary - a range whose mean is flat. Do NOT fire during a trend or a repricing event: once the mean itself moves, distance from it stops being information.", + "conditions": "the 20-period mean roughly flat, and stdev not expanding.", + "invalidation": "ZSCORE_NEG holding at or above 2 across several bars, or the mean turning down. Both say the distribution moved rather than the price.", + "shape": "4h; a visible stretch away from a horizontal mean.", + "calibratedOn": "crypto, 4h - continuous trading, no session boundaries, and a higher baseline volatility than an index future. On a session-bound instrument the 20-period window straddles the overnight gap.", + "nearestConfused": [ + { + "slug": "cci_extreme", + "id": "mean_reversion/cci_extreme", + "distinction": "the same idea measured more strictly and more slowly, so it produces far fewer signals. If both fire, that is one setup, not two." + } + ], + "provenance": null, + "requiredHostSignals": [ + "ZSCORE_NEG" + ], + "sourcePath": "library/mean_reversion/zscore_reversion.nano", + "irPath": "library/mean_reversion/zscore_reversion_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/absolute_momentum_filter", + "slug": "absolute_momentum_filter", + "name": "AbsoluteMomentumFilter", + "category": "momentum", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "a periodic allocation decision, not a trade entry. Do NOT fire it intrabar or expect it to catch a turn - a six-month lookback is by design late, and a rule that reads it every five minutes is reading noise around a number that moves once a month. INPUTS: close (series). No other host signal.", + "conditions": "both windows positive. The short window exists to veto the case where the long return is still positive purely because of a base effect from six months ago while the last month has been falling.", + "invalidation": "either window turning negative, at which point the else arm emits OBSERVE. This rule never proposes a sell - going flat is the host's decision, and OBSERVE is the honest way to say \"the filter is off\".", + "shape": "1d; a slow, mostly-on switch that flips a handful of times a year.", + "calibratedOn": "liquid, long-only equity index proxies, daily bars, no dividends adjustment assumed. 126 and 21 are trading-day approximations of six months and one month; they are conventions from the published academic time-series-momentum literature, not a fit. Zero is the only threshold, so nothing here needs re-scaling per instrument - but the rule does assume an instrument with a positive long-run drift, and inverts badly on one without.", + "nearestConfused": [ + { + "slug": "roc_momentum", + "id": "momentum/roc_momentum", + "distinction": "that is a single host-supplied ROC compared with a threshold, sized as an entry signal. This is two computed windows used as a portfolio-level gate, and its output is a state, not a setup." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/momentum/absolute_momentum_filter.nano", + "irPath": "library/momentum/absolute_momentum_filter_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/roc_momentum", + "slug": "roc_momentum", + "name": "RocMomentum", + "category": "momentum", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "trend or expansion. Do NOT fire in chop - in a range a 5 percent 10-bar move marks the top of the range, so this becomes buy-the-high.", + "conditions": "range expanding, higher highs on the timeframe above.", + "invalidation": "ROC decaying back under the threshold while price stalls (momentum exhaustion), or the whole move coming from one spike bar.", + "shape": "1h; a sustained directional push, not a single candle.", + "calibratedOn": "crypto, 1h. The threshold is the danger here - a 5 percent move over 10 bars is an ordinary afternoon for BTC and a limit-move event for an index future. Do not carry the number across instrument classes.", + "nearestConfused": [ + { + "slug": "macd_histogram_flip", + "id": "trend/macd_histogram_flip", + "distinction": "that signals a change of trend, this one signals continuation of a trend already running." + } + ], + "provenance": null, + "requiredHostSignals": [ + "ROC" + ], + "sourcePath": "library/momentum/roc_momentum.nano", + "irPath": "library/momentum/roc_momentum_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/rsi_oversold_reversal", + "slug": "rsi_oversold_reversal", + "name": "RsiOversoldReversal", + "category": "momentum", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "a pullback inside an uptrend, or a range. Do NOT fire in a sustained downtrend - RSI can hold under 30 for dozens of bars while price keeps falling, and this is the single most common way the strategy loses.", + "conditions": "the higher timeframe trending up or neutral, and a prior support level within reach.", + "invalidation": "RSI making a lower low together with price. Oversold that gets more oversold is trend, not exhaustion.", + "shape": "15m; a flush into support that turns within a few bars.", + "calibratedOn": "crypto, 15m. RSI is bounded 0-100 so the threshold travels, but how long an instrument stays sub-30 does not.", + "nearestConfused": [ + { + "slug": "stochastic_oversold", + "id": "momentum/stochastic_oversold", + "distinction": "that measures position inside the recent range and fires earlier and far more often." + }, + { + "slug": "volume_spike_confirmation", + "id": "volume/volume_spike_confirmation", + "distinction": "that is this exact RSI condition plus a volume gate, so it is a strict subset - when both fire it is one setup, not two." + } + ], + "provenance": null, + "requiredHostSignals": [ + "RSI" + ], + "sourcePath": "library/momentum/rsi_oversold_reversal.nano", + "irPath": "library/momentum/rsi_oversold_reversal_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/stochastic_oversold", + "slug": "stochastic_oversold", + "name": "StochasticOversold", + "category": "momentum", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "range. Do NOT fire in a trend - %K pins under 20 for the length of a downtrend, and this is the fastest of the oscillators, so it pins first.", + "conditions": "a defined support level, and a range wide enough that the low of the lookback window is meaningful.", + "invalidation": "%K flat under 20 across several bars rather than turning up.", + "shape": "30m; price probing the bottom of an established range.", + "calibratedOn": "crypto, 30m. The 0-100 bound travels; the dwell time below 20 is a property of the instrument.", + "nearestConfused": [ + { + "slug": "rsi_oversold_reversal", + "id": "momentum/rsi_oversold_reversal", + "distinction": "RSI measures average gain against average loss, %K measures position within the recent high-low range. %K fires earlier and more often, so it is the noisier of the pair." + }, + { + "slug": "williams_r_reversal", + "id": "momentum/williams_r_reversal", + "distinction": "that is the same construction inverted. Firing both is one idea counted twice." + } + ], + "provenance": null, + "requiredHostSignals": [ + "STOCH_K" + ], + "sourcePath": "library/momentum/stochastic_oversold.nano", + "irPath": "library/momentum/stochastic_oversold_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/stochastic_reclaim", + "slug": "stochastic_reclaim", + "name": "StochasticReclaim", + "category": "momentum", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "a range, or a pullback inside an uptrend. Do NOT fire in a sustained downtrend: %K pins near zero there and every small bounce produces a reclaim, so the rule turns into a metronome that buys each leg down. INPUTS: high, low, close (series). No other host signal.", + "conditions": "%K at or above 20 this bar and below 20 last bar. One bar, one signal, then silence until it goes back under.", + "invalidation": "%K falling back under 20 within a bar or two, or a new low in price while %K makes a higher low - the second is the classic setup that looks like this one and is not.", + "shape": "1h; a flush to the bottom of the recent range that turns.", + "calibratedOn": "crypto, 1h. %K is bounded 0..100, so 20 travels across instruments in a way an absolute price threshold does not; what does not travel is how long an instrument sits pinned at the bottom of its range, which is what decides whether \"reclaim\" means anything.", + "nearestConfused": [ + { + "slug": "stochastic_oversold", + "id": "momentum/stochastic_oversold", + "distinction": "that is the level test on baseline IR - it fires on every bar %K spends under 20, which in a downtrend is most of them. This fires once per excursion. If you are comparing fire counts between the two, the ratio is the point, not a bug." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "high", + "low" + ], + "sourcePath": "library/momentum/stochastic_reclaim.nano", + "irPath": "library/momentum/stochastic_reclaim_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "momentum/williams_r_reversal", + "slug": "williams_r_reversal", + "name": "WilliamsRReversal", + "category": "momentum", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "range, or a pullback within an uptrend. Do NOT fire in a sustained downtrend - like every position-in-range oscillator it pins at the bottom and stays there.", + "conditions": "a defined range with a floor that has held at least once.", + "invalidation": "WILLR_POS remaining under 20 while the range floor gives way.", + "shape": "1h; a probe of the low of the 14-period range that recovers.", + "calibratedOn": "high-beta crypto, 1h. The 0-100 bound travels; on a slower instrument the oscillator reaches 20 far less often, so this will simply go quiet rather than misfire.", + "nearestConfused": [ + { + "slug": "stochastic_oversold", + "id": "momentum/stochastic_oversold", + "distinction": "Williams %R is the inverted stochastic - the same measurement of position within the recent range. These two agreeing is a single idea counted twice, not confirmation." + } + ], + "provenance": null, + "requiredHostSignals": [ + "WILLR_POS" + ], + "sourcePath": "library/momentum/williams_r_reversal.nano", + "irPath": "library/momentum/williams_r_reversal_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/consecutive_loss_circuit", + "slug": "consecutive_loss_circuit", + "name": "ConsecutiveLossCircuit", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them, though it is most informative in a regime the strategy was not built for - which is precisely when a run of losses appears.", + "conditions": "none. The host decides what closes a decision and what counts as a loss; Nano only reads the count.", + "invalidation": "none. The counter is reset by the host, deliberately, after review.", + "shape": "5m; the count only changes when a decision closes, so a faster cadence re-reads an unchanged number.", + "calibratedOn": "strategies that close decisions discretely. A continuously rebalanced book has no natural notion of a consecutive loss and should not arm this.", + "nearestConfused": [ + { + "slug": "daily_loss_limit", + "id": "risk/daily_loss_limit", + "distinction": "that measures magnitude, this measures sequence. Four small losses in a row can cost almost nothing and still be the clearest evidence available that the regime has changed. A book can trip this while nowhere near any loss limit, which is the point." + } + ], + "provenance": null, + "requiredHostSignals": [ + "CONSECUTIVE_LOSSES" + ], + "sourcePath": "library/risk/consecutive_loss_circuit.nano", + "irPath": "library/risk/consecutive_loss_circuit_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/correlation_cluster_guard", + "slug": "correlation_cluster_guard", + "name": "CorrelationClusterGuard", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "matters most in a stress regime, when correlations converge and a book that looked diversified stops being diversified.", + "conditions": "the host must publish a cluster assignment. Nano does not compute correlation - CLUSTER_EXPOSURE_PCT is the host's answer to \"how much of the book is really one bet\".", + "invalidation": "the cluster breaking up, which is a host measurement and not something this rule can observe.", + "shape": "15m; cluster membership is a rolling estimate and re-checking it faster than it updates only adds noise.", + "calibratedOn": "multi-asset books. A single-sector mandate is one cluster by construction and should not arm this without raising the threshold.", + "nearestConfused": [ + { + "slug": "position_concentration_cap", + "id": "risk/position_concentration_cap", + "distinction": "that one counts a single instrument. This one counts many instruments that happen to be the same trade. A book can pass the concentration cap on every line and fail this badly." + } + ], + "provenance": null, + "requiredHostSignals": [ + "CLUSTER_EXPOSURE_PCT" + ], + "sourcePath": "library/risk/correlation_cluster_guard.nano", + "irPath": "library/risk/correlation_cluster_guard_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/daily_loss_limit", + "slug": "daily_loss_limit", + "name": "DailyLossLimit", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. This is a control, not a directional hypothesis.", + "conditions": "none. It is armed from the session open.", + "invalidation": "none. A limit is not a trade. It resets on the next session boundary, which the host owns - Nano has no clock of its own.", + "shape": "1m, so a fast tape cannot run through the limit between checks.", + "calibratedOn": "nothing instrument-specific. 2 percent is a session-level figure and travels unchanged.", + "nearestConfused": [ + { + "slug": "max_drawdown_breaker", + "id": "risk/max_drawdown_breaker", + "distinction": "that measures peak-to-trough over the life of the book and can sit quiet through a catastrophic single day if the book was up beforehand. This one resets daily and catches exactly that case. Both should be armed; neither subsumes the other." + } + ], + "provenance": null, + "requiredHostSignals": [ + "DAY_LOSS_PCT" + ], + "sourcePath": "library/risk/daily_loss_limit.nano", + "irPath": "library/risk/daily_loss_limit_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/leverage_ceiling", + "slug": "leverage_ceiling", + "name": "LeverageCeiling", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. Leverage is a constraint, not a forecast.", + "conditions": "none. Armed whenever the book is open.", + "invalidation": "none. It is reset by reducing size, deliberately, outside this rule.", + "shape": "5m; gross exposure moves on fills and on marks, and 5m is fast enough to catch both without re-checking a number that has not changed.", + "calibratedOn": "nothing instrument-specific, but 3x is a house figure rather than a universal one - a futures book and a cash equity book do not mean the same thing by \"gross\".", + "nearestConfused": [ + { + "slug": "position_concentration_cap", + "id": "risk/position_concentration_cap", + "distinction": "that measures how the book is distributed, this measures how large it is in total. A perfectly diversified book at 5x gross passes the concentration cap and belongs to this rule." + } + ], + "provenance": null, + "requiredHostSignals": [ + "GROSS_LEVERAGE" + ], + "sourcePath": "library/risk/leverage_ceiling.nano", + "irPath": "library/risk/leverage_ceiling_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/max_drawdown_breaker", + "slug": "max_drawdown_breaker", + "name": "MaxDrawdownBreaker", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. This is a control, not a directional hypothesis, and it is always applicable. It must never be promoted into an execution slot or counted as a setup - it emits PAUSE, never BUY or SELL.", + "conditions": "none. It is armed whenever the book is open.", + "invalidation": "none. A breaker is not a trade and does not get invalidated; it is reset by the risk agent, deliberately and outside this strategy.", + "shape": "1m, so it reacts within a bar rather than after one.", + "calibratedOn": "nothing instrument-specific. 5 percent is a portfolio-level figure and is the one threshold in this library that travels unchanged.", + "nearestConfused": [ + { + "slug": "atr_volatility_halt", + "id": "volatility/atr_volatility_halt", + "distinction": "that one halts on market volatility, this one halts on realised portfolio loss. They are not redundant - the tape can be calm while the book bleeds, and violent while the book is flat. Both should be armed, and neither substitutes for the other." + } + ], + "provenance": null, + "requiredHostSignals": [ + "DRAWDOWN" + ], + "sourcePath": "library/risk/max_drawdown_breaker.nano", + "irPath": "library/risk/max_drawdown_breaker_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/position_concentration_cap", + "slug": "position_concentration_cap", + "name": "PositionConcentrationCap", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. Concentration risk does not care about trend.", + "conditions": "none. Armed whenever the book is open.", + "invalidation": "none. It is reset by the book changing shape, not by a signal.", + "shape": "5m; position weights move on fills, not on ticks, so a faster cadence would add checks without adding information.", + "calibratedOn": "nothing instrument-specific. 25 percent is a book-level figure. A concentrated book by mandate should raise it deliberately rather than disarm the rule.", + "nearestConfused": [ + { + "slug": "leverage_ceiling", + "id": "risk/leverage_ceiling", + "distinction": "that measures total size against equity, this measures distribution. A book at 1x gross with everything in one name is fully inside the leverage limit and is exactly the situation this rule exists for." + } + ], + "provenance": null, + "requiredHostSignals": [ + "MAX_POSITION_PCT" + ], + "sourcePath": "library/risk/position_concentration_cap.nano", + "irPath": "library/risk/position_concentration_cap_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "risk/stale_data_halt", + "slug": "stale_data_halt", + "name": "StaleDataHalt", + "category": "risk", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them, and it outranks every directional rule. A signal computed from a stale tape is not a weak signal, it is a fabricated one.", + "conditions": "none. Armed whenever anything downstream is consuming the feed.", + "invalidation": "none. Freshness is not a view.", + "shape": "1m. The cadence bounds how long a stale tape can go unnoticed, so it is set to the tightest interval the library uses.", + "calibratedOn": "liquid instruments on a continuous session. A thin or session-bound instrument has legitimate 30-second gaps and needs a wider bound, or the rule fires all day and gets disarmed - which is worse than never arming it.", + "nearestConfused": [ + { + "slug": "event_liquidity_halt", + "id": "event_volatility/event_liquidity_halt", + "distinction": "that asks whether the book can be traded, this asks whether the numbers describing it are real at all. A liquid market with a dead feed passes that rule and fails this one." + } + ], + "provenance": null, + "requiredHostSignals": [ + "FEED_AGE_SEC" + ], + "sourcePath": "library/risk/stale_data_halt.nano", + "irPath": "library/risk/stale_data_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/donchian_breakout", + "slug": "donchian_breakout", + "name": "DonchianBreakout", + "category": "trend", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "expansion. Do NOT fire in a range - inside a range the 20-period high IS the range ceiling, so every touch mean-reverts and this becomes a systematic top-buyer. Range versus expansion is the whole decision here.", + "conditions": "a prior contraction to break out of, and participation behind the break (rising volume or range).", + "invalidation": "a close back inside the channel. A breakout that does not hold is a failed breakout and usually resolves the other way.", + "shape": "1d; a clean push through a flat multi-week ceiling.", + "calibratedOn": "crypto, daily. DONCHIAN_POS is scale-free, so the threshold travels cleanly; what does not travel is the gap behaviour of a session-bound instrument, where the open can clear the channel outright.", + "nearestConfused": [ + { + "slug": "bb_squeeze_breakout", + "id": "volatility/bb_squeeze_breakout", + "distinction": "that anticipates expansion while still compressed; this confirms expansion once it has begun. Sequence, not synonym - if both fire, the squeeze fired first and this is its confirmation." + } + ], + "provenance": null, + "requiredHostSignals": [ + "DONCHIAN_POS" + ], + "sourcePath": "library/trend/donchian_breakout.nano", + "irPath": "library/trend/donchian_breakout_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/donchian_high_breakout", + "slug": "donchian_high_breakout", + "name": "DonchianHighBreakout", + "category": "trend", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "expansion out of a contraction. Do NOT fire inside a range - the twenty-bar extreme IS the range boundary there, so both arms fire at the edges of a market that is going nowhere and each is faded immediately. INPUTS: high, low, close (series). No other host signal.", + "conditions": "a close beyond the prior twenty-bar extreme. The else arm is the mirror, so this rule is symmetric by construction: one channel, two sides, and they can never both be true on one bar because the channel top is never below the channel floor.", + "invalidation": "a close back inside the channel on the following bar. A breakout that does not hold is a failed breakout, and this rule proposes entries only - the host owns the stop and the exit.", + "shape": "1d; a push through a flat multi-week ceiling or floor.", + "calibratedOn": "crypto and futures, daily. Twenty is Donchian's published convention. The rule assumes a continuously traded instrument: on a session-bound one the open can gap clear through the channel, which makes the breakout price unreachable and turns every signal into a chase.", + "nearestConfused": [ + { + "slug": "donchian_breakout", + "id": "trend/donchian_breakout", + "distinction": "that is the same idea on baseline IR, reading a host-computed DONCHIAN_POS ratio and firing long only. This computes the channel from raw bars, so the host contract is just OHLC, and it carries the short arm the ratio form cannot express without a second signal." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "high", + "low" + ], + "sourcePath": "library/trend/donchian_high_breakout.nano", + "irPath": "library/trend/donchian_high_breakout_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/ema_pullback_continuation", + "slug": "ema_pullback_continuation", + "name": "EmaPullbackContinuation", + "category": "trend", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "an established uptrend that breathes. Do NOT fire in a downtrend or in a range - in a range the two averages braid together, so the band between them narrows to nothing and \"pullback\" and \"breakdown\" become the same bar. INPUTS: close (series). No other host signal.", + "conditions": "the close at or below the fast average and above the slow one. That pair IS the trend test: requiring slow < close <= fast forces the two averages into uptrend order, so a separate `fast_ma > slow_ma` condition is implied by the band and cannot change the outcome on any frame. One was written here first and removed - a conjunct no test can isolate is decoration, and this file would rather be short than look thorough.", + "invalidation": "a close below the slow average, which is also what disarms the rule - the second condition inverts, so the rule stops on its own.", + "shape": "1d; a stair-step advance that keeps returning to the 20-day line.", + "calibratedOn": "liquid US equity index proxies, daily. 20/50 is a convention, not a discovery - the pair only needs to be far enough apart that the band between them is wide enough to catch a normal pullback. Both periods are `param`s so a host can re-fit them without editing the rule.", + "nearestConfused": [ + { + "slug": "golden_cross", + "id": "trend/golden_cross", + "distinction": "that fires once, on the crossing bar, and says the trend began. This fires repeatedly, inside a trend that already exists, and says nothing about when it started." + }, + { + "slug": "rsi_oversold_reversal", + "id": "momentum/rsi_oversold_reversal", + "distinction": "that measures exhaustion in an oscillator; this measures location against two averages and can fire with RSI at 55." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/trend/ema_pullback_continuation.nano", + "irPath": "library/trend/ema_pullback_continuation_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/golden_cross", + "slug": "golden_cross", + "name": "GoldenCross", + "category": "trend", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "established uptrend. Read this as a REGIME FILTER, not an entry. The distinction matters: a positive spread is a persistent state that stays true for months, so treating it as a trigger fires it on every single bar of a bull market. Use it to permit or veto other setups, not to open a trade.", + "conditions": "none beyond the spread itself - that is precisely the problem with using it alone.", + "invalidation": "the spread crossing back through zero, which is a death cross and a regime change rather than a stop on a position.", + "shape": "1d; the slow pair of averages fanned apart and holding.", + "calibratedOn": "US large-cap equity ETF, daily, where the 50/200 pair is the convention. On a 24/7 instrument \"200 days\" spans a different amount of price action than it does on a session-bound one.", + "nearestConfused": [ + { + "slug": "macd_histogram_flip", + "id": "trend/macd_histogram_flip", + "distinction": "that is an event on a fast pair and marks a moment; this is a state on a slow pair and marks a season. If both are true, only the MACD flip carries timing information." + } + ], + "provenance": null, + "requiredHostSignals": [ + "SMA_SPREAD" + ], + "sourcePath": "library/trend/golden_cross.nano", + "irPath": "library/trend/golden_cross_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/macd_histogram_flip", + "slug": "macd_histogram_flip", + "name": "MacdHistogramFlip", + "category": "trend", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "trend initiation, or resumption after a pullback. Do NOT fire in chop - the histogram oscillates around zero in a range, so it flips constantly and produces a stream of one-bar signals that mean nothing.", + "conditions": "a directional context on the timeframe above; the flip is a timing input and needs a trend to be timing into.", + "invalidation": "the histogram dropping back below zero within a bar or two. A flip that does not persist was noise around the zero line.", + "shape": "4h; histogram bars crossing from below zero to above and staying.", + "calibratedOn": "crypto, 4h. The zero crossing is scale-free and travels, but flip frequency scales with noise, so a choppier instrument will produce many more of these for the same amount of real trend.", + "nearestConfused": [ + { + "slug": "golden_cross", + "id": "trend/golden_cross", + "distinction": "that is a persistent state on a slow average pair, this is a discrete event on a fast one." + }, + { + "slug": "roc_momentum", + "id": "momentum/roc_momentum", + "distinction": "that measures the size of a move already underway, this measures a change in its direction." + } + ], + "provenance": null, + "requiredHostSignals": [ + "MACD_HIST" + ], + "sourcePath": "library/trend/macd_histogram_flip.nano", + "irPath": "library/trend/macd_histogram_flip_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/macd_zero_line_reclaim", + "slug": "macd_zero_line_reclaim", + "name": "MacdZeroLineReclaim", + "category": "trend", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "the turn out of a downtrend or a long range into an advance. Do NOT fire inside an established trend - the line is already well above zero there and the crossing happened bars or months ago. INPUTS: close (series). No other host signal.", + "conditions": "the line above zero this bar, at or below zero last bar, and the histogram positive so the line is above its own signal rather than merely above zero on a spike that is already fading. WHY THE HISTOGRAM TERM IS NOT REDUNDANT: it looks redundant, and an earlier revision of this file deleted it on the argument that a line crossing up through zero has its signal EMA below it by construction. That argument holds only for the FIRST cross out of a sustained negative stretch. After a rally and a retrace the signal EMA still carries the memory of the earlier high line values, so it sits ABOVE the line at the moment of a second crossing. The concrete case is pinned in tests as `test_macd_zero_reclaim_ignores_a_second_cross_under_its_own_signal`: a 45-bar decline, 19 bars up, 11 bars down, 4 bars up, where bar 78 has line = +0.3243, line[1] = -0.0980 and hist = -3.6859. Without this term the rule fires there. That is the fading-spike case, and it is the reason the term exists.", + "invalidation": "the line falling back under zero. A reclaim that does not hold is a failed turn; the rule cannot re-fire until the line goes negative again and comes back, which is the behaviour you want.", + "shape": "1d; one bar, at the crossing.", + "calibratedOn": "liquid US equity index proxies, daily. 12/26/9 is Appel's published default and is a convention rather than a fitted value; the zero crossing itself is scale-free, so the rule travels across instruments in a way that an absolute MACD threshold would not.", + "nearestConfused": [ + { + "slug": "macd_histogram_flip", + "id": "trend/macd_histogram_flip", + "distinction": "that watches the line against its own signal, which happens several times inside a single trend. This watches the line against zero, which happens once per regime change. Same family, an order of magnitude apart in fire rate." + }, + { + "slug": "golden_cross", + "id": "trend/golden_cross", + "distinction": "same idea in a different coordinate system - golden_cross reads a host-supplied SMA spread on a much slower pair, this computes an EMA spread on a fast one." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/trend/macd_zero_line_reclaim.nano", + "irPath": "library/trend/macd_zero_line_reclaim_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "trend/supertrend_flip_long", + "slug": "supertrend_flip_long", + "name": "SupertrendFlipLong", + "category": "trend", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "trend initiation after a bearish leg. Do NOT fire in a range - inside a range the band is broken in both directions repeatedly and the flip becomes a metronome. The ATR_PCT floor is the cheap version of that filter and not a substitute for knowing the regime.", + "conditions": "all four, AND-chained. Only the first is about what price did; the other three are the host asserting a tradeable plan exists.", + "invalidation": "a close back under the SuperTrend line. Note the line and the rule's stop are different levels - the indicator trails its own band while the stop sits under structure - so the two can disagree, and the host decides which one governs.", + "shape": "15m; a bearish band flipping to a bullish one under price, with enough range in the bar for a stop to sit clear of the noise.", + "calibratedOn": "MNQ futures, 15m. The three floors are scale-free ratios and travel across instruments; the 15m bar does not - on a slower chart the same flip carries a much larger stop in points for the same 0.5 ATR.", + "nearestConfused": [ + { + "slug": "golden_cross", + "id": "trend/golden_cross", + "distinction": "that is a slow persistent state and reads as a regime filter; this is a discrete event on a single bar." + }, + { + "slug": "donchian_breakout", + "id": "trend/donchian_breakout", + "distinction": "that requires price through a prior extreme, this requires only the band to flip, which can happen well inside one." + }, + { + "slug": "atr_volatility_halt", + "id": "volatility/atr_volatility_halt", + "distinction": "that uses ATR_PCT as a ceiling to stand down, this uses the same series as a floor to stand up - not opposites, and both can be true of the same violent tape." + } + ], + "provenance": null, + "requiredHostSignals": [ + "ATR_PCT", + "STOP_DISTANCE_ATR", + "SUPERTREND_FLIP_BULL", + "TARGET_R" + ], + "sourcePath": "library/trend/supertrend_flip_long.nano", + "irPath": "library/trend/supertrend_flip_long_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volatility/atr_regime_halt", + "slug": "atr_regime_halt", + "name": "AtrRegimeHalt", + "category": "volatility", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "all of them, and it outranks every directional rule beneath it. Do NOT expect it to fire during a slow grind higher in volatility - a baseline that drifts up with the market is a baseline the ratio never clears, and that is a deliberate limitation of any self-referential measure. INPUTS: high, low, close (series). No other host signal.", + "conditions": "ATR at or above twice its own hundred-bar average. Nothing else. A control with a second condition is a control that can be argued with.", + "invalidation": "none. This is a circuit breaker, not a view. It emits PAUSE and OBSERVE and proposes no direction; the host decides what a pause means.", + "shape": "5m. The cadence bounds how long a volatility shock can go unnoticed.", + "calibratedOn": "liquid continuously traded instruments, 5m bars. The 2.0 multiple is a convention. The hundred-bar baseline assumes a session long enough to contain it; on a session-bound instrument the baseline straddles the overnight gap and the first bars of a session read as an expansion.", + "nearestConfused": [ + { + "slug": "atr_volatility_halt", + "id": "volatility/atr_volatility_halt", + "distinction": "that reads a host-supplied ATR_PCT against an absolute 5 percent ceiling, so it is calibrated per instrument and silent on anything quieter than its threshold. This is relative to the instrument's own history, so it arms everywhere - and correspondingly it will not catch an instrument that is dangerously volatile all the time, which the absolute rule will. Ship both; they fail in opposite directions." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "high", + "low" + ], + "sourcePath": "library/volatility/atr_regime_halt.nano", + "irPath": "library/volatility/atr_regime_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volatility/atr_volatility_halt", + "slug": "atr_volatility_halt", + "name": "AtrVolatilityHalt", + "category": "volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. This is a control, not a directional hypothesis. It is always applicable, emits PAUSE rather than BUY or SELL, and must never be promoted into an execution slot or counted as a setup.", + "conditions": "none. It is armed whenever the feed is live.", + "invalidation": "none - a breaker is not a trade. It releases when ATR_PCT falls back under the threshold.", + "shape": "5m, so a volatility spike is caught inside the move rather than after it.", + "calibratedOn": "crypto, 5m - and this is the most dangerous threshold in the library to transplant. A 5 percent ATR is an ordinary volatile session in crypto and a once-in-years event on an index future, whose ATR_PCT normally sits well under 1 percent. Carried across unchanged this breaker does not misfire, it does something worse: it never fires at all, and the volatility brake silently disappears. Re-derive the threshold per instrument class.", + "nearestConfused": [ + { + "slug": "max_drawdown_breaker", + "id": "risk/max_drawdown_breaker", + "distinction": "that halts on realised portfolio loss, this halts on market volatility. Both should be armed; neither substitutes for the other, because the tape can be violent while the book is flat." + } + ], + "provenance": null, + "requiredHostSignals": [ + "ATR_PCT" + ], + "sourcePath": "library/volatility/atr_volatility_halt.nano", + "irPath": "library/volatility/atr_volatility_halt_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volatility/bb_squeeze_breakout", + "slug": "bb_squeeze_breakout", + "name": "BbSqueezeBreakout", + "category": "volatility", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "contraction, on the edge of expansion. Do NOT fire once expansion is already underway - by then the bands are wide, the edge is gone, and the entry is late.", + "conditions": "band width at the low end of its own recent range, and momentum already leaning positive. The squeeze alone is directionless; MOM is what picks the side, and it is the weaker half of the pair.", + "invalidation": "width expanding while price resolves downward. A squeeze resolves in some direction, and being early is not the same as being right.", + "shape": "1h; bands pinched to a narrow ribbon, price coiled against the top.", + "calibratedOn": "crypto, 1h. BB_WIDTH < 4 is an absolute percentage and does NOT travel - band width percent scales with the instrument's own volatility, so on a quieter instrument 4 percent is permanently satisfied and the squeeze condition stops discriminating at all.", + "nearestConfused": [ + { + "slug": "bollinger_band_touch", + "id": "mean_reversion/bollinger_band_touch", + "distinction": "same indicator, opposite regime - that one needs the range to hold, this one is betting it breaks." + }, + { + "slug": "donchian_breakout", + "id": "trend/donchian_breakout", + "distinction": "this anticipates the expansion, that confirms it after the fact." + } + ], + "provenance": null, + "requiredHostSignals": [ + "BB_WIDTH", + "MOM" + ], + "sourcePath": "library/volatility/bb_squeeze_breakout.nano", + "irPath": "library/volatility/bb_squeeze_breakout_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volatility/squeeze_release_expansion", + "slug": "squeeze_release_expansion", + "name": "SqueezeReleaseExpansion", + "category": "volatility", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "the first bar out of a contraction. Do NOT fire once expansion is established - the squeeze test fails by construction there, which is the point of anchoring it to a rolling minimum rather than a fixed number. INPUTS: close (series). No other host signal.", + "conditions": "prior width at its own fifty-bar low, width rising, and the close above the middle band so the release has a side. The direction test is the weakest of the three: a squeeze resolves somewhere, and picking the side from one bar's position is a guess dressed as a condition.", + "invalidation": "width expanding while the close falls back under the middle band. Being early to an expansion is not the same as being right about it.", + "shape": "1h; a narrow ribbon that starts to flare with price on the top side.", + "calibratedOn": "crypto, 1h. 20/2.0 is Bollinger's convention and 50 bars is the memory over which \"squeezed\" is defined. Because the squeeze is relative, this rule fires on any instrument - including one whose bands are always wide - so the fifty-bar window is doing the calibration and should be re-chosen when the cadence changes.", + "nearestConfused": [ + { + "slug": "bb_squeeze_breakout", + "id": "volatility/bb_squeeze_breakout", + "distinction": "same family, and the difference is the whole reason this entry exists. That one tests BB_WIDTH < 4, an absolute percentage its own header admits does not travel - on a quiet instrument the condition is permanently satisfied and stops discriminating. This one asks whether width is low *for this instrument*, which is scale-free." + }, + { + "slug": "donchian_high_breakout", + "id": "trend/donchian_high_breakout", + "distinction": "that confirms an expansion in price; this anticipates one from volatility." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close" + ], + "sourcePath": "library/volatility/squeeze_release_expansion.nano", + "irPath": "library/volatility/squeeze_release_expansion_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volume/obv_trend", + "slug": "obv_trend", + "name": "ObvTrend", + "category": "volume", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "any directional regime. This is a CONFIRMATION input and is weak on its own - a positive OBV slope is true through most of any advance, so firing it standalone is close to firing on \"the market went up\".", + "conditions": "compose it with a directional trigger and let this gate the trigger. It answers \"is there participation behind the move\", nothing else.", + "invalidation": "slope flattening or turning negative while price still rises - the classic distribution divergence, and the one case where this input is genuinely informative on its own.", + "shape": "4h; OBV grinding upward underneath a rising price.", + "calibratedOn": "crypto, 4h - and volume is the least portable series in this library. Crypto volume is per-venue and unaudited; futures volume is exchange-consolidated; equity volume fragments across lit and dark venues. An OBV slope computed on one is not comparable to the other.", + "nearestConfused": [ + { + "slug": "volume_spike_confirmation", + "id": "volume/volume_spike_confirmation", + "distinction": "that is a single-bar capitulation event, this is sustained accumulation across a window. Opposite time scales." + } + ], + "provenance": null, + "requiredHostSignals": [ + "OBV_SLOPE" + ], + "sourcePath": "library/volume/obv_trend.nano", + "irPath": "library/volume/obv_trend_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volume/volume_climax_reversal", + "slug": "volume_climax_reversal", + "name": "VolumeClimaxReversal", + "category": "volume", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "capitulation at the end of a decline. Do NOT fire on the first heavy bar of a breakdown - the shape is identical for one bar, and only what happens afterwards distinguishes them. Requiring the close under the fifty-bar average is what keeps this a reversal rule and not a breakout rule. INPUTS: high, low, close, volume (series). Volume must be a real traded quantity; a synthetic or tick-count proxy changes what the surge multiple means.", + "conditions": "volume at least three times its twenty-bar average, range at least twice ATR, close in the top 40 percent of the bar, and price below its fifty-bar average. A flat bar has zero range and yields no close position at all, so such a bar is absent rather than counted as satisfying the test.", + "invalidation": "a lower low on ordinary volume within a few bars. Climax volume that does not mark the low was distribution, not exhaustion.", + "shape": "1d; a wide, enormous-volume down bar that closes near its high.", + "calibratedOn": "liquid US equities, daily. The 3x surge and 2x range multiples are conventions and are the two numbers most in need of re-fitting per instrument - a thin name prints 3x volume routinely. The close-position threshold of 0.6 is scale-free and travels.", + "nearestConfused": [ + { + "slug": "volume_spike_confirmation", + "id": "volume/volume_spike_confirmation", + "distinction": "that pairs a host VOL_RATIO with an RSI gate on baseline IR and is a confirmation filter for an entry that already exists. This is a standalone reversal pattern about the shape of one bar, and it says nothing about any oscillator." + }, + { + "slug": "obv_trend", + "id": "volume/obv_trend", + "distinction": "that is a cumulative flow measure over many bars; this is a single-bar event." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "high", + "low", + "volume" + ], + "sourcePath": "library/volume/volume_climax_reversal.nano", + "irPath": "library/volume/volume_climax_reversal_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volume/volume_spike_confirmation", + "slug": "volume_spike_confirmation", + "name": "VolumeSpikeConfirmation", + "category": "volume", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "capitulation or climax, inside a larger range or an uptrend pullback. Do NOT fire in an orderly downtrend - steady selling produces oversold RSI without the volume climax, and the volume gate is the only thing separating this from catching a falling knife.", + "conditions": "both halves must hold in the same bar. The volume spike is the evidence of exhaustion; the RSI reading alone is not.", + "invalidation": "the next bar making a new low on equal or greater volume. That is continuation on participation, which is the opposite of exhaustion.", + "shape": "15m; a high-volume flush candle with a long lower wick.", + "calibratedOn": "crypto, 15m. RSI 30 travels; the 3x volume ratio does not - it depends entirely on the venue's volume profile and on how much of the instrument's real volume the feed actually sees.", + "nearestConfused": [ + { + "slug": "rsi_oversold_reversal", + "id": "momentum/rsi_oversold_reversal", + "distinction": "this is that exact condition plus a volume gate, so it is a strict subset of it. When both fire it is one setup with two names, and treating them as two independent confirmations double-counts the same evidence." + } + ], + "provenance": null, + "requiredHostSignals": [ + "RSI", + "VOL_RATIO" + ], + "sourcePath": "library/volume/volume_spike_confirmation.nano", + "irPath": "library/volume/volume_spike_confirmation_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "volume/vwap_band_reversion", + "slug": "vwap_band_reversion", + "name": "VwapBandReversion", + "category": "volume", + "irMaturity": "v1", + "irVersion": "1.0.0", + "regime": "intraday range trading around a well-populated volume profile. Do NOT fire in a trending session - in a one-way tape the close is below VWAP for hours and the discount is a description of the trend, not a deviation from it. This rule has no trend filter and that is its main weakness. INPUTS: close, volume (series). Volume must be real traded size; the weighting is the entire point, and a constant-volume feed collapses VWAP to a simple moving average without any error being raised.", + "conditions": "at least a one-percent discount to rolling VWAP, that discount narrower than it was one bar ago, and RSI under 40. The narrowing term is the trigger; RSI is a brake that removes the fastest part of a decline.", + "invalidation": "a close that keeps making new lows while the discount widens. A widening discount is the rule being wrong, not the rule being early.", + "shape": "15m; a drift below the session's volume-weighted centre whose discount has started to reclaim.", + "calibratedOn": "crypto, 15m, continuous session. The one-percent discount is absolute and does NOT travel: on a low-volatility instrument it is never reached, and on a fast one it is reached every bar. It is a `param` for exactly that reason. The twenty-bar VWAP window is also a session-shape assumption - it is a rolling window, not an anchored session VWAP, so it carries no notion of where the session began.", + "nearestConfused": [ + { + "slug": "bollinger_lower_reclaim", + "id": "mean_reversion/bollinger_lower_reclaim", + "distinction": "that measures distance in units of dispersion and waits for the excursion to end; this measures distance as a plain percentage of a volume-weighted centre and enters into it. NOT rsi_oversold_reversal: RSI here is a veto with a loose threshold of 40, not the trigger - this rule can fire with RSI at 39 and nothing oversold about it." + } + ], + "provenance": null, + "requiredHostSignals": [ + "close", + "volume" + ], + "sourcePath": "library/volume/vwap_band_reversion.nano", + "irPath": "library/volume/vwap_band_reversion_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_batch_unaccounted", + "slug": "aether_release_notes_batch_unaccounted", + "name": "AetherReleaseNotesBatchUnaccounted", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them, but only over a completed scan. The count is only meaningful when aether_release_notes_coverage is satisfied; the two rules are deployed together and the coverage guard is what makes this one's zero mean something.", + "conditions": "the host counts from the scan result, and publishes no count at all when it has no scan - absence is the honest answer, and the evaluator turns an absent count into INPUT_UNAVAILABLE rather than into a calm zero.", + "invalidation": "the outstanding merges being written up. The count falls back to zero and the rule stops proposing a hold, with no memory of having fired.", + "shape": "5m, matching the merge scan. The count cannot change faster than the scan that produces it.", + "calibratedOn": "a ceiling of one, which is policy rather than measurement and travels to any host that publishes release notes as batches. What does not travel is the definition of \"covered\" - which merges a release note is expected to mention is the publishing host's editorial rule.", + "nearestConfused": [ + { + "slug": "aether_release_notes_uncovered_merge", + "id": "watchdog/aether_release_notes_uncovered_merge", + "distinction": "that one observes a single merge landing without a candidate, one pull request at a time, and proposes no hold. This one is the batch-scope arithmetic and holds publication." + } + ], + "provenance": null, + "requiredHostSignals": [ + "UNACCOUNTED_MERGE_COUNT" + ], + "sourcePath": "library/watchdog/aether_release_notes_batch_unaccounted.nano", + "irPath": "library/watchdog/aether_release_notes_batch_unaccounted_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_candidate_unattached", + "slug": "aether_release_notes_candidate_unattached", + "name": "AetherReleaseNotesCandidateUnattached", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. This is a property of the draft, not of the system it describes.", + "conditions": "the host resets PENDING_AGE_SECONDS when the candidate is revised, so the age measures the current state rather than the candidate's whole life.", + "invalidation": "the attachment arriving, or the candidate being withdrawn. Either takes the rule out of its trigger with no memory of having fired.", + "shape": "5m against an hour-long threshold, so the alert lands within a twelfth of the grace period it is measuring.", + "calibratedOn": "a one-hour grace period, which is a policy figure and does not travel - a programme that drafts notes in a batch at the end of the week should raise it, and one that generates them automatically should lower it until the threshold is longer than the generator's own latency.", + "nearestConfused": [ + { + "slug": "aether_release_notes_uncovered_merge", + "id": "watchdog/aether_release_notes_uncovered_merge", + "distinction": "that one fires when no candidate exists at all. This one fires when a candidate exists and cannot be traced back to what it claims to describe." + } + ], + "provenance": null, + "requiredHostSignals": [ + "HAS_RELEASE_CANDIDATE", + "HAS_SOURCE_ATTACHMENT", + "PENDING_AGE_SECONDS" + ], + "sourcePath": "library/watchdog/aether_release_notes_candidate_unattached.nano", + "irPath": "library/watchdog/aether_release_notes_candidate_unattached_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_copy_unvalidated", + "slug": "aether_release_notes_copy_unvalidated", + "name": "AetherReleaseNotesCopyUnvalidated", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them, for public surfaces only. An internal candidate has PUBLIC_CANDIDATE 0 and this rule is silent on it by construction.", + "conditions": "the host publishes COPY_VALIDATED 0 until a validation has actually run. A default of 1 makes this rule vacuous and the vacuity is invisible from outside, because a rule that never fires and a rule with nothing to report look identical.", + "invalidation": "the validation completing. The rule proposes a hold; it does not latch and does not decide when the hold ends.", + "shape": "5m, matching the rest of the release-note family so one batch is evaluated by every rule on the same tick.", + "calibratedOn": "nothing numeric; both comparisons are against host booleans. What does not travel is what validation means - a one-person read and a formal claims review both publish the same 1, and the host owns which it is.", + "nearestConfused": [ + { + "slug": "aether_release_notes_proof_missing", + "id": "watchdog/aether_release_notes_proof_missing", + "distinction": "that is the other half of the same disjunction and asks whether the evidence can be fetched. This one asks whether anybody read the words." + } + ], + "provenance": null, + "requiredHostSignals": [ + "COPY_VALIDATED", + "PUBLIC_CANDIDATE" + ], + "sourcePath": "library/watchdog/aether_release_notes_copy_unvalidated.nano", + "irPath": "library/watchdog/aether_release_notes_copy_unvalidated_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_coverage", + "slug": "aether_release_notes_coverage", + "name": "AetherReleaseNotesCoverage", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. Coverage is a property of the host's own scan, not of whatever the scan is looking at.", + "conditions": "the host publishes exactly one coverage fact as 1 on every bar, and derives it from the API response rather than from the parsed result. An error path that returns an empty list must publish MERGE_COVERAGE_ERROR and never MERGE_COVERAGE_EMPTY - that substitution is the outage this rule exists to catch.", + "invalidation": "the scan completing. The rule does not latch and does not decide when the hold ends; the host does.", + "shape": "5m, the cadence the merge scan itself runs on. Reading coverage faster than the scan that produces it only re-reads the same answer.", + "calibratedOn": "nothing numeric; both comparisons are against the host's own booleans. What does not travel is the vocabulary itself. A host with a different set of coverage states publishes its own and re-derives which of them are complete scans before reusing this rule.", + "nearestConfused": [ + { + "slug": "aether_release_notes_batch_unaccounted", + "id": "watchdog/aether_release_notes_batch_unaccounted", + "distinction": "that rule reads the count and fires when the scan worked and found merges nobody wrote up. This one fires when there is no trustworthy count to read, and it deliberately does not read the count - an outage must never arrive at the batch rule as a zero." + } + ], + "provenance": null, + "requiredHostSignals": [ + "MERGE_COVERAGE_AVAILABLE", + "MERGE_COVERAGE_EMPTY" + ], + "sourcePath": "library/watchdog/aether_release_notes_coverage.nano", + "irPath": "library/watchdog/aether_release_notes_coverage_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_proof_missing", + "slug": "aether_release_notes_proof_missing", + "name": "AetherReleaseNotesProofMissing", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them, for public surfaces only. An internal candidate has PUBLIC_CANDIDATE 0 and this rule is silent on it by construction.", + "conditions": "the host decides what counts as required proof for a given surface before publishing the boolean, and publishes 0 when it does not know rather than assuming the proof exists.", + "invalidation": "the proof becoming retrievable. The rule proposes a hold; it does not latch and does not decide when the hold ends.", + "shape": "5m, matching the rest of the release-note family so one batch is evaluated by every rule on the same tick.", + "calibratedOn": "nothing numeric; both comparisons are against host booleans. What does not travel is the proof policy - which claims need evidence on which surface is the publishing organisation's decision.", + "nearestConfused": [ + { + "slug": "aether_release_notes_copy_unvalidated", + "id": "watchdog/aether_release_notes_copy_unvalidated", + "distinction": "that is the other half of the same disjunction and asks whether the words were checked. This one asks whether the evidence behind the words can be fetched at all." + } + ], + "provenance": null, + "requiredHostSignals": [ + "PUBLIC_CANDIDATE", + "REQUIRED_PROOF_AVAILABLE" + ], + "sourcePath": "library/watchdog/aether_release_notes_proof_missing.nano", + "irPath": "library/watchdog/aether_release_notes_proof_missing_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/aether_release_notes_uncovered_merge", + "slug": "aether_release_notes_uncovered_merge", + "name": "AetherReleaseNotesUncoveredMerge", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. This is bookkeeping about a merge that already happened, not a forecast about anything.", + "conditions": "the host has decided which repository and which base branch are canonical before publishing these facts. Both are policy, and a host that publishes 1 for every repository turns this rule into noise.", + "invalidation": "a release candidate picking the merge up. HAS_RELEASE_CANDIDATE goes to 1 and the rule stops firing, with no memory of having fired - the host owns the follow-up, not this rule.", + "shape": "5m; merges arrive when they arrive, and five minutes is fast enough that the author is still around to answer.", + "calibratedOn": "nothing numeric; every comparison is against a host boolean. What does not travel is the canonical repository and base, which are per-programme decisions and belong to whoever runs the release.", + "nearestConfused": [ + { + "slug": "aether_release_notes_batch_unaccounted", + "id": "watchdog/aether_release_notes_batch_unaccounted", + "distinction": "that one reads the batch-scope count and proposes a hold on publication. This one looks at a single merge and deliberately only observes - a missing changelog line is a thing to notice, not a thing to stop the world over." + } + ], + "provenance": null, + "requiredHostSignals": [ + "CANONICAL_BASE", + "CANONICAL_REPOSITORY", + "HAS_RELEASE_CANDIDATE", + "IS_RELEASE_NOTES_PR", + "MERGED" + ], + "sourcePath": "library/watchdog/aether_release_notes_uncovered_merge.nano", + "irPath": "library/watchdog/aether_release_notes_uncovered_merge_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/credential_age_alert", + "slug": "credential_age_alert", + "name": "CredentialAgeAlert", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. Key age is a clock, not a market state.", + "conditions": "none, beyond the host knowing which credentials are in scope and what its own rotation limit is.", + "invalidation": "rotation. Age returns to zero and the rule stops firing, with no memory of having fired - the host owns the ticket, not this rule.", + "shape": "1d; age changes once a day, and checking it faster only produces the same answer more often.", + "calibratedOn": "a 90-day rotation policy, firing at 80 to leave ten days of warning. Neither number travels - both are policy figures, and a site with a 30-day policy should carry the same ten-day margin at 20.", + "nearestConfused": [ + { + "slug": "trusted_route_guard", + "id": "watchdog/trusted_route_guard", + "distinction": "that reports something already broken and proposes a pause. This one reports something that will break, so it proposes review and deliberately does not halt anything." + } + ], + "provenance": null, + "requiredHostSignals": [ + "CREDENTIAL_AGE_DAYS" + ], + "sourcePath": "library/watchdog/credential_age_alert.nano", + "irPath": "library/watchdog/credential_age_alert_ir.json" + }, + { + "metadataVersion": "StrategyMetadataV1", + "id": "watchdog/trusted_route_guard", + "slug": "trusted_route_guard", + "name": "TrustedRouteGuard", + "category": "watchdog", + "irMaturity": "baseline", + "irVersion": "0.1.0", + "regime": "all of them. Route availability is a precondition, not a forecast, so this rule is armed whenever the system is running.", + "conditions": "none. The host is responsible for deciding what \"the trusted route\" is and for not publishing a 1 on a single dropped probe.", + "invalidation": "the route coming back. This rule proposes a pause; it does not latch, and it does not decide when the pause ends - the host does.", + "shape": "1m; fast enough to notice a real outage, slow enough that a transient blip has already been smoothed away by the host's own measurement window.", + "calibratedOn": "nothing numeric to calibrate - the threshold is the host's own binary. What does not travel is the definition of \"trusted\", which is site-specific and belongs to whoever operates the network.", + "nearestConfused": [ + { + "slug": "endpoint_posture_ceiling", + "id": null, + "distinction": "that counts how many endpoints are out of policy while the system still works. This one says the path itself is gone." + } + ], + "provenance": null, + "requiredHostSignals": [ + "TRUSTED_ROUTE_DOWN" + ], + "sourcePath": "library/watchdog/trusted_route_guard.nano", + "irPath": "library/watchdog/trusted_route_guard_ir.json" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 5174bec..1d57761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aether-nano" -version = "1.0.4" +version = "1.0.5" description = "A deterministic strategy DSL and reference runtime for host-governed decision rules." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/check_contribution.py b/scripts/check_contribution.py index fa61fae..c2a7788 100644 --- a/scripts/check_contribution.py +++ b/scripts/check_contribution.py @@ -14,7 +14,8 @@ python scripts/check_contribution.py --all `--write` generates or repairs the `_ir.json` partner in the library's exact -format, so nobody has to hand-reflow JSON to match its neighbours. +format and refreshes the derived strategy catalog, so nobody has to hand-reflow +JSON or maintain metadata in two places. Exit code 0 means the entry is shaped like every other entry in the library. Exit code 1 prints one line per problem, each naming the file and the rule. @@ -38,7 +39,13 @@ from nano.library.contribution import ( # noqa: E402 baseline_control_frames, module_control_frame, - source_provenance_issues, +) +from nano.library.catalog import ( # noqa: E402 + CatalogDiagnostic, + CatalogValidationError, + catalog_diagnostics, + parse_strategy_metadata, + write_catalog, ) from nano.ir.module import NanoModule # noqa: E402 from nano.ir.schema import NANO_IR_VERSION_BASELINE # noqa: E402 @@ -47,17 +54,6 @@ from nano.ir.graph import StrategyGraph # noqa: E402 from nano.runtime.interpreter import execute # noqa: E402 -# Every entry in the library carries this header. It is what makes a rule -# reviewable by someone who did not write it: not "what does this compute" — -# the source says that — but when it is meant to fire and when it is wrong. -REQUIRED_HEADER_FIELDS = ( - ("REGIME:", "which market or system state this rule is for"), - ("CONDITIONS:", "what must already be true before it is armed"), - ("INVALIDATION:", "what makes it wrong, so a reader can disprove it"), - ("SHAPE:", "the timeframe and the picture it is describing"), - ("CALIBRATED ON:", "where the numbers came from, and what does not travel"), -) - # The only effects a library entry may declare. Nano proposes intents and writes # its own run log; anything else on this list would mean the language had grown # a way to reach outside the host, which is the one thing it must not do. @@ -96,6 +92,16 @@ def ir_path(nano_path: Path) -> Path: return nano_path.with_name(f"{nano_path.stem}_ir.json") +def catalog_problem(diagnostic: CatalogDiagnostic) -> str: + """Render catalog diagnostics with repository-relative paths.""" + + path = diagnostic.path + if path == "library" or path.startswith("library/"): + path = f"nano/{path}" + location = f"{path}:{diagnostic.line}" if diagnostic.line is not None else path + return f"{location}: {diagnostic.message}" + + def check_entry(nano_path: Path, write: bool, problems: list[str]) -> None: rel = nano_path.relative_to(ROOT).as_posix() @@ -106,20 +112,6 @@ def check_entry(nano_path: Path, write: bool, problems: list[str]) -> None: header = [line.strip() for line in source.splitlines() if line.strip().startswith("//")] header_text = "\n".join(header) - if not header: - problems.append( - f"{rel}: no `//` comment header. Every library entry documents its " - "signal contract and its invalidation before the source." - ) - for field, why in REQUIRED_HEADER_FIELDS: - if field not in header_text: - problems.append( - f"{rel}: comment header is missing `// {field}` — {why}. " - "See nano/library/README.md." - ) - - problems.extend(f"{rel}: {issue}" for issue in source_provenance_issues(header)) - try: document = compile_to_dict(source) # The library ships two corpora. A baseline entry names the feed signals @@ -139,6 +131,20 @@ def check_entry(nano_path: Path, write: bool, problems: list[str]) -> None: ) return + # Use the same field-aware parser that generates the hosted artifact. Its + # optional SOURCE validation delegates to contribution.py, preserving the + # exact provenance policy landed in #30. + try: + parse_strategy_metadata( + source, + document, + category=nano_path.parent.name, + slug=nano_path.stem, + source_path=rel, + ) + except CatalogValidationError as error: + problems.extend(catalog_problem(item) for item in error.diagnostics) + if document.get("effects") != ALLOWED_EFFECTS: problems.append( f"{rel}: declares effects {document.get('effects')!r}; a library entry " @@ -277,7 +283,7 @@ def main() -> int: parser.add_argument( "--write", action="store_true", - help="generate or repair the `_ir.json` partner in library format", + help="generate or repair pinned IR and the derived strategy catalog", ) args = parser.parse_args() @@ -300,6 +306,22 @@ def main() -> int: if args.all or not args.paths: check_orphans(problems) + # The catalog is generated, never hand-maintained. A normal check proves it + # is byte-identical to source/IR; --write refreshes it only after the selected + # entries have passed the existing compile, replay, and provenance gates. + if not problems: + if args.write: + try: + output = write_catalog(LIBRARY) + except CatalogValidationError as error: + problems.extend(catalog_problem(item) for item in error.diagnostics) + else: + print(f"wrote {output.relative_to(ROOT).as_posix()}") + else: + problems.extend( + catalog_problem(item) for item in catalog_diagnostics(LIBRARY) + ) + if problems: print(f"\n{len(problems)} problem(s):\n", file=sys.stderr) for problem in problems: diff --git a/scripts/generate_catalog.py b/scripts/generate_catalog.py new file mode 100644 index 0000000..4250eae --- /dev/null +++ b/scripts/generate_catalog.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Regenerate the strategy catalog from canonical source headers and pinned IR.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from nano.library.catalog import ( # noqa: E402 + CatalogValidationError, + write_catalog, +) + + +def main() -> int: + try: + output = write_catalog(ROOT / "nano" / "library") + except CatalogValidationError as error: + for diagnostic in error.diagnostics: + print(diagnostic.render(), file=sys.stderr) + return 1 + print(f"wrote {output.relative_to(ROOT).as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/golden/receipt_drift.json b/tests/golden/receipt_drift.json index 010fea9..35fc056 100644 --- a/tests/golden/receipt_drift.json +++ b/tests/golden/receipt_drift.json @@ -1 +1 @@ -{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Drift","moduleHash":"sha256:f43458398231b977af7a1114b0e0e7760cd18a09910c9e32d178f7d8d1e45c24","nanoVersion":"1.0.4","reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":5,"firstTimestamp":0,"frameHash":"sha256:173b1650bc76a5efe8f882e3695d2b03047fc813ac4ddd51eaafbbd576dd7f8f","lastTimestamp":240,"signals":["close"]},"provenance":{"sourceHash":"sha256:308da8d305e125c2e5d35a97bd7e2cd32ab3f255635c5c67116cfc4c09c10bea"},"receiptVersion":1,"run":{"escalations":[],"intents":[{"intent":"OBSERVE","timestamp":120},{"asset":"BTC","confidence":0.7,"intent":"BUY","timestamp":180},{"intent":"OBSERVE","timestamp":240}],"log":[{"detail":"Drift tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0},{"detail":"n6 has no value at bar 0","event":"condition.unwarmed","timestamp":0},{"detail":"n6 has no value at bar 1","event":"condition.unwarmed","timestamp":60},{"detail":"n6 -> False","event":"condition.evaluated","timestamp":120},{"detail":"OBSERVE asset=None","event":"intent.emitted","timestamp":120},{"detail":"n6 -> True","event":"condition.evaluated","timestamp":180},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":180},{"detail":"n6 -> False","event":"condition.evaluated","timestamp":240},{"detail":"OBSERVE asset=None","event":"intent.emitted","timestamp":240}],"warmupBarsSkipped":2}} \ No newline at end of file +{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Drift","moduleHash":"sha256:f43458398231b977af7a1114b0e0e7760cd18a09910c9e32d178f7d8d1e45c24","nanoVersion":"1.0.5","reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":5,"firstTimestamp":0,"frameHash":"sha256:173b1650bc76a5efe8f882e3695d2b03047fc813ac4ddd51eaafbbd576dd7f8f","lastTimestamp":240,"signals":["close"]},"provenance":{"sourceHash":"sha256:308da8d305e125c2e5d35a97bd7e2cd32ab3f255635c5c67116cfc4c09c10bea"},"receiptVersion":1,"run":{"escalations":[],"intents":[{"intent":"OBSERVE","timestamp":120},{"asset":"BTC","confidence":0.7,"intent":"BUY","timestamp":180},{"intent":"OBSERVE","timestamp":240}],"log":[{"detail":"Drift tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0},{"detail":"n6 has no value at bar 0","event":"condition.unwarmed","timestamp":0},{"detail":"n6 has no value at bar 1","event":"condition.unwarmed","timestamp":60},{"detail":"n6 -> False","event":"condition.evaluated","timestamp":120},{"detail":"OBSERVE asset=None","event":"intent.emitted","timestamp":120},{"detail":"n6 -> True","event":"condition.evaluated","timestamp":180},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":180},{"detail":"n6 -> False","event":"condition.evaluated","timestamp":240},{"detail":"OBSERVE asset=None","event":"intent.emitted","timestamp":240}],"warmupBarsSkipped":2}} \ No newline at end of file diff --git a/tests/golden/receipt_empty.json b/tests/golden/receipt_empty.json index 3b5033e..856bd05 100644 --- a/tests/golden/receipt_empty.json +++ b/tests/golden/receipt_empty.json @@ -1 +1 @@ -{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Drift","moduleHash":"sha256:f43458398231b977af7a1114b0e0e7760cd18a09910c9e32d178f7d8d1e45c24","nanoVersion":"1.0.4","reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":0,"frameHash":"sha256:fad6747b2406c3e3e67ed553f010625725bc2f910129ed7479a1e50d5e9fdbea","signals":["close"]},"provenance":{"sourceHash":"sha256:308da8d305e125c2e5d35a97bd7e2cd32ab3f255635c5c67116cfc4c09c10bea"},"receiptVersion":1,"run":{"escalations":[],"intents":[],"log":[{"detail":"Drift tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0}],"warmupBarsSkipped":0}} \ No newline at end of file +{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Drift","moduleHash":"sha256:f43458398231b977af7a1114b0e0e7760cd18a09910c9e32d178f7d8d1e45c24","nanoVersion":"1.0.5","reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":0,"frameHash":"sha256:fad6747b2406c3e3e67ed553f010625725bc2f910129ed7479a1e50d5e9fdbea","signals":["close"]},"provenance":{"sourceHash":"sha256:308da8d305e125c2e5d35a97bd7e2cd32ab3f255635c5c67116cfc4c09c10bea"},"receiptVersion":1,"run":{"escalations":[],"intents":[],"log":[{"detail":"Drift tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0}],"warmupBarsSkipped":0}} \ No newline at end of file diff --git a/tests/golden/receipt_reasoning.json b/tests/golden/receipt_reasoning.json index 4221db5..3e5f429 100644 --- a/tests/golden/receipt_reasoning.json +++ b/tests/golden/receipt_reasoning.json @@ -1 +1 @@ -{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","llm.call","llmre.escalate","log.append"],"irVersion":"1.0.0","module":"Ask","moduleHash":"sha256:64f6b7772c76b293a9e5a8ddd3ddf6cb51e5fb1370cd6cd3979a5f3b6a0eabbc","nanoVersion":"1.0.4","reasoningRequired":true,"tier":"nano+","warmupDeclared":0},"inputs":{"bars":2,"firstTimestamp":0,"frameHash":"sha256:dad557a402d06845035c88d772fac0be496eb9a7099fa628a45cda7402142604","lastTimestamp":60,"signals":["close"]},"provenance":{"sourceHash":"sha256:31491116b0c6c727dedf4c7c71ce8c64d37a1d4869c7f35058ef5bf7cae2ae78"},"receiptVersion":1,"run":{"escalations":[{"escalate":"Desk","isAgent":true,"reason":"rule body","timestamp":60}],"intents":[{"asset":"BTC","intent":"BUY","timestamp":0}],"log":[{"detail":"Ask tier=nano+ effects=['intent.emit', 'llm.call', 'llmre.escalate', 'log.append'] warmup=0","event":"module.loaded","timestamp":0},{"detail":"Judge -> ['score']","event":"infer.called","timestamp":0},{"detail":"Judge -> ['score']","event":"infer.called","timestamp":60},{"detail":"n9 -> True","event":"condition.evaluated","timestamp":0},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":0},{"detail":"n9 -> False","event":"condition.evaluated","timestamp":60},{"detail":"Desk: rule body","event":"llmre.escalated","timestamp":60}],"warmupBarsSkipped":0}} \ No newline at end of file +{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","llm.call","llmre.escalate","log.append"],"irVersion":"1.0.0","module":"Ask","moduleHash":"sha256:64f6b7772c76b293a9e5a8ddd3ddf6cb51e5fb1370cd6cd3979a5f3b6a0eabbc","nanoVersion":"1.0.5","reasoningRequired":true,"tier":"nano+","warmupDeclared":0},"inputs":{"bars":2,"firstTimestamp":0,"frameHash":"sha256:dad557a402d06845035c88d772fac0be496eb9a7099fa628a45cda7402142604","lastTimestamp":60,"signals":["close"]},"provenance":{"sourceHash":"sha256:31491116b0c6c727dedf4c7c71ce8c64d37a1d4869c7f35058ef5bf7cae2ae78"},"receiptVersion":1,"run":{"escalations":[{"escalate":"Desk","isAgent":true,"reason":"rule body","timestamp":60}],"intents":[{"asset":"BTC","intent":"BUY","timestamp":0}],"log":[{"detail":"Ask tier=nano+ effects=['intent.emit', 'llm.call', 'llmre.escalate', 'log.append'] warmup=0","event":"module.loaded","timestamp":0},{"detail":"Judge -> ['score']","event":"infer.called","timestamp":0},{"detail":"Judge -> ['score']","event":"infer.called","timestamp":60},{"detail":"n9 -> True","event":"condition.evaluated","timestamp":0},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":0},{"detail":"n9 -> False","event":"condition.evaluated","timestamp":60},{"detail":"Desk: rule body","event":"llmre.escalated","timestamp":60}],"warmupBarsSkipped":0}} \ No newline at end of file diff --git a/tests/golden/receipt_tuned.json b/tests/golden/receipt_tuned.json index 20f506d..bb5140b 100644 --- a/tests/golden/receipt_tuned.json +++ b/tests/golden/receipt_tuned.json @@ -1 +1 @@ -{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Tuned","moduleHash":"sha256:9bac1ba07ba7516c289efeac8bbd55b8d93202ba983e6127fa2ab59d7b00724f","nanoVersion":"1.0.4","params":[{"name":"window","type":"int","value":3}],"reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":5,"firstTimestamp":0,"frameHash":"sha256:fe7d16ee1e9ce451bbbf20366d16d6cbd0987aed76fef08f41777ff4ce7035ab","lastTimestamp":240,"signals":["close"]},"provenance":{"sourceHash":"sha256:c243804836eecc54c7893626032216ff0b6a808738916af343d339232ad6cfcf"},"receiptVersion":1,"run":{"escalations":[],"intents":[{"asset":"BTC","intent":"BUY","timestamp":120},{"asset":"BTC","intent":"BUY","timestamp":180},{"asset":"BTC","intent":"BUY","timestamp":240}],"log":[{"detail":"Tuned tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0},{"detail":"n5 has no value at bar 0","event":"condition.unwarmed","timestamp":0},{"detail":"n5 has no value at bar 1","event":"condition.unwarmed","timestamp":60},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":120},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":120},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":180},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":180},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":240},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":240}],"warmupBarsSkipped":2}} \ No newline at end of file +{"identity":{"compiler":{"name":"nnc","version":"1.0.0"},"effects":["intent.emit","log.append"],"irVersion":"1.0.0","module":"Tuned","moduleHash":"sha256:9bac1ba07ba7516c289efeac8bbd55b8d93202ba983e6127fa2ab59d7b00724f","nanoVersion":"1.0.5","params":[{"name":"window","type":"int","value":3}],"reasoningRequired":false,"tier":"nano","warmupDeclared":2},"inputs":{"bars":5,"firstTimestamp":0,"frameHash":"sha256:fe7d16ee1e9ce451bbbf20366d16d6cbd0987aed76fef08f41777ff4ce7035ab","lastTimestamp":240,"signals":["close"]},"provenance":{"sourceHash":"sha256:c243804836eecc54c7893626032216ff0b6a808738916af343d339232ad6cfcf"},"receiptVersion":1,"run":{"escalations":[],"intents":[{"asset":"BTC","intent":"BUY","timestamp":120},{"asset":"BTC","intent":"BUY","timestamp":180},{"asset":"BTC","intent":"BUY","timestamp":240}],"log":[{"detail":"Tuned tier=nano effects=['intent.emit', 'log.append'] warmup=2","event":"module.loaded","timestamp":0},{"detail":"n5 has no value at bar 0","event":"condition.unwarmed","timestamp":0},{"detail":"n5 has no value at bar 1","event":"condition.unwarmed","timestamp":60},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":120},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":120},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":180},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":180},{"detail":"n5 -> True","event":"condition.evaluated","timestamp":240},{"detail":"BUY asset=BTC","event":"intent.emitted","timestamp":240}],"warmupBarsSkipped":2}} \ No newline at end of file diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..863c63a --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,384 @@ +"""Generated strategy metadata remains a projection of source and pinned IR.""" + +from __future__ import annotations + +import ast +import importlib.util +import json +from pathlib import Path + +import pytest + +from nano.library.catalog import ( + CatalogDiagnostic, + CatalogValidationError, + build_catalog, + catalog_diagnostics, + catalog_path, + generate_catalog_text, + load_catalog, + parse_strategy_metadata, + write_catalog, +) + + +LIBRARY = Path(__file__).parents[1] / "nano" / "library" +ROOT = Path(__file__).parents[1] + +_CHECKER_SPEC = importlib.util.spec_from_file_location( + "_nano_check_contribution_test", ROOT / "scripts" / "check_contribution.py" +) +assert _CHECKER_SPEC is not None and _CHECKER_SPEC.loader is not None +check_contribution = importlib.util.module_from_spec(_CHECKER_SPEC) +_CHECKER_SPEC.loader.exec_module(check_contribution) + +BASELINE_IR = { + "type": "Strategy", + "nanoIrVersion": "0.1.0", + "name": "CatalogFixture", + "effects": ["intent.emit", "log.append"], + "nodes": [ + {"type": "Schedule", "interval": "1m"}, + {"type": "Condition", "signal": "SCORE", "operator": ">", "value": 0}, + {"type": "Intent", "action": "OBSERVE"}, + ], +} + +HEADER = """\ +// REGIME: orderly trend with liquid execution. +// CONDITIONS: score confirms the continuation. +// INVALIDATION: score loses zero. +// SHAPE: one-minute continuation after a shallow reset. +// CALIBRATED ON: normalized research fixtures; thresholds do not travel. +// NOT other_strategy: this waits for confirmation instead of predicting it. +strategy CatalogFixture { + every 1m { + if SCORE > 0 { observe() } + } +} +""" + + +def _strategy(document, slug): + return next(row for row in document["strategies"] if row["slug"] == slug) + + +def _tiny_library(root: Path) -> Path: + category = root / "trend" + category.mkdir(parents=True) + (category / "catalog_fixture.nano").write_text(HEADER, encoding="utf-8") + (category / "catalog_fixture_ir.json").write_text( + json.dumps(BASELINE_IR), encoding="utf-8" + ) + return root + + +def _unknown_root_key(document): + document["unknownRoot"] = True + + +def _unknown_strategy_key(document): + document["strategies"][0]["unknownEntry"] = True + + +def _omit_provenance(document): + document["strategies"][0].pop("provenance") + + +def _duplicate_confused_slug(document): + confused = document["strategies"][0]["nearestConfused"] + confused.append(dict(confused[0])) + + +def test_checked_in_catalog_is_byte_identical_and_counts_the_landed_corpus(): + first = generate_catalog_text(LIBRARY) + second = generate_catalog_text(LIBRARY) + document = json.loads(first) + + assert first == second + assert catalog_path(LIBRARY).read_bytes() == first.encode("utf-8") + assert document["strategyCount"] == 53 + assert document["irMaturityCounts"] == {"baseline": 41, "v1": 12} + assert document["categoryCounts"] == { + "event_volatility": 11, + "mean_reversion": 6, + "momentum": 6, + "risk": 7, + "trend": 7, + "volatility": 4, + "volume": 4, + "watchdog": 8, + } + assert catalog_diagnostics(LIBRARY) == () + + +def test_ids_slugs_and_serialized_order_are_stable_and_unambiguous(): + document = build_catalog(LIBRARY) + rows = document["strategies"] + ids = [row["id"] for row in rows] + slugs = [row["slug"] for row in rows] + + assert ids == sorted(ids) + assert len(ids) == len(set(ids)) == len(set(slugs)) == 53 + assert all(row["metadataVersion"] == "StrategyMetadataV1" for row in rows) + + +def test_baseline_and_v1_host_inputs_come_from_their_pinned_ir_shapes(): + document = load_catalog() + baseline = _strategy(document, "golden_cross") + v1 = _strategy(document, "ema_pullback_continuation") + + assert baseline["irMaturity"] == "baseline" + assert baseline["requiredHostSignals"] == ["SMA_SPREAD"] + assert v1["irMaturity"] == "v1" + assert v1["requiredHostSignals"] == ["close"] + + +@pytest.mark.parametrize( + ("mutated", "message"), + [ + (HEADER.replace("// REGIME:", "// MARKET:"), "missing `// REGIME:`"), + (HEADER.replace("// REGIME:", "//REGIME:"), "missing `// REGIME:`"), + (HEADER.replace("orderly trend with liquid execution.", ""), "field is empty"), + ( + HEADER.replace( + "// CONDITIONS:", "// REGIME: duplicate.\n// CONDITIONS:" + ), + "duplicate `REGIME:`", + ), + (HEADER.replace("// NOT other_strategy:", "// DIFFERENT:"), "nearest-confused"), + ( + HEADER.replace("NOT other_strategy", "NOT catalog_fixture"), + "points back to the same strategy", + ), + ( + HEADER.replace( + "// NOT other_strategy:", + "// NOT other_strategy: first distinction.\n" + "// NOT other_strategy:", + ), + "duplicate `NOT other_strategy:`", + ), + (HEADER.replace("// REGIME:", "// SOURCE:\n// REGIME:"), "SOURCE"), + ], +) +def test_malformed_headers_fail_with_field_specific_diagnostics(mutated, message): + with pytest.raises(CatalogValidationError, match=message): + parse_strategy_metadata( + mutated, + BASELINE_IR, + category="trend", + slug="catalog_fixture", + source_path="nano/library/trend/catalog_fixture.nano", + ) + + +def test_category_and_slug_must_be_stable_identifiers(): + with pytest.raises(CatalogValidationError, match="lowercase snake_case"): + parse_strategy_metadata( + HEADER, + BASELINE_IR, + category="Trend Rules", + slug="Catalog-Fixture", + ) + + +def test_unknown_ir_maturity_is_rejected_instead_of_guessed(): + document = dict(BASELINE_IR, nanoIrVersion="2.0.0") + with pytest.raises(CatalogValidationError, match="unsupported nanoIrVersion"): + parse_strategy_metadata( + HEADER, + document, + category="trend", + slug="catalog_fixture", + ) + + +def test_nearest_confused_slug_may_be_one_word(): + metadata = parse_strategy_metadata( + HEADER.replace("other_strategy", "breakout"), + BASELINE_IR, + category="trend", + slug="catalog_fixture", + ) + assert metadata.nearest_confused[0].slug == "breakout" + + +def test_prose_not_travel_is_not_mistaken_for_a_strategy_slug(): + source = HEADER.replace( + "thresholds do not travel.", "the absolute threshold does NOT travel: scale it." + ) + metadata = parse_strategy_metadata( + source, + BASELINE_IR, + category="trend", + slug="catalog_fixture", + ) + assert [item.slug for item in metadata.nearest_confused] == ["other_strategy"] + + +def test_optional_source_is_projected_only_when_authored(): + source = HEADER.replace( + "// REGIME:", "// SOURCE: public exchange specification.\n// REGIME:" + ) + metadata = parse_strategy_metadata( + source, + BASELINE_IR, + category="trend", + slug="catalog_fixture", + ) + assert metadata.provenance == "public exchange specification." + + +def test_source_without_the_contribution_header_spelling_is_not_provenance(): + source = HEADER.replace("// REGIME:", "//SOURCE: not a field.\n// REGIME:") + metadata = parse_strategy_metadata( + source, + BASELINE_IR, + category="trend", + slug="catalog_fixture", + ) + assert metadata.provenance is None + + +def test_catalog_check_detects_source_drift_without_a_sidecar_edit(tmp_path): + library = _tiny_library(tmp_path / "library") + write_catalog(library) + assert catalog_diagnostics(library) == () + + source = library / "trend" / "catalog_fixture.nano" + source.write_text( + source.read_text(encoding="utf-8").replace("orderly trend", "choppy trend"), + encoding="utf-8", + ) + diagnostics = catalog_diagnostics(library) + assert len(diagnostics) == 1 + assert "stale or non-canonical" in diagnostics[0].message + + +def test_catalog_check_rejects_newline_normalization_as_byte_drift(tmp_path): + library = _tiny_library(tmp_path / "library") + artifact = write_catalog(library) + artifact.write_bytes(artifact.read_bytes().replace(b"\n", b"\r\n")) + + diagnostics = catalog_diagnostics(library) + assert len(diagnostics) == 1 + assert "stale or non-canonical" in diagnostics[0].message + + +def test_loaded_artifact_rejects_count_and_entry_shape_mutations(tmp_path): + library = _tiny_library(tmp_path / "library") + artifact = write_catalog(library) + document = json.loads(artifact.read_text(encoding="utf-8")) + document["strategyCount"] = 2 + document["categoryCounts"] = {"trend": 2} + document["irMaturityCounts"] = {"baseline": 2} + document["strategies"][0]["slug"] = "Bad Slug" + document["strategies"][0]["requiredHostSignals"] = ["SCORE", "SCORE"] + artifact.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(CatalogValidationError) as raised: + load_catalog(library) + rendered = str(raised.value) + assert "strategyCount does not match" in rendered + assert "requiredHostSignals must be unique" in rendered + assert "categoryCounts does not match" in rendered + assert "irMaturityCounts does not match" in rendered + assert "stable lowercase snake_case" in rendered + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (_unknown_root_key, "catalog keys must match StrategyMetadataV1 exactly"), + (_unknown_strategy_key, "strategy keys must match StrategyMetadataV1 exactly"), + (_omit_provenance, "provenance"), + (_duplicate_confused_slug, "nearestConfused slugs must be unique"), + ], +) +def test_loaded_artifact_rejects_closed_schema_mutations(tmp_path, mutate, message): + library = _tiny_library(tmp_path / "library") + artifact = write_catalog(library) + document = json.loads(artifact.read_text(encoding="utf-8")) + mutate(document) + artifact.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(CatalogValidationError, match=message): + load_catalog(library) + + +def test_multiple_nearest_confused_entries_resolve_when_a_slug_exists(): + document = load_catalog() + known_ids = {row["slug"]: row["id"] for row in document["strategies"]} + confused = [ + item + for row in document["strategies"] + for item in row["nearestConfused"] + ] + + assert confused + assert all(item["id"] == known_ids.get(item["slug"]) for item in confused) + assert any(item["id"] is None for item in confused) + + +def test_contribution_entry_check_surfaces_catalog_parser_diagnostics(monkeypatch): + def reject(*args, **kwargs): + raise CatalogValidationError( + ( + CatalogDiagnostic( + "nano/library/trend/golden_cross.nano", + "mutated metadata is not catalogable", + 4, + ), + ) + ) + + monkeypatch.setattr(check_contribution, "parse_strategy_metadata", reject) + problems = [] + check_contribution.check_entry( + LIBRARY / "trend" / "golden_cross.nano", False, problems + ) + assert any("golden_cross.nano:4" in problem for problem in problems) + assert any("not catalogable" in problem for problem in problems) + + +def test_contribution_check_fails_on_generated_catalog_drift(monkeypatch, capsys): + monkeypatch.setattr( + check_contribution, + "catalog_diagnostics", + lambda root: ( + CatalogDiagnostic( + "library/catalog/strategy_metadata_v1.json", "mutation survived" + ), + ), + ) + monkeypatch.setattr( + "sys.argv", + [ + "check_contribution.py", + str(LIBRARY / "trend" / "golden_cross.nano"), + ], + ) + + assert check_contribution.main() == 1 + assert "nano/library/catalog/strategy_metadata_v1.json" in capsys.readouterr().err + + +def test_python_sources_do_not_import_the_unpacked_scripts_namespace(): + violations = [] + for source_root in (ROOT / "nano", ROOT / "scripts", ROOT / "tests"): + for path in sorted(source_root.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + if any(name == "scripts" or name.startswith("scripts.") for name in names): + violations.append(f"{path.relative_to(ROOT).as_posix()}:{node.lineno}") + assert violations == [], ( + "top-level scripts are CLI entry files, not an installed package: " + + ", ".join(violations) + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c9c0da2..3625859 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -387,6 +387,98 @@ def test_graph_json_is_consumable_by_a_host_renderer(): assert document["entries"] +# -- nano library ------------------------------------------------------------- + + +def test_library_list_is_stable_and_complete(capsys): + code, out, err = _run(["library", "list"], capsys) + lines = out.splitlines() + + assert code == EXIT_OK + assert err == "" + assert lines[0] == "ID\tIR\tHOST SIGNALS" + assert len(lines) == 54 + assert lines[1].startswith("event_volatility/cpi_impulse_pullback_long\t0.1.0\t") + assert lines[-1].startswith("watchdog/trusted_route_guard\t0.1.0\t") + + +def test_library_show_accepts_a_stable_slug(capsys): + code, out, err = _run( + ["library", "show", "ema_pullback_continuation"], capsys + ) + document = json.loads(out) + + assert code == EXIT_OK + assert err == "" + assert document["id"] == "trend/ema_pullback_continuation" + assert document["irMaturity"] == "v1" + assert document["requiredHostSignals"] == ["close"] + + +def test_library_search_uses_authored_and_derived_metadata(capsys): + code, out, err = _run(["library", "search", "trend continuation"], capsys) + + assert code == EXIT_OK + assert err == "" + assert "trend/ema_pullback_continuation" in out + assert "trend/golden_cross" not in out + + +def test_library_search_rejects_an_empty_query(capsys): + code, out, err = _run(["library", "search", " "], capsys) + + assert code == EXIT_USAGE + assert out == "" + assert "non-empty query" in err + + +def test_library_filters_compose_and_watchdog_count_is_pinned(capsys): + code, out, _ = _run(["library", "filter", "--category", "watchdog"], capsys) + assert code == EXIT_OK + assert len(out.splitlines()) == 9 + + code, out, _ = _run( + ["library", "filter", "--category", "trend", "--input", "close"], + capsys, + ) + assert code == EXIT_OK + assert "trend/ema_pullback_continuation" in out + assert "trend/golden_cross" not in out + + +def test_library_filter_without_a_dimension_is_a_usage_error(capsys): + code, out, err = _run(["library", "filter"], capsys) + + assert code == EXIT_USAGE + assert out == "" + assert "needs --category, --regime, or --input" in err + + +def test_library_filter_rejects_an_empty_dimension(capsys): + code, out, err = _run(["library", "filter", "--regime", " "], capsys) + + assert code == EXIT_USAGE + assert out == "" + assert "values must not be empty" in err + + +def test_library_unknown_show_is_a_usage_error(capsys): + code, out, err = _run(["library", "show", "not_a_strategy"], capsys) + + assert code == EXIT_USAGE + assert out == "" + assert "unknown library strategy" in err + + +def test_library_check_regenerates_byte_identically(capsys): + code, out, err = _run(["library", "check"], capsys) + + assert code == EXIT_OK + assert err == "" + assert "53 strategies" in out + assert "41 baseline + 12 v1" in out + + # -- nano indicators / version / help ---------------------------------------- @@ -444,6 +536,7 @@ def test_parser_exposes_every_documented_command(): "replay", "visualize", "indicators", + "library", "version", } <= set(choices)