# Plugin System (Issue #98)

**Parent**: [/python/opendeviationbar/CLAUDE.md](/python/opendeviationbar/CLAUDE.md)

FeatureProvider plugin system for external feature enrichment of open deviation bars via entry-point discovery.

---

## File Map

| File           | Purpose                                                            |
| -------------- | ------------------------------------------------------------------ |
| `protocol.py`  | `FeatureProvider` runtime-checkable Protocol                       |
| `loader.py`    | Entry-point discovery, caching, `enrich_bars()`                    |
| `migration.py` | ClickHouse `ALTER TABLE ADD COLUMN IF NOT EXISTS`                  |
| `__init__.py`  | Re-exports: `FeatureProvider`, `discover_providers`, `enrich_bars` |

---

## Writing a FeatureProvider Plugin

### 1. Implement the protocol

```python
# my_plugin/opendeviationbar_plugin.py
class MyFeatureProvider:
    name = "myplugin"
    version = "<version>"  # SSoT: plugin's own pyproject.toml
    columns = ("myplugin_signal", "myplugin_regime")
    min_bars = 20

    def enrich(self, bars, symbol, threshold_decimal_bps):
        bars["myplugin_signal"] = compute_signal(bars["Close"].values)
        bars["myplugin_regime"] = classify(bars["myplugin_signal"].values)
        # NaN for warmup
        bars.loc[bars.index[:self.min_bars], self.columns] = float("nan")
        return bars  # MUST return same object
```

### 2. Register entry point

```toml
# In plugin's pyproject.toml:
[project.entry-points."opendeviationbar.feature_providers"]
myplugin = "my_plugin.opendeviationbar_plugin:MyFeatureProvider"
```

### 3. Optional: add to opendeviationbar's optional deps

```toml
# In opendeviationbar's pyproject.toml [project.optional-dependencies]:
myplugin = ["my-plugin-package>=1.0"]
```

---

## Protocol Constraints

| Constraint         | Rule                                                           |
| ------------------ | -------------------------------------------------------------- |
| In-place mutation  | Add columns to input DataFrame, return same object             |
| Column prefix      | All columns prefixed with provider `name` (e.g., `laguerre_*`) |
| Warmup NaN         | Rows `0..min_bars-1` must be `NaN`, not `0.0`                  |
| Idempotent         | Calling `enrich()` twice produces same result                  |
| Non-anticipative   | Feature at `bar[i]` uses only `bars[0..i]`                     |
| Row-preserving     | No reordering, filtering, or adding rows                       |
| Read-only existing | Must NOT modify columns already present                        |

---

## Pipeline Integration

```
Rust processor → bars_df → enrich_bars() → bulk INSERT → ClickHouse
                           ^^^^^^^^^^^^
                    python/opendeviationbar/orchestration/open_deviation_bars.py:665-669
```

`enrich_bars()` is called post-Rust, pre-cache. Zero overhead when no plugins installed (lazy discovery, cached after first call).

---

## Data Flow

1. `_get_providers()` → lazy `discover_providers()` (scans `opendeviationbar.feature_providers` entry-point group)
2. `register_plugin_columns()` → adds column names to `constants._PLUGIN_FEATURE_COLUMNS`
3. `enrich_bars()` → calls each provider's `enrich()`, emits hook events
4. `bulk_operations.py` → includes `_PLUGIN_FEATURE_COLUMNS` in INSERT column list
5. `query_operations.py` → includes plugin columns in SELECT when `include_plugin_features=True`
6. `migration.py` → `ALTER TABLE ADD COLUMN IF NOT EXISTS` for each plugin column

---

## Hook Events

| Event                    | When                           | Payload                                             |
| ------------------------ | ------------------------------ | --------------------------------------------------- |
| `PLUGIN_ENRICH_COMPLETE` | All providers finished         | `symbol`, `provider_count`, `threshold_decimal_bps` |
| `PLUGIN_ENRICH_FAILED`   | A provider raised an exception | `symbol`, `provider_name`, `threshold_decimal_bps`  |

Provider failures are non-fatal (logged, hook emitted, pipeline continues).

---

## ClickHouse Schema

**Plugin columns are NOT in `schema.sql`** — they are self-managed by each plugin via `migrate_plugin_columns()` in `migration.py`. This keeps `schema.sql` clean (core columns only) while plugins add their own `Nullable(Float64) DEFAULT NULL` columns idempotently.

### Migration Pattern

1. Plugin implements `FeatureProvider.columns` (tuple of column names)
2. On startup, `migrate_plugin_columns()` calls `ALTER TABLE ADD COLUMN IF NOT EXISTS` for each column
3. Migration is registered in `opendeviationbar_cache.plugin_migrations` (ReplacingMergeTree)
4. Query the registry to see which plugins have run:

```sql
SELECT * FROM opendeviationbar_cache.plugin_migrations FINAL
```

### Column Naming Convention

All plugin columns MUST be prefixed with the provider's `name` attribute:

- `laguerre_rsi`, `laguerre_regime`, ... (provider name = `"laguerre"`)
- `myplugin_signal`, `myplugin_regime`, ... (provider name = `"myplugin"`)

### Registry Table

```sql
CREATE TABLE IF NOT EXISTS opendeviationbar_cache.plugin_migrations (
    plugin_name LowCardinality(String),
    plugin_version String,
    columns_added Array(String),
    applied_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
) ENGINE = ReplacingMergeTree(applied_at)
ORDER BY (plugin_name)
```

### Key Files

| File           | Purpose                                                   |
| -------------- | --------------------------------------------------------- |
| `migration.py` | `migrate_plugin_columns()` + `_register_migration()`      |
| `schema.sql`   | Registry table DDL (core columns only, no plugin columns) |

Existing core columns: [/python/opendeviationbar/clickhouse/CLAUDE.md](/python/opendeviationbar/clickhouse/CLAUDE.md)
