Skip to content

Commit 3ef5f07

Browse files
authored
Add real price extraction for overlay replay (#2)
1 parent 14b25d9 commit 3ef5f07

10 files changed

Lines changed: 340 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ jobs:
2121
run: python scripts/validate_latest_signal.py examples/latest_signal.example.json
2222
- name: Run example overlay replay
2323
run: python scripts/backtest_signal_overlay.py --prices examples/price_history.example.csv --signals examples/signal_history --symbol QQQ
24+
- name: Run example price extraction
25+
run: |
26+
python scripts/extract_price_history.py \
27+
--source examples/price_history.example.csv \
28+
--target /tmp/qqq_overlay_prices.csv \
29+
--symbols QQQ

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ python scripts/backtest_signal_overlay.py \
8383
The replay tests a deterministic risk-reducing overlay only. It does not call
8484
AI models and does not treat the example as production evidence.
8585

86+
Extract compact real-price input from an existing QuantStrategyLab price file:
87+
88+
```bash
89+
python scripts/extract_price_history.py \
90+
--source ../UsEquitySnapshotPipelines/data/output/tqqq_growth_income_real_full_archive_2026-05-26/price_history.csv \
91+
--target data/input/qqq_price_history.csv \
92+
--symbols QQQ
93+
```
94+
95+
Then replay stored shadow signals against those prices:
96+
97+
```bash
98+
python scripts/backtest_signal_overlay.py \
99+
--prices data/input/qqq_price_history.csv \
100+
--signals data/output/signal_history \
101+
--symbol QQQ \
102+
--output data/output/tmp/replay_summary.json
103+
```
104+
105+
The price loader accepts both this repository's compact `date,symbol,close`
106+
schema and the existing QuantStrategyLab `symbol,as_of,close` schema.
107+
86108
## Artifact Contract
87109

88110
The latest artifact path is:

docs/architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ The first overlay harness intentionally measures only:
4747
This is enough to identify whether the stored AI context would have reduced
4848
risk or created unacceptable opportunity cost before any runtime integration.
4949

50+
The replay harness can read either compact `date,symbol,close` CSV files or the
51+
existing QuantStrategyLab `symbol,as_of,close` price-history files. Large source
52+
files should stay in their owning strategy repositories or object storage; this
53+
repository only stores small extracted replay inputs when needed for research.
54+
5055
## Risk Notes
5156

5257
The artifact is research evidence, not a trading instruction. Missing evidence,

scripts/backtest_signal_overlay.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919

2020
def main() -> int:
2121
parser = argparse.ArgumentParser(description="Replay shadow AI signals as a deterministic risk overlay.")
22-
parser.add_argument("--prices", default="examples/price_history.example.csv", help="CSV with date,symbol,close")
22+
parser.add_argument(
23+
"--prices",
24+
default="examples/price_history.example.csv",
25+
help="CSV with symbol,close and date or as_of",
26+
)
2327
parser.add_argument("--signals", default="examples/signal_history", help="Signal JSON file or directory")
2428
parser.add_argument("--symbol", default="QQQ", help="Risk asset symbol to test")
2529
parser.add_argument("--min-confidence", type=float, default=0.55)

scripts/extract_price_history.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import sys
7+
from pathlib import Path
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
sys.path.insert(0, str(ROOT / "src"))
11+
12+
from ai_long_horizon_signal_pipelines.price_history import write_filtered_price_history # noqa: E402
13+
14+
15+
def main() -> int:
16+
parser = argparse.ArgumentParser(
17+
description="Extract a compact date,symbol,close CSV for overlay replay."
18+
)
19+
parser.add_argument("--source", required=True, help="Input CSV with symbol,close and date or as_of")
20+
parser.add_argument("--target", default="data/input/price_history.csv", help="Output CSV path")
21+
parser.add_argument("--symbols", default="QQQ", help="Comma-separated symbols to keep")
22+
parser.add_argument("--start-date", help="Optional inclusive YYYY-MM-DD lower bound")
23+
parser.add_argument("--end-date", help="Optional inclusive YYYY-MM-DD upper bound")
24+
args = parser.parse_args()
25+
26+
summary = write_filtered_price_history(
27+
args.source,
28+
args.target,
29+
symbols=args.symbols,
30+
start_date=args.start_date,
31+
end_date=args.end_date,
32+
)
33+
print(json.dumps(summary.__dict__, ensure_ascii=True, indent=2))
34+
return 0
35+
36+
37+
if __name__ == "__main__":
38+
raise SystemExit(main())
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
"""Shadow-only long-horizon AI signal artifact helpers."""
22

33
from .overlay_backtest import OverlayPolicy, backtest_overlay
4+
from .price_history import PriceExtractionSummary, write_filtered_price_history
45
from .schema import SignalValidationError, validate_signal
56

6-
__all__ = ["OverlayPolicy", "SignalValidationError", "backtest_overlay", "validate_signal"]
7+
__all__ = [
8+
"OverlayPolicy",
9+
"PriceExtractionSummary",
10+
"SignalValidationError",
11+
"backtest_overlay",
12+
"validate_signal",
13+
"write_filtered_price_history",
14+
]

src/ai_long_horizon_signal_pipelines/overlay_backtest.py

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from __future__ import annotations
22

3-
import csv
43
import datetime as dt
54
import json
65
from dataclasses import dataclass
76
from pathlib import Path
87
from typing import Any
98

9+
from .price_history import parse_price_date, read_price_rows
1010
from .schema import validate_signal
1111

1212

@@ -33,25 +33,14 @@ class OverlayPolicy:
3333

3434

3535
def parse_date(value: str) -> dt.date:
36-
return dt.date.fromisoformat(value)
36+
return parse_price_date(value)
3737

3838

3939
def load_price_history(path: Path, *, symbol: str) -> list[PricePoint]:
40-
rows: list[PricePoint] = []
41-
with path.open(newline="", encoding="utf-8") as handle:
42-
reader = csv.DictReader(handle)
43-
required = {"date", "symbol", "close"}
44-
missing = required.difference(reader.fieldnames or ())
45-
if missing:
46-
raise ValueError(f"price history missing columns: {', '.join(sorted(missing))}")
47-
for row in reader:
48-
if str(row["symbol"]).strip().upper() != symbol.upper():
49-
continue
50-
close = float(row["close"])
51-
if close <= 0:
52-
raise ValueError(f"close must be positive for {symbol} on {row['date']}")
53-
rows.append(PricePoint(date=parse_date(row["date"]), close=close))
54-
rows.sort(key=lambda item: item.date)
40+
rows = [
41+
PricePoint(date=row.date, close=row.close)
42+
for row in read_price_rows(path, symbols=[symbol])
43+
]
5544
if len(rows) < 2:
5645
raise ValueError(f"price history for {symbol} requires at least two rows")
5746
return rows
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
import csv
4+
import datetime as dt
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
9+
DATE_COLUMNS = ("date", "as_of")
10+
11+
12+
@dataclass(frozen=True)
13+
class PriceRow:
14+
date: dt.date
15+
symbol: str
16+
close: float
17+
18+
19+
@dataclass(frozen=True)
20+
class PriceExtractionSummary:
21+
source: str
22+
target: str
23+
input_rows: int
24+
output_rows: int
25+
symbols: list[str]
26+
start_date: str | None
27+
end_date: str | None
28+
29+
30+
def parse_price_date(value: object) -> dt.date:
31+
text = str(value or "").strip()
32+
if not text:
33+
raise ValueError("price date is required")
34+
if text.endswith("Z"):
35+
text = f"{text[:-1]}+00:00"
36+
try:
37+
return dt.date.fromisoformat(text)
38+
except ValueError:
39+
return dt.datetime.fromisoformat(text).date()
40+
41+
42+
def _resolve_date_column(fieldnames: list[str] | None) -> str:
43+
fields = set(fieldnames or [])
44+
for column in DATE_COLUMNS:
45+
if column in fields:
46+
return column
47+
expected = " or ".join(DATE_COLUMNS)
48+
raise ValueError(f"price history missing date column: expected {expected}")
49+
50+
51+
def _normalize_symbols(symbols: list[str] | tuple[str, ...] | set[str] | str) -> list[str]:
52+
if isinstance(symbols, str):
53+
raw_symbols = symbols.split(",")
54+
else:
55+
raw_symbols = list(symbols)
56+
normalized: list[str] = []
57+
for symbol in raw_symbols:
58+
text = str(symbol or "").strip().upper()
59+
if text and text not in normalized:
60+
normalized.append(text)
61+
if not normalized:
62+
raise ValueError("at least one symbol is required")
63+
return normalized
64+
65+
66+
def read_price_rows(
67+
path: str | Path,
68+
*,
69+
symbols: list[str] | tuple[str, ...] | set[str] | str,
70+
start_date: str | None = None,
71+
end_date: str | None = None,
72+
) -> list[PriceRow]:
73+
selected_symbols = set(_normalize_symbols(symbols))
74+
start = parse_price_date(start_date) if start_date else None
75+
end = parse_price_date(end_date) if end_date else None
76+
rows_by_key: dict[tuple[dt.date, str], PriceRow] = {}
77+
78+
with Path(path).open(newline="", encoding="utf-8") as handle:
79+
reader = csv.DictReader(handle)
80+
required = {"symbol", "close"}
81+
missing = required.difference(reader.fieldnames or ())
82+
if missing:
83+
raise ValueError(f"price history missing columns: {', '.join(sorted(missing))}")
84+
date_column = _resolve_date_column(reader.fieldnames)
85+
86+
for raw_row in reader:
87+
symbol = str(raw_row["symbol"]).strip().upper()
88+
if symbol not in selected_symbols:
89+
continue
90+
row_date = parse_price_date(raw_row[date_column])
91+
if start and row_date < start:
92+
continue
93+
if end and row_date > end:
94+
continue
95+
close = float(raw_row["close"])
96+
if close <= 0:
97+
raise ValueError(f"close must be positive for {symbol} on {row_date.isoformat()}")
98+
rows_by_key[(row_date, symbol)] = PriceRow(date=row_date, symbol=symbol, close=close)
99+
100+
return [rows_by_key[key] for key in sorted(rows_by_key)]
101+
102+
103+
def write_filtered_price_history(
104+
source: str | Path,
105+
target: str | Path,
106+
*,
107+
symbols: list[str] | tuple[str, ...] | set[str] | str,
108+
start_date: str | None = None,
109+
end_date: str | None = None,
110+
) -> PriceExtractionSummary:
111+
selected_symbols = _normalize_symbols(symbols)
112+
start = parse_price_date(start_date) if start_date else None
113+
end = parse_price_date(end_date) if end_date else None
114+
rows_by_key: dict[tuple[dt.date, str], PriceRow] = {}
115+
input_rows = 0
116+
117+
with Path(source).open(newline="", encoding="utf-8") as handle:
118+
reader = csv.DictReader(handle)
119+
required = {"symbol", "close"}
120+
missing = required.difference(reader.fieldnames or ())
121+
if missing:
122+
raise ValueError(f"price history missing columns: {', '.join(sorted(missing))}")
123+
date_column = _resolve_date_column(reader.fieldnames)
124+
125+
for raw_row in reader:
126+
input_rows += 1
127+
symbol = str(raw_row["symbol"]).strip().upper()
128+
if symbol not in selected_symbols:
129+
continue
130+
row_date = parse_price_date(raw_row[date_column])
131+
if start and row_date < start:
132+
continue
133+
if end and row_date > end:
134+
continue
135+
close = float(raw_row["close"])
136+
if close <= 0:
137+
raise ValueError(f"close must be positive for {symbol} on {row_date.isoformat()}")
138+
rows_by_key[(row_date, symbol)] = PriceRow(date=row_date, symbol=symbol, close=close)
139+
140+
output_path = Path(target)
141+
output_path.parent.mkdir(parents=True, exist_ok=True)
142+
rows = [rows_by_key[key] for key in sorted(rows_by_key)]
143+
with output_path.open("w", newline="", encoding="utf-8") as handle:
144+
writer = csv.DictWriter(handle, fieldnames=["date", "symbol", "close"])
145+
writer.writeheader()
146+
for row in rows:
147+
writer.writerow({"date": row.date.isoformat(), "symbol": row.symbol, "close": row.close})
148+
149+
return PriceExtractionSummary(
150+
source=str(source),
151+
target=str(target),
152+
input_rows=input_rows,
153+
output_rows=len(rows),
154+
symbols=selected_symbols,
155+
start_date=start.isoformat() if start else None,
156+
end_date=end.isoformat() if end else None,
157+
)

tests/test_overlay_backtest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,24 @@ def test_backtest_overlay_reduces_drawdown_on_synthetic_path() -> None:
4545
assert summary["overlay"]["max_drawdown"] > summary["baseline"]["max_drawdown"]
4646
assert 0 < summary["overlay"]["avg_exposure"] <= 1.0
4747
assert summary["overlay"]["turnover"] > 0
48+
49+
50+
def test_load_price_history_accepts_quant_strategy_as_of_schema(tmp_path) -> None:
51+
prices_path = tmp_path / "prices.csv"
52+
prices_path.write_text(
53+
"\n".join(
54+
[
55+
"symbol,as_of,close,volume",
56+
"QQQ,2026-01-02,100,1000",
57+
"SPY,2026-01-02,90,1000",
58+
"QQQ,2026-01-05,101,1000",
59+
]
60+
)
61+
+ "\n",
62+
encoding="utf-8",
63+
)
64+
65+
prices = load_price_history(prices_path, symbol="QQQ")
66+
67+
assert [price.date.isoformat() for price in prices] == ["2026-01-02", "2026-01-05"]
68+
assert [price.close for price in prices] == [100.0, 101.0]

0 commit comments

Comments
 (0)