---
phase: 29-delete-strategy-exchange-gap-floor
plan: 02
type: execute
wave: 2
depends_on: ["29-01"]
files_modified:
  - python/opendeviationbar/sidecar/__init__.py
  - tests/test_sidecar_exchange_gap_floor.py
autonomous: true
requirements: [RESUME-01]

must_haves:
  truths:
    - "Sidecar resume TID is clamped above the highest known exchange gap TID for each symbol"
    - "No REST or Parquet fill is attempted for trade ID ranges where the exchange had no trades"
    - "Exchange gap floor lookup is logged for observability"
  artifacts:
    - path: "python/opendeviationbar/sidecar/__init__.py"
      provides: "Exchange gap floor clamping in resume TID calculation"
      contains: "_clamp_resume_tid_above_exchange_gaps"
    - path: "tests/test_sidecar_exchange_gap_floor.py"
      provides: "Unit tests for exchange gap floor clamping"
      contains: "test_clamp_above_exchange_gap"
  key_links:
    - from: "python/opendeviationbar/sidecar/__init__.py"
      to: "opendeviationbar_cache.kintsugi_log"
      via: "SELECT query for exchange_gap entries"
      pattern: "result.*=.*exchange_gap"
---

<objective>
Clamp sidecar resume TID above known exchange gaps so fill_from_rest never tries to fill unfillable trade ID ranges.

Purpose: Exchange gaps are permanent TID ranges where the exchange had no trades (verified by kintsugi with checksum-verified Parquet). There are only 2 known exchange gaps across all history, but the guard prevents the sidecar from wasting REST API calls on ranges that will always return 0 trades. The resume TID is adjusted upward past the highest exchange gap's anchor_last_tid so fill_from_rest starts from a valid trade range.

Output: `_clamp_resume_tid_above_exchange_gaps()` function in sidecar/**init**.py with unit tests.
</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/29-delete-strategy-exchange-gap-floor/29-01-SUMMARY.md
@python/opendeviationbar/sidecar/__init__.py
@python/opendeviationbar/kintsugi/repair.py
@python/opendeviationbar/kintsugi/discovery.py

<interfaces>
<!-- kintsugi_log exchange gap query pattern (from discovery.py line 497-503) -->
```python
exgap_result = cache.client.query(
    "SELECT DISTINCT symbol, threshold_decimal_bps, gap_start_ms "
    "FROM opendeviationbar_cache.kintsugi_log "
    "WHERE result = 'exchange_gap'"
)
```

<!-- kintsugi_log schema (from schema.sql) -->

```sql
CREATE TABLE IF NOT EXISTS opendeviationbar_cache.kintsugi_log (
    timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
    symbol LowCardinality(String),
    threshold_decimal_bps UInt32,
    gap_start_us Int64,     -- microsecond timestamp of gap start
    gap_end_us Int64,       -- microsecond timestamp of gap end
    ariadne_from_id Int64,  -- the TID anchor used for repair attempt
    result LowCardinality(String),  -- 'exchange_gap' for permanent gaps
)
```

<!-- Resume TID calculation (sidecar/__init__.py lines 283-340) -->
<!-- resume_tids: dict[str, int | None] where value = min(max(last_agg_trade_id)) across thresholds -->
<!-- This is the threshold furthest behind — fill from there covers all thresholds -->

<!-- Exchange gap classification (kintsugi/repair.py lines 446-454) -->
<!-- is_exchange_gap=True when Parquet has bounding trades but 0 trades in TID range -->
<!-- Logged to kintsugi_log with result='exchange_gap' -->
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement exchange gap floor clamping for resume TID</name>
  <files>python/opendeviationbar/sidecar/__init__.py, tests/test_sidecar_exchange_gap_floor.py</files>
  <read_first>
    - python/opendeviationbar/sidecar/__init__.py (lines 260-375 — full Aion startup path, post Plan 01 changes)
    - python/opendeviationbar/kintsugi/discovery.py (lines 495-505 — exchange gap dismissed set query)
    - python/opendeviationbar/kintsugi/repair.py (lines 430-460 — exchange gap classification)
    - python/opendeviationbar/clickhouse/schema.sql (lines 488-507 — kintsugi_log schema)
  </read_first>
  <behavior>
    - Test 1: test_clamp_above_exchange_gap — When kintsugi_log has an exchange_gap with ariadne_from_id=5000 for BTCUSDT and resume_tid=4000, the clamped resume_tid becomes 5000
    - Test 2: test_no_exchange_gaps_no_change — When kintsugi_log has zero exchange_gap entries, resume_tid is unchanged
    - Test 3: test_resume_tid_already_above_gap — When resume_tid=6000 and exchange_gap ariadne_from_id=5000, resume_tid stays 6000 (max, not overwrite)
    - Test 4: test_multiple_exchange_gaps_uses_highest — When two exchange gaps exist (from_id=3000 and from_id=5000), the highest (5000) is used as floor
    - Test 5: test_per_symbol_clamping — Exchange gap for BTCUSDT does not affect ETHUSDT resume_tid
    - Test 6: test_query_failure_logs_warning — If kintsugi_log query fails (ConnectionError), log warning and return resume_tids unchanged (non-fatal)
  </behavior>
  <action>
    Create a function `_clamp_resume_tid_above_exchange_gaps(ch_cache, resume_tids: dict[str, int | None]) -> dict[str, int | None]` that:

    1. **Query exchange gaps**: Run a single query to get the highest ariadne_from_id per symbol from kintsugi_log:
       ```sql
       SELECT symbol, max(ariadne_from_id) AS max_gap_tid
       FROM opendeviationbar_cache.kintsugi_log
       WHERE result = 'exchange_gap' AND ariadne_from_id > 0
       GROUP BY symbol
       ```
       This is ONE query total (not per symbol). The `ariadne_from_id` field is the TID anchor that was used when the gap was first discovered — it represents the last known good TID before the exchange gap.

    2. **Clamp resume_tid**: For each symbol, if `max_gap_tid > resume_tid`, set `resume_tid = max_gap_tid`. This ensures fill_from_rest starts from `max_gap_tid + 1`, skipping the unfillable range.

    3. **Log**: When clamping occurs, log:
       ```
       aion %s: resume_tid clamped %d -> %d (exchange gap floor from kintsugi_log)
       ```

    4. **Error handling**: Wrap the query in try/except for (OSError, RuntimeError, ConnectionError). On failure, log warning and return resume_tids unchanged. Exchange gap floor is a guard, not a gate — sidecar MUST start even if the query fails.

    5. **Call site**: Insert the call between Step 2 (resume TID calculation) and Step 3 (DELETE) in `_create_engine()`:
       ```python
       # Step 2.5: Clamp resume TIDs above known exchange gaps
       resume_tids = _clamp_resume_tid_above_exchange_gaps(ch_cache, resume_tids)
       ```

    Write tests in `tests/test_sidecar_exchange_gap_floor.py` using unittest.mock to mock `ch_cache.client.query()`.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_sidecar_exchange_gap_floor.py -xvs 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - grep -n "_clamp_resume_tid_above_exchange_gaps" python/opendeviationbar/sidecar/__init__.py shows function definition AND call site
    - grep -n "exchange.gap.floor\|exchange_gap" python/opendeviationbar/sidecar/__init__.py shows log message about clamping
    - grep -n "result.*=.*exchange_gap" python/opendeviationbar/sidecar/__init__.py shows the kintsugi_log query
    - grep -n "test_clamp_above_exchange_gap" tests/test_sidecar_exchange_gap_floor.py shows test exists
    - The function is called BEFORE the DELETE step and BEFORE engine seeding
  </acceptance_criteria>
  <done>
    - _clamp_resume_tid_above_exchange_gaps() function exists and is called between resume TID calculation and DELETE step
    - Single CH query gets highest exchange gap TID per symbol
    - resume_tid is only raised (never lowered) by exchange gap floor
    - Query failure is non-fatal (log warning, proceed with unclamped TIDs)
    - All 6 test cases pass
  </done>
</task>

</tasks>

<verification>
- `uv run --python 3.13 pytest tests/test_sidecar_exchange_gap_floor.py -xvs` passes all tests
- `grep -c "exchange_gap" python/opendeviationbar/sidecar/__init__.py` shows exchange gap references exist
- The exchange gap floor is applied BEFORE the DELETE step (call order verified in code)
</verification>

<success_criteria>

- Exchange gap floor prevents fill_from_rest from attempting unfillable TID ranges
- Resume TID is only raised (max semantics), never lowered
- Single query to kintsugi_log (not per-symbol)
- Non-fatal on query failure — sidecar starts regardless
  </success_criteria>

<output>
After completion, create `.planning/phases/29-delete-strategy-exchange-gap-floor/29-02-SUMMARY.md`
</output>
