支持1d级别插针模型
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}`",
|
||||
|
||||
@@ -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; "
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user