# Plan: Condition Audit for Rule-Based Backtests

## Context

Strategy "Emmanuel MA v2" opens only 3 positions across 12 months of 4h BTC candles (~2190 bars). With 11 entry conditions all gated by `all_of`, we have no visibility into which conditions are suppressing trades. We need:

1. **Always-on summary** — per-condition pass rates displayed on the backtest result page
2. **Opt-in detailed audit** — per-bar per-condition evaluation results, downloadable as JSON

The summary alone will pinpoint culprits (e.g. "clean touch filter was true on only 2% of bars"). The detailed audit enables deeper investigation.

## Design

### Two tiers of audit

**Tier 1: Condition pass-rate summary (always-on for rule-based backtests)**
- During backtest, evaluate ALL conditions in `AllOf`/`AnyOf` (no short-circuit) and maintain per-condition counters
- Store the summary in `result_summary.condition_audit_summary` — no new table needed
- Display on dashboard backtest detail page
- Overhead: negligible (counter increments only; no per-bar data stored)

**Tier 2: Detailed per-bar audit (opt-in via `audit_conditions` flag)**
- Store per-bar, per-rule, per-condition evaluation results + intermediate expression values in a new DB table
- Downloadable via API endpoint
- Triggered by a boolean flag on the paper run

### Side-channel via `Arc<Mutex<>>` on RuleStrategy

The barter `AlgoStrategy` trait requires `fn generate_algo_orders(&self, state)` — immutable `&self`, return type is `(cancels, opens)`. No channel for audit data. Solution: `Arc<Mutex<Vec<BarAudit>>>` field on `RuleStrategy` provides interior mutability through `&self`. The runner drains it after each `process_chunk()`.

### CandleRecord needs a timestamp

`CandleRecord` currently has only OHLCV fields — no timestamp. Add `open_time: Option<DateTime<Utc>>` so audit entries can report when each bar was evaluated. The timestamp is already available in the event data — it's just discarded during conversion to `CandleRecord`.

## Files to modify

| File | Changes |
|---|---|
| `services/paper-executor/src/strategy/audit.rs` | **New file.** Audit data structures: `BarAudit`, `ConditionAudit`, `ConditionAuditSummary`, `AuditLog` type alias |
| `services/paper-executor/src/strategy/mod.rs` | Add `pub mod audit;` |
| `services/paper-executor/src/strategy/signal.rs` | Add `evaluate_with_audit()` — non-short-circuit evaluation that records per-condition results + intermediate values into a `Vec<ConditionAudit>` |
| `services/paper-executor/src/strategy/rule_strategy.rs` | Add `audit_log: Option<AuditLog>` + `bar_counter: Arc<AtomicU32>` fields. When audit_log is Some, use `evaluate_with_audit` instead of `evaluate`. Always maintain summary counters. |
| `services/paper-executor/src/strategy/instrument_data.rs` | Add `open_time: Option<DateTime<Utc>>` to `CandleRecord`. Preserve timestamp in `process()` |
| `services/paper-executor/src/runner.rs` | Wire audit log creation, drain after each chunk, compute summary at end, merge into `result_summary` |
| `crates/swym-dal/src/models/run_config.rs` | Add `#[serde(default)] pub audit_conditions: bool` to `RunConfig` |
| `crates/swym-dal/migrations/YYYYMMDD_condition_audit.sql` | New `paper_run_condition_audit` table for detailed per-bar data |
| `crates/swym-dal/src/models/condition_audit.rs` | **New file.** `ConditionAuditRow` DB model |
| `crates/swym-dal/src/repo/condition_audit.rs` | **New file.** `insert_batch`, `has_audit`, `load_detail_page` |
| `crates/swym-dal/src/repo/mod.rs` | Add `pub mod condition_audit;` |
| `crates/swym-dal/src/models/mod.rs` | Add `pub mod condition_audit;` |
| `services/api/src/handlers/paper_runs.rs` | Add `get_condition_audit_summary` + `get_condition_audit_detail` endpoints |
| `services/api/src/main.rs` | Register 2 new routes |
| `dashboard/src/pages/PaperRunDetailPage.tsx` | Add "Condition Audit" section: pass-rate table + download button |

## Implementation phases

### Phase 1: Data structures

**`services/paper-executor/src/strategy/audit.rs`** (new file):
```rust
pub struct ConditionAudit {
    pub path: String,              // e.g. "when.conditions[2]"
    pub kind: String,              // "compare", "cross_over", "position", etc.
    pub comment: Option<String>,   // from condition comment field
    pub result: bool,
    pub insufficient_data: bool,   // true when None (not enough history)
    pub left_value: Option<String>,  // for Compare/CrossOver/CrossUnder
    pub right_value: Option<String>,
}

pub struct BarAudit {
    pub bar_index: u32,
    pub bar_time: DateTime<Utc>,
    pub rule_index: u8,
    pub rule_comment: Option<String>,
    pub rule_fired: bool,
    pub conditions: Vec<ConditionAudit>,
}

pub struct ConditionPassRate {
    pub path: String,
    pub kind: String,
    pub comment: Option<String>,
    pub true_count: u32,
    pub true_rate: f64,             // true_count / total_bars
    pub insufficient_data_count: u32,
}

pub struct RuleAuditSummary {
    pub rule_index: u8,
    pub rule_comment: Option<String>,
    pub times_fired: u32,
    pub fire_rate: f64,
    pub conditions: Vec<ConditionPassRate>,
}

pub struct ConditionAuditSummary {
    pub total_bars: u32,
    pub rules: Vec<RuleAuditSummary>,
}

pub type AuditLog = Arc<Mutex<Vec<BarAudit>>>;
```

**`services/paper-executor/src/strategy/instrument_data.rs`**:
- Add `pub open_time: Option<DateTime<Utc>>` to `CandleRecord`
- In `process()` for `DataKind::Candle`: pass `Some(event.time_exchange)` when constructing `CandleRecord`
- In `process()` for `DataKind::Trade` with aggregator: pass `Some(completed.open_time)`
- Default to `None` for backward compatibility

### Phase 2: Non-short-circuit evaluation (`signal.rs`)

Add `evaluate_with_audit(cond, ctx, path, audits) -> bool`:
- `AllOf`: evaluate ALL children via `evaluate_with_audit`, collect into Vec, return `.all()`
- `AnyOf`: evaluate ALL children, return `.any()`
- `Not`: delegate, negate, no separate audit entry (child records itself)
- Leaf conditions (`Compare`, `CrossOver`, `CrossUnder`, `Position`, `EmaCrossover`, `EmaTrend`, `Rsi`, `Bollinger`, `PriceLevel`, `EventCount`): evaluate, push `ConditionAudit` with kind, result, intermediate values, then return result
- For `Compare`: capture `left_value` and `right_value` from `eval_expr()`
- For `CrossOver`/`CrossUnder`: capture `l_now`, `r_now` as left/right (the decisive current-bar values)
- For `Rsi`: capture computed RSI value as `left_value`, threshold as `right_value`
- For `Bollinger`: capture price as `left_value`, band value as `right_value`
- For `EventCount`: capture fired count as `left_value`, threshold count as `right_value`

### Phase 3: Wire into RuleStrategy

**`rule_strategy.rs`**:
- Add fields: `audit_log: Option<AuditLog>`, `bar_counter: Arc<AtomicU32>`
- Update `new()` to initialize `audit_log: None`, `bar_counter: Arc::new(AtomicU32::new(0))`
- In `generate_algo_orders`, when `audit_log.is_some()`:
  - Increment bar_counter
  - Get `bar_time` from `instrument_state.data.last_event_time`
  - For each rule: call `evaluate_with_audit`, build `BarAudit`, push to audit_log
  - If rule fired, proceed with order placement (same as existing code)
- When `audit_log.is_none()`: existing code path unchanged

**`swym_strategy.rs`**:
- Add `pub fn set_audit_log(&mut self, log: AuditLog)` that delegates to `RuleBased` variant

### Phase 4: Summary computation

In `runner.rs`, after the backtest loop completes:
- Drain any remaining audit entries
- Compute `ConditionAuditSummary` from all `BarAudit` entries:
  - Group by (rule_index, condition path)
  - Count true/false/insufficient_data for each
  - Compute rates
- Merge into `result_summary` JSON under key `"condition_audit_summary"`
- For the always-on summary (no detailed audit), we still need the non-short-circuit evaluation. Two approaches:
  - **Option A**: Always set up an `AuditLog` for rule-based backtests, drain after each chunk, only persist detail to DB when `audit_conditions: true`, but always compute summary from drained data
  - **Option B**: Use a separate lightweight counter-only path that doesn't allocate per-bar `BarAudit` structs
  - **Recommendation**: Option A — simpler code, and the memory overhead is small (~2190 bars × 4 rules × ~500 bytes = ~4MB peak between drains). Summary is computed from drained data; detail rows only written to DB when opt-in.

### Phase 5: Database storage (opt-in detail)

**Migration** (`paper_run_condition_audit`):
```sql
CREATE TABLE paper_run_condition_audit (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    run_id      UUID NOT NULL REFERENCES paper_runs(id) ON DELETE CASCADE,
    bar_index   INTEGER NOT NULL,
    bar_time    TIMESTAMPTZ NOT NULL,
    rule_index  SMALLINT NOT NULL,
    rule_fired  BOOLEAN NOT NULL,
    conditions  JSONB NOT NULL
);
CREATE INDEX idx_condition_audit_run ON paper_run_condition_audit(run_id, bar_index);
```

~8,760 rows for 2190 bars × 4 rules. `conditions` is a JSONB array of `ConditionAudit` objects (~1-2KB each).

**`RunConfig`**: Add `#[serde(default)] pub audit_conditions: bool`.

**`runner.rs` chunk loop**: When `audit_conditions`, after draining audit log, call `condition_audit::insert_batch()`.

### Phase 6: API endpoints

**`GET /api/v1/paper-runs/{id}/condition-audit`** — returns summary from `result_summary.condition_audit_summary`. No DB query needed.

**`GET /api/v1/paper-runs/{id}/condition-audit/detail?offset=0&limit=1000`** — paginated fetch from `paper_run_condition_audit` table. Returns 404 if no audit data exists.

### Phase 7: Dashboard

Add a "Condition Audit" card to `PaperRunDetailPage.tsx`, shown only when `result_summary.condition_audit_summary` exists:

- **Table layout**: one row per condition, grouped by rule
  - Columns: Rule #, Comment, Condition Path, Kind, Pass Rate (%), Insufficient Data (%)
  - Sort by pass rate ascending (rarest conditions first — these are the bottlenecks)
  - Color coding: < 5% = red, 5-20% = orange, > 20% = default
- **Download button**: when `audit_conditions` was true, fetch `/condition-audit/detail` and trigger JSON download

## Verification

1. `cargo check` — compiles
2. `cargo clippy` — no new warnings
3. `cargo test -p paper-executor` — new tests for `evaluate_with_audit` (verify all conditions evaluated, correct paths, correct intermediate values)
4. `cargo test -p swym-dal` — migration applies, repo functions work
5. Deploy to dev, run Emmanuel MA v2 backtest — verify summary appears on result page
6. Re-run with `audit_conditions: true` — verify detail download works
7. `pnpm tsc --noEmit` — dashboard type-checks
