Skill: kingmadellc/portfolio-drift-monitor (version 1.1.4)

--- name: Portfolio Drift Monitor description: "Real-time Kalshi portfolio drift alerts — monitors positions and fires when any moves beyond your configured threshold since last check. Snapshot comparison, rate-limited checking, directional emoji indicators. Schedule hourly to catch position swings between morning and evening briefs. Part of the OpenClaw Prediction Market Trading Stack — risk management layer that pairs with Kalshi Command Center and Market Morning Brief." --- # Portfolio Drift Monitor Real-time monitoring of Kalshi portfolio positions with configurable alerts when positions move significantly. Ideal for active traders tracking exposure, P&L swings, and position concentration. ## Overview The Portfolio Drift Monitor tracks your Kalshi positions and alerts when any position moves beyond your configured threshold percentage since the last check. It: - **Compares snapshots**: Current positions vs. last recorded snapshot - **Detects drift**: Flags positions that moved ≥ threshold (default 5%) - **Rate-limits checks**: Prevents excessive API calls (configurable, default 60min interval) - **Formats alerts**: Emoji indicators (📈/📉), directional arrows, price changes - **Persistent state**: Stores snapshots locally at `~/.openclaw/state/portfolio_snapshot.json` ## When to Use This Skill - You hold Kalshi positions overnight and want alerts if something moves sharply - You want hourly drift checks between your morning and evening briefs - You manage multiple positions and need to catch concentration risk early - You want a lightweight risk monitoring layer that runs on a schedule without manual checking ## Prerequisites - **kalshi_python SDK**: Install via `pip install kalshi-python` - **Kalshi API credentials**: `KALSHI_KEY_ID` and `KALSHI_KEY_PATH` environment variables - **API key file**: Located at path specified by `KALSHI_KEY_PATH` (typically `~/.kalshi/key.pem`) ## Configuration Set these environment variables or edit defaults in your environment: | Variable | Default | Purpose | |----------|---------|---------| | `KALSHI_KEY_ID` | (required) | Your Kalshi API key ID from dev.kalshi.com | | `KALSHI_KEY_PATH` | (required) | Path to your Kalshi private key file (ES256 PEM) | | `PORTFOLIO_DRIFT_THRESHOLD` | `5.0` | Percentage change to trigger alert (e.g., 5 = 5%) | | `PORTFOLIO_DRIFT_INTERVAL` | `60` | Minutes between checks (rate limiting, prevents API spam) | ## How to Trigger Use any of these phrases in conversation: - "check portfolio drift" - "portfolio alert" - "position monitor" - "drift check" - "kalshi positions" ## How It Works ### Step 1: Fetch Current Portfolio The skill calls Kalshi's API to get your current open positions: ```bash python scripts/portfolio_drift.py ``` This reads `KALSHI_KEY_ID` and `KALSHI_KEY_PATH` from environment, authenticates with Kalshi, and retrieves all open positions. ### Step 2: Load Previous Snapshot On first run, creates baseline snapshot. On subsequent runs, compares current positions with the last recorded snapshot stored at `~/.openclaw/state/portfolio_snapshot.json`. ### Step 3: Detect Drift For each position, calculates percent change in: - **Holdings** (share count) - **Unrealized P&L** (USD value) - **Price** (avg entry price vs. current) If any metric ≥ threshold, flags that position. ### Step 4: Format and Output Outputs drift alerts with: - Directional emoji: 📈 (up) or 📉 (down) - Position symbol and side (YES/NO contract) - Percentage change with sign (e.g., "+12.5%") - Time since last check ### Step 5: Update Snapshot Saves current portfolio as new baseline for next check. ## Output Format Example output for a position that drifted: ``` 📈 YES/TRUMP.25DEC → +12.5% (↑$1,250 P&L, ↑350 shares) Last check: 47 minutes ago 📉 NO/ELEX.HOUSEGOV → -8.3% (↓$425 unrealized P&L) Last check: 42 minutes ago No significant drift: KALSHI.USD, CRYPTO.BTC ``` ### Error Handling - **First run**: Creates baseline snapshot, no alerts - **API failure**: Logs error, doesn't update snapshot - **Rate limit hit**: Skips check if interval not elapsed - **No positions**: Outputs "Portfolio is empty" ## Command Examples ### Check portfolio now (bypassing rate limit) ```bash PORTFOLIO_DRIFT_THRESHOLD=3.0 python scripts/portfolio_drift.py ``` ### Check with custom threshold ```bash PORTFOLIO_DRIFT_THRESHOLD=10.0 python scripts/portfolio_drift.py ``` ### Check and see full state file ```bash python scripts/portfolio_drift.py && cat ~/.openclaw/state/portfolio_snapshot.json | python -m json.tool ``` ## Threshold Tuning | Profile | Threshold | Interval | Use Case | |---------|-----------|----------|----------| | **Tight** | 3% | 30 min | Active day trading, many positions | | **Standard** | 5% | 60 min | Default — balanced signal-to-noise | | **Loose** | 10% | 120 min | Long-term positions, fewer alerts | | **News-reactive** | 2% | 15 min | Event-driven trading (FOMC, elections) | ## OpenClaw Ecosystem Integration Risk management layer for the Prediction Market Trading Stack. Schedule hourly checks to catch position swings between Morning Briefs. **Scheduling:** ```yaml skills: portfolio-drift-monitor: enabled: true schedule: "0 * * * *" # Every hour timeout_seconds: 30 ``` | Connected Skill | How It Connects | |----------------|-----------------| | **Market Morning Brief** | Morning/evening briefs bookend your day; drift alerts fill the gaps | | **Kalshi Command Center** | Monitors positions opened through the command center | | **Kalshalyst** | Tracks exposure on positions flagged by contrarian analysis | **Install the complete stack:** ```bash clawhub install kalshalyst kalshi-command-center polymarket-command-center prediction-market-arbiter xpulse portfolio-drift-monitor market-morning-brief personality-engine ``` ## Performance & Cost - **Runtime:** <5 seconds per check - **API calls:** 1 (Kalshi portfolio read) - **Cost:** $0 (Kalshi free tier) - **State file:** ~2KB (`portfolio_snapshot.json`) ### Scaling 24 checks/day (hourly) = 24 Kalshi API calls. Well within free tier limits. ## Debugging Check logs and state: ```bash # View current snapshot cat ~/.openclaw/state/portfolio_snapshot.json | python -m json.tool # Run manually with debug output python -u scripts/portfolio_drift.py 2>&1 | head -50 # Verify credentials echo $KALSHI_KEY_ID ls -la $KALSHI_KEY_PATH ``` ## Agent Bug-Fix Protocol **CRITICAL**: When you (the agent) discover and fix a bug in any script during a conversation, you MUST write the fix to the source file on disk using file editing tools. Showing the fix in chat is NOT sufficient — the next scheduled run starts a fresh session and will not have your conversational fix. Checklist after finding a bug: 1. Edit the actual `.py` file on disk (e.g., `~/skills/portfolio-drift-monitor/scripts/portfolio_drift.py`) 2. Also update the installed copy at `~/.openclaw/skills/portfolio-drift-monitor/scripts/portfolio_drift.py` 3. Confirm both files are written before telling the user the fix is done 4. If the fix is significant, stage a git commit in `~/skills/` Never say "fixed" if you only demonstrated the fix in conversation output. ## Known Limitations - **Kalshi-only:** Currently monitors Kalshi positions only. Polymarket positions require separate tracking. - **Price-based drift:** Drift is calculated on mid-market price changes, not on volume or open interest shifts. - **No alerting transport:** Outputs to stdout. Your OpenClaw agent or a wrapper script handles delivery (iMessage, Slack, etc.). ## Additional Resources - For complete configuration reference, see [drift-config.md](references/drift-config.md) - For API details, consult [kalshi_python SDK docs](https://docs.kalshi.com) --- ## Feedback & Issues Found a bug? Have a feature request? Want to share results? - **GitHub Issues**: [github.com/kingmadellc/openclaw-prediction-stack/issues](https://github.com/kingmadellc/openclaw-prediction-stack/issues) - **X/Twitter**: [@KingMadeLLC](https://x.com/KingMadeLLC) Part of the **OpenClaw Prediction Stack** — the first prediction market skill suite on ClawHub.

path: README.md
content: # Portfolio Drift Monitor

Real-time Kalshi portfolio drift alerts — monitors positions and fires when any moves beyond your configured threshold.

## Install

```bash
clawhub install portfolio-drift-monitor
```

## Quick Start

1. Get a Kalshi API key at https://kalshi.com (Settings → API)
2. Set environment variables: `KALSHI_KEY_ID` and `KALSHI_KEY_PATH`
3. Run: `python scripts/portfolio_drift.py`
4. Schedule hourly for continuous monitoring

## Configuration

| Variable | Default | Purpose |
|----------|---------|---------|
| `PORTFOLIO_DRIFT_THRESHOLD` | `5.0` | Percentage change to trigger alert |
| `PORTFOLIO_DRIFT_INTERVAL` | `60` | Minutes between checks |

## Full Documentation

See [SKILL.md](SKILL.md) for complete documentation including threshold tuning profiles, ecosystem integration, and debugging.

## Part of the OpenClaw Prediction Market Trading Stack

```bash
clawhub install kalshalyst kalshi-command-center polymarket-command-center prediction-market-arbiter xpulse portfolio-drift-monitor market-morning-brief personality-engine
```

**Author**: KingMadeLLC


path: references/drift-config.md
content: # Portfolio Drift Monitor Configuration Reference

Complete configuration guide for the Portfolio Drift Monitor skill.

## Environment Variables

### Required Variables

#### `KALSHI_KEY_ID`
**Type:** String
**Required:** Yes
**Description:** Your Kalshi API key ID from dev.kalshi.com

**Where to find:**
1. Log in to https://dev.kalshi.com
2. Navigate to API keys
3. Copy the "Key ID" value

**Example:**
```bash
export KALSHI_KEY_ID="key_abcd1234"
```

#### `KALSHI_KEY_PATH`
**Type:** Path
**Required:** Yes
**Description:** Absolute path to your Kalshi private key file (ES256 PEM format)

**Where to find:**
1. When creating API key at dev.kalshi.com, download the private key
2. Save securely, typically at `~/.kalshi/key.pem`

**Example:**
```bash
export KALSHI_KEY_PATH="$HOME/.kalshi/key.pem"
```

**Security:** Keep this file private. Never commit to git. Restrict permissions:
```bash
chmod 600 ~/.kalshi/key.pem
```

### Optional Variables

#### `PORTFOLIO_DRIFT_THRESHOLD`
**Type:** Float
**Default:** `5.0`
**Unit:** Percentage (%)
**Description:** Minimum position change percentage to trigger an alert

**Behavior:**
- Position must change ≥ this percentage to generate alert
- Checks changes in: shares held, P&L value, avg entry price
- Uses maximum drift across all metrics

**Examples:**
```bash
# Alert on 3% change (more sensitive)
export PORTFOLIO_DRIFT_THRESHOLD="3.0"

# Alert on 10% change (less sensitive, fewer false alerts)
export PORTFOLIO_DRIFT_THRESHOLD="10.0"

# Alert on any change (very sensitive)
export PORTFOLIO_DRIFT_THRESHOLD="0.5"
```

**Recommended values:**
- Day traders / active monitoring: 2-3%
- Swing traders: 5-7%
- Position traders: 10-15%

#### `PORTFOLIO_DRIFT_INTERVAL`
**Type:** Integer
**Default:** `60`
**Unit:** Minutes
**Description:** Minimum time between checks for rate limiting

**Behavior:**
- If checked within interval, skips check and reports when next check available
- Prevents API spam and excessive polling
- When integrated with proactive agent, set interval on agent, not here

**Examples:**
```bash
# Check every 15 minutes (aggressive monitoring)
export PORTFOLIO_DRIFT_INTERVAL="15"

# Check every 2 hours (light monitoring)
export PORTFOLIO_DRIFT_INTERVAL="120"

# Check every time (no rate limiting, only for manual runs)
export PORTFOLIO_DRIFT_INTERVAL="0"
```

**Recommended values:**
- Proactive agent integration: 15-30 min (agent controls interval)
- Manual checks: 60+ min (prevents accidental spam)

## Configuration Methods

### Method 1: Environment Variables (Recommended for automated)

Set in shell profile or in launchd plist:

```bash
# ~/.zshrc or ~/.bash_profile
export KALSHI_KEY_ID="key_abc123"
export KALSHI_KEY_PATH="$HOME/.kalshi/key.pem"
export PORTFOLIO_DRIFT_THRESHOLD="5.0"
export PORTFOLIO_DRIFT_INTERVAL="60"
```

Reload shell:
```bash
source ~/.zshrc
```

### Method 2: Command line (For ad-hoc runs)

```bash
KALSHI_KEY_ID="key_abc123" \
KALSHI_KEY_PATH="$HOME/.kalshi/key.pem" \
PORTFOLIO_DRIFT_THRESHOLD="3.0" \
python scripts/portfolio_drift.py
```

### Method 3: Launchd plist (For automated scheduled checks)

Example plist for Mac launchd service (`com.portfolio-drift-monitor`):

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.portfolio-drift-monitor</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/python3</string>
        <string>/path/to/portfolio_drift.py</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
        <key>KALSHI_KEY_ID</key>
        <string>key_abc123</string>
        <key>KALSHI_KEY_PATH</key>
        <string>/Users/YOUR_USERNAME/.kalshi/key.pem</string>
        <key>PORTFOLIO_DRIFT_THRESHOLD</key>
        <string>5.0</string>
        <key>PORTFOLIO_DRIFT_INTERVAL</key>
        <string>60</string>
    </dict>
    <key>StartInterval</key>
    <integer>900</integer>
    <key>KeepAlive</key>
    <false/>
    <key>StandardOutPath</key>
    <string>/var/log/portfolio-drift.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/portfolio-drift.error</string>
</dict>
</plist>
```

## State File

### Location
`~/.openclaw/state/portfolio_snapshot.json`

### Purpose
Stores the last recorded portfolio state for drift comparison on next run.

### Structure
```json
{
  "timestamp": "2026-03-09T15:30:45.123456",
  "total_positions": 5,
  "positions": {
    "TRUMP.25DEC": {
      "side": "YES",
      "shares": 100,
      "avg_price": 0.65,
      "pnl": 25.00,
      "pnl_percent": 3.8,
      "risk": 35.00,
      "timestamp": "2026-03-09T15:30:45.123456"
    }
  }
}
```

### Auto-management
- Created automatically on first run
- Updated after each successful check
- Never manually edited (script maintains it)
- Safe to delete to reset baseline

## Drift Calculation Details

### What is "Drift"?

A position has drifted when its key metrics change significantly since last check.

### Metrics Checked

For each position, monitor:

1. **Share Count Drift**
   - Formula: `|current_shares - previous_shares| / previous_shares * 100`
   - Detects: Position size changes (scaling up/down, partial exits)

2. **P&L Drift**
   - Formula: `|current_pnl - previous_pnl| / previous_pnl * 100`
   - Detects: USD value changes (market movement, realized gains/losses)

3. **Price Drift**
   - Formula: `|current_avg_price - previous_avg_price| / previous_avg_price * 100`
   - Detects: Average entry price changes (adding at different prices)

### Alert Threshold

A position triggers an alert if **any** metric ≥ threshold percentage.

Example with 5% threshold:
- Position with 3% shares change + 4% P&L change → No alert (below 5%)
- Position with 3% shares change + 6% P&L change → Alert (6% > 5%)

## Output Format

### Drift Detected
```
🚨 Portfolio Drift Alert

📈 YES/TRUMP.25DEC → +12.5% (↑$1,250 P&L, ↑350 shares)
   Last check: 47 minutes ago

📉 NO/ELEX.HOUSEGOV → -8.3% (↓$425 unrealized P&L)
   Last check: 42 minutes ago

---
✓ Stable (3): KALSHI.USD, CRYPTO.BTC, ECON.UNEMP
```

### No Drift
```
✓ No drift detected (5 positions stable)
```

### First Run
```
✅ Baseline established: 5 positions recorded
```

### Rate Limited
```
Rate limited: next check in 47 minutes
```

### Error
```
ERROR: Failed to fetch portfolio from Kalshi: Connection timeout
```

## Troubleshooting

### "KALSHI_KEY_ID and KALSHI_KEY_PATH environment variables required"

**Fix:** Set environment variables before running:
```bash
export KALSHI_KEY_ID="key_abc123"
export KALSHI_KEY_PATH="$HOME/.kalshi/key.pem"
python scripts/portfolio_drift.py
```

### "Kalshi key file not found"

**Fix:** Verify key path exists and is readable:
```bash
ls -la $KALSHI_KEY_PATH
# Expected output: -rw------- YOUR_USER YOUR_USER 1234 ...
```

If file doesn't exist, download from dev.kalshi.com and save to path.

### "Failed to initialize Kalshi client: Authentication failed"

**Fix:** Verify API credentials are correct:
```bash
# Confirm values match what's in dev.kalshi.com
echo $KALSHI_KEY_ID
cat $KALSHI_KEY_PATH | head -1  # Should start with "-----BEGIN PRIVATE KEY-----"
```

### "Rate limited: next check in X minutes"

**Normal behavior** when checking within interval. Either:
- Wait for interval to elapse
- Manually bypass by setting `PORTFOLIO_DRIFT_INTERVAL=0`

### Position shows 0% drift but expected alert

**Possible causes:**
- Position hasn't changed since last check
- Change is below threshold percentage
- Threshold is too high — try lowering it

Check snapshot to debug:
```bash
cat ~/.openclaw/state/portfolio_snapshot.json | python -m json.tool
```

## Integration Examples

### With OpenClaw Scheduled Task

```yaml
skills:
  portfolio-drift-monitor:
    enabled: true
    schedule: "0 * * * *"  # Every hour
    timeout_seconds: 30
    env:
      KALSHI_KEY_ID: "${KALSHI_KEY_ID}"
      KALSHI_KEY_PATH: "${KALSHI_KEY_PATH}"
      PORTFOLIO_DRIFT_THRESHOLD: "5.0"
```

### Manual Monitoring Script

```bash
#!/bin/bash
# Run every hour manually
while true; do
  PORTFOLIO_DRIFT_INTERVAL=0 python scripts/portfolio_drift.py
  sleep 3600  # 1 hour
done
```

### With System Cron (macOS)

Add to `launchd` for recurring checks without daemon:

```bash
# Create plist as shown in Method 3 above
# Install:
launchctl load ~/Library/LaunchAgents/com.portfolio-drift-monitor.plist

# Check status:
launchctl list | grep portfolio

# Uninstall:
launchctl unload ~/Library/LaunchAgents/com.portfolio-drift-monitor.plist
```


path: requirements.txt
content: kalshi-python==1.0.0
pyyaml==6.0.3


path: scripts/portfolio_drift.py
content: #!/usr/bin/env python3
"""
Portfolio Drift Monitor for Kalshi

Monitors Kalshi portfolio positions for significant drift (position changes).
Alerts when any position moves beyond configured threshold percentage.

Environment variables:
  KALSHI_KEY_ID: API key ID from dev.kalshi.com
  KALSHI_KEY_PATH: Path to Kalshi private key file (PEM format)
  PORTFOLIO_DRIFT_THRESHOLD: Percentage change to trigger alert (default: 5.0)
  PORTFOLIO_DRIFT_INTERVAL: Minutes between checks for rate limiting (default: 60)

State file:
  ~/.openclaw/state/portfolio_snapshot.json - Stores last known portfolio state
"""

import os
import json
import sys
import argparse
import urllib.request
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, Any, List, Tuple

try:
    from kalshi_python_sync import Configuration, KalshiClient
    _KALSHI_SDK_AVAILABLE = True
except ImportError:
    try:
        from kalshi_python import Configuration, KalshiClient
        _KALSHI_SDK_AVAILABLE = True
    except ImportError:
        _KALSHI_SDK_AVAILABLE = False

try:
    import yaml
except ImportError:
    yaml = None


# ── Slack Notifications ──────────────────────────────────────────────────────
def _notify_slack(message: str) -> None:
    """Send a notification to Slack webhook.

    Reads OPENCLAW_SLACK_WEBHOOK env var first, then falls back to
    slack_webhook_url from ~/.openclaw/config.yaml. If no webhook is
    configured, prints to stdout and returns silently.

    Args:
        message: The message text to send
    """
    # Try env var first
    webhook_url = os.getenv("OPENCLAW_SLACK_WEBHOOK")

    # Fall back to config.yaml
    if not webhook_url:
        config_path = Path.home() / ".openclaw" / "config.yaml"
        if config_path.exists() and yaml:
            try:
                with open(config_path) as f:
                    config = yaml.safe_load(f) or {}
                    webhook_url = config.get("slack_webhook_url") or config.get("slack", {}).get("webhook_url")
            except Exception:
                pass

    # No webhook configured — just print to stdout
    if not webhook_url:
        print(f"[Slack] {message}")
        return

    # POST to webhook (catch all exceptions to never crash the monitor)
    try:
        payload = json.dumps({"text": message})
        req = urllib.request.Request(
            webhook_url,
            data=payload.encode("utf-8"),
            headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=5) as response:
            response.read()
    except Exception:
        # Notification failure should never crash the monitor
        pass


# ── API Schema Normalization ────────────────────────────────────────────────
def _normalize_position(p: dict) -> dict:
    """Normalize Kalshi API v3 position fields.

    Kalshi API v3 returns event_positions with different field names than
    the market-level positions the parser expects. This maps event-level
    fields to the canonical names used downstream.
    """
    normalized = dict(p)
    if "ticker" not in normalized and "market_ticker" not in normalized:
        if "event_ticker" in normalized:
            normalized["ticker"] = normalized["event_ticker"]
    if "position_fp" not in normalized and "position" not in normalized:
        if "total_cost_shares_fp" in normalized:
            normalized["position_fp"] = normalized["total_cost_shares_fp"]
    if "realized_pnl" not in normalized and "pnl" not in normalized:
        if "realized_pnl_dollars" in normalized:
            normalized["realized_pnl"] = normalized["realized_pnl_dollars"]
    if "market_exposure_dollars" in normalized:
        normalized["exposure"] = normalized["market_exposure_dollars"]
    if "total_cost_dollars" in normalized:
        normalized["total_cost"] = normalized["total_cost_dollars"]
    return normalized


class PortfolioDriftMonitor:
    """Monitors Kalshi portfolio drift with rate limiting and threshold alerts."""

    def __init__(self):
        """Initialize monitor with configuration from config.yaml (env vars as fallback)."""
        if not _KALSHI_SDK_AVAILABLE:
            raise RuntimeError(
                "Kalshi SDK not installed.\n"
                "Install with: pip install kalshi-python-sync\n"
                "Or: pip install kalshi-python"
            )

        # Load from config.yaml first, env vars as fallback
        config = self._load_config()
        kalshi_cfg = config.get("kalshi", {})

        self.key_id = kalshi_cfg.get("api_key_id") or os.getenv("KALSHI_KEY_ID")
        self.key_path = kalshi_cfg.get("private_key_file") or os.getenv("KALSHI_KEY_PATH")
        self.threshold_pct = float(os.getenv("PORTFOLIO_DRIFT_THRESHOLD", "5.0"))
        self.interval_minutes = int(os.getenv("PORTFOLIO_DRIFT_INTERVAL", "60"))

        # Validate credentials with clear guidance
        if not self.key_id or not self.key_path:
            raise ValueError(
                "Kalshi credentials not configured.\n"
                "\n"
                "Add to ~/.openclaw/config.yaml:\n"
                "  kalshi:\n"
                "    enabled: true\n"
                "    api_key_id: YOUR_KEY_ID\n"
                "    private_key_file: keys/private.key\n"
                "\n"
                "Or set env vars: KALSHI_KEY_ID + KALSHI_KEY_PATH\n"
                "Get API keys at: https://kalshi.com/account/api"
            )

        if not os.path.exists(self.key_path):
            raise FileNotFoundError(
                f"Kalshi private key file not found: {self.key_path}\n"
                "Download your private key from https://kalshi.com/account/api\n"
                "and save it to the path configured in config.yaml."
            )

        # State file location
        self.state_dir = Path.home() / ".openclaw" / "state"
        self.state_file = self.state_dir / "portfolio_snapshot.json"
        self.state_dir.mkdir(parents=True, exist_ok=True)

        # Initialize Kalshi client (v3 SDK pattern)
        try:
            with open(self.key_path) as f:
                private_key = f.read()

            config_obj = Configuration(
                host="https://api.elections.kalshi.com/trade-api/v2"
            )
            config_obj.api_key_id = self.key_id
            config_obj.private_key_pem = private_key
            self.client = KalshiClient(config_obj)
        except Exception as e:
            raise RuntimeError(f"Failed to initialize Kalshi client: {e}")

    @staticmethod
    def _load_config() -> dict:
        """Load config from ~/.openclaw/config.yaml."""
        config_path = Path.home() / ".openclaw" / "config.yaml"
        if config_path.exists() and yaml:
            with open(config_path) as f:
                return yaml.safe_load(f) or {}
        return {}

    def get_current_portfolio(self) -> Dict[str, Any]:
        """
        Fetch current portfolio from Kalshi API using raw API call.

        Uses _portfolio_api.get_positions_without_preload_content() to avoid
        SDK v3 pydantic deserialization bugs (typed get_positions() returns
        None attributes for position fields, causing false empty portfolios).

        Returns:
            Dict with portfolio metadata and positions indexed by market symbol
        """
        try:
            # Raw API call — avoids SDK pydantic deserialization bug (Issue #9)
            # SDK typed get_positions() returns broken pydantic objects with None fields
            resp = self.client._portfolio_api.get_positions_without_preload_content(limit=100)
            raw_data = json.loads(resp.read())

            # Schema validation
            _KNOWN_POS_KEYS = ("event_positions", "positions", "market_positions")
            _EMPTY_ONLY_KEYS = {"cursor"}  # Kalshi returns only cursor when portfolio is empty

            if raw_data and not any(k in raw_data for k in _KNOWN_POS_KEYS):
                if not set(raw_data.keys()) <= _EMPTY_ONLY_KEYS:
                    raise RuntimeError(
                        f"SCHEMA DRIFT: Kalshi API response has none of the expected position keys. "
                        f"Got: {sorted(raw_data.keys())}. Expected one of: {_KNOWN_POS_KEYS}. "
                        f"Kalshi changed their API — fix field names in portfolio_drift.py."
                    )

            raw_positions = (
                raw_data.get("event_positions")
                or raw_data.get("positions")
                or raw_data.get("market_positions")
                or []
            )

            # Index by ticker, filter out settled/zero-exposure positions
            positions = {}
            for pos in raw_positions:
                pos = _normalize_position(pos)
                ticker = pos.get("ticker") or pos.get("market_ticker") or "unknown"
                side = pos.get("side", "unknown")
                _pos_val = pos.get("position_fp") or pos.get("position") or pos.get("total_traded") or pos.get("shares", 0)
                try:
                    shares = float(_pos_val or 0)
                except (ValueError, TypeError):
                    shares = 0.0
                try:
                    exposure = float(pos.get("exposure", -1) or -1)
                except (ValueError, TypeError):
                    exposure = -1
                if shares == 0.0 and exposure == 0.0:
                    continue
                if shares == 0.0 and exposure < 0:
                    continue
                avg_price = float(pos.get("average_price", pos.get("avg_price", 0)) or 0)
                pnl = float(pos.get("realized_pnl", pos.get("pnl", 0)) or 0)
                total_cost = float(pos.get("total_cost", 0) or 0)
                if avg_price == 0.0 and shares > 0 and total_cost > 0:
                    avg_price = round(total_cost / shares, 4)
                positions[ticker] = {
                    "side": side,
                    "shares": shares,
                    "exposure": exposure if exposure >= 0 else 0.0,
                    "avg_price": avg_price,
                    "pnl": pnl,
                    "pnl_percent": 0.0,
                    "risk": 0.0,
                    "timestamp": datetime.utcnow().isoformat()
                }
            return {
                "timestamp": datetime.utcnow().isoformat(),
                "positions": positions,
                "total_positions": len(positions)
            }
        except Exception as e:
            raise RuntimeError(f"Failed to fetch positions from Kalshi: {e}")

    def load_portfolio_snapshot(self) -> Dict[str, Any]:
        """
        Load last saved portfolio snapshot.

        Returns:
            Previous portfolio state, or empty dict if no snapshot exists
        """
        if not self.state_file.exists():
            return {}

        try:
            with open(self.state_file, "r") as f:
                return json.load(f)
        except Exception as e:
            print(f"WARNING: Failed to load snapshot: {e}")
            return {}

    def save_portfolio_snapshot(self, portfolio: Dict[str, Any]) -> None:
        """
        Save current portfolio as baseline for next check.

        Args:
            portfolio: Current portfolio state from get_current_portfolio()
        """
        try:
            with open(self.state_file, "w") as f:
                json.dump(portfolio, f, indent=2)
        except Exception as e:
            print(f"ERROR: Failed to save snapshot: {e}")

    def should_check_now(self, last_snapshot: Dict[str, Any]) -> bool:
        """
        Check if enough time has elapsed for next check (rate limiting).

        Args:
            last_snapshot: Previous portfolio state

        Returns:
            True if interval has elapsed or no previous snapshot, False otherwise
        """
        if not last_snapshot or "timestamp" not in last_snapshot:
            return True

        try:
            last_time = datetime.fromisoformat(last_snapshot["timestamp"])
            elapsed = datetime.utcnow() - last_time
            return elapsed >= timedelta(minutes=self.interval_minutes)
        except Exception:
            return True

    def calculate_drift(self, current: Dict[str, Any], previous: Dict[str, Any]) -> List[Tuple[str, float, Dict[str, Any]]]:
        """
        Compare current and previous positions to detect drift.

        Args:
            current: Current portfolio from get_current_portfolio()
            previous: Previous portfolio snapshot

        Returns:
            List of (symbol, percent_change, details) tuples for positions exceeding threshold
        """
        current_pos = current.get("positions", {})
        prev_pos = previous.get("positions", {})

        drifted = []

        # Check all positions (current and previous)
        all_symbols = set(current_pos.keys()) | set(prev_pos.keys())

        for symbol in all_symbols:
            curr = current_pos.get(symbol, {})
            prev = prev_pos.get(symbol, {})

            # Calculate changes in key metrics
            changes = {}
            max_drift = 0.0

            # Shares drift
            curr_shares = curr.get("shares", 0)
            prev_shares = prev.get("shares", 0)
            if prev_shares != 0:
                share_drift = abs((curr_shares - prev_shares) / prev_shares * 100)
                changes["shares"] = (curr_shares - prev_shares, share_drift)
                max_drift = max(max_drift, share_drift)

            # P&L drift
            curr_pnl = curr.get("pnl", 0)
            prev_pnl = prev.get("pnl", 0)
            if prev_pnl != 0:
                pnl_drift = abs((curr_pnl - prev_pnl) / prev_pnl * 100)
                changes["pnl"] = (curr_pnl - prev_pnl, pnl_drift)
                max_drift = max(max_drift, pnl_drift)

            # Price drift (avg entry)
            curr_price = curr.get("avg_price", 0)
            prev_price = prev.get("avg_price", 0)
            if prev_price != 0:
                price_drift = abs((curr_price - prev_price) / prev_price * 100)
                changes["price"] = (curr_price - prev_price, price_drift)
                max_drift = max(max_drift, price_drift)

            # Flag if exceeds threshold
            if max_drift >= self.threshold_pct:
                drifted.append((symbol, max_drift, {
                    "current": curr,
                    "previous": prev,
                    "changes": changes
                }))

        return drifted

    def format_drift_alert(self, symbol: str, drift_pct: float, details: Dict[str, Any]) -> str:
        """
        Format drift detection as readable alert.

        Args:
            symbol: Market symbol
            drift_pct: Percentage change
            details: Position details with current/previous/changes

        Returns:
            Formatted alert string with emoji indicators
        """
        curr = details.get("current", {})
        prev = details.get("previous", {})
        changes = details.get("changes", {})

        # Determine direction
        curr_pnl = curr.get("pnl", 0)
        prev_pnl = prev.get("pnl", 0)
        direction_emoji = "📈" if curr_pnl >= prev_pnl else "📉"
        arrow = "↑" if curr_pnl >= prev_pnl else "↓"

        # Side indicator
        side = curr.get("side", "?")

        # Build change details
        change_strs = []

        if "pnl" in changes:
            pnl_change, _ = changes["pnl"]
            change_strs.append(f"{arrow}${abs(pnl_change):.0f} P&L")

        if "shares" in changes:
            share_change, _ = changes["shares"]
            change_strs.append(f"{arrow}{abs(share_change):.0f} shares")

        change_detail = ", ".join(change_strs) if change_strs else "position change"

        # Calculate minutes since last check
        try:
            prev_time = datetime.fromisoformat(prev.get("timestamp", ""))
            minutes_ago = int((datetime.utcnow() - prev_time).total_seconds() / 60)
            time_str = f"Last check: {minutes_ago} minutes ago"
        except Exception:
            time_str = "Last check: unknown time"

        return f"{direction_emoji} {side}/{symbol} → +{drift_pct:.1f}% ({change_detail})\n   {time_str}"

    def _preflight_schema_check(self) -> None:
        """Verify Kalshi API response schema before running drift check.

        Uses raw API call (not SDK typed method) to fetch 1 position and assert
        expected keys exist. Raises RuntimeError if schema has drifted.
        """
        try:
            resp = self.client._portfolio_api.get_positions_without_preload_content(limit=1)
            data = json.loads(resp.read())

            _KNOWN_POS_KEYS = ("event_positions", "positions", "market_positions")
            _EMPTY_ONLY_KEYS = {"cursor"}  # Kalshi returns only cursor when portfolio is empty
            if data and not any(k in data for k in _KNOWN_POS_KEYS):
                if set(data.keys()) <= _EMPTY_ONLY_KEYS:
                    return  # Empty portfolio, schema is fine
                raise RuntimeError(
                    f"SCHEMA DRIFT DETECTED: Kalshi API response contains none of the expected "
                    f"position keys {_KNOWN_POS_KEYS}. Got: {sorted(data.keys())}. "
                    f"Kalshi changed their API — update field names in portfolio_drift.py before "
                    f"the drift monitor silently reports empty portfolios."
                )
        except RuntimeError:
            raise  # re-raise schema drift errors
        except Exception as e:
            # Non-schema errors (auth, network) are handled downstream — don't block startup
            print(f"WARNING: Preflight schema check skipped: {e}")

    def run(self) -> None:
        """Execute portfolio drift check and output alerts."""
        # Preflight: verify API schema hasn't changed
        self._preflight_schema_check()

        # Load previous snapshot
        previous_snapshot = self.load_portfolio_snapshot()

        # Check rate limit — exit silently (no user-facing notification for non-events)
        if not self.should_check_now(previous_snapshot):
            return

        # Fetch current portfolio
        try:
            current_portfolio = self.get_current_portfolio()
        except Exception as e:
            print(f"ERROR: {e}")
            return

        # Handle empty portfolio
        if current_portfolio.get("total_positions", 0) == 0:
            msg = "Portfolio is empty — snapshot updated, no alerts."
            print(msg)
            _notify_slack(msg)
            self.save_portfolio_snapshot(current_portfolio)
            return

        # First run: establish baseline
        if not previous_snapshot:
            print(f"✅ Baseline established: {current_portfolio['total_positions']} positions recorded")
            self.save_portfolio_snapshot(current_portfolio)
            return

        # Detect drift
        drifted_positions = self.calculate_drift(current_portfolio, previous_snapshot)

        if drifted_positions:
            alert_lines = ["🚨 Portfolio Drift Alert\n"]
            for symbol, drift_pct, details in drifted_positions:
                alert_text = self.format_drift_alert(symbol, drift_pct, details)
                print(alert_text)
                alert_lines.append(alert_text)

            print("\n---")
            stable_count = current_portfolio["total_positions"] - len(drifted_positions)
            if stable_count > 0:
                stable_symbols = [
                    s for s in current_portfolio["positions"].keys()
                    if not any(s == sym for sym, _, _ in drifted_positions)
                ]
                stable_msg = f"✓ Stable ({stable_count}): {', '.join(stable_symbols[:5])}"
                print(stable_msg)
                alert_lines.append(stable_msg)

            # Send full alert to Slack
            _notify_slack("\n".join(alert_lines))
        else:
            stable_msg = f"✓ No drift detected ({current_portfolio['total_positions']} positions stable)"
            print(stable_msg)
            _notify_slack(stable_msg)

        # Update snapshot for next check
        self.save_portfolio_snapshot(current_portfolio)


def main():
    """Entry point."""
    parser = argparse.ArgumentParser(description="Portfolio Drift Monitor for Kalshi")
    parser.add_argument("--test-slack", action="store_true", help="Send a test Slack notification and exit")
    args = parser.parse_args()

    if args.test_slack:
        _notify_slack("PORTFOLIO-DRIFT TEST: Slack notification is working.")
        print("Sent test Slack notification.")
        return

    try:
        monitor = PortfolioDriftMonitor()
        monitor.run()
    except (ValueError, FileNotFoundError, RuntimeError) as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)
    except KeyboardInterrupt:
        print("\nInterrupted")
        sys.exit(130)
    except Exception as e:
        print(f"ERROR: Unexpected error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)