# Phase 2: Exness Daily Tick Cache Seeder Service with Registry Integration - Research

**Researched:** 2026-03-23
**Domain:** Exness forex tick data ingestion, systemd service deployment, symbol registry, Parquet storage
**Confidence:** HIGH

## Summary

This phase builds an Exness forex tick cache seeder that mirrors the existing Binance seeder (`scripts/tick_cache_seeder.py`). The Binance seeder is a mature, battle-tested reference implementation with daily Parquet writes, idempotent operation, newest-first ordering, monit monitoring, and Telegram reporting. The Exness seeder follows the same architecture but is simpler: no rate limiting (Exness has zero rate limits), no REST fallback (no equivalent API), and smaller data volumes (~10x less than Binance crypto).

The Rust `ExnessFetcher.fetch_day()` already handles HTTP download, ZIP extraction, and CSV parsing but is NOT exposed via PyO3. The seeder has two viable approaches: (1) add a PyO3 binding and call from Python, or (2) implement the download in pure Python (like the Binance seeder does via `requests`). Given the CONTEXT.md states "Uses `ExnessFetcher.fetch_day()`", adding a thin PyO3 binding is the intended path. However, the Binance seeder pattern uses pure Python HTTP and that works well. The recommendation is: **download in Python via `requests`** (matching the Binance seeder pattern exactly), since the Rust fetcher returns `Vec<ExnessTick>` which would need extra conversion to Polars, while Python can parse the CSV directly with Polars. The Rust ExnessFetcher is better suited for bar construction (Phase 3).

**Primary recommendation:** Mirror `tick_cache_seeder.py` structure precisely. Download Exness ZIPs in Python via `requests`, parse CSV with Polars, write daily Parquet files via `TickStorage`. Read symbols from registry filtered by `exchange="exness"`. Deploy as systemd oneshot + 1h timer.

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions

- All 10 Exness forex pairs MUST be added to `python/opendeviationbar/data/symbols.toml` as SSoT
- Registry entries follow exact same schema as Binance symbols: `enabled`, `asset_class = "forex"`, `exchange = "exness"`, `market = "raw_spread"`, `tier`, `listing_date`, `effective_start`, `thresholds`, `min_threshold`
- Seeder reads from `get_registered_symbols(enabled_only=True)` filtered by `exchange = "exness"` -- no hardcoded symbol lists
- Instruments: EURUSD, GBPUSD, USDJPY, AUDUSD, USDCAD, NZDUSD, EURGBP, EURJPY, GBPJPY, XAUUSD
- Thresholds: EURUSD at [50, 100, 200], XAUUSD TBD (researcher should determine)
- New script: `scripts/exness_tick_cache_seeder.py` mirrors `tick_cache_seeder.py` structure
- Uses `ExnessFetcher.fetch_day()` from Rust crate (already converted to daily)
- Writes daily Parquet: `~/.cache/opendeviationbar/ticks/EXNESS_RAW_SPREAD_{SYMBOL}/YYYY-MM-DD.parquet`
- Parquet schema: `timestamp` (ms), `bid` (f64), `ask` (f64)
- Idempotent: skips cached days
- Newest-first ordering
- Historical start default: `OPENDEVIATIONBAR_EXNESS_SEEDER_HISTORICAL_START` env var (default: `2022-01-01`)
- Weekend/holiday 404s silently skipped
- State file: `/var/tmp/opendeviationbar-exness-seeder-state.txt`
- systemd oneshot + 1h timer
- `OnBootSec=300`, `OnUnitActiveSec=3600`
- `MemoryHigh=2G`, `MemoryMax=4G`
- `TZ=UTC`, `LD_PRELOAD=libmimalloc.so.2`, `MIMALLOC_PURGE_DELAY=1000`
- Telegram reporting: healthy = ephemeral (auto-delete 15min), error = persistent LOUD
- Monit: `exness-seeder-state` monitor, 2h threshold
- CLAUDE.md updates: scripts/CLAUDE.md, scripts/systemd/CLAUDE.md, deploy/CLAUDE.md, symbols.toml header, root CLAUDE.md

### Claude's Discretion

- Exact concurrent download count for Exness (suggest 4)
- Whether to add `source_url_pattern` field to symbols.toml
- Parquet compression level (ZSTD-3 recommended)
- Whether Exness seeder shares `TickStorage` or uses parallel implementation

### Deferred Ideas (OUT OF SCOPE)

- Exness bar construction pipeline (mid-price -> open deviation bars)
- Exness kintsugi integration (gap detection + repair)
- Exness ClickHouse table/schema
- Exness streaming (real-time quotes)
  </user_constraints>

## Standard Stack

### Core

| Library           | Version            | Purpose                           | Why Standard                                |
| ----------------- | ------------------ | --------------------------------- | ------------------------------------------- |
| polars            | (project existing) | CSV parsing and Parquet write     | Already used by Binance seeder, TickStorage |
| requests          | (project existing) | HTTP download of daily ZIPs       | Already used by Binance seeder              |
| pydantic-settings | (project existing) | `ExnessSeederConfig` via env vars | Matches `SeederConfig` pattern              |

### Supporting

| Library                                        | Version  | Purpose                       | When to Use                                 |
| ---------------------------------------------- | -------- | ----------------------------- | ------------------------------------------- |
| `opendeviationbar.storage.parquet.TickStorage` | existing | Daily Parquet file management | Write/check tick data                       |
| `opendeviationbar.symbol_registry`             | existing | Read enabled Exness symbols   | `get_registered_symbols(enabled_only=True)` |
| `opendeviationbar.notify.telegram`             | existing | Send status messages          | Healthy/error reporting                     |

### Alternatives Considered

| Instead of           | Could Use                   | Tradeoff                                                                                                                               |
| -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Python requests      | Rust ExnessFetcher via PyO3 | Rust fetcher not exposed to Python yet; adding binding adds complexity; Python `requests` matches Binance seeder pattern exactly       |
| Custom Parquet write | TickStorage.write_ticks()   | TickStorage expects `agg_trade_id` column for dedup; Exness data has no trade IDs -- needs modified write path or direct Parquet write |

## Architecture Patterns

### Recommended Project Structure

```
scripts/
  exness_tick_cache_seeder.py          # Main seeder script (mirrors tick_cache_seeder.py)
scripts/systemd/
  opendeviationbar-exness-seeder.service  # systemd oneshot
  opendeviationbar-exness-seeder.timer    # 1h timer
python/opendeviationbar/
  config/
    exness_seeder.py                    # ExnessSeederConfig (pydantic-settings)
  data/
    symbols.toml                        # Add 10 Exness entries
deploy/monit/
  opendeviationbar.conf                 # Add exness-seeder-state monitor
```

### Pattern 1: Mirror Binance Seeder Structure

**What:** Follow `tick_cache_seeder.py` structure exactly: main() -> per-symbol loop -> per-day concurrent downloads -> daily Parquet write
**When to use:** Always -- this is the locked architecture decision
**Example:**

```python
# From tick_cache_seeder.py pattern:
def main() -> int:
    cfg = ExnessSeederConfig()
    storage = TickStorage()
    symbols = get_registered_symbols(enabled_only=True)
    exness_symbols = {k: v for k, v in symbols.items() if v.get("exchange") == "exness"}

    for symbol in exness_symbols:
        _seed_symbol(symbol, effective_start, storage, cfg.max_concurrent_downloads, ...)

    # Write state file for monit
    state_path.write_text(f"ok\ntimestamp={datetime.now(UTC).isoformat()}\n...")
    return 0
```

### Pattern 2: Exness-Specific Download Helper

**What:** Download Exness daily ZIP, extract CSV, parse with Polars, normalize to 3-column schema
**When to use:** Per-day download worker
**Example:**

```python
def _download_exness_day(symbol: str, date_str: str, timeout_s: int) -> pl.DataFrame | None:
    """Download one Exness daily ZIP. Returns None on 404 (weekend) or error."""
    raw_spread = f"{symbol}_Raw_Spread"
    d = date.fromisoformat(date_str)
    url = (
        f"https://ticks.ex2archive.com/ticks/{raw_spread}"
        f"/{d.year:04d}/{d.month:02d}/{d.day:02d}"
        f"/Exness_{raw_spread}_{d.year:04d}_{d.month:02d}_{d.day:02d}.zip"
    )

    resp = requests.get(url, timeout=timeout_s)
    if resp.status_code == 404:
        return None  # Weekend/holiday -- expected
    resp.raise_for_status()

    with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
        csv_name = zf.namelist()[0]
        with zf.open(csv_name) as csv_f:
            df = pl.read_csv(
                csv_f,
                has_header=True,
                schema_overrides={
                    "Timestamp": pl.Utf8,
                    "Bid": pl.Float64,
                    "Ask": pl.Float64,
                },
            )

    # Parse ISO 8601 timestamps to ms
    df = df.with_columns(
        pl.col("Timestamp")
        .str.to_datetime("%Y-%m-%d %H:%M:%S%.3fZ")
        .dt.epoch(time_unit="ms")
        .alias("timestamp")
    )

    # Select canonical 3 columns
    return df.select([
        pl.col("timestamp"),
        pl.col("Bid").alias("bid"),
        pl.col("Ask").alias("ask"),
    ]).sort("timestamp")
```

### Pattern 3: TickStorage Write for Quote Data (No agg_trade_id)

**What:** Exness data lacks `agg_trade_id`, so TickStorage's dedup-on-trade-ID path does not apply. Direct atomic Parquet write is needed.
**When to use:** Writing Exness Parquet files
**Example:**

```python
# TickStorage.write_ticks() does dedup on agg_trade_id -- Exness has none.
# Options:
# A) Use TickStorage with modified schema (it handles missing agg_trade_id gracefully)
# B) Write directly using _atomic_write_parquet()

# Option A is preferred -- write_ticks() already handles:
# - Timestamp normalization (us -> ms)
# - Day partitioning
# - Atomic write (tmp + rename)
# - The "no agg_trade_id" path falls through to simple overwrite (line 310-312)

from opendeviationbar.storage.parquet_io import _atomic_write_parquet
parquet_path = storage._get_parquet_path(cache_key, date_str)
_atomic_write_parquet(df, parquet_path)
```

### Pattern 4: Exness SeederConfig (pydantic-settings)

**What:** Configuration via environment variables, mirroring SeederConfig
**When to use:** All configurable values
**Example:**

```python
class ExnessSeederConfig(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="OPENDEVIATIONBAR_EXNESS_SEEDER_",
        case_sensitive=False,
    )

    historical_start: str = "2022-01-01"
    max_concurrent_downloads: int = 4
    download_timeout_s: int = 60
    state_file: str = "/var/tmp/opendeviationbar-exness-seeder-state.txt"
```

### Anti-Patterns to Avoid

- **Hardcoded symbol lists:** Always read from `symbols.toml` via `get_registered_symbols()`. The CONTEXT.md explicitly locks this.
- **Logging weekend 404s as errors:** Forex markets are closed Sat/Sun. 404 is expected. Log at DEBUG level only.
- **Using Rust ExnessFetcher for download:** The Rust fetcher returns `Vec<ExnessTick>` (Rust structs). Converting to Polars requires PyO3 serialization overhead. Python `requests` + Polars CSV parsing is simpler and matches the Binance seeder pattern.
- **Sharing `SeederConfig` with Binance:** The Exness seeder has different defaults (1h timer vs 5min, 4 workers vs 12, different state file). Create a separate `ExnessSeederConfig`.

## Don't Hand-Roll

| Problem                   | Don't Build        | Use Instead                                        | Why                                          |
| ------------------------- | ------------------ | -------------------------------------------------- | -------------------------------------------- |
| Parquet write with ZSTD-3 | Custom compression | `_atomic_write_parquet()` from `parquet_io.py`     | Atomic write, correct compression, validated |
| Timestamp normalization   | Manual epoch math  | Polars `str.to_datetime().dt.epoch()`              | Handles ISO 8601 variants, timezone-aware    |
| Symbol discovery          | Hardcoded lists    | `get_registered_symbols(enabled_only=True)`        | Registry SSoT, exchange filter               |
| Telegram notifications    | Direct HTTP calls  | `opendeviationbar.notify.telegram.send_telegram()` | Handles ephemeral/persistent, auto-delete    |
| State file freshness      | Custom monitoring  | Monit `check file` with `timestamp >`              | Already proven pattern for Binance seeder    |
| Config from env vars      | `os.environ.get()` | `pydantic-settings BaseSettings`                   | Type validation, prefix stripping, defaults  |

## Common Pitfalls

### Pitfall 1: Exness CSV Timestamp Format

**What goes wrong:** Exness CSV timestamps are ISO 8601 strings (`2024-01-15 00:00:00.032Z`), NOT epoch integers like Binance Vision.
**Why it happens:** Copying Binance seeder code assumes integer timestamps.
**How to avoid:** Parse with `pl.col("Timestamp").str.to_datetime()` then convert to epoch ms. Handle both `Z`-suffix and bare timestamps (Rust client handles both).
**Warning signs:** All timestamps are 0 or NaN in output Parquet.

### Pitfall 2: Exness CSV Has Headers (Binance Vision Does Not)

**What goes wrong:** Using `has_header=False` like Binance seeder.
**Why it happens:** Blindly copying `_download_vision_day()` pattern.
**How to avoid:** Set `has_header=True` for Exness CSV. Column names are `"Exness","Symbol","Timestamp","Bid","Ask"`.
**Warning signs:** First row of data contains string values instead of numbers.

### Pitfall 3: TickStorage write_ticks() Expects agg_trade_id

**What goes wrong:** `write_ticks()` has dedup logic that assumes `agg_trade_id` column exists. Without it, the code takes the "no trade ID column, overwrite" path (line 310-312) which works correctly for daily files.
**Why it happens:** TickStorage was designed for Binance aggTrades with trade IDs.
**How to avoid:** For idempotent daily writes, the overwrite path is correct -- Exness daily data is complete and deterministic. Alternatively, bypass `write_ticks()` and use `_atomic_write_parquet()` directly for cleaner semantics.
**Warning signs:** Warning logs about "trade-ID continuity check failed" (from validation that expects agg_trade_id).

### Pitfall 4: Weekend 404s Logged as Errors

**What goes wrong:** HTTP 404 for Saturday/Sunday dates triggers error-level logging, polluting logs and triggering false monit alerts.
**Why it happens:** Forex markets close Friday 5pm ET to Sunday 5pm ET. No data files exist for those days.
**How to avoid:** Treat 404 as expected (DEBUG level). Only log WARNING/ERROR for non-404 failures (503, timeout, ZIP corruption).
**Warning signs:** Hundreds of ERROR lines in journal for every seeder run.

### Pitfall 5: Cache Key Collision with Binance Symbols

**What goes wrong:** Using bare symbol name (`XAUUSD`) as cache key could collide if Binance ever adds a gold pair.
**Why it happens:** Not using the `EXNESS_RAW_SPREAD_` prefix for the Parquet directory.
**How to avoid:** Cache key = `EXNESS_RAW_SPREAD_{SYMBOL}` (locked in CONTEXT.md). This matches the `BINANCE_SPOT_` / `BINANCE_UM_` prefix convention.
**Warning signs:** Parquet files overwriting each other, wrong data for wrong instrument.

### Pitfall 6: Timestamp Format Variants

**What goes wrong:** Some Exness CSV rows have `Z` suffix, some don't. Polars `str.to_datetime` fails on mixed formats.
**Why it happens:** Exness data inconsistency across years/instruments.
**How to avoid:** Strip trailing `Z` before parsing, or use a format that handles both. The Rust client already handles this (`from_csv` in `client.rs` tries both formats).
**Warning signs:** `ComputeError: conversion from`str`to`datetime`failed` in Polars.

## Code Examples

### Exness URL Construction

```python
# Source: CONTEXT.md + crates/opendeviationbar-providers/src/exness/client.rs
def _exness_url(symbol: str, d: date) -> str:
    raw_spread = f"{symbol}_Raw_Spread"
    return (
        f"https://ticks.ex2archive.com/ticks/{raw_spread}"
        f"/{d.year:04d}/{d.month:02d}/{d.day:02d}"
        f"/Exness_{raw_spread}_{d.year:04d}_{d.month:02d}_{d.day:02d}.zip"
    )
```

### Registry Filter for Exness Symbols

```python
# Source: symbol_registry.py + CONTEXT.md
from opendeviationbar.symbol_registry import get_symbol_entries

def get_exness_symbols() -> dict[str, dict]:
    """Get all enabled Exness symbols from registry."""
    entries = get_symbol_entries()
    return {
        name: entry
        for name, entry in entries.items()
        if entry.exchange == "exness" and entry.enabled
    }
```

### Telegram Reporting Pattern

```python
# Source: tick_cache_seeder.py pattern + opendeviationbar.notify.telegram
from opendeviationbar.notify.telegram import send_telegram

# Healthy run (ephemeral)
send_telegram(
    f"<b>Exness seeder:</b> {symbols_processed} symbols, "
    f"{total_seeded} new days, {elapsed:.0f}s elapsed",
    disable_notification=True,  # silent
    ephemeral_ttl_minutes=15,
)

# Error (persistent, LOUD)
send_telegram(
    f"<b>Exness seeder FAILED:</b> {symbol} download error: {exc}",
    disable_notification=False,  # LOUD
)
```

### symbols.toml Exness Entry Template

```toml
# Source: CONTEXT.md locked decisions
[symbols.EURUSD]
enabled = true
asset_class = "forex"
exchange = "exness"
market = "raw_spread"
tier = 1
listing_date = 2022-01-01
effective_start = 2022-01-03  # Monday -- first trading day
thresholds = [50, 100, 200]
min_threshold = 50
keywords = ["euro", "dollar", "eurusd", "forex", "major"]
processing_notes = "Exness Raw_Spread variant. Tick data = bid/ask quotes, not trades. ~60K ticks/day."
```

## XAUUSD Threshold Research

XAUUSD (Gold) has fundamentally different characteristics from forex pairs:

- Price level: ~$3000 (vs ~1.0 for EURUSD)
- Daily range: ~$30-50 on normal days, ~$80-100 on volatile days
- Spread: ~$0.06 (not zero like forex)
- Tick volume: ~500K ticks/day (much higher than forex ~60K)

Threshold analysis for XAUUSD at price $3000:

- 50 dbps = 0.005% = $0.15 movement -> extremely high bar count (likely infeasible)
- 100 dbps = 0.01% = $0.30 -> still very high frequency
- 200 dbps = 0.02% = $0.60 -> moderate frequency
- 500 dbps = 0.05% = $1.50 -> reasonable for analysis
- 1000 dbps = 0.1% = $3.00 -> low frequency

**Recommendation:** XAUUSD thresholds = [200, 500, 1000]. Start higher than forex because gold has wider spreads and more volatile intraday moves. The 200 dbps threshold maps to ~$0.60 movement which is meaningful for gold microstructure. This aligns with how Binance crypto thresholds scale: more volatile instruments use higher thresholds.

## Discretion Recommendations

### Concurrent Download Count: 4

**Rationale:** Exness has zero rate limiting, but only 10 symbols x 1h interval means no rush. 4 concurrent downloads is plenty. Each ZIP is ~2-18MB, downloads in 1-2 seconds. Even with 4 workers, 365 days takes <3 minutes per symbol. Lower concurrency = simpler error handling, lower memory.

### source_url_pattern in symbols.toml: NO

**Rationale:** Not needed now. The URL pattern is deterministic and specific to the seeder script. Adding it would expand the registry schema without clear benefit. The seeder already constructs URLs from symbol name + date. If needed later, it can be added without breaking changes.

### Parquet Compression: ZSTD-3

**Rationale:** Matches existing TickStorage compression. Consistent across all tick data. Already proven optimal for this workload (empirical benchmark 2026-01-07).

### TickStorage Integration: Share TickStorage with Direct Parquet Write

**Rationale:** Use `TickStorage._get_parquet_path()` and `TickStorage._get_symbol_dir()` for path management (consistent directory structure). Use `_atomic_write_parquet()` directly for writing (bypasses `write_ticks()` trade-ID validation which is irrelevant for Exness quote data). This avoids modifying TickStorage while reusing its path conventions.

## Validation Architecture

### Test Framework

| Property           | Value                                                   |
| ------------------ | ------------------------------------------------------- |
| Framework          | pytest (existing)                                       |
| Config file        | `pyproject.toml` [tool.pytest]                          |
| Quick run command  | `uv run --python 3.13 pytest tests/ -x -q --timeout=30` |
| Full suite command | `mise run test-py`                                      |

### Phase Requirements -> Test Map

| Req ID  | Behavior                                          | Test Type | Automated Command                                              | File Exists? |
| ------- | ------------------------------------------------- | --------- | -------------------------------------------------------------- | ------------ |
| EXNS-01 | Exness download + parse ZIP -> 3-column Polars DF | unit      | `pytest tests/test_exness_seeder.py::test_download_parse -x`   | Wave 0       |
| EXNS-02 | Weekend 404 returns None (not error)              | unit      | `pytest tests/test_exness_seeder.py::test_weekend_404 -x`      | Wave 0       |
| EXNS-03 | Registry filter returns only exness symbols       | unit      | `pytest tests/test_exness_seeder.py::test_registry_filter -x`  | Wave 0       |
| EXNS-04 | Parquet write produces valid 3-column file        | unit      | `pytest tests/test_exness_seeder.py::test_parquet_write -x`    | Wave 0       |
| EXNS-05 | Idempotent: skips already-cached days             | unit      | `pytest tests/test_exness_seeder.py::test_idempotent_skip -x`  | Wave 0       |
| EXNS-06 | State file written on completion                  | unit      | `pytest tests/test_exness_seeder.py::test_state_file -x`       | Wave 0       |
| EXNS-07 | 10 Exness symbols in symbols.toml                 | unit      | `pytest tests/test_exness_seeder.py::test_registry_entries -x` | Wave 0       |

### Sampling Rate

- **Per task commit:** `uv run --python 3.13 pytest tests/test_exness_seeder.py -x -q`
- **Per wave merge:** `mise run test-py`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tests/test_exness_seeder.py` -- covers EXNS-01 through EXNS-07
- [ ] `python/opendeviationbar/config/exness_seeder.py` -- ExnessSeederConfig

## Environment Availability

> This phase creates new systemd services deployed on bigblack. Development is on macOS.

| Dependency        | Required By                | Available          | Version | Fallback                |
| ----------------- | -------------------------- | ------------------ | ------- | ----------------------- |
| Python 3.13       | All scripts                | Yes (macOS dev)    | 3.13.x  | --                      |
| polars            | CSV parsing, Parquet write | Yes (existing dep) | --      | --                      |
| requests          | HTTP download              | Yes (existing dep) | --      | --                      |
| pydantic-settings | Config                     | Yes (existing dep) | --      | --                      |
| systemd           | Service deployment         | No (macOS)         | --      | Deploy on bigblack only |
| monit             | Monitoring                 | No (macOS)         | --      | Deploy on bigblack only |
| mimalloc          | Memory optimization        | No (macOS)         | --      | bigblack only           |

**Missing dependencies with no fallback:** None (all core deps available on dev machine).

**Missing dependencies with fallback:**

- systemd/monit/mimalloc: Not available on macOS dev machine. Service files are developed locally but only deployed/tested on bigblack via `mise run deploy:bigblack`.

## Open Questions

1. **Exness Timestamp Format Consistency**
   - What we know: Rust client handles both `Z`-suffix and bare ISO 8601 timestamps. CSV header is `"Exness","Symbol","Timestamp","Bid","Ask"`.
   - What's unclear: Whether the format varies across years (like Binance Vision ms->us switch). The Rust client tests only 2024 data.
   - Recommendation: Parse with lenient format. Strip `Z` suffix if present before Polars parsing.

2. **Exness Data Availability Start Date**
   - What we know: CONTEXT.md says default 2022-01-01. The `download_exness_forex.py` script also uses 2022-01-01.
   - What's unclear: Whether some instruments have data before 2022 or started after.
   - Recommendation: Use 2022-01-01 as default. Per-symbol `effective_start` in registry can override.

3. **TickStorage write_ticks() Behavior Without agg_trade_id**
   - What we know: `write_ticks()` has a "no trade ID column, overwrite" fallback path (line 310-312). But the validation gate at line 268/316 calls `validate_trade_id_continuity(write_df)` which may fail.
   - What's unclear: Whether `validate_trade_id_continuity()` gracefully handles DataFrames without agg_trade_id column.
   - Recommendation: Bypass `write_ticks()` entirely. Use `_atomic_write_parquet()` directly for cleaner semantics. Exness daily files are complete and deterministic -- no merge/dedup needed.

## Sources

### Primary (HIGH confidence)

- `scripts/tick_cache_seeder.py` -- Binance seeder reference implementation (verified line by line)
- `crates/opendeviationbar-providers/src/exness/client.rs` -- Rust ExnessFetcher, URL pattern, CSV format
- `crates/opendeviationbar-providers/src/exness/types.rs` -- ExnessInstrument enum (10 instruments)
- `python/opendeviationbar/storage/parquet.py` -- TickStorage API, write_ticks behavior
- `python/opendeviationbar/storage/parquet_io.py` -- \_atomic_write_parquet, ZSTD-3 constants
- `python/opendeviationbar/config/seeder.py` -- SeederConfig pattern (pydantic-settings)
- `scripts/systemd/opendeviationbar-seeder.service` -- systemd oneshot template
- `scripts/systemd/opendeviationbar-seeder.timer` -- timer template
- `deploy/monit/opendeviationbar.conf` -- monit configuration (10 monitors)
- `python/opendeviationbar/data/symbols.toml` -- registry schema
- `02-CONTEXT.md` -- all locked decisions

### Secondary (MEDIUM confidence)

- XAUUSD threshold analysis based on gold price characteristics and project threshold conventions

## Project Constraints (from CLAUDE.md)

- **Python 3.13 ONLY** -- never use 3.14 or any other version. `uv run --python 3.13`.
- **Local-First CI/CD** -- all quality gates run locally via `mise run check-full`. No GitHub Actions for testing.
- **ClickHouse COMMENT = SSoT** -- not relevant for this phase (no CH schema changes).
- **DST-correct sessions** -- use `zoneinfo` with IANA timezone names, never fixed UTC hours for forex session detection. Relevant for future bar construction, not this phase.
- **Thread-local connection reuse** -- never create DB connections in a loop. Not directly relevant (seeder doesn't use CH).
- **Leverage Rust** -- always use Rust crates for heavy lifting, Python handles I/O only. For this phase, the "heavy lifting" (bar construction) is deferred. The seeder is pure I/O (download + write).
- **Kintsugi-first gap filling** -- not relevant for this phase (deferred).
- **Symbol Registry Gate** -- every symbol must be registered before processing. The seeder reads FROM the registry.
- **CLAUDE.md hub-and-spoke network** -- every new directory gets a CLAUDE.md. New script files get module docstrings.

## Metadata

**Confidence breakdown:**

- Standard stack: HIGH - all libraries are already used in the project
- Architecture: HIGH - direct mirror of battle-tested Binance seeder
- Pitfalls: HIGH - derived from actual code analysis of existing seeder + Exness Rust client
- Thresholds: MEDIUM - XAUUSD thresholds based on price analysis, not empirical bar count verification

**Research date:** 2026-03-23
**Valid until:** 2026-04-23 (stable infrastructure phase, no fast-moving dependencies)
