---
phase: 17-kintsugi-aion-repair
plan: 02
type: execute
wave: 2
depends_on: [17-01]
files_modified:
  - python/opendeviationbar/kintsugi/repair.py
autonomous: true
requirements: [KINT-01]

must_haves:
  truths:
    - "Aion shard repair reprocesses from anchor TID forward, not by day-recompute"
    - "Aion repair uses a single continuous processor (no day-boundary reset)"
    - "Aion repair does NOT mark the last bar as orphan"
    - "Day-mode repair is completely unchanged (backward compatible)"
    - "Aion repair uses Parquet tick cache or REST -- not Vision day-recompute"
  artifacts:
    - path: "python/opendeviationbar/kintsugi/repair.py"
      provides: "Aion repair path via _repair_aion()"
      contains: "_repair_aion"
  key_links:
    - from: "python/opendeviationbar/kintsugi/repair.py"
      to: "python/opendeviationbar/storage/parquet.py"
      via: "TickStorage.read_ticks for Parquet-based repair"
      pattern: "TickStorage"
    - from: "python/opendeviationbar/kintsugi/repair.py"
      to: "python/opendeviationbar/processors/core.py"
      via: "OpenDeviationBarProcessor for bar construction"
      pattern: "OpenDeviationBarProcessor"
---

<objective>
Implement the Aion repair strategy: TID-range reprocessing from gap anchor forward instead of day-recompute. Branch _repair() to dispatch to _repair_aion() for Aion symbols.

Purpose: Aion bars span UTC midnight continuously -- day-recompute is meaningless for them. Must reprocess the exact TID range of the gap through a single continuous processor.

Output: \_repair_aion() function in repair.py that handles Aion gap repair with Parquet-first, REST-fallback data sourcing.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-kintsugi-aion-repair/17-01-PLAN.md

@python/opendeviationbar/kintsugi/repair.py
@python/opendeviationbar/kintsugi/\_helpers.py
@python/opendeviationbar/kintsugi/discovery.py
@python/opendeviationbar/storage/parquet.py
@python/opendeviationbar/processors/core.py
@python/opendeviationbar/orchestration/helpers.py
@python/opendeviationbar/backfill.py

<interfaces>
<!-- Key types from Plan 01 -->

From python/opendeviationbar/kintsugi/\_helpers.py (created in Plan 01):

```python
def _get_mode_for_symbol(symbol: str) -> str:
    """Get ouroboros_mode for a symbol from the registry. Returns 'day' default."""
```

From python/opendeviationbar/kintsugi/discovery.py:

```python
@dataclass
class Shard:
    symbol: str
    threshold_decimal_bps: int
    gap_start_ms: int          # microseconds (despite name)
    gap_end_ms: int            # microseconds (despite name)
    anchor_last_tid: int | None
    resume_first_tid: int | None
    missing_trade_count: int
    is_overlap: bool
    is_genesis: bool
```

From python/opendeviationbar/storage/parquet.py:

```python
class TickStorage:
    def has_ticks(self, symbol: str, start_ms: int, end_ms: int) -> bool: ...
    def read_ticks(self, symbol: str, start_ms: int, end_ms: int) -> pl.DataFrame: ...
```

From python/opendeviationbar/orchestration/helpers.py:

```python
def _select_trade_columns(df: pl.DataFrame) -> pl.DataFrame: ...
def _stream_bars_with_processor(trade_batches, processor, include_incomplete=False): ...
```

From python/opendeviationbar/backfill.py:

```python
def _process_gap_fallback_starttime(
    symbol, threshold_decimal_bps, start_ms, end_ms,
    include_microstructure, overlap_strategy, _skip_lock
) -> int: ...
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement _repair_aion() and branch _repair() dispatch</name>
  <files>
    python/opendeviationbar/kintsugi/repair.py
  </files>
  <read_first>
    python/opendeviationbar/kintsugi/repair.py
    python/opendeviationbar/kintsugi/_helpers.py
    python/opendeviationbar/kintsugi/discovery.py
    python/opendeviationbar/storage/parquet.py
    python/opendeviationbar/orchestration/helpers.py
  </read_first>
  <action>
1. In `_repair()`, after `ouroboros_mode = _get_mode_for_symbol(shard.symbol)` (added by Plan 01) and after the conditional today-guard block, add a dispatch branch:

```python
# Dispatch: Aion uses TID-range repair, Day uses day-recompute
if ouroboros_mode == "aion":
    return _repair_aion(
        shard,
        include_microstructure=include_microstructure,
        verbose=verbose,
    )
```

Place this BEFORE the existing day-mode logic (the `days_to_repair` list building, batching, etc.). The entire existing day-mode path remains untouched below the dispatch -- backward compatible.

1. Add a new `_repair_aion()` function in repair.py. This is the core KINT-01 implementation. The function:

```python
def _repair_aion(
    shard: Shard,
    *,
    include_microstructure: bool = True,
    verbose: bool = False,
) -> int:
    """Repair an Aion shard by reprocessing trades in the TID range.

    Unlike day-recompute, Aion repair:
    - Processes the exact TID range [anchor_last_tid+1, resume_first_tid-1]
    - Uses a single continuous processor (no day-boundary resets)
    - Does NOT mark the last bar as orphan (bars connect seamlessly)
    - Sources: Parquet tick cache first, REST fromId fallback

    For genesis shards (is_genesis=True), uses the timestamp range to
    identify dates and processes all available trades continuously.
    """
```

**Data sourcing strategy:**

- Convert the shard's TID range to approximate date range using gap_start_ms/gap_end_ms timestamps
- For each date in the range, try to load trades from Parquet tick cache via `TickStorage.read_ticks()`
- Filter trades to TID range: `agg_trade_id > anchor_last_tid AND agg_trade_id < resume_first_tid` (for interior gaps) or just from the start date (for genesis)
- If Parquet is unavailable or incomplete, fall back to REST via `_process_gap_fallback_starttime()` with the gap's timestamp range

**Processing:**

- Create ONE `OpenDeviationBarProcessor` for the entire TID range (no per-day reset)
- Stream trades through the processor in chunks (50K if microstructure, 100K otherwise)
- Use `_stream_bars_with_processor()` with `include_incomplete=False` -- no orphan bar flush (Aion bars are continuous; the forming bar connects to the next known bar)
- For genesis shards: use `include_incomplete=True` since there may be no "next bar" to connect to

**Writing:**

- Collect all bars into a single Polars DataFrame
- Do NOT add `is_orphan=True` to the last bar (critical difference from day-mode)
- Write via `cache.store_bars_batch()` with `overlap_strategy="replace"` and `_skip_lock=True`
- Version string: `f"{__version__}+kintsugi-aion"`

**Implementation sketch:**

```python
def _repair_aion(
    shard: Shard,
    *,
    include_microstructure: bool = True,
    verbose: bool = False,
) -> int:
    import polars as pl
    from opendeviationbar.processors.core import OpenDeviationBarProcessor
    from opendeviationbar.orchestration.helpers import (
        _select_trade_columns,
        _stream_bars_with_processor,
    )

    storage = TickStorage()

    # Convert gap timestamps to date range for Parquet lookup
    lo_us = min(shard.gap_start_ms, shard.gap_end_ms)
    hi_us = max(shard.gap_start_ms, shard.gap_end_ms)
    start_date = datetime.fromtimestamp(lo_us / 1_000_000, tz=UTC).date()
    end_date = datetime.fromtimestamp(hi_us / 1_000_000, tz=UTC).date()

    # Try Parquet first: load trades for all days in the gap range
    all_trades: list[pl.DataFrame] = []
    parquet_available = True
    current = start_date
    while current <= end_date:
        day_start_ms = int(datetime.combine(current, datetime.min.time(), UTC).timestamp() * 1000)
        day_end_ms = day_start_ms + 86_400_000 - 1
        if storage.has_ticks(shard.symbol, day_start_ms, day_end_ms - 1):
            ticks = storage.read_ticks(shard.symbol, day_start_ms, day_end_ms)
            if not ticks.is_empty():
                all_trades.append(ticks)
        else:
            parquet_available = False
            break
        current += timedelta(days=1)

    if not parquet_available or not all_trades:
        # Fallback to REST time-based fetch for the entire gap range
        _thread_local.last_source = "rest"
        from opendeviationbar.backfill import _process_gap_fallback_starttime
        return _process_gap_fallback_starttime(
            symbol=shard.symbol,
            threshold_decimal_bps=shard.threshold_decimal_bps,
            start_ms=lo_us // 1000,
            end_ms=hi_us // 1000,
            include_microstructure=include_microstructure,
            overlap_strategy="replace",
            _skip_lock=True,
        )

    _thread_local.last_source = "parquet"

    # Combine all daily Parquet files into one continuous frame
    trades_df = pl.concat(all_trades, rechunk=False)
    del all_trades

    # Filter to TID range (interior gaps only -- genesis has no anchor)
    if shard.anchor_last_tid is not None:
        trades_df = trades_df.filter(pl.col("agg_trade_id") > shard.anchor_last_tid)
    if shard.resume_first_tid is not None and not shard.is_genesis:
        trades_df = trades_df.filter(pl.col("agg_trade_id") < shard.resume_first_tid)

    trades_df = _select_trade_columns(trades_df)

    if trades_df.is_empty():
        return 0

    # Single continuous processor -- NO day resets
    processor = OpenDeviationBarProcessor(
        threshold_decimal_bps=shard.threshold_decimal_bps,
        symbol=shard.symbol,
    )

    chunk_size = 50_000 if include_microstructure else 100_000

    def _trade_batches(ticks=trades_df):
        n = len(ticks)
        for start in range(0, n, chunk_size):
            yield ticks.slice(start, chunk_size)

    bar_frames: list[pl.DataFrame] = []
    # include_incomplete=True for genesis (no next bar), False for interior gaps
    for bar_batch in _stream_bars_with_processor(
        _trade_batches(),
        processor,
        include_incomplete=shard.is_genesis,
    ):
        bar_frames.append(bar_batch)

    del trades_df, processor

    if not bar_frames:
        return 0

    bars_df = pl.concat(bar_frames, rechunk=False)
    del bar_frames

    if bars_df.is_empty():
        return 0

    # NO orphan marking for Aion -- bars are continuous
    # Genesis Aion: mark last bar orphan only if include_incomplete was used
    if shard.is_genesis:
        is_orphan_col = [False] * (len(bars_df) - 1) + [True]
        bars_df = bars_df.with_columns(pl.Series("is_orphan", is_orphan_col))

    from opendeviationbar import __version__
    cache = _get_worker_cache()
    written = cache.store_bars_batch(
        symbol=shard.symbol,
        threshold_decimal_bps=shard.threshold_decimal_bps,
        bars=bars_df,
        version=f"{__version__}+kintsugi-aion",
        overlap_strategy="replace",
        _skip_lock=True,
    )

    del bars_df

    logger.info(
        "kintsugi Aion repair: %s@%d -- %d bars from TID %s to %s (source=parquet)",
        shard.symbol,
        shard.threshold_decimal_bps,
        written,
        shard.anchor_last_tid,
        shard.resume_first_tid,
    )
    return written
```

Key design choices:

- Single continuous processor (no `reset_at_ouroboros()` at midnight) -- bars naturally span days
- No orphan marking for interior gaps (bars connect to the next known bar)
- Genesis Aion shards DO mark last bar orphan (incomplete at forming edge)
- Parquet-first sourcing (zero network cost), REST fallback for uncached days
- TID filtering on Parquet trades ensures only the gap range is processed
- `overlap_strategy="replace"` ensures atomic DELETE+INSERT of the repaired TID range
  </action>
  <verify>
  <automated>cd /Users/terryli/eon/opendeviationbar-py && grep -c '\_repair_aion' python/opendeviationbar/kintsugi/repair.py && grep 'ouroboros_mode == "aion"' python/opendeviationbar/kintsugi/repair.py</automated>
  </verify>
  <acceptance_criteria>
  - grep '\_repair_aion' python/opendeviationbar/kintsugi/repair.py returns at least 2 matches (definition + dispatch call)
  - grep 'ouroboros_mode == "aion"' python/opendeviationbar/kintsugi/repair.py returns a match (dispatch branch)
  - grep 'kintsugi-aion' python/opendeviationbar/kintsugi/repair.py returns a match (version string)
  - grep 'is_orphan' in_repair_aion shows orphan marking only for genesis, not interior gaps
  - The existing \_try_parquet_day_replace, \_try_vision_day_replace, \_repair_under_lock functions are UNCHANGED
  - \_repair_aion uses include_incomplete=shard.is_genesis (not always True)
    </acceptance_criteria>
    <done>
    Aion repair via \_repair_aion() processes trades from the gap's TID range through a single continuous processor. Day-mode repair path is completely unchanged. Parquet-first sourcing with REST fallback. No orphan marking for interior Aion gaps. Genesis Aion shards mark the forming-edge bar as orphan.
    </done>
    </task>

</tasks>

<verification>
After plan completion:
1. `grep '_repair_aion' python/opendeviationbar/kintsugi/repair.py` shows definition and dispatch
2. `grep -c 'is_orphan' python/opendeviationbar/kintsugi/repair.py` shows orphan marking exists in both paths (day-mode and Aion genesis only)
3. `uv run --python 3.13 python -c "from opendeviationbar.kintsugi.repair import _repair_aion; print('import ok')"` succeeds
4. Existing day-mode functions (_try_parquet_day_replace, _try_vision_day_replace, _repair_under_lock) are byte-for-byte identical to pre-phase state
</verification>

<success_criteria>

- \_repair_aion() exists and handles TID-range repair for Aion symbols
- \_repair() dispatches to_repair_aion() when ouroboros_mode == "aion"
- Day-mode repair is completely unchanged (backward compatible)
- No orphan marking for interior Aion gaps
- Single continuous processor for Aion (no day boundary reset)
  </success_criteria>

<output>
After completion, create `.planning/phases/17-kintsugi-aion-repair/17-02-SUMMARY.md`
</output>
