---
phase: 18-monitor-validation-gates
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - scripts/heartbeat/checks/yesterday.py
  - scripts/heartbeat/config.py
  - tests/test_heartbeat_yesterday_junction.py
autonomous: true
requirements: [MON-01]

must_haves:
  truths:
    - "Aion symbols get a rolling-window freshness check instead of yesterday orphan completion"
    - "Day-mode symbols still get the existing crossover_tid orphan bar check"
    - "Aion freshness window is configurable via environment variable"
    - "When an Aion symbol has no bar within the freshness window, yesterday_completion reflects the issue"
  artifacts:
    - path: "scripts/heartbeat/checks/yesterday.py"
      provides: "Mode-branching yesterday check with Aion freshness"
      contains: "aion"
    - path: "scripts/heartbeat/config.py"
      provides: "AION_FRESHNESS_WINDOW_MIN constant"
      contains: "AION_FRESHNESS_WINDOW_MIN"
    - path: "tests/test_heartbeat_yesterday_junction.py"
      provides: "New test cases for Aion freshness branch"
      contains: "aion"
  key_links:
    - from: "scripts/heartbeat/checks/yesterday.py"
      to: "opendeviationbar.symbol_registry"
      via: "get_symbol_entries() for per-symbol mode lookup"
      pattern: "ouroboros_mode"
    - from: "scripts/heartbeat/checks/yesterday.py"
      to: "ClickHouse"
      via: "max(close_time_us) query for Aion freshness"
      pattern: "close_time_us"
---

<objective>
Replace the yesterday orphan completion check with a rolling-window freshness check for Aion symbols.

Purpose: Aion symbols have no midnight reset and no orphan bars, so the existing crossover_tid-based yesterday check is meaningless for them. Instead, Aion symbols need a freshness check -- has the symbol produced a bar within the last N minutes? Day-mode symbols keep the existing behavior unchanged.

Output: Mode-branching yesterday check, configurable freshness window, and updated tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-monitor-validation-gates/18-RESEARCH.md

@scripts/heartbeat/checks/yesterday.py
@scripts/heartbeat/checks/freshness.py
@scripts/heartbeat/config.py
@tests/test_heartbeat_yesterday_junction.py
</context>

<interfaces>
<!-- From symbol_registry.py: SymbolEntry -->
```python
@dataclass
class SymbolEntry:
    symbol: str
    exchange: str
    market: str
    enabled: bool
    asset_class: str
    ouroboros_mode: OuroborosMode  # .value is "day" or "aion"
    # ... other fields
```

<!-- From stathera_query.py -->

```python
FULL_TABLE: str  # "opendeviationbar_cache.open_deviation_bars"
```

<!-- From crossover.py -->

```python
def get_crossover_tid(symbol: str, date: date | None = None) -> int | None: ...
```

</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Aion freshness check in yesterday.py</name>
  <files>scripts/heartbeat/checks/yesterday.py, scripts/heartbeat/config.py, tests/test_heartbeat_yesterday_junction.py</files>
  <read_first>
    - scripts/heartbeat/checks/yesterday.py (full file)
    - scripts/heartbeat/checks/freshness.py (freshness query pattern)
    - scripts/heartbeat/config.py (full file, health tier keys)
    - tests/test_heartbeat_yesterday_junction.py (full file -- existing test patterns and mocks)
  </read_first>
  <behavior>
    - Test 1: Aion symbol with recent bar (5 min ago) -> yesterday_{symbol} = True
    - Test 2: Aion symbol with stale bar (60 min ago, window=30) -> yesterday_{symbol} = False with detail
    - Test 3: Day-mode symbol still uses crossover_tid check (existing behavior unchanged)
    - Test 4: Mixed registry (BTCUSDT=aion, EURUSD=day) routes each to correct check
    - Test 5: Aion symbol with no bars at all -> yesterday_{symbol} = "no_bars"
    - Test 6: Registry lookup failure -> falls back to day-mode check (safe default)
  </behavior>
  <action>
    **config.py: Add Aion freshness constant**

    Add after `ALERT_DEDUP_TTL`:
    ```python
    # Aion mode: rolling-window freshness check (replaces yesterday completion)
    # Default 30 min -- generous for high thresholds (1000 dbps produce fewer bars)
    AION_FRESHNESS_WINDOW_MIN = int(
        os.environ.get("OPENDEVIATIONBAR_AION_FRESHNESS_WINDOW_MIN", "30")
    )
    ```
    Add `import os` at top if not present.

    **yesterday.py: Branch by ouroboros_mode**

    Refactor `collect_yesterday_completion()` to:

    1. At top of function, load symbol entries:
       ```python
       from opendeviationbar.symbol_registry import get_symbol_entries
       try:
           entries = get_symbol_entries()
       except Exception:
           entries = {}
       ```

    2. Inside the `for symbol in get_heartbeat_symbols()` loop, determine mode:
       ```python
       entry = entries.get(symbol)
       mode = entry.ouroboros_mode.value if entry else "day"
       ```

    3. If `mode == "aion"`, run freshness check instead of crossover check:
       - Query: `SELECT max(close_time_us) FROM {FULL_TABLE} FINAL WHERE symbol = '{symbol}' AND close_time_us > 0`
       - Convert to minutes ago: `stale_min = (now_us - max_close_us) / 60_000_000`
       - Compare against `AION_FRESHNESS_WINDOW_MIN` from config
       - If fresh: `results[f"yesterday_{symbol}"] = True` and `results[f"yesterday_{symbol}_detail"] = f"aion:fresh ({stale_min:.0f}m ago)"`
       - If stale: `results[f"yesterday_{symbol}"] = False` and `results[f"yesterday_{symbol}_detail"] = f"aion:stale ({stale_min:.0f}m, window={AION_FRESHNESS_WINDOW_MIN}m)"`
       - If no bars: `results[f"yesterday_{symbol}"] = "no_bars"`

    4. If `mode == "day"`, keep existing crossover_tid logic unchanged.

    The aggregate `yesterday_completion` boolean at the bottom remains unchanged -- it already uses `all(results.get(f"yesterday_{s}") is not False for s in checked_symbols)`.

    **tests/test_heartbeat_yesterday_junction.py: Add Aion test cases**

    Add `ouroboros_mode` to the `FakeSymbolEntry` dataclass (default `"day"` for backward compat with existing tests, wrapped as a simple object with `.value`). Add test functions:
    - `test_aion_symbol_fresh()`: Mock BTCUSDT as aion, return recent close_time_us -> True
    - `test_aion_symbol_stale()`: Mock BTCUSDT as aion, return old close_time_us -> False
    - `test_mixed_mode_routing()`: BTCUSDT=aion + EURUSD=day, verify each routes correctly
    - `test_aion_no_bars()`: Aion symbol with empty result -> "no_bars"

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_heartbeat_yesterday_junction.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "aion" scripts/heartbeat/checks/yesterday.py
    - grep -q "AION_FRESHNESS_WINDOW_MIN" scripts/heartbeat/config.py
    - grep -q "ouroboros_mode" scripts/heartbeat/checks/yesterday.py
    - grep -q "aion" tests/test_heartbeat_yesterday_junction.py
  </acceptance_criteria>
  <done>
    - Aion symbols checked via rolling-window freshness (not crossover_tid)
    - Day-mode symbols retain existing orphan bar completion check
    - Freshness window configurable via OPENDEVIATIONBAR_AION_FRESHNESS_WINDOW_MIN (default 30)
    - All existing + new test cases pass
  </done>
</task>

</tasks>

<verification>
1. `uv run --python 3.13 pytest tests/test_heartbeat_yesterday_junction.py -x -v` -- all tests pass
2. `grep -c "aion" scripts/heartbeat/checks/yesterday.py` confirms Aion branching exists
3. `grep "AION_FRESHNESS_WINDOW_MIN" scripts/heartbeat/config.py` confirms configurable constant
</verification>

<success_criteria>

- Aion symbols get freshness check (last bar within N min)
- Day-mode symbols keep existing crossover_tid orphan check
- Freshness window is configurable
- Tests cover both modes, stale/fresh scenarios, and edge cases
  </success_criteria>

<output>
After completion, create `.planning/phases/18-monitor-validation-gates/18-02-SUMMARY.md`
</output>
