---
phase: 01-dedup-floor-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - crates/opendeviationbar-core/src/processor.rs
  - crates/opendeviationbar-core/src/checkpoint.rs
  - crates/opendeviationbar-core/tests/processor_tests.rs
autonomous: true
requirements: [RUST-01]

must_haves:
  truths:
    - "OpenDeviationBarProcessor tracks last completed bar trade ID separately from last_trade_id"
    - "last_completed_bar_tid updates only on bar completion and orphan emission, never on every trade"
    - "Checkpoint round-trips preserve last_completed_bar_tid across serialize/deserialize"
    - "Old checkpoints without last_completed_bar_tid deserialize correctly with None default"
  artifacts:
    - path: "crates/opendeviationbar-core/src/processor.rs"
      provides: "last_completed_bar_tid field and accessor"
      contains: "last_completed_bar_tid"
    - path: "crates/opendeviationbar-core/src/checkpoint.rs"
      provides: "last_completed_bar_tid in Checkpoint struct"
      contains: "last_completed_bar_tid"
    - path: "crates/opendeviationbar-core/tests/processor_tests.rs"
      provides: "Tests proving field tracks completed bars only"
      contains: "last_completed_bar_tid"
  key_links:
    - from: "processor.rs process_single_trade()"
      to: "self.last_completed_bar_tid"
      via: "set on Ok(Some(bar)) return path"
      pattern: "last_completed_bar_tid.*=.*Some.*last_agg_trade_id"
    - from: "processor.rs reset_at_ouroboros()"
      to: "self.last_completed_bar_tid"
      via: "set to orphan bar trade ID before reset"
      pattern: "last_completed_bar_tid"
    - from: "processor.rs create_checkpoint()"
      to: "checkpoint.last_completed_bar_tid"
      via: "field persistence"
      pattern: "last_completed_bar_tid.*self\\.last_completed_bar_tid"
    - from: "checkpoint.rs from_checkpoint()"
      to: "processor.last_completed_bar_tid"
      via: "field restoration"
      pattern: "last_completed_bar_tid.*checkpoint\\.last_completed_bar_tid"
---

<objective>
Add `last_completed_bar_tid` field to `OpenDeviationBarProcessor` and `Checkpoint` so that the dedup floor can be initialized from the last COMPLETED bar's trade ID rather than the forming bar tail.

Purpose: RUST-01 requires `committed_floors` to use the last completed bar's trade ID. This plan creates the tracking field and accessor in the core crate. The live_engine wiring happens in Plan 02.

Output: `processor.rs` with new field + accessor, `checkpoint.rs` with serialization support, tests proving correctness.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-dedup-floor-fix/01-CONTEXT.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From crates/opendeviationbar-core/src/processor.rs (lines 23-110):

```rust
pub struct OpenDeviationBarProcessor {
    threshold_decimal_bps: u32,
    pub threshold_ratio: i64,
    current_bar_state: Option<OpenDeviationBarState>,
    price_window: PriceWindow,
    last_trade_id: Option<i64>,         // Updated on EVERY trade (line 433)
    last_timestamp_us: i64,
    anomaly_summary: AnomalySummary,
    resumed_from_checkpoint: bool,
    prevent_same_timestamp_close: bool,
    defer_open: bool,
    trade_history: Option<TradeHistory>,
    inter_bar_config: Option<InterBarConfig>,
    include_intra_bar_features: bool,
    intra_bar_config: crate::intrabar::IntraBarConfig,
    max_gap_us: i64,
}
```

From crates/opendeviationbar-core/src/processor.rs (line 567):

```rust
pub fn last_agg_trade_id(&self) -> Option<i64> {
    self.last_trade_id
}
```

From crates/opendeviationbar-core/src/checkpoint.rs (lines 53-114):

```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
    #[serde(default = "default_checkpoint_version")]
    pub version: u32,
    pub symbol: String,
    pub threshold_decimal_bps: u32,
    pub incomplete_bar: Option<OpenDeviationBar>,
    pub thresholds: Option<(FixedPoint, FixedPoint)>,
    pub last_timestamp_us: i64,
    pub last_trade_id: Option<i64>,
    pub price_hash: u64,
    pub anomaly_summary: AnomalySummary,
    #[serde(default = "default_prevent_same_timestamp_close")]
    pub prevent_same_timestamp_close: bool,
    #[serde(default)]
    pub defer_open: bool,
}
```

Existing pattern for field addition (defer_open, added in Issue #46):

- Checkpoint: `#[serde(default)]` annotation for backward compat
- create_checkpoint(): `checkpoint.defer_open = self.defer_open;`
- from_checkpoint(): `defer_open: checkpoint.defer_open,`
- No Checkpoint::new() signature change (set after construction)

Bar completion path (process_single_trade, line 528):

```rust
let completed_bar = self.current_bar_state.take().unwrap().bar;
self.defer_open = true;
Ok(Some(completed_bar))
```

Orphan emission path (reset_at_ouroboros, line 1101-1120):

```rust
pub fn reset_at_ouroboros(&mut self) -> Option<OpenDeviationBar> {
    let orphaned = self.current_bar_state.take().map(|mut state| {
        state.bar.compute_microstructure_features();
        state.bar
    });
    self.price_window = PriceWindow::new();
    self.last_trade_id = None;
    self.last_timestamp_us = 0;
    self.resumed_from_checkpoint = false;
    self.defer_open = false;
    // ...
    orphaned
}
```

Batch path bar completion (process_agg_trade_records_with_options, line 772):

```rust
bars.push(current_bar.take().unwrap().bar);
defer_open = true;
```

</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add last_completed_bar_tid to processor and checkpoint</name>
  <files>
    crates/opendeviationbar-core/src/processor.rs,
    crates/opendeviationbar-core/src/checkpoint.rs,
    crates/opendeviationbar-core/tests/processor_tests.rs
  </files>
  <read_first>
    crates/opendeviationbar-core/src/processor.rs,
    crates/opendeviationbar-core/src/checkpoint.rs,
    crates/opendeviationbar-core/tests/processor_tests.rs
  </read_first>
  <behavior>
    - Test 1: Fresh processor has last_completed_bar_tid() == None
    - Test 2: After processing trades that complete a bar, last_completed_bar_tid() == Some(bar.last_agg_trade_id) of the completed bar
    - Test 3: After processing trades that do NOT complete a bar, last_completed_bar_tid() remains None (not updated by every trade)
    - Test 4: After reset_at_ouroboros() emits an orphan bar, last_completed_bar_tid() == Some(orphan.last_agg_trade_id) — NOT reset to None (orphan IS a completed bar for dedup purposes)
    - Test 5: After reset_at_ouroboros() with no forming bar (returns None), last_completed_bar_tid() is unchanged
    - Test 6: Checkpoint round-trip preserves last_completed_bar_tid — create_checkpoint() captures it, from_checkpoint() restores it
    - Test 7: Old checkpoint JSON without last_completed_bar_tid deserializes to None (backward compat via #[serde(default)])
  </behavior>
  <action>
    **In processor.rs:**

    1. Add field to `OpenDeviationBarProcessor` struct (after `last_trade_id: Option<i64>` at line ~43):
       ```rust
       /// Last completed bar's trade ID for dedup floor initialization (v1.4)
       ///
       /// Updated ONLY on bar completion (process_single_trade returns Some) and
       /// orphan emission (reset_at_ouroboros). NOT updated on every trade.
       /// This gives committed_floors a floor that excludes the forming bar tail,
       /// preventing suppression of bars at the REST-to-WS junction.
       last_completed_bar_tid: Option<i64>,
       ```

    2. Add public accessor (after `last_agg_trade_id()` at line ~567):
       ```rust
       /// Get the last COMPLETED bar's aggregate trade ID.
       ///
       /// Unlike `last_agg_trade_id()` which includes the forming bar tail,
       /// this returns the trade ID from the most recently completed or orphaned bar.
       /// Used by committed_floors initialization to avoid suppressing junction bars.
       ///
       /// Returns `None` for fresh processors that have not yet completed any bar.
       pub fn last_completed_bar_tid(&self) -> Option<i64> {
           self.last_completed_bar_tid
       }
       ```

    3. Update `new()` (line ~215, after `last_trade_id: None`):
       Add `last_completed_bar_tid: None,`

    4. Update `process_single_trade()` bar completion path (after line 528 `let completed_bar = ...`):
       ```rust
       self.last_completed_bar_tid = Some(completed_bar.last_agg_trade_id);
       ```
       Place this BEFORE `self.defer_open = true;` and AFTER `let completed_bar = self.current_bar_state.take().unwrap().bar;`

    5. Update `reset_at_ouroboros()` (line ~1101):
       After computing `orphaned` but BEFORE resetting other fields:
       ```rust
       if let Some(ref bar) = orphaned {
           self.last_completed_bar_tid = Some(bar.last_agg_trade_id);
       }
       // Do NOT reset last_completed_bar_tid to None — it persists across day boundaries
       ```
       Place this between the `let orphaned = ...` block (line 1102-1108) and `self.price_window = PriceWindow::new();` (line 1109).
       IMPORTANT: Do NOT add `self.last_completed_bar_tid = None;` to the reset block. The field must survive ouroboros resets because the floor needs to persist.

    6. Update `create_checkpoint()` (line ~852):
       After `checkpoint.defer_open = self.defer_open;` (line 872), add:
       ```rust
       checkpoint.last_completed_bar_tid = self.last_completed_bar_tid;
       ```

    7. Update `from_checkpoint()` (line ~936 in the Ok(Self { ... }) block):
       After `defer_open: checkpoint.defer_open,` (line 946), add:
       ```rust
       last_completed_bar_tid: checkpoint.last_completed_bar_tid,
       ```

    8. Update batch path `process_agg_trade_records_with_options()`:
       After the bar completion at line 772 `bars.push(current_bar.take().unwrap().bar);`:
       ```rust
       // Update last_completed_bar_tid for batch path consistency
       self.last_completed_bar_tid = Some(bars.last().unwrap().last_agg_trade_id);
       ```

    **In checkpoint.rs:**

    9. Add field to `Checkpoint` struct (after `defer_open` field at line ~113):
       ```rust
       /// Last completed bar's trade ID for dedup floor (v1.4)
       /// Defaults to None for old checkpoints (backward compatible).
       #[serde(default)]
       pub last_completed_bar_tid: Option<i64>,
       ```

    10. Update `Checkpoint::new()` (line ~138 in the Self { ... } block):
        After `defer_open: false,` (line 149), add:
        ```rust
        last_completed_bar_tid: None,
        ```

    **Tests in processor_tests.rs:**

    11. Add test module `mod last_completed_bar_tid_tests` with tests for behaviors 1-7 listed above.
        Use the existing test patterns in processor_tests.rs (create processor with `OpenDeviationBarProcessor::new(1000)`, create AggTrade records, process them, assert).
        For the checkpoint round-trip test, use `serde_json::from_str` on a JSON string that omits `last_completed_bar_tid` to verify backward compat.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && cargo nextest run -p opendeviationbar-core --no-fail-fast 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - processor.rs contains `last_completed_bar_tid: Option<i64>` field in OpenDeviationBarProcessor struct
    - processor.rs contains `pub fn last_completed_bar_tid(&self) -> Option<i64>`
    - processor.rs `process_single_trade()` sets `self.last_completed_bar_tid = Some(completed_bar.last_agg_trade_id)` on bar completion path
    - processor.rs `reset_at_ouroboros()` sets `self.last_completed_bar_tid = Some(bar.last_agg_trade_id)` when orphan emitted, does NOT reset to None
    - processor.rs `process_agg_trade_records_with_options()` sets `self.last_completed_bar_tid` on bar completion
    - processor.rs `create_checkpoint()` includes `checkpoint.last_completed_bar_tid = self.last_completed_bar_tid`
    - processor.rs `from_checkpoint()` includes `last_completed_bar_tid: checkpoint.last_completed_bar_tid`
    - processor.rs `new()` includes `last_completed_bar_tid: None`
    - checkpoint.rs contains `#[serde(default)]` above `pub last_completed_bar_tid: Option<i64>`
    - checkpoint.rs `Checkpoint::new()` includes `last_completed_bar_tid: None`
    - processor_tests.rs contains test functions verifying all 7 behaviors
    - `cargo nextest run -p opendeviationbar-core` exits 0
  </acceptance_criteria>
  <done>
    OpenDeviationBarProcessor tracks last_completed_bar_tid separately from last_trade_id.
    Field updates only on bar completion and orphan emission.
    Checkpoint serialization preserves the field with backward compatibility.
    All existing and new tests pass.
  </done>
</task>

</tasks>

<verification>
```bash
# All opendeviationbar-core tests pass
cargo nextest run -p opendeviationbar-core --no-fail-fast

# New accessor exists and is public

grep -n "pub fn last_completed_bar_tid" crates/opendeviationbar-core/src/processor.rs

# Checkpoint field has serde(default) for backward compat

grep -B1 "last_completed_bar_tid" crates/opendeviationbar-core/src/checkpoint.rs | grep "serde(default)"

# Field is NOT reset in ouroboros (absence check)

grep -A5 "reset_at_ouroboros" crates/opendeviationbar-core/src/processor.rs | grep -v "last_completed_bar_tid = None"

```
</verification>

<success_criteria>
- `last_completed_bar_tid` field exists in both `OpenDeviationBarProcessor` and `Checkpoint`
- Field updates ONLY on bar completion (process_single_trade returns Some) and orphan emission (reset_at_ouroboros)
- Field does NOT update on every trade (unlike `last_trade_id`)
- Checkpoint round-trip preserves the value
- Old checkpoints without the field deserialize cleanly (default to None)
- All existing opendeviationbar-core tests continue to pass
- New tests verify all 7 behaviors
</success_criteria>

<output>
After completion, create `.planning/phases/01-dedup-floor-fix/01-01-SUMMARY.md`
</output>
```
