支持1d级别插针模型

This commit is contained in:
2026-08-25 09:23:30 +08:00
parent baddd7763d
commit c0df80154b
12 changed files with 299 additions and 65 deletions

View File

@@ -14,16 +14,17 @@ from .pipeline import (
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Train a unified 8H futures drawdown model")
parser = argparse.ArgumentParser(description="Train a unified futures drawdown model")
sub = parser.add_subparsers(dest="command", required=True)
prepare = sub.add_parser("prepare", help="filter symbols, call banbot, export complete 8H CSVs")
prepare = sub.add_parser("prepare", help="filter symbols, call banbot, export complete timeframe CSVs")
prepare.add_argument("--out-dir", type=Path, default=Path("data"))
prepare.add_argument("--banbot-dir", type=Path, default=Path("/home/ctyun/ban/banbot"))
prepare.add_argument("--banbot-config", type=Path, default=Path("/home/ctyun/ban/banbot/biz/config.yml"))
prepare.add_argument("--dsn", default=None)
prepare.add_argument("--db-type", choices=["auto", "questdb", "timescale"], default="auto")
prepare.add_argument("--exchange", default="binance")
prepare.add_argument("--timeframe", choices=["8h", "1d"], default="8h")
prepare.add_argument("--end", dest="end_ms", default=None, help="UTC date/ISO timestamp or milliseconds")
prepare.add_argument("--history-months", type=int, default=36)
prepare.add_argument("--holdout-months", type=int, default=10)
@@ -55,7 +56,7 @@ def build_parser() -> argparse.ArgumentParser:
run.add_argument("--volatility-rank-threshold", type=float, default=VOLATILITY_RANK_THRESHOLD)
predict = sub.add_parser(
"predict", help="predict signed robust future-low q20/q80 from an exported 8H CSV"
"predict", help="predict signed robust future-low q20/q80 from an exported timeframe CSV"
)
predict.add_argument("--artifact", type=Path, default=Path("artifacts/v1/model.joblib"))
predict.add_argument("--csv", type=Path, required=True)
@@ -74,7 +75,7 @@ def main() -> None:
dsn=args.dsn,
db_type=args.db_type,
exchange=args.exchange,
end_ms=parse_time_ms(args.end_ms) if args.end_ms else None,
end_ms=parse_time_ms(args.end_ms, args.timeframe) if args.end_ms else None,
history_months=args.history_months,
holdout_months=args.holdout_months,
min_age_days=args.min_age_days,
@@ -82,6 +83,7 @@ def main() -> None:
skip_download=args.skip_download,
banbot_cmd=args.banbot_cmd,
banbot_export_cmd=args.banbot_export_cmd,
timeframe=args.timeframe,
)
print(json.dumps({"selected": len(result["symbols"]), "excluded": len(result["excluded"])}, indent=2))
elif args.command == "train":
@@ -104,7 +106,7 @@ def main() -> None:
dsn=args.dsn,
db_type=args.db_type,
exchange=args.exchange,
end_ms=parse_time_ms(args.end_ms) if args.end_ms else None,
end_ms=parse_time_ms(args.end_ms, args.timeframe) if args.end_ms else None,
history_months=args.history_months,
holdout_months=args.holdout_months,
min_age_days=args.min_age_days,
@@ -112,6 +114,7 @@ def main() -> None:
skip_download=args.skip_download,
banbot_cmd=args.banbot_cmd,
banbot_export_cmd=args.banbot_export_cmd,
timeframe=args.timeframe,
)
result = run_training(
data_dir=args.out_dir,

View File

@@ -31,13 +31,14 @@ from .pipeline import (
compute_indicators,
evaluate_predictions,
fit_asset_scaler,
floor_to_8h,
floor_to_timeframe,
load_manifest,
load_raw_frames,
months_before,
recent_split_boundaries,
scale_asset_profile,
split_holdout_symbols,
timeframe_step_ms,
)
@@ -274,6 +275,7 @@ def build_variant_dataset(
validation_end_ms: int,
calibration_end_ms: int,
volatility_rank_threshold: float,
step_ms: int = EIGHT_H_MS,
) -> tuple[pd.DataFrame, list[str], dict[str, dict[str, float]]]:
indicators = {symbol: compute_indicators(frame) for symbol, frame in frames.items()}
raw_profiles = {
@@ -292,7 +294,7 @@ def build_variant_dataset(
train_set = set(train_symbols)
validation_set = set(validation_symbols)
test_set = set(test_symbols)
step = EIGHT_H_MS
step = int(step_ms)
rows: list[pd.DataFrame] = []
candidate_counts = {"train": 0, "validation": 0, "calibration": 0, "test": 0}
selected_counts = {name: 0 for name in candidate_counts}
@@ -515,10 +517,13 @@ def _artifact(
manifest: dict[str, Any],
end_ms: int,
test_start_ms: int,
timeframe: str,
step_ms: int,
) -> dict[str, Any]:
return {
"version": 4,
"timeframe": "8h",
"timeframe": timeframe,
"step_ms": step_ms,
"window": WINDOW,
"horizon": HORIZON,
"target_kind": "signed_future_low_q20_return_atr_normalized",
@@ -572,10 +577,12 @@ def run_experiments(
) -> dict[str, Any]:
started = time.monotonic()
output_dir.mkdir(parents=True, exist_ok=True)
_progress("loading manifest and raw 8H frames")
_progress("loading manifest and raw frames")
manifest = load_manifest(data_dir)
frames = load_raw_frames(data_dir, manifest)
end_ms = floor_to_8h(int(manifest["end_ms"]))
timeframe = str(manifest.get("timeframe", "8h"))
step_ms = int(manifest.get("step_ms") or timeframe_step_ms(timeframe))
end_ms = floor_to_timeframe(int(manifest["end_ms"]), timeframe)
test_start_ms = months_before(end_ms, test_months)
indicators = {symbol: compute_indicators(frame) for symbol, frame in frames.items()}
raw_profiles = {
@@ -585,13 +592,14 @@ def run_experiments(
profiles = {symbol: profile for symbol, profile in raw_profiles.items() if profile is not None}
train_symbols, validation_symbols, test_symbols = split_holdout_symbols(profiles, seed)
scaler = fit_asset_scaler(profiles, train_symbols)
validation_end_ms, calibration_end_ms = recent_split_boundaries(test_start_ms, end_ms)
validation_end_ms, calibration_end_ms = recent_split_boundaries(test_start_ms, end_ms, step_ms)
_progress(
f"dataset={len(frames)} symbols; train/validation/test symbols="
f"{len(train_symbols)}/{len(validation_symbols)}/{len(test_symbols)}"
)
stored_baseline = json.loads(baseline_report.read_text())
stored_baseline_score = _baseline_score(stored_baseline)
stored_test = stored_baseline.get("test") or stored_baseline.get("selected", {}).get("test", {})
_progress(f"stored baseline validation selection score={stored_baseline_score:.8f}")
results: list[dict[str, Any]] = []
fitted_by_name: dict[str, tuple[FeatureVariant, dict[str, Any], pd.DataFrame, dict[str, dict[str, float]]]] = {}
@@ -618,6 +626,7 @@ def run_experiments(
validation_end_ms,
calibration_end_ms,
volatility_rank_threshold,
step_ms,
)
filter_stats = dataset.attrs.get("volatility_filter", {})
_progress(
@@ -673,7 +682,7 @@ def run_experiments(
"validation_selection_score": comparison_baseline_score,
"validation": next(item["validation"] for item in results if item["name"] == "baseline"),
"test": baseline_test,
"stored_test": stored_baseline["test"],
"stored_test": stored_test,
}
best_test = _evaluate_test(best_dataset, best_fitted)
best_dir = output_dir / "best"
@@ -687,13 +696,22 @@ def run_experiments(
manifest,
end_ms,
test_start_ms,
timeframe,
step_ms,
),
best_dir / "model.joblib",
)
report = {
"protocol": {
"data_dir": str(data_dir.resolve()),
"timeframe": timeframe,
"step_ms": step_ms,
"end_ms": end_ms,
"history_start_ms": manifest.get("history_start_ms"),
"training_snapshot_ms": manifest.get("training_snapshot_ms"),
"min_age_days": manifest.get("min_age_days", 730),
"eligible_symbols": len(manifest.get("symbols", [])),
"excluded_symbols": len(manifest.get("excluded", [])),
"test_start_ms": test_start_ms,
"validation_end_ms": validation_end_ms,
"calibration_end_ms": calibration_end_ms,
@@ -724,9 +742,13 @@ def run_experiments(
def _write_report_markdown(path: Path, report: dict[str, Any]) -> None:
baseline = report["baseline"]
selected = report["selected"]
protocol = report["protocol"]
lines = [
"# Feature experiment report",
"",
f"- timeframe: `{protocol.get('timeframe', '8h')}`",
f"- eligible/excluded symbols: `{protocol.get('eligible_symbols')}`/`{protocol.get('excluded_symbols')}`",
f"- min listing age: `{protocol.get('min_age_days', 730)}` days",
f"- baseline: `{baseline['feature_count']}` features, validation score `{baseline['validation_selection_score']:.8f}`",
f"- selected: `{selected['name']}`, `{selected['feature_count']}` features, validation score `{selected['validation_selection_score']:.8f}`",
f"- selected test MAE/RMSE: `{selected['test']['mae_point']:.6f}` / `{selected['test']['rmse_point']:.6f}`",

View File

@@ -13,7 +13,7 @@ from urllib.parse import urlparse
import joblib
import pandas as pd
from .pipeline import load_database_url, parse_time_ms, predict_from_csv, prepare_data
from .pipeline import load_database_url, parse_time_ms, predict_from_csv, prepare_data, timeframe_step_ms
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -42,6 +42,12 @@ def _database_endpoint(dsn: str) -> tuple[str, int]:
return parsed.hostname or "127.0.0.1", parsed.port or 8812
def output_columns_for_timeframe(timeframe: str) -> tuple[str, ...]:
timeframe_step_ms(timeframe)
volume_column = f"quote_volume_usdt_{timeframe}"
return (*OUTPUT_COLUMNS[:4], volume_column, *OUTPUT_COLUMNS[5:])
def ensure_database(
banbot_config: Path,
dsn: str | None,
@@ -86,6 +92,9 @@ def ensure_database(
def build_predictions(data_dir: Path, artifact: Path, output_dir: Path) -> dict[str, object]:
manifest = json.loads((data_dir / "manifest.json").read_text())
artifact_data = joblib.load(artifact)
timeframe = str(manifest.get("timeframe", artifact_data.get("timeframe", "8h")))
output_columns = output_columns_for_timeframe(timeframe)
volume_column = output_columns[4]
trained_symbols = set(artifact_data.get("asset_profiles", {}))
if trained_symbols:
skipped_symbols = [
@@ -117,7 +126,7 @@ def build_predictions(data_dir: Path, artifact: Path, output_dir: Path) -> dict[
"symbol": result["symbol"],
"latest_bar_start_utc": _utc_label(int(result["timestamp"])),
"close": result["close"],
"quote_volume_usdt_8h": float(latest["quote"]),
volume_column: float(latest["quote"]),
"atr_rank_200_pct": (
result["volatility_rank_200"] * 100
if result["volatility_rank_200"] is not None
@@ -134,19 +143,20 @@ def build_predictions(data_dir: Path, artifact: Path, output_dir: Path) -> dict[
if index == 1 or index == total or index % 25 == 0:
print(f"[latest-predict] predicted {index}/{total}", flush=True)
rows.sort(key=lambda row: float(row["quote_volume_usdt_8h"]), reverse=True)
rows.sort(key=lambda row: float(row[volume_column]), reverse=True)
for rank, row in enumerate(rows, 1):
row["rank"] = rank
output_dir.mkdir(parents=True, exist_ok=True)
output_csv = output_dir / "predictions.csv"
with output_csv.open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=OUTPUT_COLUMNS)
writer = csv.DictWriter(handle, fieldnames=output_columns)
writer.writeheader()
writer.writerows(rows)
summary = {
"model": str(artifact.resolve()),
"timeframe": timeframe,
"symbols": len(rows),
"skipped_symbols": skipped_symbols,
"eligible_predictions": sum(row["drawdown_point_pct"] is not None for row in rows),
@@ -176,6 +186,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--dsn", default=None)
parser.add_argument("--db-type", choices=["auto", "questdb", "timescale"], default="auto")
parser.add_argument("--exchange", default="binance")
parser.add_argument("--timeframe", choices=["8h", "1d"], default="8h")
return parser
@@ -192,11 +203,12 @@ def main() -> None:
dsn=args.dsn,
db_type=args.db_type,
exchange=args.exchange,
end_ms=parse_time_ms(args.end) if args.end else None,
end_ms=parse_time_ms(args.end, args.timeframe) if args.end else None,
history_months=args.history_months,
holdout_months=args.holdout_months,
min_age_days=args.min_age_days,
chunk_size=args.chunk_size,
timeframe=args.timeframe,
)
print(
f"[latest-predict] exported {len(manifest['symbols'])} symbols; "

View File

@@ -25,7 +25,12 @@ from sklearn.metrics import mean_absolute_error, mean_squared_error
EIGHT_H_MS = 8 * 60 * 60 * 1000
DAY_MS = 24 * 60 * 60 * 1000
HOUR_MS = 60 * 60 * 1000
TIMEFRAME_STEP_MS = {
"8h": EIGHT_H_MS,
"1d": DAY_MS,
}
WINDOW = 18
HORIZON = 6
VOLATILITY_RANK_WINDOW = 200
@@ -62,9 +67,21 @@ class Symbol:
combined: bool = False
def parse_time_ms(value: str | int | None) -> int:
def timeframe_step_ms(timeframe: str) -> int:
try:
return TIMEFRAME_STEP_MS[str(timeframe).lower()]
except KeyError as exc:
raise ValueError(f"unsupported timeframe: {timeframe}; expected 8h or 1d") from exc
def floor_to_timeframe(value_ms: int, timeframe: str) -> int:
step_ms = timeframe_step_ms(timeframe)
return value_ms - value_ms % step_ms
def parse_time_ms(value: str | int | None, timeframe: str = "8h") -> int:
if value is None:
return floor_to_8h(int(datetime.now(timezone.utc).timestamp() * 1000))
return floor_to_timeframe(int(datetime.now(timezone.utc).timestamp() * 1000), timeframe)
if isinstance(value, int):
return value
text = str(value).strip()
@@ -83,7 +100,7 @@ def parse_time_ms(value: str | int | None) -> int:
def floor_to_8h(value_ms: int) -> int:
return value_ms - value_ms % EIGHT_H_MS
return floor_to_timeframe(value_ms, "8h")
def months_before(value_ms: int, months: int) -> int:
@@ -237,6 +254,7 @@ def run_banbot_export(
history_start_ms: int,
end_ms: int,
banbot_export_cmd: str = "go run ./cmd/cbpred-export",
timeframe: str = "8h",
) -> None:
if not symbols:
raise ValueError("no eligible symbols to export")
@@ -253,6 +271,8 @@ def run_banbot_export(
str(history_start_ms),
"--end-ms",
str(end_ms),
"--timeframe",
timeframe,
"--out-dir",
str(out_dir),
]
@@ -260,7 +280,9 @@ def run_banbot_export(
subprocess.run(command, cwd=project_dir, check=True)
def aggregate_8h(rows: Iterable[tuple[Any, ...]]) -> pd.DataFrame:
def aggregate_ohlcv(rows: Iterable[tuple[Any, ...]], timeframe: str = "8h") -> pd.DataFrame:
step_ms = timeframe_step_ms(timeframe)
bars_per_bucket = step_ms // HOUR_MS
columns = CSV_COLUMNS
values = list(rows)
if not values:
@@ -275,12 +297,14 @@ def aggregate_8h(rows: Iterable[tuple[Any, ...]]) -> pd.DataFrame:
frame["buy_volume"] = frame["buy_volume"].fillna(0.0)
frame["trade_num"] = frame["trade_num"].fillna(0).astype(np.int64)
frame["quote"] = np.where(frame["quote"] > 0, frame["quote"], frame["volume"] * frame["close"])
frame["bucket"] = (frame["timestamp"] // EIGHT_H_MS) * EIGHT_H_MS
frame["bucket"] = (frame["timestamp"] // step_ms) * step_ms
output: list[dict[str, Any]] = []
for bucket, group in frame.groupby("bucket", sort=True):
group = group.sort_values("timestamp")
expected = bucket + np.arange(8, dtype=np.int64) * HOUR_MS
if len(group) != 8 or not np.array_equal(group["timestamp"].to_numpy(), expected):
expected = bucket + np.arange(bars_per_bucket, dtype=np.int64) * HOUR_MS
if len(group) != bars_per_bucket or not np.array_equal(
group["timestamp"].to_numpy(), expected
):
continue
output.append(
{
@@ -298,6 +322,10 @@ def aggregate_8h(rows: Iterable[tuple[Any, ...]]) -> pd.DataFrame:
return pd.DataFrame(output, columns=columns)
def aggregate_8h(rows: Iterable[tuple[Any, ...]]) -> pd.DataFrame:
return aggregate_ohlcv(rows, "8h")
def symbol_filename(symbol: Symbol) -> str:
slug = symbol.symbol.replace("/", "_").replace(":", "_")
return f"{symbol.sid}_{slug}.csv"
@@ -318,11 +346,13 @@ def prepare_data(
skip_download: bool = False,
banbot_cmd: str = "go run .",
banbot_export_cmd: str = "go run ./cmd/cbpred-export",
timeframe: str = "8h",
) -> dict[str, Any]:
step_ms = timeframe_step_ms(timeframe)
out_dir.mkdir(parents=True, exist_ok=True)
raw_dir = out_dir / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
end_ms = floor_to_8h(end_ms or parse_time_ms(None))
end_ms = floor_to_timeframe(end_ms or parse_time_ms(None, timeframe), timeframe)
history_start_ms = months_before(end_ms, history_months)
training_snapshot_ms = months_before(end_ms, holdout_months)
database_url = load_database_url(banbot_config, dsn)
@@ -356,12 +386,13 @@ def prepare_data(
history_start_ms,
end_ms,
banbot_export_cmd,
timeframe,
)
manifest_symbols: list[dict[str, Any]] = []
for symbol in symbols:
path = raw_dir / symbol_filename(symbol)
if not path.exists():
excluded.append({**symbol.__dict__, "reason": "no_complete_8h_bars"})
excluded.append({**symbol.__dict__, "reason": f"no_complete_{timeframe}_bars"})
continue
frame = pd.read_csv(path)
frame["timestamp"] = pd.to_numeric(frame["timestamp"], errors="coerce")
@@ -370,18 +401,21 @@ def prepare_data(
frame = frame[frame["timestamp"] < symbol.delist_ms]
frame.to_csv(path, index=False, float_format="%.12g")
if frame.empty:
excluded.append({**symbol.__dict__, "reason": "no_complete_8h_bars"})
excluded.append({**symbol.__dict__, "reason": f"no_complete_{timeframe}_bars"})
path.unlink(missing_ok=True)
continue
manifest_symbols.append({**symbol.__dict__, "path": str(path.relative_to(out_dir)), "bars": len(frame)})
manifest = {
"version": 2,
"timeframe": "8h",
"timeframe": timeframe,
"storage_timeframe": "banbot_orm",
"step_ms": EIGHT_H_MS,
"step_ms": step_ms,
"end_ms": end_ms,
"history_start_ms": history_start_ms,
"training_snapshot_ms": training_snapshot_ms,
"history_months": history_months,
"holdout_months": holdout_months,
"min_age_days": min_age_days,
"db_type": actual_db_type,
"exchange": exchange,
"symbols": manifest_symbols,
@@ -727,6 +761,7 @@ def build_feature_dataset(
validation_end_ms: int | None = None,
calibration_end_ms: int | None = None,
volatility_rank_threshold: float = VOLATILITY_RANK_THRESHOLD,
step_ms: int = EIGHT_H_MS,
) -> tuple[pd.DataFrame, list[str], dict[str, dict[str, float]]]:
if not 0.0 <= volatility_rank_threshold <= 1.0:
raise ValueError("volatility_rank_threshold must be between 0 and 1")
@@ -745,7 +780,7 @@ def build_feature_dataset(
rows: list[pd.DataFrame] = []
candidate_counts = {"train": 0, "validation": 0, "calibration": 0, "test": 0, "recent": 0}
selected_counts = {name: 0 for name in candidate_counts}
step = EIGHT_H_MS
step = int(step_ms)
explicit_splits = train_symbols is not None and validation_symbols is not None and test_symbols is not None
train_set = set(train_symbols or [])
validation_set = set(validation_symbols or [])
@@ -874,13 +909,15 @@ def split_holdout_symbols(symbols: Iterable[str], seed: int = 42, holdout_ratio:
return train_symbols, validation, test
def recent_split_boundaries(start_ms: int, end_ms: int) -> tuple[int, int]:
def recent_split_boundaries(
start_ms: int, end_ms: int, step_ms: int = EIGHT_H_MS
) -> tuple[int, int]:
"""Use the first 30% for model selection, next 20% for calibration, last 50% for final test."""
duration = end_ms - start_ms
validation_end = floor_to_8h(start_ms + int(duration * 0.30))
calibration_end = floor_to_8h(start_ms + int(duration * 0.50))
validation_end = max(start_ms + EIGHT_H_MS, min(validation_end, end_ms - 2 * EIGHT_H_MS))
calibration_end = max(validation_end + EIGHT_H_MS, min(calibration_end, end_ms - EIGHT_H_MS))
validation_end = start_ms + (int(duration * 0.30) // step_ms) * step_ms
calibration_end = start_ms + (int(duration * 0.50) // step_ms) * step_ms
validation_end = max(start_ms + step_ms, min(validation_end, end_ms - 2 * step_ms))
calibration_end = max(validation_end + step_ms, min(calibration_end, end_ms - step_ms))
return validation_end, calibration_end
@@ -1182,12 +1219,14 @@ def run_training(
) -> dict[str, Any]:
if not 0.0 <= volatility_rank_threshold <= 1.0:
raise ValueError("volatility_rank_threshold must be between 0 and 1")
_progress("training: loading manifest and raw 8H frames")
_progress("training: loading manifest and raw frames")
manifest = load_manifest(data_dir)
frames = load_raw_frames(data_dir, manifest)
if len(frames) < 3:
raise ValueError("at least 3 symbols with raw data are required")
end_ms = floor_to_8h(end_ms or int(manifest["end_ms"]))
timeframe = str(manifest.get("timeframe", "8h"))
step_ms = int(manifest.get("step_ms") or timeframe_step_ms(timeframe))
end_ms = floor_to_timeframe(end_ms or int(manifest["end_ms"]), timeframe)
test_start_ms = months_before(end_ms, test_months)
profile_indicators = {symbol: compute_indicators(frame) for symbol, frame in frames.items()}
raw_profiles = {
@@ -1199,7 +1238,7 @@ def run_training(
raise ValueError("at least 3 symbols with enough pre-test history are required")
train_symbols, validation_symbols, test_symbols = split_holdout_symbols(profiles, seed)
asset_scaler = fit_asset_scaler(profiles, train_symbols)
validation_end_ms, calibration_end_ms = recent_split_boundaries(test_start_ms, end_ms)
validation_end_ms, calibration_end_ms = recent_split_boundaries(test_start_ms, end_ms, step_ms)
_progress(
f"training: {len(frames)} symbols, {len(profiles)} profiles; "
f"train/validation/test symbols={len(train_symbols)}/{len(validation_symbols)}/{len(test_symbols)}"
@@ -1226,6 +1265,7 @@ def run_training(
validation_end_ms,
calibration_end_ms,
volatility_rank_threshold,
step_ms,
)
filter_stats = dataset.attrs.get("volatility_filter", {})
_progress(
@@ -1367,7 +1407,8 @@ def run_training(
artifact_dir.mkdir(parents=True, exist_ok=True)
artifact = {
"version": 3,
"timeframe": "8h",
"timeframe": timeframe,
"step_ms": step_ms,
"window": WINDOW,
"horizon": HORIZON,
"target_kind": TARGET_KIND,
@@ -1396,6 +1437,15 @@ def run_training(
joblib.dump(artifact, artifact_dir / "model.joblib")
report = {
"protocol": {
"timeframe": timeframe,
"step_ms": step_ms,
"data_dir": str(data_dir.resolve()),
"end_ms": end_ms,
"history_start_ms": manifest.get("history_start_ms"),
"training_snapshot_ms": manifest.get("training_snapshot_ms"),
"min_age_days": manifest.get("min_age_days", 730),
"eligible_symbols": len(manifest.get("symbols", [])),
"excluded_symbols": len(manifest.get("excluded", [])),
"train_anchor_before": test_start_ms,
"recent_window_start": test_start_ms,
"recent_window_end": end_ms,
@@ -1485,6 +1535,8 @@ def predict_from_csv(
raise ValueError(
"artifact uses an unsupported target or requires full-universe feature context"
)
timeframe = str(artifact.get("timeframe", "8h"))
step_ms = int(artifact.get("step_ms") or timeframe_step_ms(timeframe))
frame = pd.read_csv(csv_path)
for column in CSV_COLUMNS:
frame[column] = pd.to_numeric(frame[column], errors="coerce")
@@ -1497,7 +1549,7 @@ def predict_from_csv(
raise ValueError("--at is earlier than the model training snapshot")
matches = np.flatnonzero(frame["timestamp"].to_numpy(dtype=np.int64) == int(at_ms))
if len(matches) == 0:
raise ValueError("--at must match an 8H bar timestamp in the CSV")
raise ValueError(f"--at must match a {timeframe} bar timestamp in the CSV")
anchor_index = int(matches[-1])
if symbol is None:
for candidate, path in artifact.get("symbol_files", {}).items():
@@ -1519,6 +1571,8 @@ def predict_from_csv(
)
response = {
"timestamp": int(frame.iloc[anchor_index]["timestamp"]),
"timeframe": timeframe,
"step_ms": step_ms,
"close": float(frame.iloc[anchor_index]["close"]),
"model": artifact["model_name"],
"feature_stage": feature_stage,