fix: support legacy research snapshots on macOS
This commit is contained in:
@@ -120,13 +120,32 @@ atomically and resume verifies a fingerprint over the implementation commit,
|
|||||||
this specification hash, snapshot SHA-256, cache key, arm definitions, costs,
|
this specification hash, snapshot SHA-256, cache key, arm definitions, costs,
|
||||||
and cohort manifest. An authoritative run refuses a dirty worktree.
|
and cohort manifest. An authoritative run refuses a dirty worktree.
|
||||||
|
|
||||||
|
The loader reads only ticker ID/symbol and the OHLCV columns used by replay, so
|
||||||
|
snapshots created before SEC metadata added `tickers.cik`, `tickers.sic`, and
|
||||||
|
`tickers.sic_description` remain valid. Do not migrate or alter the research
|
||||||
|
snapshot: its original SHA-256 is part of the run fingerprint.
|
||||||
|
|
||||||
|
macOS environment setup from the repository root (zsh):
|
||||||
|
|
||||||
|
python3 -m venv .venv
|
||||||
|
./.venv/bin/python -m pip install -e '.[dev]'
|
||||||
|
|
||||||
Preflight:
|
Preflight:
|
||||||
|
|
||||||
python scripts/run_portfolio_construction_matrix.py backtest_snapshots/research.sqlite --run-id prod505-capacity-bracket-daily-v1 --validate-only
|
./.venv/bin/python scripts/run_portfolio_construction_matrix.py \
|
||||||
|
backtest_snapshots/research.sqlite \
|
||||||
|
--run-id prod505-capacity-bracket-daily-v1 \
|
||||||
|
--workers auto \
|
||||||
|
--resume \
|
||||||
|
--validate-only
|
||||||
|
|
||||||
Authoritative run:
|
Authoritative run:
|
||||||
|
|
||||||
python scripts/run_portfolio_construction_matrix.py backtest_snapshots/research.sqlite --run-id prod505-capacity-bracket-daily-v1 --workers auto --resume
|
./.venv/bin/python scripts/run_portfolio_construction_matrix.py \
|
||||||
|
backtest_snapshots/research.sqlite \
|
||||||
|
--run-id prod505-capacity-bracket-daily-v1 \
|
||||||
|
--workers auto \
|
||||||
|
--resume
|
||||||
|
|
||||||
Commit only the compact final JSON and Markdown reports. Raw curves, trades,
|
Commit only the compact final JSON and Markdown reports. Raw curves, trades,
|
||||||
candidate caches, and checkpoints remain ignored.
|
candidate caches, and checkpoints remain ignored.
|
||||||
|
|||||||
@@ -210,6 +210,43 @@ def _worker_run_cell(cell: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_snapshot_columns(
|
||||||
|
db: AsyncSession,
|
||||||
|
ticker_id: int,
|
||||||
|
) -> tuple | None:
|
||||||
|
'''Load only the stable OHLCV columns required by the research replay.
|
||||||
|
|
||||||
|
Research snapshots can predate unrelated additions to the Ticker ORM model
|
||||||
|
(for example SEC CIK/SIC metadata). Keeping this query column-scoped avoids
|
||||||
|
requiring or mutating those newer application-schema fields.
|
||||||
|
'''
|
||||||
|
from app.models.ohlcv import OHLCVRecord
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
OHLCVRecord.date,
|
||||||
|
OHLCVRecord.open,
|
||||||
|
OHLCVRecord.high,
|
||||||
|
OHLCVRecord.low,
|
||||||
|
OHLCVRecord.close,
|
||||||
|
OHLCVRecord.volume,
|
||||||
|
)
|
||||||
|
.where(OHLCVRecord.ticker_id == ticker_id)
|
||||||
|
.order_by(OHLCVRecord.date)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
[row[0].toordinal() for row in rows],
|
||||||
|
[float(row[1]) for row in rows],
|
||||||
|
[float(row[2]) for row in rows],
|
||||||
|
[float(row[3]) for row in rows],
|
||||||
|
[float(row[4]) for row in rows],
|
||||||
|
[int(row[5]) for row in rows],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _load_snapshot(
|
async def _load_snapshot(
|
||||||
snapshot: Path,
|
snapshot: Path,
|
||||||
*,
|
*,
|
||||||
@@ -238,14 +275,16 @@ async def _load_snapshot(
|
|||||||
refresh=False,
|
refresh=False,
|
||||||
)
|
)
|
||||||
ticker_result = await db.execute(
|
ticker_result = await db.execute(
|
||||||
select(Ticker).order_by(Ticker.symbol)
|
select(Ticker.id, Ticker.symbol).order_by(Ticker.symbol)
|
||||||
)
|
)
|
||||||
symbols = [
|
ticker_rows = [
|
||||||
ticker.symbol for ticker in ticker_result.scalars().all()
|
(int(ticker_id), str(symbol))
|
||||||
|
for ticker_id, symbol in ticker_result.all()
|
||||||
]
|
]
|
||||||
|
symbols = [symbol for _ticker_id, symbol in ticker_rows]
|
||||||
prices: dict[str, tuple] = {}
|
prices: dict[str, tuple] = {}
|
||||||
for index, symbol in enumerate(symbols, 1):
|
for index, (ticker_id, symbol) in enumerate(ticker_rows, 1):
|
||||||
columns = await bt._fetch_columns(db, symbol)
|
columns = await _fetch_snapshot_columns(db, ticker_id)
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
prices[symbol] = columns
|
prices[symbol] = columns
|
||||||
if not quiet and index % 50 == 0:
|
if not quiet and index % 50 == 0:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -17,6 +19,7 @@ from scripts.portfolio_capacity_research import (
|
|||||||
from scripts.run_portfolio_construction_matrix import (
|
from scripts.run_portfolio_construction_matrix import (
|
||||||
_assert_clean_worktree,
|
_assert_clean_worktree,
|
||||||
_checkpoint_state,
|
_checkpoint_state,
|
||||||
|
_load_snapshot,
|
||||||
_markdown,
|
_markdown,
|
||||||
_operational_summary,
|
_operational_summary,
|
||||||
_worker_init,
|
_worker_init,
|
||||||
@@ -101,6 +104,85 @@ def test_new_simulator_option_defaults_match_explicit_defaults():
|
|||||||
assert legacy == explicit
|
assert legacy == explicit
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_snapshot_accepts_pre_sec_ticker_schema(tmp_path, monkeypatch):
|
||||||
|
snapshot = tmp_path / 'legacy-research.sqlite'
|
||||||
|
with sqlite3.connect(snapshot) as connection:
|
||||||
|
connection.executescript(
|
||||||
|
'''
|
||||||
|
CREATE TABLE tickers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
symbol VARCHAR(10) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120),
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE ohlcv_records (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ticker_id INTEGER NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
open FLOAT NOT NULL,
|
||||||
|
high FLOAT NOT NULL,
|
||||||
|
low FLOAT NOT NULL,
|
||||||
|
close FLOAT NOT NULL,
|
||||||
|
volume BIGINT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO tickers VALUES
|
||||||
|
(1, 'LEGACY', 'Legacy Co', '2024-01-01 00:00:00');
|
||||||
|
INSERT INTO ohlcv_records VALUES
|
||||||
|
(1, 1, '2024-01-02', 100, 102, 99, 101, 1000000,
|
||||||
|
'2024-01-02 00:00:00');
|
||||||
|
'''
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recommendation_config(_db):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def activation_config(_db):
|
||||||
|
return {'min_momentum_percentile': 80.0}
|
||||||
|
|
||||||
|
async def exit_policy(_db):
|
||||||
|
return {'mode': 'atr_trailing', 'hold_days': 30, 'atr_multiplier': 3.0}
|
||||||
|
|
||||||
|
async def benchmark_closes(_db, *, days, refresh):
|
||||||
|
assert days is None
|
||||||
|
assert refresh is False
|
||||||
|
return {date(2024, 1, 2): 100.0}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.recommendation_service.get_recommendation_config',
|
||||||
|
recommendation_config,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.admin_service.get_activation_config',
|
||||||
|
activation_config,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.paper_trade_service.get_exit_policy',
|
||||||
|
exit_policy,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.services.backtest_service._load_benchmark_closes_for_backtest',
|
||||||
|
benchmark_closes,
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded = asyncio.run(_load_snapshot(snapshot, quiet=True))
|
||||||
|
|
||||||
|
assert loaded['symbols'] == ['LEGACY']
|
||||||
|
assert loaded['prices']['LEGACY'] == (
|
||||||
|
[date(2024, 1, 2).toordinal()],
|
||||||
|
[100.0],
|
||||||
|
[102.0],
|
||||||
|
[99.0],
|
||||||
|
[101.0],
|
||||||
|
[1_000_000],
|
||||||
|
)
|
||||||
|
with sqlite3.connect(snapshot) as connection:
|
||||||
|
columns = {
|
||||||
|
row[1] for row in connection.execute('PRAGMA table_info(tickers)')
|
||||||
|
}
|
||||||
|
assert {'cik', 'sic', 'sic_description'}.isdisjoint(columns)
|
||||||
|
|
||||||
|
|
||||||
def test_unbounded_count_and_effective_risk_floor():
|
def test_unbounded_count_and_effective_risk_floor():
|
||||||
start = date(2025, 1, 6)
|
start = date(2025, 1, 6)
|
||||||
ords = [start.toordinal() + offset for offset in range(4)]
|
ords = [start.toordinal() + offset for offset in range(4)]
|
||||||
|
|||||||
Reference in New Issue
Block a user