---
phase: 16-sidecar-aion-startup
plan: 02
type: execute
wave: 2
depends_on: [16-01]
files_modified:
  - python/opendeviationbar/sidecar/__init__.py
  - python/opendeviationbar/sidecar/midnight.py
autonomous: true
requirements: [SIDE-01, SIDE-02]

must_haves:
  truths:
    - "Aion symbol startup resumes from checkpoint TID without running midnight preflight or clean-slate DELETE"
    - "Day symbol startup is unchanged -- midnight preflight and clean-slate DELETE still run"
    - "Cross-midnight bars from Aion symbols are not discarded in main loop or sync_flush"
    - "Mixed Day+Aion symbol fleet works correctly in a single sidecar instance"
  artifacts:
    - path: "python/opendeviationbar/sidecar/__init__.py"
      provides: "Branched startup path and conditional cross-midnight filter"
      contains: "aion_symbols"
    - path: "python/opendeviationbar/sidecar/midnight.py"
      provides: "Conditional cross-midnight filter in sync_flush"
      contains: "symbol_modes"
  key_links:
    - from: "python/opendeviationbar/sidecar/__init__.py"
      to: "python/opendeviationbar/symbol_registry.py"
      via: "get_symbol_entries() for per-symbol ouroboros_mode lookup"
      pattern: "get_symbol_entries\\|ouroboros_mode"
    - from: "python/opendeviationbar/sidecar/__init__.py"
      to: "src/stream_manager_bindings.rs"
      via: "symbol_modes dict passed to StreamManager constructor"
      pattern: "symbol_modes"
---

<objective>
Branch the Python sidecar startup path so Aion symbols use checkpoint-resume while Day symbols keep the existing clean-slate path, and make cross-midnight bar filters conditional on mode.

Purpose: Implements SIDE-01 (skip midnight preflight + DELETE for Aion) and SIDE-02 (checkpoint-resume startup). Without this, Aion symbols would have all their bars deleted on every restart.
Output: `_create_engine()` partitions symbols by mode, runs correct startup path for each, passes `symbol_modes` to Rust engine.
</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/16-sidecar-aion-startup/16-01-SUMMARY.md

<interfaces>
<!-- From symbol_registry.py (Phase 13): per-symbol mode lookup -->
```python
from opendeviationbar.symbol_registry import get_symbol_entries
from opendeviationbar.ouroboros import OuroborosMode

entries = get_symbol_entries()

# entries["BTCUSDT"].ouroboros_mode == OuroborosMode.AION

````

<!-- From sidecar/__init__.py: _create_engine signature -->
```python
def _create_engine(
    config: SidecarConfig,
    trace_id: str,
    gap_fill_results: dict | None,
    valid_pairs: set[tuple[str, int]] | None = None,
) -> tuple[object, int, dict[tuple[str, int], int]]:
````

<!-- From sidecar/checkpoint.py: checkpoint injection -->

```python
def _inject_checkpoints(engine, symbols, thresholds, trace_id, gap_fill_results, valid_pairs):
    # Returns (loaded_count, loaded_pairs_set)
```

<!-- From sidecar/midnight.py: key functions -->

```python
def _run_midnight_preflight(engine, symbols, thresholds) -> dict[str, int] | None:
def _check_yesterday_integrity(symbols, thresholds, preflight_verified) -> dict[str, str] | None:
def _sync_flush_rest_bars(engine, valid_pairs) -> int:
def _process_parquet_ticks(symbols, thresholds) -> dict[str, int] | None:
```

<!-- From StreamManager PyO3 (Plan 01): symbol_modes parameter -->

```python
engine = StreamManager(
    symbols, thresholds, include_microstructure,
    # ... existing params ...
    symbol_modes={"BTCUSDT": "aion", "EURUSD": "day"},  # NEW from Plan 01
)
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Branch _create_engine() for Aion vs Day startup paths</name>
  <files>python/opendeviationbar/sidecar/__init__.py</files>
  <read_first>
    python/opendeviationbar/sidecar/__init__.py,
    python/opendeviationbar/sidecar/checkpoint.py,
    python/opendeviationbar/sidecar/midnight.py
  </read_first>
  <action>
Modify `_create_engine()` in `python/opendeviationbar/sidecar/__init__.py` to branch startup based on per-symbol `ouroboros_mode`.

**Step 1: Build symbol_modes dict and partition symbols (after engine creation, before start_ws)**

After the `StreamManager(...)` or `OdbEngine(...)` constructor call (~line 211-220), add:

```python
from opendeviationbar.symbol_registry import get_symbol_entries
from opendeviationbar.ouroboros import OuroborosMode

entries = get_symbol_entries()
symbol_modes: dict[str, str] = {}
for sym in config.symbols:
    entry = entries.get(sym)
    if entry and entry.ouroboros_mode == OuroborosMode.AION:
        symbol_modes[sym] = "aion"
    else:
        symbol_modes[sym] = "day"
aion_symbols = [s for s, m in symbol_modes.items() if m == "aion"]
day_symbols = [s for s, m in symbol_modes.items() if m == "day"]
logger.info("symbol modes: %d aion, %d day", len(aion_symbols), len(day_symbols))
```

**Step 2: Pass symbol_modes to StreamManager constructor**

In the `StreamManager(...)` call, add `symbol_modes=symbol_modes` parameter. For `OdbEngine`, skip (it doesn't support streaming yet).

**Step 3: Branch the startup path based on `needs_fill`**

Currently lines 228-424 are the startup logic. Restructure as follows:

a) **WS start** stays the same (all symbols, line 223-226).

b) **Aion path** (checkpoint-resume): If `aion_symbols` is not empty AND `needs_fill` is True:

```python
if aion_symbols:
    loaded, loaded_pairs = _inject_checkpoints(
        engine, aion_symbols, config.thresholds, trace_id,
        gap_fill_results=gap_fill_results, valid_pairs=valid_pairs,
    )
    _seed_trade_ids_from_clickhouse(engine, aion_symbols, config.thresholds)
    logger.info("aion: injected %d checkpoints, seeded %d symbols", loaded, len(aion_symbols))
```

c) **Day path** (existing clean-slate): If `day_symbols` is not empty AND `needs_fill` is True:

- `_run_midnight_preflight(engine, day_symbols, config.thresholds)` -- pass day_symbols ONLY
- `_check_yesterday_integrity(day_symbols, config.thresholds, preflight_verified)` -- day_symbols ONLY
- The DELETE loop: filter to day_symbols only. Replace the `get_registered_symbols(enabled_only=True)` call with iterating `day_symbols` instead:

  ```python
  symbols_to_delete = day_symbols
  pairs = []
  for sym in symbols_to_delete:
      for thr in get_thresholds_for_symbol(sym):
          pairs.append((sym, thr))
  ```

- `_process_parquet_ticks(day_symbols, config.thresholds)` -- day_symbols ONLY

d) **fill_from_rest** stays the same (all symbols -- Rust handles per-symbol mode internally via symbol_modes on the config). The REST fill seed TID for Aion symbols comes from checkpoints + CH seeds set above; for Day symbols it comes from midnight preflight.

e) **sync_flush** stays the same (handled in Task 2 for cross-midnight filter).

**Step 4: Fix the `loaded` variable scoping**

Currently `loaded` and `loaded_pairs` are set on the `needs_fill` branch (line 232). With the split:

- Initialize `loaded, loaded_pairs = 0, set()` before the branch
- Aion path updates `loaded, loaded_pairs` from `_inject_checkpoints`
- Day path keeps them at 0 (no checkpoint injection for Day)

**Step 5: Conditional cross-midnight filter in main loop**

At line 1170-1183, the cross-midnight bar discard filter. Make it conditional:

```python
if (
    symbol_modes.get(symbol, "day") == "day"
    and open_us > 0
    and close_us > 0
    and (open_us // 86_400_000_000) != (close_us // 86_400_000_000)
):
    logger.warning(
        "cross-midnight bar discarded: %s@%d open=%d close=%d "
        "(kintsugi will heal the gap)",
        symbol, threshold, open_us, close_us,
    )
    continue
```

The `symbol_modes` dict must be accessible in `_main_loop`. Either:

- Pass it as a parameter to `run_sidecar`'s internal scope (it's defined in `_create_engine`, so store on config or return it)
- Or build it again in the main loop scope from `get_symbol_entries()`

Cleanest approach: build `symbol_modes` at the top of `run_sidecar()` and pass to both `_create_engine` and the main loop. Add `symbol_modes` as a module-level variable or closure variable. Since `_create_engine` is called from `run_sidecar()`, return `symbol_modes` from `_create_engine` by changing the return type to a 4-tuple:

```python
return engine, loaded, min_tid_guard, symbol_modes
```

Update the call site in `run_sidecar()` and in `recovery.py` to unpack the 4th value.

**Step 6: Update recovery.py**

In `recovery.py` `_restart_engine_with_recovery()` (line 53), update to receive and pass through the 4th return value. The function returns `(engine, success, min_tid_guard)` -- extend to also return `symbol_modes`:

```python
new_engine, _, new_min_tid_guard, new_symbol_modes = _create_engine(...)
# ...
return new_engine, True, new_min_tid_guard, new_symbol_modes
```

Update the return type annotation and failure return to include a 4th element (empty dict on failure).
</action>
<verify>
<automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "from opendeviationbar.sidecar import \_create_engine; print('import OK')" 2>&1 | tail -3</automated>
</verify>
<acceptance_criteria> - `_create_engine()` partitions symbols into aion_symbols and day_symbols - Midnight preflight only runs for day_symbols - DELETE loop only deletes bars for day_symbols - Aion symbols get checkpoint injection + CH seeding instead - Cross-midnight filter in main loop is conditional on symbol mode - symbol_modes dict is passed to StreamManager constructor - recovery.py updated to handle 4-tuple return
</acceptance_criteria>
<done>Sidecar startup branches correctly: Aion symbols resume from checkpoint, Day symbols run clean-slate path; cross-midnight filter is mode-aware</done>
</task>

<task type="auto">
  <name>Task 2: Conditional cross-midnight filter in sync_flush</name>
  <files>python/opendeviationbar/sidecar/midnight.py</files>
  <read_first>
    python/opendeviationbar/sidecar/midnight.py
  </read_first>
  <action>
Modify `_sync_flush_rest_bars()` in `midnight.py` to accept a `symbol_modes` parameter and make the cross-midnight filter conditional.

1. Add `symbol_modes: dict[str, str] | None = None` parameter to `_sync_flush_rest_bars()`.

2. At lines 504-516, make the cross-midnight filter conditional:

   ```python
   # L1: cross-midnight filter (#264) -- only for Day-mode symbols
   open_us = bar.get("open_time_us", 0)
   close_us = bar.get("close_time_us", 0)
   sym_mode = (symbol_modes or {}).get(symbol, "day")
   if (
       sym_mode == "day"
       and open_us > 0
       and close_us > 0
       and (open_us // day_us) != (close_us // day_us)
   ):
       cross_midnight_count += 1
       logger.warning(
           "sync_flush: cross-midnight bar discarded: %s@%d open=%d close=%d",
           symbol, threshold, open_us, close_us,
       )
       continue
   ```

3. Update the call site in `__init__.py` (line 423) to pass `symbol_modes`:

   ```python
   flushed = _sync_flush_rest_bars(engine, valid_pairs, symbol_modes=symbol_modes)
   ```

4. Also update `_run_midnight_preflight()` and `_check_yesterday_integrity()` to only process symbols passed to them (they should already do this since we pass `day_symbols` from Task 1, but verify the functions iterate their `symbols` parameter, not `config.symbols` or `get_registered_symbols()`).
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && python -c "from opendeviationbar.sidecar.midnight import _sync_flush_rest_bars; import inspect; sig = inspect.signature(_sync_flush_rest_bars); print('symbol_modes' in sig.parameters); print('OK')" 2>&1 | tail -3</automated>
  </verify>
  <acceptance_criteria>
    - `_sync_flush_rest_bars()` accepts `symbol_modes` parameter
    - Cross-midnight filter in sync_flush is conditional on mode (Aion bars pass through)
    - `_run_midnight_preflight()` processes only the symbols list passed to it
    - `_check_yesterday_integrity()` processes only the symbols list passed to it
  </acceptance_criteria>
  <done>All cross-midnight filters are mode-aware; Aion bars spanning midnight are accepted in both main loop and sync_flush</done>
</task>

</tasks>

<verification>
1. `python -c "from opendeviationbar.sidecar import _create_engine"` -- imports without error
2. `grep -n "aion_symbols\|day_symbols\|symbol_modes" python/opendeviationbar/sidecar/__init__.py` -- confirms branching exists
3. `grep -n "symbol_modes" python/opendeviationbar/sidecar/midnight.py` -- confirms conditional filter
4. `grep -n "midnight_preflight\|_check_yesterday_integrity" python/opendeviationbar/sidecar/__init__.py | grep day_symbols` -- confirms Day-only paths
5. `maturin develop && uv run --python 3.13 pytest tests/ -x -q --co 2>&1 | tail -5` -- test collection works (no import errors)
</verification>

<success_criteria>

- Aion symbol startup: checkpoint injection + CH seeding, NO midnight preflight, NO DELETE today
- Day symbol startup: unchanged (midnight preflight + clean-slate DELETE + Parquet processing)
- Cross-midnight bars: accepted for Aion symbols, discarded for Day symbols (both main loop and sync_flush)
- symbol_modes dict flows from Python registry through to Rust StreamManager
- recovery.py restart path preserves symbol_modes context
  </success_criteria>

<output>
After completion, create `.planning/phases/16-sidecar-aion-startup/16-02-SUMMARY.md`
</output>
