from __future__ import annotations

from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any

import orjson
import polars as pl
import typer

from uforia.analytics import (
    build_composite_component_series,
    build_composite_signal_series,
    build_composite_transition_series,
    build_funding_context_series,
    build_gamma_exposure_series,
    build_market_composite_component_series,
    build_market_composite_signal_series,
    build_market_composite_transition_series,
    build_options_adaptive_hedge_series,
    build_options_carry_series,
    build_options_dislocation_series,
    build_options_dispersion_series,
    build_options_expiry_flow_series,
    build_options_funding_skew_series,
    build_options_ml_carry_series,
    build_options_positioning_series,
    build_options_relative_value_series,
    build_options_sessionality_series,
    build_options_signal_series,
    build_options_skew_series,
    build_options_surface_factor_series,
    build_options_surface_snapshot,
    build_perps_composite_series,
    build_perps_signal_series,
    build_perps_transition_series,
    build_straddle_breakeven_series,
    build_term_structure_momentum_series,
    build_vol_cone_series,
    build_vol_index,
    build_vol_regime_transition_series,
    compute_surface_features,
)
from uforia.config import get_config
from uforia.ingest.deribit import (
    archive_deribit_options_once,
    fetch_dvol,
    fetch_funding_rate,
    process_archiver_cycle,
    rebuild_option_chain_from_archive,
    run_deribit_options_daemon,
)
from uforia.signals import build_razor_series, compute_qvvix, compute_rvvix
from uforia.storage import DatasetName, DatasetStore, ReadMode, RunStatus
from uforia.validation import validate_btc_vvix, validate_options_family, validate_vol_suite

if TYPE_CHECKING:
    from uforia.execution import ExecutionService, ExecutionVenue
    from uforia.execution.models import ExecutionVenueHealth

app = typer.Typer(help="uforia trading research CLI")
ingest_app = typer.Typer(help="Market data ingestion")
build_app = typer.Typer(help="Derived dataset builders")
validate_app = typer.Typer(help="Validation and reporting")
maintenance_app = typer.Typer(help="Maintenance and compaction")
microstructure_app = typer.Typer(help="Microstructure import, training, and inference")
db_app = typer.Typer(help="Timescale migration and verification")
execution_app = typer.Typer(help="Execution state sync and operational helpers")
app.add_typer(ingest_app, name="ingest")
app.add_typer(build_app, name="build")
app.add_typer(validate_app, name="validate")
app.add_typer(maintenance_app, name="maintenance")
app.add_typer(microstructure_app, name="microstructure")
app.add_typer(db_app, name="db")
app.add_typer(execution_app, name="execution")

DB_FILTER_OPTION = typer.Option(None, "--filter")


def _parse_dt(value: str) -> datetime:
    parsed = datetime.fromisoformat(value)
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _store() -> DatasetStore:
    return DatasetStore(get_config().storage.root)


def _dump_json(value: object) -> None:
    typer.echo(orjson.dumps(value, option=orjson.OPT_INDENT_2).decode())


def _parse_filters(values: list[str] | None) -> dict[str, str]:
    filters: dict[str, str] = {}
    for value in values or []:
        key, sep, item = value.partition("=")
        if not sep:
            raise typer.BadParameter(f"Invalid filter '{value}', expected key=value")
        filters[key] = item
    return filters


def _import_microstructure_builders() -> Any:
    from uforia.microstructure.builders import (
        build_microstructure_feature_series,
        build_microstructure_label_series,
        build_microstructure_regime_series,
        build_microstructure_signal_series,
    )

    return {
        "build_microstructure_feature_series": build_microstructure_feature_series,
        "build_microstructure_label_series": build_microstructure_label_series,
        "build_microstructure_regime_series": build_microstructure_regime_series,
        "build_microstructure_signal_series": build_microstructure_signal_series,
    }


def _import_microstructure_live() -> Any:
    from uforia.microstructure.live import (
        build_live_microstructure_bars,
        ingest_live_engine_state,
        run_live_microstructure_inference_daemon,
        run_live_microstructure_inference_once,
    )

    return {
        "build_live_microstructure_bars": build_live_microstructure_bars,
        "ingest_live_engine_state": ingest_live_engine_state,
        "run_live_microstructure_inference_daemon": run_live_microstructure_inference_daemon,
        "run_live_microstructure_inference_once": run_live_microstructure_inference_once,
    }


def _import_microstructure_training() -> Any:
    from uforia.microstructure.training import (
        evaluate_microstructure_model,
        export_microstructure_model,
        generate_microstructure_model_output,
        train_microstructure_model,
    )

    return {
        "evaluate_microstructure_model": evaluate_microstructure_model,
        "export_microstructure_model": export_microstructure_model,
        "generate_microstructure_model_output": generate_microstructure_model_output,
        "train_microstructure_model": train_microstructure_model,
    }


def _import_execution_runtime() -> Any:
    from uforia.execution import ExecutionService, ExecutionVenue
    from uforia.execution.models import ExecutionVenueHealth

    return {
        "ExecutionService": ExecutionService,
        "ExecutionVenue": ExecutionVenue,
        "ExecutionVenueHealth": ExecutionVenueHealth,
    }


@db_app.command("init")
def db_init_command() -> None:
    store = _store()
    store.db_init()
    typer.echo("Initialized Timescale schema")


@db_app.command("migrate")
def db_migrate_command() -> None:
    store = _store()
    store.db_init()
    typer.echo("Applied DB schema migration")


@db_app.command("backfill")
def db_backfill_command(
    dataset: str = typer.Option(...),
    start: str | None = typer.Option(None),
    end: str | None = typer.Option(None),
    filter_values: list[str] | None = DB_FILTER_OPTION,
) -> None:
    result = _store().db_backfill(
        DatasetName(dataset),
        start=_parse_dt(start) if start else None,
        end=_parse_dt(end) if end else None,
        filters=_parse_filters(filter_values),
    )
    _dump_json(result)


@db_app.command("verify")
def db_verify_command(
    dataset: str = typer.Option(...),
    start: str | None = typer.Option(None),
    end: str | None = typer.Option(None),
    filter_values: list[str] | None = DB_FILTER_OPTION,
) -> None:
    result = _store().db_verify(
        DatasetName(dataset),
        start=_parse_dt(start) if start else None,
        end=_parse_dt(end) if end else None,
        filters=_parse_filters(filter_values),
    )
    _dump_json(result)


@db_app.command("backfill-all")
def db_backfill_all_command() -> None:
    _dump_json(_store().db_backfill_all())


@execution_app.command("seed-markets")
def execution_seed_markets(
    asset: str | None = typer.Option(None),
    venue: str | None = typer.Option(None),
) -> None:
    execution = _import_execution_runtime()
    service = execution["ExecutionService"](_store(), get_config())
    written = service.seed_market_snapshots(asset=asset.upper() if asset else None, venue=venue)
    typer.echo(f"Wrote {written} execution market partitions")


@execution_app.command("sync-state")
def execution_sync_state(
    health_path: str = typer.Option("data/execution_live/health/latest.json"),
) -> None:
    execution = _import_execution_runtime()
    path = Path(health_path)
    service = execution["ExecutionService"](_store(), get_config())
    if not path.exists():
        typer.echo(f"Missing health file: {path}")
        raise typer.Exit(code=1)
    payload = orjson.loads(path.read_bytes())
    rows: list[Any] = []
    venue_rows = payload.get("venues") or payload.get("health") or []
    for item in venue_rows:
        venue_value = str(item.get("venue", "hyperliquid")).lower()
        if venue_value not in {venue.value for venue in execution["ExecutionVenue"]}:
            continue
        rows.append(
            execution["ExecutionVenueHealth"](
                venue=execution["ExecutionVenue"](venue_value),
                account=str(item.get("account", "default")),
                status=str(item.get("status", "online")),
                ws_connected=bool(item.get("ws_connected", False)),
                private_ws_connected=bool(item.get("private_ws_connected", False)),
                last_heartbeat_ms=int(item["last_heartbeat_ms"])
                if item.get("last_heartbeat_ms") is not None
                else None,
                latency_ms=int(item["latency_ms"]) if item.get("latency_ms") is not None else None,
                last_error=item.get("last_error"),
                reconnect_count=int(item.get("reconnect_count", 0)),
            )
        )
    if not rows:
        typer.echo("No execution venue health rows found")
        raise typer.Exit(code=1)
    written = service.write_health_snapshot(rows)
    typer.echo(f"Wrote {written} execution health partitions")


@execution_app.command("sync-live-state")
def execution_sync_live_state(
    venue: str | None = typer.Option(None),
    account: str | None = typer.Option(None),
    asset: str | None = typer.Option(None),
) -> None:
    execution = _import_execution_runtime()
    service = execution["ExecutionService"](_store(), get_config())
    written = service.sync_live_state(
        venue=venue,
        account=account,
        asset=asset.upper() if asset else None,
    )
    _dump_json(written)


@ingest_app.command("deribit-options")
def ingest_deribit_options(
    underlying: str = typer.Option("BTC"),
) -> None:
    result = archive_deribit_options_once(
        _store(),
        as_of=datetime.now(tz=timezone.utc),
        underlying=underlying,
    )
    _dump_json(result.__dict__)
    if result.status == RunStatus.FAILED:
        raise typer.Exit(code=1)


@ingest_app.command("deribit-options-once")
def ingest_deribit_options_once(
    as_of: str = typer.Option(...),
    force: bool = typer.Option(False),
    underlying: str = typer.Option("BTC"),
) -> None:
    result = archive_deribit_options_once(
        _store(),
        as_of=_parse_dt(as_of),
        force=force,
        underlying=underlying,
    )
    _dump_json(result.__dict__)
    if result.error_message:
        raise typer.Exit(code=1)


@ingest_app.command("deribit-options-daemon")
def ingest_deribit_options_daemon(
    poll_seconds: int = typer.Option(get_config().ingestion.archive_poll_seconds),
    grace_seconds: int = typer.Option(get_config().ingestion.archive_grace_seconds),
    force_current_hour: bool = typer.Option(False),
    underlying: str = typer.Option("BTC"),
) -> None:
    run_deribit_options_daemon(
        _store(),
        poll_seconds=poll_seconds,
        grace_seconds=grace_seconds,
        force_current_hour=force_current_hour,
        underlying=underlying,
    )


@ingest_app.command("ingestion-runs")
def ingest_ingestion_runs(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    frame = _store().read(
        DatasetName.INGESTION_RUNS,
        start=_parse_dt(start),
        end=_parse_dt(end),
        read_mode=ReadMode.ALL_RUNS,
    )
    _dump_json(frame.to_dicts())


@ingest_app.command("deribit-dvol")
def ingest_deribit_dvol(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = fetch_dvol(start=_parse_dt(start), end=_parse_dt(end), underlying=underlying)
    paths = store.write(DatasetName.DVOL_SNAPSHOT, frame, stats={"rows": frame.height})
    typer.echo(f"Wrote {len(paths)} DVOL partitions")


@ingest_app.command("deribit-funding")
def ingest_deribit_funding(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = fetch_funding_rate(start=_parse_dt(start), end=_parse_dt(end), underlying=underlying)
    paths = store.write(DatasetName.FUNDING_RATE_SNAPSHOT, frame, stats={"rows": frame.height})
    typer.echo(f"Wrote {len(paths)} funding partitions")


@build_app.command("perps-signals")
def build_perps_signals_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    venue_scope: str = typer.Option("all", help="all, binance, or bybit"),
) -> None:
    store = _store()
    frame = build_perps_signal_series(
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        venue_scope=venue_scope,
    )
    paths = store.write(
        DatasetName.PERPS_SIGNAL_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper(), "venue_scope": venue_scope},
    )
    typer.echo(f"Wrote {len(paths)} perps signal partitions")


@build_app.command("perps-composites")
def build_perps_composites_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    venue_scope: str = typer.Option("all", help="all, binance, or bybit"),
) -> None:
    store = _store()
    frame = build_perps_composite_series(
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        venue_scope=venue_scope,
    )
    paths = store.write(
        DatasetName.PERPS_COMPOSITE_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper(), "venue_scope": venue_scope},
    )
    typer.echo(f"Wrote {len(paths)} perps composite partitions")


@build_app.command("perps-transitions")
def build_perps_transitions_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    venue_scope: str = typer.Option("all", help="all, binance, or bybit"),
) -> None:
    store = _store()
    frame = build_perps_transition_series(
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        venue_scope=venue_scope,
    )
    paths = store.write(
        DatasetName.PERPS_TRANSITION_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper(), "venue_scope": venue_scope},
    )
    typer.echo(f"Wrote {len(paths)} perps transition partitions")


@build_app.command("composite")
def build_composite_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    store = _store()
    start_dt = _parse_dt(start)
    end_dt = _parse_dt(end)
    frame = build_composite_signal_series(
        store,
        start_dt,
        end_dt,
        asset=asset.upper(),
    )
    paths = store.write(
        DatasetName.COMPOSITE_SIGNAL_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    market_frame = build_market_composite_signal_series(store, start_dt, end_dt)
    market_paths = store.write(
        DatasetName.COMPOSITE_SIGNAL_SERIES,
        market_frame,
        stats={"rows": market_frame.height, "asset": "MARKET"},
    )
    typer.echo(
        "Wrote "
        f"{len(paths)} composite partitions for {asset.upper()} "
        f"and {len(market_paths)} market partitions"
    )


@build_app.command("composite-components")
def build_composite_components_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    store = _store()
    start_dt = _parse_dt(start)
    end_dt = _parse_dt(end)
    frame = build_composite_component_series(
        store,
        start_dt,
        end_dt,
        asset=asset.upper(),
    )
    paths = store.write(
        DatasetName.COMPOSITE_COMPONENT_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    market_frame = build_market_composite_component_series(store, start_dt, end_dt)
    market_paths = store.write(
        DatasetName.COMPOSITE_COMPONENT_SERIES,
        market_frame,
        stats={"rows": market_frame.height, "asset": "MARKET"},
    )
    typer.echo(
        "Wrote "
        f"{len(paths)} composite component partitions for "
        f"{asset.upper()} and {len(market_paths)} market partitions"
    )


@build_app.command("composite-transitions")
def build_composite_transitions_command(
    asset: str = typer.Option(..., help="BTC or ETH"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    store = _store()
    start_dt = _parse_dt(start)
    end_dt = _parse_dt(end)
    frame = build_composite_transition_series(
        store,
        start_dt,
        end_dt,
        asset=asset.upper(),
    )
    paths = store.write(
        DatasetName.COMPOSITE_TRANSITION_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    market_frame = build_market_composite_transition_series(store, start_dt, end_dt)
    market_paths = store.write(
        DatasetName.COMPOSITE_TRANSITION_SERIES,
        market_frame,
        stats={"rows": market_frame.height, "asset": "MARKET"},
    )
    typer.echo(
        "Wrote "
        f"{len(paths)} composite transition partitions for "
        f"{asset.upper()} and {len(market_paths)} market partitions"
    )


@build_app.command("vol-index")
def build_vol_index_command(
    source: str = typer.Option(..., help="dvol or bvix"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_vol_index(
        store,
        _parse_dt(start),
        _parse_dt(end),
        source=source,
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.VOL_INDEX_SERIES,
        frame,
        stats={"rows": frame.height, "source": source, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} vol-index partitions")


@build_app.command("rvvix")
def build_rvvix_command(
    source: str = typer.Option("dvol"),
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    windows: str = typer.Option("7,14,30"),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    requested_start = _parse_dt(start)
    requested_end = _parse_dt(end)
    lookback_start = requested_start - timedelta(days=max(int(item) for item in windows.split(",")))
    series = store.read(
        DatasetName.VOL_INDEX_SERIES,
        start=lookback_start,
        end=requested_end,
        filters={"underlying": underlying, "source": source},
    )
    computed = compute_rvvix(series, windows=tuple(int(item) for item in windows.split(",")))
    if computed.is_empty():
        typer.echo("No vol-index rows found for the requested range")
        return
    vvix = computed.filter(
        (pl.col("ts") >= pl.lit(requested_start)) & (pl.col("ts") <= pl.lit(requested_end))
    ).select(
        [
            "ts",
            "date",
            pl.lit(underlying).alias("underlying"),
            pl.lit(source).alias("source"),
            pl.col("value").alias("vol_index_value"),
            pl.col("rvvix_7d"),
            pl.col("rvvix_14d"),
            pl.col("rvvix_30d"),
            pl.lit(None, dtype=pl.Float64).alias("qvvix_30d"),
            pl.lit("rvvix-v1").alias("model_version"),
            pl.when(pl.col("value").is_null())
            .then(pl.lit("missing_vol_index"))
            .otherwise(pl.lit("ok"))
            .alias("quality_flag"),
        ]
    )
    vvix = _merge_with_existing_vvix(store, vvix, source=source, underlying=underlying)
    paths = store.write(
        DatasetName.VVIX_SERIES,
        vvix,
        stats={"rows": vvix.height, "source": source, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} VVIX partitions")


@build_app.command("surface-features")
def build_surface_features_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    options = store.read(
        DatasetName.OPTION_CHAIN_SNAPSHOT,
        start=_parse_dt(start),
        end=_parse_dt(end),
        filters={"underlying": underlying},
    )
    rows: list[pl.DataFrame] = []
    for _, snapshot in options.partition_by("ts", maintain_order=True, as_dict=True).items():
        try:
            rows.append(compute_surface_features(snapshot))
        except ValueError:
            continue
    frame = pl.concat(rows, how="vertical_relaxed") if rows else pl.DataFrame()
    paths = store.write(
        DatasetName.SURFACE_FEATURES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} surface-feature partitions")


@build_app.command("qvvix")
def build_qvvix_command(
    source: str = typer.Option("dvol"),
    ts: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    point_ts = _parse_dt(ts)
    try:
        qvvix = compute_qvvix(store, point_ts, source=source, underlying=underlying)
    except ValueError as exc:
        typer.echo(f"Skipping qVVIX build for {underlying} {source}: {exc}")
        return
    vol_index = store.read(
        DatasetName.VOL_INDEX_SERIES,
        start=point_ts,
        end=point_ts,
        filters={"underlying": underlying, "source": source},
    )
    current = vol_index.sort("ts").tail(1)
    if current.is_empty():
        raise typer.BadParameter(f"No {source} vol-index point found at {point_ts.isoformat()}")
    vvix = pl.DataFrame(
        [
            {
                "ts": point_ts,
                "date": point_ts.date(),
                "underlying": underlying,
                "source": source,
                "vol_index_value": float(current.item(0, "value")),
                "rvvix_7d": None,
                "rvvix_14d": None,
                "rvvix_30d": None,
                "qvvix_30d": qvvix,
                "model_version": "qvvix-ewma-v2",
                "quality_flag": "ok",
            }
        ]
    )
    vvix = _merge_with_existing_vvix(store, vvix, source=source, underlying=underlying)
    paths = store.write(
        DatasetName.VVIX_SERIES,
        vvix,
        stats={"rows": vvix.height, "source": source, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} qVVIX partitions")


@build_app.command("options-surface")
def build_options_surface_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_surface_snapshot(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_SURFACE_SNAPSHOT,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-surface partitions")


@build_app.command("options-carry")
def build_options_carry_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_carry_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_CARRY_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-carry partitions")


@build_app.command("options-skew")
def build_options_skew_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_skew_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_SKEW_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-skew partitions")


@build_app.command("options-dislocations")
def build_options_dislocations_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_dislocation_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_DISLOCATION_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-dislocation partitions")


@build_app.command("options-rv")
def build_options_relative_value_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    store = _store()
    frame = build_options_relative_value_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
    )
    paths = store.write(
        DatasetName.OPTIONS_RELATIVE_VALUE_SERIES,
        frame,
        stats={"rows": frame.height},
    )
    typer.echo(f"Wrote {len(paths)} options-rv partitions")


@build_app.command("options-positioning")
def build_options_positioning_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_positioning_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_POSITIONING_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-positioning partitions")


@build_app.command("surface-factor-rv")
def build_options_surface_factor_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_surface_factor_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_SURFACE_FACTOR_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} surface-factor partitions")


@build_app.command("ml-carry-toxicity")
def build_options_ml_carry_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_ml_carry_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_ML_CARRY_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} ml-carry partitions")


@build_app.command("expiry-flow-map")
def build_options_expiry_flow_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_expiry_flow_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_EXPIRY_FLOW_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} expiry-flow partitions")


@build_app.command("adaptive-hedging")
def build_options_adaptive_hedge_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_adaptive_hedge_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_ADAPTIVE_HEDGE_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} adaptive-hedge partitions")


@build_app.command("sessionality-clocks")
def build_options_sessionality_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_sessionality_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_SESSIONALITY_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} sessionality partitions")


@build_app.command("btc-eth-dispersion")
def build_options_dispersion_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    store = _store()
    frame = build_options_dispersion_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
    )
    paths = store.write(
        DatasetName.OPTIONS_DISPERSION_SERIES,
        frame,
        stats={"rows": frame.height, "pair_id": "BTC_ETH"},
    )
    typer.echo(f"Wrote {len(paths)} dispersion partitions")


@build_app.command("funding-basis-skew")
def build_options_funding_skew_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_funding_skew_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_FUNDING_SKEW_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} funding-basis-skew partitions")


@build_app.command("options-signals")
def build_options_signals_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_options_signal_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.OPTIONS_SIGNAL_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} options-signal partitions")


@build_app.command("alpha-canvas")
def build_alpha_canvas_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlyings: str = typer.Option("BTC,ETH"),
) -> None:
    store = _store()
    start_dt = _parse_dt(start)
    end_dt = _parse_dt(end)
    names = [item.strip().upper() for item in underlyings.split(",") if item.strip()]
    for underlying in names:
        steps: list[tuple[DatasetName, pl.DataFrame]] = [
            (
                DatasetName.OPTIONS_SURFACE_SNAPSHOT,
                build_options_surface_snapshot(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_CARRY_SERIES,
                build_options_carry_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_SKEW_SERIES,
                build_options_skew_series(store, start=start_dt, end=end_dt, underlying=underlying),
            ),
            (
                DatasetName.OPTIONS_DISLOCATION_SERIES,
                build_options_dislocation_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_POSITIONING_SERIES,
                build_options_positioning_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.VOL_CONE_SERIES,
                build_vol_cone_series(store, start=start_dt, end=end_dt, underlying=underlying),
            ),
            (
                DatasetName.TERM_STRUCTURE_MOMENTUM_SERIES,
                build_term_structure_momentum_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.GAMMA_EXPOSURE_SERIES,
                build_gamma_exposure_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.FUNDING_CONTEXT_SERIES,
                build_funding_context_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.STRADDLE_BREAKEVEN_SERIES,
                build_straddle_breakeven_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_SURFACE_FACTOR_SERIES,
                build_options_surface_factor_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_ML_CARRY_SERIES,
                build_options_ml_carry_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_EXPIRY_FLOW_SERIES,
                build_options_expiry_flow_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_ADAPTIVE_HEDGE_SERIES,
                build_options_adaptive_hedge_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_SESSIONALITY_SERIES,
                build_options_sessionality_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
            (
                DatasetName.OPTIONS_FUNDING_SKEW_SERIES,
                build_options_funding_skew_series(
                    store, start=start_dt, end=end_dt, underlying=underlying
                ),
            ),
        ]
        for dataset, frame in steps:
            store.write(dataset, frame, stats={"rows": frame.height, "underlying": underlying})

    rv = build_options_relative_value_series(store, start=start_dt, end=end_dt)
    store.write(DatasetName.OPTIONS_RELATIVE_VALUE_SERIES, rv, stats={"rows": rv.height})
    dispersion = build_options_dispersion_series(store, start=start_dt, end=end_dt)
    store.write(
        DatasetName.OPTIONS_DISPERSION_SERIES, dispersion, stats={"rows": dispersion.height}
    )

    for underlying in names:
        signal_frame = build_options_signal_series(
            store,
            start=start_dt,
            end=end_dt,
            underlying=underlying,
        )
        store.write(
            DatasetName.OPTIONS_SIGNAL_SERIES,
            signal_frame,
            stats={"rows": signal_frame.height, "underlying": underlying},
        )
        transition_frame = build_vol_regime_transition_series(
            store,
            start=start_dt,
            end=end_dt,
            underlying=underlying,
        )
        store.write(
            DatasetName.VOL_REGIME_TRANSITION_SERIES,
            transition_frame,
            stats={"rows": transition_frame.height, "underlying": underlying},
        )
    typer.echo(f"Built alpha canvas for {', '.join(names)}")


@build_app.command("vol-cone")
def build_vol_cone_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_vol_cone_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.VOL_CONE_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} vol-cone partitions")


@build_app.command("term-structure-momentum")
def build_term_structure_momentum_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_term_structure_momentum_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.TERM_STRUCTURE_MOMENTUM_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} term-structure-momentum partitions")


@build_app.command("gamma-exposure")
def build_gamma_exposure_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_gamma_exposure_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.GAMMA_EXPOSURE_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} gamma-exposure partitions")


@build_app.command("funding-context")
def build_funding_context_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_funding_context_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.FUNDING_CONTEXT_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} funding-context partitions")


@build_app.command("straddle-breakeven")
def build_straddle_breakeven_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_straddle_breakeven_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.STRADDLE_BREAKEVEN_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} straddle-breakeven partitions")


@build_app.command("vol-regime-transitions")
def build_vol_regime_transitions_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_vol_regime_transition_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.VOL_REGIME_TRANSITION_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} vol-regime-transition partitions")


@build_app.command("razor")
def build_razor_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    store = _store()
    frame = build_razor_series(
        store,
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    paths = store.write(
        DatasetName.RAZOR_SERIES,
        frame,
        stats={"rows": frame.height, "underlying": underlying},
    )
    typer.echo(f"Wrote {len(paths)} razor partitions")


@validate_app.command("btc-vvix")
def validate_btc_vvix_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
) -> None:
    report = validate_btc_vvix(_store(), _parse_dt(start), _parse_dt(end))
    _dump_json(report)


@validate_app.command("vol-suite")
def validate_vol_suite_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    report = validate_vol_suite(
        _store(),
        _parse_dt(start),
        _parse_dt(end),
        underlying=underlying,
    )
    _dump_json(report)


@validate_app.command("options-family")
def validate_options_family_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    report = validate_options_family(
        _store(),
        _parse_dt(start),
        _parse_dt(end),
        underlying=underlying,
    )
    _dump_json(report)


@validate_app.command("archiver-cycle")
def validate_archiver_cycle(
    now: str = typer.Option(...),
) -> None:
    results = process_archiver_cycle(_store(), now=_parse_dt(now))
    _dump_json([result.__dict__ for result in results])


@maintenance_app.command("compact")
def compact_dataset_command(
    dataset: str = typer.Option(...),
    start_date: str = typer.Option(...),
    end_date: str = typer.Option(...),
    underlying: str | None = typer.Option(None),
    source: str | None = typer.Option(None),
) -> None:
    filters: dict[str, Any] = {}
    if underlying is not None:
        filters["underlying"] = underlying
    if source is not None:
        filters["source"] = source
    result = _store().compact(
        DatasetName(dataset),
        start_date=datetime.fromisoformat(start_date).date(),
        end_date=datetime.fromisoformat(end_date).date(),
        filters=filters or None,
    )
    _dump_json(result)


@maintenance_app.command("rebuild-options-from-archive")
def rebuild_options_from_archive_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    underlying: str = typer.Option("BTC"),
) -> None:
    result = rebuild_option_chain_from_archive(
        _store(),
        start=_parse_dt(start),
        end=_parse_dt(end),
        underlying=underlying,
    )
    _dump_json(result)


@microstructure_app.command("import-history")
def import_microstructure_history_command(
    root: str = typer.Option(str(get_config().modeling.microstructure_import_root)),
    assets: str = typer.Option("BTC,ETH"),
) -> None:
    del root, assets
    raise typer.BadParameter(
        "Historical parquet import has been removed. Load microstructure history into Postgres before training."
    )


@microstructure_app.command("ingest-live")
def ingest_microstructure_live_command(
    live_root: str = typer.Option("data/microstructure_live"),
    assets: str = typer.Option("BTC,ETH"),
    lookback_hours: int = typer.Option(6),
) -> None:
    live = _import_microstructure_live()
    result = live["ingest_live_engine_state"](
        _store(),
        Path(live_root),
        assets=tuple(asset.strip().upper() for asset in assets.split(",") if asset.strip()),
        lookback_hours=lookback_hours,
    )
    _dump_json(result)


@microstructure_app.command("build-live-bars")
def build_microstructure_live_bars_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    asset: str = typer.Option("BTC"),
) -> None:
    live = _import_microstructure_live()
    frame = live["build_live_microstructure_bars"](
        _store(),
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
    )
    paths = _store().write(
        DatasetName.MICROSTRUCTURE_BAR,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    typer.echo(f"Wrote {len(paths)} live microstructure bar partitions")


@microstructure_app.command("infer-live-once")
def infer_live_microstructure_once_command(
    live_root: str = typer.Option(""),
    assets: str = typer.Option("BTC,ETH"),
    horizons: str = typer.Option("30s,1m,2m,5m"),
    lookback_hours: int = typer.Option(6),
) -> None:
    live = _import_microstructure_live()
    result = live["run_live_microstructure_inference_once"](
        _store(),
        Path(live_root) if live_root else None,
        assets=tuple(asset.strip().upper() for asset in assets.split(",") if asset.strip()),
        horizons=tuple(item.strip() for item in horizons.split(",") if item.strip()),
        lookback_hours=lookback_hours,
    )
    _dump_json(result)


@microstructure_app.command("infer-live-daemon")
def infer_live_microstructure_daemon_command(
    live_root: str = typer.Option(""),
    assets: str = typer.Option("BTC,ETH"),
    horizons: str = typer.Option("30s,1m,2m,5m"),
    interval_seconds: int = typer.Option(15),
    lookback_hours: int = typer.Option(6),
) -> None:
    live = _import_microstructure_live()
    live["run_live_microstructure_inference_daemon"](
        _store(),
        Path(live_root) if live_root else None,
        assets=tuple(asset.strip().upper() for asset in assets.split(",") if asset.strip()),
        horizons=tuple(item.strip() for item in horizons.split(",") if item.strip()),
        interval_seconds=interval_seconds,
        lookback_hours=lookback_hours,
    )


@microstructure_app.command("build-features")
def build_microstructure_features_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    asset: str = typer.Option("BTC"),
    horizons: str = typer.Option("30s,1m,2m,5m"),
) -> None:
    store = _store()
    builders = _import_microstructure_builders()
    frame = builders["build_microstructure_feature_series"](
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        horizons=tuple(item.strip() for item in horizons.split(",") if item.strip()),
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_FEATURE_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    typer.echo(f"Wrote {len(paths)} microstructure feature partitions")


@microstructure_app.command("build-labels")
def build_microstructure_labels_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    asset: str = typer.Option("BTC"),
    horizons: str = typer.Option("30s,1m,2m,5m"),
    tau_bps: float = typer.Option(get_config().modeling.microstructure_tau_bps),
) -> None:
    store = _store()
    builders = _import_microstructure_builders()
    frame = builders["build_microstructure_label_series"](
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        horizons=tuple(item.strip() for item in horizons.split(",") if item.strip()),
        tau_bps=tau_bps,
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_LABEL_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    typer.echo(f"Wrote {len(paths)} microstructure label partitions")


@microstructure_app.command("build-regime")
def build_microstructure_regime_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    asset: str = typer.Option("BTC"),
) -> None:
    store = _store()
    builders = _import_microstructure_builders()
    frame = builders["build_microstructure_regime_series"](
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_REGIME_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    typer.echo(f"Wrote {len(paths)} microstructure regime partitions")


@microstructure_app.command("train")
def train_microstructure_command(
    asset: str = typer.Option("BTC"),
    horizon: str = typer.Option("1m"),
) -> None:
    store = _store()
    training = _import_microstructure_training()
    run_frame, model = training["train_microstructure_model"](
        store,
        asset=asset.upper(),
        horizon=horizon,
    )
    if run_frame.is_empty() or not model:
        typer.echo("No training rows available")
        raise typer.Exit(code=1)
    artifact = training["export_microstructure_model"](
        model,
        asset=asset.upper(),
        horizon=horizon,
    )
    run_frame = run_frame.with_columns(
        pl.lit(str(artifact["artifact_uri"])).alias("artifact_uri"),
        pl.lit(orjson.dumps(artifact).decode()).alias("artifact_payload"),
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_TRAINING_RUN,
        run_frame,
        stats={"rows": run_frame.height, "asset": asset.upper(), "horizon": horizon},
    )
    typer.echo(f"Wrote {len(paths)} microstructure training partitions")
    typer.echo(str(artifact["artifact_uri"]))


@microstructure_app.command("evaluate")
def evaluate_microstructure_command(
    asset: str = typer.Option("BTC"),
    horizon: str = typer.Option("1m"),
) -> None:
    store = _store()
    latest_run = (
        store.read(
            DatasetName.MICROSTRUCTURE_TRAINING_RUN,
            filters={"asset": asset.upper(), "horizon": horizon, "source": "tcn_boost_fusion"},
        )
        .sort("ts")
        .tail(1)
    )
    if latest_run.is_empty():
        typer.echo("No training artifact found")
        raise typer.Exit(code=1)
    artifact_payload = latest_run.item(0, "artifact_payload")
    model = orjson.loads(str(artifact_payload))
    training = _import_microstructure_training()
    frame = training["evaluate_microstructure_model"](
        store,
        model,
        asset=asset.upper(),
        horizon=horizon,
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_BACKTEST_RUN,
        frame,
        stats={"rows": frame.height, "asset": asset.upper(), "horizon": horizon},
    )
    typer.echo(f"Wrote {len(paths)} microstructure backtest partitions")


@microstructure_app.command("generate-signals")
def generate_microstructure_signals_command(
    asset: str = typer.Option("BTC"),
    horizon: str = typer.Option("1m"),
) -> None:
    store = _store()
    latest_run = (
        store.read(
            DatasetName.MICROSTRUCTURE_TRAINING_RUN,
            filters={"asset": asset.upper(), "horizon": horizon, "source": "tcn_boost_fusion"},
        )
        .sort("ts")
        .tail(1)
    )
    if latest_run.is_empty():
        typer.echo("No training artifact found")
        raise typer.Exit(code=1)
    artifact_payload = latest_run.item(0, "artifact_payload")
    training = _import_microstructure_training()
    output_frame, signal_frame, _ = training["generate_microstructure_model_output"](
        store,
        orjson.loads(str(artifact_payload)),
        asset=asset.upper(),
        horizon=horizon,
    )
    output_paths = store.write(
        DatasetName.MICROSTRUCTURE_MODEL_OUTPUT,
        output_frame,
        stats={"rows": output_frame.height, "asset": asset.upper(), "horizon": horizon},
    )
    signal_paths = store.write(
        DatasetName.MICROSTRUCTURE_SIGNAL_SERIES,
        signal_frame,
        stats={"rows": signal_frame.height, "asset": asset.upper(), "horizon": horizon},
    )
    typer.echo(f"Wrote {len(output_paths)} model-output and {len(signal_paths)} signal partitions")


@microstructure_app.command("build-paper-signals")
def build_microstructure_paper_signals_command(
    start: str = typer.Option(...),
    end: str = typer.Option(...),
    asset: str = typer.Option("BTC"),
    horizons: str = typer.Option("30s,1m,2m,5m"),
) -> None:
    store = _store()
    builders = _import_microstructure_builders()
    frame = builders["build_microstructure_signal_series"](
        store,
        _parse_dt(start),
        _parse_dt(end),
        asset=asset.upper(),
        horizons=tuple(item.strip() for item in horizons.split(",") if item.strip()),
    )
    paths = store.write(
        DatasetName.MICROSTRUCTURE_SIGNAL_SERIES,
        frame,
        stats={"rows": frame.height, "asset": asset.upper()},
    )
    typer.echo(f"Wrote {len(paths)} microstructure signal partitions")


def _merge_with_existing_vvix(
    store: DatasetStore,
    frame: pl.DataFrame,
    *,
    source: str,
    underlying: str,
) -> pl.DataFrame:
    if frame.is_empty():
        return frame
    existing = store.read(
        DatasetName.VVIX_SERIES,
        start=frame["ts"].min(),
        end=frame["ts"].max(),
        filters={"source": source, "underlying": underlying},
    ).select(
        [
            "ts",
            pl.col("rvvix_7d").alias("existing_rvvix_7d"),
            pl.col("rvvix_14d").alias("existing_rvvix_14d"),
            pl.col("rvvix_30d").alias("existing_rvvix_30d"),
            pl.col("qvvix_30d").alias("existing_qvvix_30d"),
            pl.col("model_version").alias("existing_model_version"),
            pl.col("quality_flag").alias("existing_quality_flag"),
        ]
    )
    if existing.is_empty():
        return frame
    return (
        frame.join(existing, on="ts", how="left")
        .with_columns(
            pl.coalesce(pl.col("rvvix_7d"), pl.col("existing_rvvix_7d")).alias("rvvix_7d"),
            pl.coalesce(pl.col("rvvix_14d"), pl.col("existing_rvvix_14d")).alias("rvvix_14d"),
            pl.coalesce(pl.col("rvvix_30d"), pl.col("existing_rvvix_30d")).alias("rvvix_30d"),
            pl.coalesce(pl.col("qvvix_30d"), pl.col("existing_qvvix_30d")).alias("qvvix_30d"),
            pl.when(
                pl.col("existing_model_version").is_not_null() & pl.col("qvvix_30d").is_not_null()
            )
            .then(pl.lit("rvvix-v1+qvvix-ewma-v2"))
            .otherwise(pl.col("model_version"))
            .alias("model_version"),
            pl.when(pl.col("quality_flag") == "ok")
            .then(pl.coalesce(pl.col("existing_quality_flag"), pl.col("quality_flag")))
            .otherwise(pl.col("quality_flag"))
            .alias("quality_flag"),
        )
        .select(frame.columns)
    )
