diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd7d520..165f25a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,9 @@ jobs: --source examples/price_history.example.csv \ --target /tmp/qqq_overlay_prices.csv \ --symbols QQQ + - name: Build example context bundle + run: | + python scripts/build_context_bundle.py \ + --prices examples/price_history.example.csv \ + --symbols QQQ \ + --output /tmp/context_bundle.json diff --git a/.github/workflows/dispatch_shadow_signal.yml b/.github/workflows/dispatch_shadow_signal.yml index 881b714..52b8f1b 100644 --- a/.github/workflows/dispatch_shadow_signal.yml +++ b/.github/workflows/dispatch_shadow_signal.yml @@ -46,6 +46,12 @@ jobs: - name: Validate existing latest signal if present run: python scripts/validate_latest_signal.py --allow-missing + - name: Build point-in-time context bundle + run: | + python scripts/build_context_bundle.py \ + --output data/output/context_bundle/latest_context_bundle.json \ + --allow-download-errors + - name: Create or update shadow signal issue id: shadow_issue env: @@ -56,6 +62,7 @@ jobs: --source-ref "${SOURCE_REF}" \ --provider "${BRIDGE_PROVIDER}" \ --bridge-repository "${BRIDGE_REPOSITORY}" \ + --context-file data/output/context_bundle/latest_context_bundle.json \ --label "${SHADOW_SIGNAL_LABEL}" - name: Append shadow signal job summary @@ -155,3 +162,9 @@ jobs: raise RuntimeError(f"unexpected dispatch status: {status}") print(f"Dispatched {bridge_repo} for issue #{os.environ['ISSUE_NUMBER']}") PY + + - name: Upload context bundle + uses: actions/upload-artifact@v7 + with: + name: long-horizon-context-bundle + path: data/output/context_bundle/latest_context_bundle.json diff --git a/README.md b/README.md index 1924b42..836571f 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,17 @@ This repo does not own: ## Operating Model -1. A weekly workflow creates or updates a dated long-horizon shadow-signal issue. -2. The issue is dispatched to `QuantStrategyLab/CodexAuditBridge` with task +1. A weekly workflow builds a point-in-time context bundle from current market + prices. +2. The workflow creates or updates a dated long-horizon shadow-signal issue and + embeds the context bundle as review evidence. +3. The issue is dispatched to `QuantStrategyLab/CodexAuditBridge` with task `long_horizon_signal_shadow`. -3. `CodexAuditBridge` tries self-hosted Codex first and uses its own OpenAI or +4. `CodexAuditBridge` tries self-hosted Codex first and uses its own OpenAI or Anthropic API fallback only when configured. -4. Any AI-generated artifact must remain `mode=shadow` and pass local schema +5. Any AI-generated artifact must remain `mode=shadow` and pass local schema validation. -5. Downstream runtimes must treat the artifact as advisory context only until a +6. Downstream runtimes must treat the artifact as advisory context only until a separate deterministic policy engine explicitly consumes it. ## GitHub Configuration @@ -76,6 +79,21 @@ Validate the example artifact: python scripts/validate_latest_signal.py examples/latest_signal.example.json ``` +Build a context bundle from a local price file: + +```bash +python scripts/build_context_bundle.py \ + --prices examples/price_history.example.csv \ + --symbols QQQ \ + --output data/output/context_bundle/latest_context_bundle.json +``` + +Without `--prices`, the script downloads recent daily prices for the default +universe through Yahoo's chart endpoint and writes a point-in-time context bundle +for the weekly shadow issue. The scheduled workflow uses +`--allow-download-errors`, so external data-source failures still create an +operator issue with the failure recorded instead of silently skipping the run. + Validate the promoted latest artifact when it exists: ```bash diff --git a/docs/architecture.md b/docs/architecture.md index deee7b4..42a76d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,6 +19,9 @@ order routing. - `CodexAuditBridge` owns provider routing and API keys. - GitHub Issues are the first operator notification layer for scheduled shadow signal runs. +- The scheduled workflow builds the market context bundle before dispatching the + bridge and embeds that bundle into the issue, because the bridge reads the + source repository ref plus issue content. - `QuantStrategyPlugins` may later read promoted artifacts as sidecar context. - Platform repositories remain unchanged. diff --git a/scripts/build_context_bundle.py b/scripts/build_context_bundle.py new file mode 100644 index 0000000..a55127d --- /dev/null +++ b/scripts/build_context_bundle.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ai_long_horizon_signal_pipelines.context_bundle import ( # noqa: E402 + DEFAULT_UNIVERSE, + build_context_from_source, + build_error_context_bundle, + normalize_symbols, + write_context_bundle, +) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build point-in-time context for long-horizon AI review.") + parser.add_argument("--symbols", default=",".join(DEFAULT_UNIVERSE), help="Comma-separated symbol universe") + parser.add_argument("--prices", help="Optional local CSV with symbol,close and date or as_of") + parser.add_argument("--start-date", help="Optional inclusive YYYY-MM-DD lower bound") + parser.add_argument("--end-date", help="Optional inclusive YYYY-MM-DD upper bound") + parser.add_argument("--lookback-days", type=int, default=420) + parser.add_argument( + "--allow-download-errors", + action="store_true", + help="Write a degraded operator-notification context bundle instead of failing on download errors", + ) + parser.add_argument( + "--output", + default="data/output/context_bundle/latest_context_bundle.json", + help="Output JSON context bundle path", + ) + args = parser.parse_args() + + symbols = normalize_symbols(args.symbols) + try: + bundle = build_context_from_source( + symbols=symbols, + prices_path=Path(args.prices) if args.prices else None, + start_date=args.start_date, + end_date=args.end_date, + lookback_days=args.lookback_days, + ) + except Exception as exc: + if not args.allow_download_errors: + raise + bundle = build_error_context_bundle(symbols=symbols, error=f"{type(exc).__name__}: {exc}") + output_path = Path(args.output) + write_context_bundle(bundle, output_path) + print( + json.dumps( + { + "output": str(output_path), + "as_of": bundle["as_of"], + "symbols": bundle["universe"], + "price_context_symbols": sorted(bundle.get("price_context", {})), + "warnings": bundle.get("data_quality", {}).get("warnings", []), + "errors": bundle.get("data_quality", {}).get("errors", []), + } + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/post_shadow_signal_request.py b/scripts/post_shadow_signal_request.py index 721ed6a..6d26af6 100644 --- a/scripts/post_shadow_signal_request.py +++ b/scripts/post_shadow_signal_request.py @@ -5,6 +5,7 @@ import datetime as dt import json import os +from pathlib import Path import sys import urllib.error import urllib.parse @@ -67,8 +68,53 @@ def build_issue_title(as_of_date: str) -> str: return f"Long-horizon AI shadow signal: {as_of_date}" -def build_issue_body(*, as_of_date: str, source_ref: str, provider: str, bridge_repository: str) -> str: +def load_context_bundle(path: str | None) -> dict[str, Any] | None: + if not path: + return None + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def resolve_as_of_date(raw_as_of_date: str | None, context_bundle: Mapping[str, Any] | None) -> str: + if raw_as_of_date: + return raw_as_of_date + if context_bundle and str(context_bundle.get("as_of") or "").strip(): + return str(context_bundle["as_of"]).strip() + return dt.date.today().isoformat() + + +def context_markdown(context_bundle: Mapping[str, Any] | None) -> str: + if not context_bundle: + return "\n".join( + [ + "## Context", + "", + "No generated context bundle was attached to this request.", + "If evidence is insufficient, report findings and leave artifacts unchanged.", + ] + ) + context_json = json.dumps(context_bundle, ensure_ascii=True, indent=2, sort_keys=True) return "\n".join( + [ + "## Context Bundle", + "", + "Use this point-in-time context bundle as the primary evidence for the shadow signal review.", + "", + "```json", + context_json, + "```", + ] + ) + + +def build_issue_body( + *, + as_of_date: str, + source_ref: str, + provider: str, + bridge_repository: str, + context_bundle: Mapping[str, Any] | None = None, +) -> str: + sections = [ [ "## Long-Horizon Shadow Signal Request", "", @@ -92,12 +138,14 @@ def build_issue_body(*, as_of_date: str, source_ref: str, provider: str, bridge_ "- Any downstream use must remain advisory until a deterministic policy consumes the artifact.", "- If evidence is insufficient, report findings and leave artifacts unchanged.", "", - "## Context", + ], + [context_markdown(context_bundle)], + [ "", - "Use committed examples, context bundles, and existing shadow artifacts as evidence.", "Do not infer historical AI signals that were not generated point-in-time.", - ] - ) + ], + ] + return "\n".join(line for section in sections for line in section) def upsert_issue( @@ -125,7 +173,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--source-ref", default="main") parser.add_argument("--provider", default="auto") parser.add_argument("--bridge-repository", default="QuantStrategyLab/CodexAuditBridge") - parser.add_argument("--as-of-date", default=dt.date.today().isoformat()) + parser.add_argument("--as-of-date") + parser.add_argument("--context-file", help="Optional JSON context bundle to embed in the issue body") parser.add_argument("--label", default=DEFAULT_LABEL) parser.add_argument("--api-url", default=DEFAULT_API_URL) return parser.parse_args() @@ -152,12 +201,15 @@ def main() -> int: print("GITHUB_TOKEN is required", file=sys.stderr) return 1 - title = build_issue_title(args.as_of_date) + context_bundle = load_context_bundle(args.context_file) + as_of_date = resolve_as_of_date(args.as_of_date, context_bundle) + title = build_issue_title(as_of_date) body = build_issue_body( - as_of_date=args.as_of_date, + as_of_date=as_of_date, source_ref=args.source_ref, provider=args.provider, bridge_repository=args.bridge_repository, + context_bundle=context_bundle, ) try: action, issue_number, issue_url = upsert_issue( diff --git a/src/ai_long_horizon_signal_pipelines/__init__.py b/src/ai_long_horizon_signal_pipelines/__init__.py index 785cf79..7d4e92d 100644 --- a/src/ai_long_horizon_signal_pipelines/__init__.py +++ b/src/ai_long_horizon_signal_pipelines/__init__.py @@ -1,6 +1,7 @@ """Shadow-only long-horizon AI signal artifact helpers.""" from .overlay_backtest import OverlayPolicy, backtest_overlay +from .context_bundle import DEFAULT_UNIVERSE, build_context_bundle, build_context_from_source from .price_history import PriceExtractionSummary, write_filtered_price_history from .schema import SignalValidationError, validate_signal @@ -8,7 +9,10 @@ "OverlayPolicy", "PriceExtractionSummary", "SignalValidationError", + "DEFAULT_UNIVERSE", "backtest_overlay", + "build_context_bundle", + "build_context_from_source", "validate_signal", "write_filtered_price_history", ] diff --git a/src/ai_long_horizon_signal_pipelines/context_bundle.py b/src/ai_long_horizon_signal_pipelines/context_bundle.py new file mode 100644 index 0000000..201ad9a --- /dev/null +++ b/src/ai_long_horizon_signal_pipelines/context_bundle.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import datetime as dt +import json +import math +import time +import urllib.error +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import quote +from urllib.request import Request, urlopen + +from .price_history import PriceRow, parse_price_date, read_price_rows + + +DEFAULT_UNIVERSE = ("SPY", "QQQ", "SOXX", "TQQQ", "BIL", "BOXX") +YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" +YAHOO_USER_AGENT = "Mozilla/5.0" + + +@dataclass(frozen=True) +class SymbolContext: + symbol: str + as_of: str + latest_close: float + observations: int + returns: dict[str, float | None] + sma: dict[str, float | None] + drawdown_63d: float | None + realized_vol_21d: float | None + trend: str + volatility: str + + +def normalize_symbols(symbols: str | list[str] | tuple[str, ...]) -> list[str]: + raw_symbols = symbols.split(",") if isinstance(symbols, str) else list(symbols) + normalized: list[str] = [] + for symbol in raw_symbols: + text = str(symbol or "").strip().upper() + if text and text not in normalized: + normalized.append(text) + if not normalized: + raise ValueError("at least one symbol is required") + return normalized + + +def period_timestamp(value: dt.date) -> int: + return int(dt.datetime.combine(value, dt.time.min, tzinfo=dt.timezone.utc).timestamp()) + + +def fetch_yahoo_chart_payload(symbol: str, *, start: dt.date, end: dt.date | None = None) -> dict[str, Any]: + end_date = end or dt.date.today() + period1 = period_timestamp(start) + period2 = period_timestamp(end_date + dt.timedelta(days=1)) + url = ( + f"{YAHOO_CHART_URL.format(symbol=quote(symbol, safe=''))}" + f"?period1={period1}&period2={period2}&interval=1d&events=history&includeAdjustedClose=true" + ) + request = Request(url, headers={"User-Agent": YAHOO_USER_AGENT}) + last_error: Exception | None = None + for attempt in range(3): + try: + with urlopen(request, timeout=20) as response: # noqa: S310 - fixed Yahoo Finance chart endpoint. + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + last_error = exc + if exc.code not in {429, 500, 502, 503, 504}: + raise + time.sleep(1.0 + attempt) + except urllib.error.URLError as exc: + last_error = exc + time.sleep(1.0 + attempt) + raise RuntimeError(f"Yahoo chart download failed for {symbol}: {last_error}") from last_error + + +def normalize_yahoo_chart_payload(payload: Mapping[str, Any], *, symbol: str) -> list[PriceRow]: + chart = dict(payload.get("chart") or {}) + error = chart.get("error") + if error: + raise RuntimeError(f"Yahoo chart error for {symbol}: {error}") + result = list(chart.get("result") or []) + if not result: + return [] + node = dict(result[0] or {}) + timestamps = list(node.get("timestamp") or []) + indicators = dict(node.get("indicators") or {}) + adjclose_nodes = list(indicators.get("adjclose") or []) + adjclose_values = list(dict(adjclose_nodes[0] or {}).get("adjclose") or []) if adjclose_nodes else [] + quote_nodes = list(indicators.get("quote") or []) + close_values = list(dict(quote_nodes[0] or {}).get("close") or []) if quote_nodes else [] + + rows: list[PriceRow] = [] + for idx, raw_ts in enumerate(timestamps): + close = _indexed_float(adjclose_values, idx) + if close is None: + close = _indexed_float(close_values, idx) + if close is None or close <= 0: + continue + row_date = dt.datetime.fromtimestamp(int(raw_ts), tz=dt.timezone.utc).date() + rows.append(PriceRow(date=row_date, symbol=symbol.upper(), close=close)) + return rows + + +def _indexed_float(values: list[Any], idx: int) -> float | None: + if idx >= len(values): + return None + value = values[idx] + if value is None: + return None + number = float(value) + return number if math.isfinite(number) else None + + +def download_price_rows( + symbols: list[str], + *, + start: dt.date, + end: dt.date | None = None, + fetch_fn: Callable[..., Mapping[str, Any]] | None = None, +) -> list[PriceRow]: + fetch = fetch_fn or fetch_yahoo_chart_payload + rows: list[PriceRow] = [] + missing: list[str] = [] + for symbol in symbols: + payload = fetch(symbol, start=start, end=end) + symbol_rows = normalize_yahoo_chart_payload(payload, symbol=symbol) + if not symbol_rows: + missing.append(symbol) + rows.extend(symbol_rows) + time.sleep(0.05) + if missing: + raise RuntimeError(f"price download missing symbols: {', '.join(missing)}") + return sorted(rows, key=lambda item: (item.date, item.symbol)) + + +def build_symbol_context(symbol: str, rows: list[PriceRow]) -> SymbolContext: + if not rows: + raise ValueError(f"no price rows for {symbol}") + ordered = sorted(rows, key=lambda item: item.date) + closes = [row.close for row in ordered] + latest = ordered[-1] + sma_50 = moving_average(closes, 50) + sma_200 = moving_average(closes, 200) + vol_21 = realized_volatility(closes, 21) + return SymbolContext( + symbol=symbol.upper(), + as_of=latest.date.isoformat(), + latest_close=latest.close, + observations=len(ordered), + returns={ + "5d": trailing_return(closes, 5), + "21d": trailing_return(closes, 21), + "63d": trailing_return(closes, 63), + "126d": trailing_return(closes, 126), + "252d": trailing_return(closes, 252), + }, + sma={"50d": sma_50, "200d": sma_200}, + drawdown_63d=trailing_drawdown(closes, 63), + realized_vol_21d=vol_21, + trend=classify_trend(latest.close, sma_50, sma_200), + volatility=classify_volatility(vol_21), + ) + + +def trailing_return(closes: list[float], periods: int) -> float | None: + if len(closes) <= periods: + return None + base = closes[-periods - 1] + return closes[-1] / base - 1.0 if base > 0 else None + + +def moving_average(closes: list[float], periods: int) -> float | None: + if len(closes) < periods: + return None + return sum(closes[-periods:]) / periods + + +def trailing_drawdown(closes: list[float], periods: int) -> float | None: + if len(closes) < 2: + return None + window = closes[-periods:] if len(closes) >= periods else closes + peak = max(window) + return closes[-1] / peak - 1.0 if peak > 0 else None + + +def realized_volatility(closes: list[float], periods: int) -> float | None: + if len(closes) <= periods: + return None + window = closes[-periods - 1 :] + returns = [window[idx] / window[idx - 1] - 1.0 for idx in range(1, len(window))] + if len(returns) < 2: + return None + mean = sum(returns) / len(returns) + variance = sum((item - mean) ** 2 for item in returns) / (len(returns) - 1) + return math.sqrt(variance) * math.sqrt(252) + + +def classify_trend(close: float, sma_50: float | None, sma_200: float | None) -> str: + if sma_200 is None: + return "insufficient_history" + if close >= sma_200 and (sma_50 is None or sma_50 >= sma_200): + return "above_200d" + if close < sma_200 and sma_50 is not None and sma_50 < sma_200: + return "below_200d" + return "mixed" + + +def classify_volatility(volatility: float | None) -> str: + if volatility is None: + return "unknown" + if volatility >= 0.35: + return "high" + if volatility >= 0.22: + return "elevated" + return "normal" + + +def build_context_bundle( + rows: list[PriceRow], + *, + symbols: list[str], + generated_at: dt.datetime | None = None, +) -> dict[str, Any]: + contexts: dict[str, SymbolContext] = {} + warnings: list[str] = [] + for symbol in symbols: + symbol_rows = [row for row in rows if row.symbol.upper() == symbol.upper()] + if not symbol_rows: + warnings.append(f"missing price history for {symbol.upper()}") + continue + contexts[symbol.upper()] = build_symbol_context(symbol, symbol_rows) + + if not contexts: + raise ValueError("context bundle requires at least one symbol with price history") + + latest_as_of = max(parse_price_date(context.as_of) for context in contexts.values()).isoformat() + stale_symbols = [ + symbol + for symbol, context in contexts.items() + if context.as_of != latest_as_of + ] + if stale_symbols: + warnings.append(f"symbols not updated to latest as_of {latest_as_of}: {', '.join(stale_symbols)}") + + timestamp = generated_at or dt.datetime.now(dt.timezone.utc) + return { + "schema_version": "1", + "as_of": latest_as_of, + "generated_at": timestamp.isoformat().replace("+00:00", "Z"), + "horizon": "1-3 months", + "universe": [symbol.upper() for symbol in symbols], + "price_context": { + symbol: { + "as_of": context.as_of, + "latest_close": round(context.latest_close, 6), + "observations": context.observations, + "returns": round_optional_mapping(context.returns), + "sma": round_optional_mapping(context.sma), + "drawdown_63d": round_optional(context.drawdown_63d), + "realized_vol_21d": round_optional(context.realized_vol_21d), + "trend": context.trend, + "volatility": context.volatility, + } + for symbol, context in contexts.items() + }, + "existing_strategy_context": { + "ai_may_place_orders": False, + "ai_mode": "shadow", + "downstream_policy_required": True, + }, + "data_quality": { + "source": "yahoo_chart_or_operator_price_csv", + "warnings": warnings, + "synthetic": False, + }, + "notes": [ + "Point-in-time context bundle for shadow research only.", + "This bundle is evidence for AI review, not a trading instruction.", + "Historical AI judgments must come from saved artifacts, not regenerated prompts.", + ], + } + + +def build_error_context_bundle( + *, + symbols: list[str], + error: str, + as_of_date: dt.date | None = None, + generated_at: dt.datetime | None = None, +) -> dict[str, Any]: + as_of = as_of_date or dt.date.today() + timestamp = generated_at or dt.datetime.now(dt.timezone.utc) + return { + "schema_version": "1", + "as_of": as_of.isoformat(), + "generated_at": timestamp.isoformat().replace("+00:00", "Z"), + "horizon": "1-3 months", + "universe": [symbol.upper() for symbol in symbols], + "price_context": {}, + "existing_strategy_context": { + "ai_may_place_orders": False, + "ai_mode": "shadow", + "downstream_policy_required": True, + }, + "data_quality": { + "source": "yahoo_chart_or_operator_price_csv", + "warnings": ["context bundle contains no usable price context"], + "errors": [error], + "synthetic": False, + }, + "notes": [ + "Point-in-time context bundle generation failed before market evidence was available.", + "This is an operator notification input, not a trading instruction.", + ], + } + + +def round_optional(value: float | None, digits: int = 6) -> float | None: + return round(value, digits) if value is not None else None + + +def round_optional_mapping(values: Mapping[str, float | None]) -> dict[str, float | None]: + return {key: round_optional(value) for key, value in values.items()} + + +def build_context_from_source( + *, + symbols: list[str], + prices_path: Path | None = None, + start_date: str | None = None, + end_date: str | None = None, + lookback_days: int = 420, + fetch_fn: Callable[..., Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + end = parse_price_date(end_date) if end_date else dt.date.today() + start = parse_price_date(start_date) if start_date else end - dt.timedelta(days=int(lookback_days)) + if prices_path is not None: + rows = read_price_rows(prices_path, symbols=symbols, start_date=start.isoformat(), end_date=end.isoformat()) + else: + rows = download_price_rows(symbols, start=start, end=end, fetch_fn=fetch_fn) + return build_context_bundle(rows, symbols=symbols) + + +def write_context_bundle(bundle: Mapping[str, Any], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(bundle, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/tests/test_context_bundle.py b/tests/test_context_bundle.py new file mode 100644 index 0000000..f0f6a50 --- /dev/null +++ b/tests/test_context_bundle.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import datetime as dt + +from ai_long_horizon_signal_pipelines.context_bundle import ( + build_context_bundle, + build_error_context_bundle, + build_context_from_source, + normalize_yahoo_chart_payload, +) +from ai_long_horizon_signal_pipelines.price_history import PriceRow + + +def test_build_context_bundle_classifies_trend_and_preserves_shadow_boundary() -> None: + rows = [ + PriceRow(date=dt.date(2025, 1, 1) + dt.timedelta(days=idx), symbol="QQQ", close=100 + idx) + for idx in range(260) + ] + + bundle = build_context_bundle(rows, symbols=["QQQ"], generated_at=dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)) + + assert bundle["as_of"] == "2025-09-17" + assert bundle["existing_strategy_context"]["ai_may_place_orders"] is False + assert bundle["data_quality"]["synthetic"] is False + assert bundle["price_context"]["QQQ"]["trend"] == "above_200d" + assert bundle["price_context"]["QQQ"]["returns"]["21d"] is not None + + +def test_normalize_yahoo_chart_payload_uses_adjusted_close() -> None: + payload = { + "chart": { + "result": [ + { + "timestamp": [1767312000], + "indicators": { + "quote": [{"close": [100.0]}], + "adjclose": [{"adjclose": [50.0]}], + }, + } + ], + "error": None, + } + } + + rows = normalize_yahoo_chart_payload(payload, symbol="QQQ") + + assert rows == [PriceRow(date=dt.date(2026, 1, 2), symbol="QQQ", close=50.0)] + + +def test_build_context_from_source_accepts_fake_downloader() -> None: + def fake_fetch(symbol, *, start, end=None): + return { + "chart": { + "result": [ + { + "timestamp": [1767312000, 1767571200], + "indicators": {"adjclose": [{"adjclose": [100.0, 101.0]}]}, + } + ], + "error": None, + } + } + + bundle = build_context_from_source( + symbols=["QQQ"], + start_date="2026-01-01", + end_date="2026-01-06", + fetch_fn=fake_fetch, + ) + + assert bundle["as_of"] == "2026-01-05" + assert bundle["price_context"]["QQQ"]["latest_close"] == 101.0 + + +def test_build_error_context_bundle_keeps_shadow_boundary() -> None: + bundle = build_error_context_bundle( + symbols=["QQQ"], + error="RuntimeError: data source unavailable", + as_of_date=dt.date(2026, 1, 2), + generated_at=dt.datetime(2026, 1, 2, tzinfo=dt.timezone.utc), + ) + + assert bundle["as_of"] == "2026-01-02" + assert bundle["price_context"] == {} + assert bundle["data_quality"]["errors"] == ["RuntimeError: data source unavailable"] + assert bundle["existing_strategy_context"]["ai_may_place_orders"] is False diff --git a/tests/test_post_shadow_signal_request.py b/tests/test_post_shadow_signal_request.py index 8214b2b..8f2afe5 100644 --- a/tests/test_post_shadow_signal_request.py +++ b/tests/test_post_shadow_signal_request.py @@ -11,12 +11,20 @@ def test_build_issue_body_marks_notification_and_shadow_boundary() -> None: source_ref="main", provider="auto", bridge_repository="QuantStrategyLab/CodexAuditBridge", + context_bundle={"as_of": "2026-05-29", "price_context": {"QQQ": {"trend": "above_200d"}}}, ) assert "operator-facing notification" in body assert "Mode: `shadow`" in body assert "must not place orders" in body assert "Do not infer historical AI signals" in body + assert "Context Bundle" in body + assert '"QQQ"' in body + + +def test_resolve_as_of_date_prefers_context_bundle() -> None: + assert shadow_issue.resolve_as_of_date(None, {"as_of": "2026-05-29"}) == "2026-05-29" + assert shadow_issue.resolve_as_of_date("2026-05-28", {"as_of": "2026-05-29"}) == "2026-05-28" def test_upsert_issue_updates_existing_issue(monkeypatch) -> None: