Skill: lk2023060901/okx-exchange (version 1.0.1)

--- name: okx-exchange description: > OKX quantitative trading agent. Supports spot, USDT-M perpetual swap, and coin-M futures. Strategies: grid trading, trend following (MA/RSI/MACD), spot-futures arbitrage. Use for: (1) market data queries, (2) account/portfolio management, (3) order execution, (4) automated strategy execution, (5) risk management, (6) performance reports, (7) algo orders (OCO/conditional TP+SL), (8) liquidation risk alerts. Triggers: OKX trading, buy/sell crypto, grid strategy, arbitrage, trend signal, check balance, check positions, place order, stop loss, take profit, BTC price, ETH price, crypto price, coin price, bitcoin price, change interval, change stop loss, change take profit, monitoring frequency, algo order, oco order, conditional order, batch order, liquidation risk. --- # OKX Exchange Skill Quantitative trading agent for OKX exchange via the official V5 API. **API Docs:** https://www.okx.com/docs-v5/en/ ## Setup (First Time) ```bash # 1. Add credentials to .env cat >> ~/.openclaw/workspace/.env << 'EOF' OKX_API_KEY=your_key OKX_SECRET_KEY=your_secret OKX_PASSPHRASE=your_passphrase OKX_SIMULATED=1 EOF # 2. Run setup cd ~/.openclaw/workspace/skills/okx-exchange/scripts python setup.py ``` **Important:** `OKX_SIMULATED=1` = paper trading (safe). Set to `0` for live trading. ## Session Init On every session, load credentials first: ```bash source ~/.openclaw/workspace/.env cd ~/.openclaw/workspace/skills/okx-exchange/scripts ``` ## Unified CLI (Recommended) Use `okx.py` as the single entry point for all commands: ```bash python3 okx.py help # Show all commands python3 okx.py account # Portfolio overview python3 okx.py buy BTC-USDT market 0.01 python3 okx.py trend analyze BTC-USDT-SWAP ``` All individual scripts remain usable directly, but `okx.py` is preferred for agent use. ## Instrument ID Format | Type | Format | Example | |------|--------|---------| | Spot | `{BASE}-{QUOTE}` | `BTC-USDT` | | Perpetual Swap | `{BASE}-{QUOTE}-SWAP` | `BTC-USDT-SWAP` | | Futures | `{BASE}-{QUOTE}-{EXPIRY}` | `BTC-USDT-250328` | `tdMode`: `cash` (spot), `cross` (cross-margin), `isolated` (isolated-margin) ## Account & Portfolio ```bash python3 okx.py account # Full portfolio summary python3 okx.py account balance # Balances only python3 okx.py account balance USDT # Specific currency python3 okx.py account positions # All positions python3 okx.py account orders # Pending orders python3 okx.py account history # Filled order history (SPOT) python3 okx.py account history SWAP # Perpetual swap order history python3 okx.py account history SPOT BTC-USDT # History for specific symbol ``` ## Trading Mode (Live / Demo) ```bash python3 okx.py mode # Show current mode python3 okx.py mode demo # Switch to paper trading (safe) python3 okx.py mode live # Switch to live trading (requires confirmation + LIVE credentials) ``` Live credentials use separate env vars: ``` OKX_API_KEY_LIVE / OKX_SECRET_KEY_LIVE / OKX_PASSPHRASE_LIVE ``` ## Order Execution ```bash # Spot market buy python3 okx.py buy BTC-USDT market 0.01 # Spot limit sell python3 okx.py sell BTC-USDT limit 0.01 --px 50000 # Perpetual swap — long with TP/SL (attached to the order) python3 okx.py buy BTC-USDT-SWAP market 1 --td cross --pos long --tp 55000 --sl 42000 # Perpetual swap — short python3 okx.py sell BTC-USDT-SWAP market 1 --td cross --pos short # Close position (reduce-only) python3 okx.py sell BTC-USDT-SWAP market 1 --td cross --pos long --reduce # Cancel order python3 okx.py cancel BTC-USDT <ord_id> # Cancel all orders for symbol python3 okx.py cancel-all BTC-USDT # Set leverage python3 okx.py leverage BTC-USDT-SWAP 10 --td cross # Transfer funds between accounts (6=Funding, 18=Trading) python3 okx.py transfer USDT 500 funding trading # Funding → Trading python3 okx.py transfer USDT 500 trading funding # Trading → Funding ``` Skip confirmation prompt (for automation): ```bash python3 okx.py buy BTC-USDT market 0.01 --no-confirm ``` ## Algo Orders (Standalone TP/SL — for existing positions) Use when a position is already open and you want to add a stop-loss or take-profit separately. Unlike `--tp/--sl` flags (which attach to the entry order), algo orders are independent. ```bash # OCO: TP + SL together — whichever triggers first cancels the other python3 okx.py algo oco BTC-USDT-SWAP 1 --tp 55000 --sl 45000 --td cross --pos long --reduce # Conditional: single stop-loss only python3 okx.py algo stop BTC-USDT-SWAP 1 --sl 45000 --td cross --reduce # List all pending algo orders python3 okx.py algo list python3 okx.py algo list BTC-USDT-SWAP # filter by instrument # Cancel a specific algo order python3 okx.py algo cancel BTC-USDT-SWAP <algo_id> ``` **When to use which:** - `--tp/--sl` at order time → atomic, guaranteed to attach, best for new entries - `algo oco` → add/replace TP+SL after entry, or adjust levels mid-trade ## Strategies ### Trend Following (MA + RSI + MACD) ```bash # Analyze only (no trade) python3 okx.py trend analyze BTC-USDT-SWAP --bar 1H # Run with auto-execution python3 okx.py trend run BTC-USDT-SWAP 1 --bar 4H --tp 0.05 --sl 0.03 --td cross --pos long # Dry run (analysis only, no trade) python3 okx.py trend run BTC-USDT-SWAP 1 --dry ``` Signal logic: - **BUY**: MA_fast > MA_slow + RSI < 70 + MACD histogram > 0 - **SELL**: MA_fast < MA_slow + RSI > 30 + MACD histogram < 0 ### Grid Trading ```bash # Setup grid: BTC-USDT, range 40000-50000, 10 grids, 1000 USDT total python3 okx.py grid setup BTC-USDT 40000 50000 10 1000 # Check and rebalance filled orders (run periodically) python3 okx.py grid check BTC-USDT # Stop and cancel all grid orders python3 okx.py grid stop BTC-USDT ``` ### Spot-Futures Arbitrage ```bash # Scan all pairs for opportunities python3 okx.py arb scan # Check specific pair basis python3 okx.py arb basis BTC-USDT BTC-USDT-SWAP # Open arbitrage position (buy spot + short swap) python3 okx.py arb open BTC-USDT BTC-USDT-SWAP 1000 --min-basis 0.1 # Close arbitrage position when basis converges python3 okx.py arb close BTC-USDT BTC-USDT-SWAP 0.01 1 --max-basis 0.02 ``` ## Snapshot Report Fetches live data from the API on every run, persists the snapshot, and outputs a full report with a **real historical tracking table**. ```bash python3 okx.py snapshot ``` Output includes: - Total equity, initial capital, % change (relative to first snapshot) - Available USDT, major holdings - Contract positions (entry price, mark price, unrealized PnL, liquidation distance) - Historical tracking table (from `memory/okx-monitor-snapshots.json`, up to 48 entries) **All figures are sourced directly from the OKX API — no estimates or fabricated values.** ## Automated Monitoring (Cron) ```bash # Default: sl-tp every 5 minutes, scan every 30 minutes bash scripts/cron_setup.sh setup # Custom intervals (dynamic parameters) bash scripts/cron_setup.sh setup 1m # sl-tp every 1 minute bash scripts/cron_setup.sh setup 10m 1h # sl-tp every 10 minutes, scan every 1 hour # Stop all jobs bash scripts/cron_setup.sh teardown # Show status bash scripts/cron_setup.sh status ``` Cron jobs: - `okx-sl-tp` — periodically runs `snapshot` + `monitor sl-tp`, reports live data - `okx-scan` — periodically runs `monitor scan`, reports strategy signals ### Adjust cron interval (via user instruction) If the user asks to change the monitoring frequency (e.g. "change to every 10 minutes"): ```bash # Find the cron job ID first openclaw cron list # Then edit the interval (replace <id> with the okx-sl-tp job ID) openclaw cron edit <id> --every 10m # e.g. 1m, 5m, 15m, 30m, 1h ``` ### Manual monitor run ```bash python3 okx.py snapshot # Full account snapshot report (with history — recommended) python3 okx.py monitor # SL/TP check + strategy scan + liquidation risk python3 okx.py monitor sl-tp # Only SL/TP check python3 okx.py monitor scan # Only strategy scan python3 okx.py monitor liq-risk # Liquidation risk check (default: alert if within 10%) python3 okx.py monitor liq-risk 5 # Alert if mark price within 5% of liquidation price ``` ### CRITICAL: Cron reporting rules **Every cron report MUST call the scripts first. NEVER fabricate, estimate, or reuse previous numbers.** Recommended single command for cron: ```bash cd ~/.openclaw/workspace/skills/okx-exchange/scripts python3 okx.py snapshot python3 okx.py monitor sl-tp ``` `snapshot` automatically saves the snapshot and generates a report with historical tracking. Forward the output verbatim — do not add, remove, or rephrase any content. Report only what the scripts actually output. If a script returns an error, report the error — do not substitute with previously seen values. **Why this matters:** Each cron run is an isolated session with no memory of previous runs. Any numbers not fetched from the API in this session are either fabricated or stale. ## Performance Report ```bash python3 okx.py report daily # Today's P&L summary python3 okx.py report weekly # Last 7 days python3 okx.py report all # All-time ``` Output includes: trade count, win rate, total P&L, best/worst trade, and top-5 coin breakdown. ## Configuration View or update preferences without editing JSON directly: ```bash python3 okx.py prefs show python3 okx.py prefs set auto_trade true python3 okx.py prefs set stop_loss_pct 3.0 python3 okx.py prefs set watchlist BTC-USDT-SWAP,ETH-USDT-SWAP,SOL-USDT-SWAP ``` Full preferences schema (`~/.openclaw/workspace/memory/okx-trading-preferences.json`): ```json { "max_order_usd": 100, "max_leverage": 10, "price_impact_warn": 0.005, "price_impact_abort": 0.01, "require_confirm": true, "stop_loss_pct": 5.0, "take_profit_pct": 10.0, "auto_trade": false, "max_position_usd": 100, "max_daily_trades": 10, "default_sz": "0.01", "strategies": ["trend"], "watchlist": ["BTC-USDT-SWAP", "ETH-USDT-SWAP"] } ``` | Parameter | Default | Description | |-----------|---------|-------------| | `max_order_usd` | 100 | Max USD per single order | | `max_leverage` | 10 | Max allowed leverage | | `price_impact_warn` | 0.005 | Warn if market order impact > 0.5% | | `price_impact_abort` | 0.01 | Abort if market order impact > 1% | | `require_confirm` | true | Prompt before placing orders | | `stop_loss_pct` | 5.0 | Auto stop-loss at -X% unrealized PnL | | `take_profit_pct` | 10.0 | Auto take-profit at +X% unrealized PnL | | `auto_trade` | false | Execute trades automatically (no confirmation) | | `max_position_usd` | 100 | Max USD per position | | `max_daily_trades` | 10 | Max auto-trades per day | | `default_sz` | "0.01" | Default size for auto-trading | | `strategies` | ["trend"] | Active strategies: trend / arbitrage | | `watchlist` | [...] | Instruments for monitor scan | ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `OKX_API_KEY` | — | Demo API key | | `OKX_SECRET_KEY` | — | Demo API secret | | `OKX_PASSPHRASE` | — | Demo API passphrase | | `OKX_API_KEY_LIVE` | — | Live API key (used when `mode=live`) | | `OKX_SECRET_KEY_LIVE` | — | Live API secret | | `OKX_PASSPHRASE_LIVE` | — | Live API passphrase | | `OKX_SIMULATED` | `0` | Set `1` for paper trading (demo account) | | `OKX_WS` | `0` | Set `1` to enable WebSocket feed (real-time cache for tickers/candles/account/positions) | | `OKX_API_URL` | `https://www.okx.com` | Override API base URL | | `OKX_LOG_LEVEL` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | | `OKX_LOG_FORMAT` | `text` | `text` or `json` (structured logging) | | `OKX_CRON_MODE` | `0` | Set `1` to suppress INFO output (for cron) | ### WebSocket Mode (`OKX_WS=1`) When enabled, a background thread maintains real-time caches: - **Public feed**: tickers, candles — `OKXClient.ticker()` / `candles()` read from cache first - **Private feed**: account balances, positions, orders — `balance()` / `positions()` read from cache first - REST API is used as fallback when cache is empty or WS is disabled ## Trading Rules Before making any buy/sell decision, read and apply the principles in: ``` docs/trading-rules.md ``` These rules define how to observe the market, weigh signals, size positions, and learn from outcomes. They do not prescribe a fixed strategy — the agent is expected to adapt its approach based on what the market is actually showing. ## Safety Rules 1. **Always confirm before placing orders** unless `auto_trade=true` or `--no-confirm` 2. **Price impact check**: abort if >1%, warn if >0.5% 3. **Start with `OKX_SIMULATED=1`** — validate everything before going live 4. **Never print or log credentials** — load via `source .env` only 5. **Set stop-loss on every position** — meme coins and leveraged positions can move fast 6. **Daily trade limit** — auto-trading will stop when `max_daily_trades` is reached ## Memory Files | File | Purpose | |------|---------| | `memory/okx-trading-preferences.json` | Strategy config, risk parameters, and trading mode | | `memory/okx-trading-state.json` | Runtime state (daily trade count, last scan) | | `memory/okx-trading-journal.json` | Trade history from monitor (SL/TP closures, auto-entries) | | `memory/okx-trade-journal.json` | Trade history from learning system (signal analysis) | | `memory/okx-learning-model.json` | Learning model (win rates by coin/regime, optimal params) | | `memory/okx-grid-{inst_id}.json` | Grid state per instrument | ## Kill Switch ```bash # Stop all automated trading bash scripts/cron_setup.sh teardown # Or remove OKX_API_KEY from .env to prevent any API calls ```

path: README.md
content: **English** | [中文](./README_ZH.md)

---

# OKX Exchange Skill

A quantitative trading agent for OKX exchange — full automation across spot, perpetual swap, and futures via the official V5 API.

---

## Capabilities

| Category | Features |
|---|---|
| Market Data | Price, K-lines, ticker, technical indicators (MA / RSI / MACD) |
| Account | Balance, positions, pending orders, order history |
| Orders | Market/limit, hedged positions, TP/SL, batch orders, OCO algo orders |
| Strategies | Trend following, grid trading, spot-futures arbitrage |
| Risk Control | Liquidation alerts, daily trade limits, price impact checks, auto SL/TP |
| Monitoring | Account snapshots with history, cron automation, auto position close |
| Reports | Daily/weekly/all-time P&L, win rate, best/worst trade, per-coin breakdown |
| Learning | Auto-classify trades, identify success/failure patterns, optimize parameters |
| Mode Switch | Paper trading / live trading, isolated API keys per mode |

---

## Quick Start

### 1. Configure credentials

```bash
cat >> ~/.openclaw/workspace/.env << 'EOF'
# Demo / paper trading (safe default)
OKX_API_KEY=your_demo_key
OKX_SECRET_KEY=your_demo_secret
OKX_PASSPHRASE=your_demo_passphrase
OKX_SIMULATED=1

# Live trading (optional — only active when mode=live)
OKX_API_KEY_LIVE=your_live_key
OKX_SECRET_KEY_LIVE=your_live_secret
OKX_PASSPHRASE_LIVE=your_live_passphrase
EOF
```

> **`OKX_SIMULATED=1` enables paper trading (safe).** Validate everything here before switching to live.

### 2. Install dependencies

```bash
pip install -r requirements.txt
```

### 3. Initialize

```bash
cd ~/.openclaw/workspace/skills/okx-exchange/scripts
python3 setup.py
```

---

## Usage

All commands go through the unified entry point `okx.py`.

```bash
cd ~/.openclaw/workspace/skills/okx-exchange/scripts
source ~/.openclaw/workspace/.env
```

### Account

```bash
python3 okx.py account                    # Full portfolio summary
python3 okx.py account balance USDT       # Specific currency balance
python3 okx.py account positions          # Open positions
python3 okx.py account orders             # Pending orders
python3 okx.py account history SWAP       # Order history (perpetual swap)
```

### Order Execution

```bash
# Spot market buy
python3 okx.py buy BTC-USDT market 0.01

# Perpetual long with TP/SL attached
python3 okx.py buy BTC-USDT-SWAP market 1 --td cross --pos long --tp 55000 --sl 42000

# Close long position (reduce-only)
python3 okx.py sell BTC-USDT-SWAP market 1 --td cross --pos long --reduce

# Standalone OCO algo order (add/replace TP+SL on an existing position)
python3 okx.py algo oco BTC-USDT-SWAP 1 --tp 55000 --sl 45000 --td cross --pos long --reduce

# Cancel all orders for a symbol
python3 okx.py cancel-all BTC-USDT-SWAP
```

### Strategies

```bash
# Trend analysis (no trade)
python3 okx.py trend analyze BTC-USDT-SWAP --bar 1H

# Trend execution
python3 okx.py trend run BTC-USDT-SWAP 1 --bar 4H --tp 0.05 --sl 0.03 --td cross --pos long

# Grid trading — setup / rebalance / stop
python3 okx.py grid setup BTC-USDT 40000 50000 10 1000
python3 okx.py grid check BTC-USDT
python3 okx.py grid stop BTC-USDT

# Spot-futures arbitrage
python3 okx.py arb scan
python3 okx.py arb basis BTC-USDT BTC-USDT-SWAP
python3 okx.py arb open BTC-USDT BTC-USDT-SWAP 1000 --min-basis 0.1
```

### Snapshot & Monitoring

```bash
# Full account snapshot with historical tracking table
python3 okx.py snapshot

# Manual monitor commands
python3 okx.py monitor sl-tp        # SL/TP check — auto-close positions if triggered
python3 okx.py monitor scan         # Strategy scan across watchlist
python3 okx.py monitor liq-risk     # Liquidation risk alert (default: within 10%)

# Cron automation
bash scripts/cron_setup.sh setup            # Default: SL/TP every 5m, scan every 30m
bash scripts/cron_setup.sh setup 1m         # SL/TP every 1 minute
bash scripts/cron_setup.sh setup 10m 1h     # SL/TP every 10m, scan every 1h
bash scripts/cron_setup.sh teardown         # Stop all cron jobs
bash scripts/cron_setup.sh status           # Show running jobs
```

### Performance Report

```bash
python3 okx.py report daily     # Today's P&L
python3 okx.py report weekly    # Last 7 days
python3 okx.py report all       # All time
```

### Trading Mode

```bash
python3 okx.py mode             # Show current mode
python3 okx.py mode demo        # Switch to paper trading
python3 okx.py mode live        # Switch to live (requires confirmation)
```

### Preferences

```bash
python3 okx.py prefs show
python3 okx.py prefs set stop_loss_pct 3.0
python3 okx.py prefs set take_profit_pct 8.0
python3 okx.py prefs set auto_trade true
python3 okx.py prefs set watchlist BTC-USDT-SWAP,ETH-USDT-SWAP,SOL-USDT-SWAP
```

---

## File Structure

```
okx-exchange/
├── SKILL.md                        # Agent instructions & triggers
├── README.md                       # This file (English)
├── README_ZH.md                    # Chinese version
├── requirements.txt                # Python dependencies (requests)
├── .env.example                    # Credentials template
│
├── docs/
│   ├── trading-rules.md            # Trading principles & decision guidelines
│   ├── decision-engine-guide.md    # Decision engine usage
│   └── learning-system-data-management.md
│
└── scripts/
    ├── okx.py                      # Unified CLI entry point
    ├── okx_client.py               # OKX REST API wrapper
    ├── okx_ws_client.py            # WebSocket real-time feed
    ├── okx_decision.py             # Decision engine
    ├── okx_learning.py             # Learning system (pattern recognition)
    ├── monitor.py                  # Automated monitor (SL/TP, scan, liq-risk)
    ├── account.py                  # Account & position display
    ├── execute.py                  # Order execution & safety checks
    ├── report.py                   # Performance report generation
    ├── config.py                   # Config & state persistence
    ├── errors.py                   # Error type definitions
    ├── logger.py                   # Structured logging
    ├── setup.py                    # First-run initialization
    ├── cron_setup.sh               # Cron job management
    │
    ├── strategies/
    │   ├── trend.py                # Trend following (MA + RSI + MACD)
    │   ├── grid.py                 # Grid trading
    │   └── arbitrage.py            # Spot-futures arbitrage
    │
    └── tests/                      # Unit tests (371 total, 0 failures)
        ├── run_all.py
        ├── test_account.py
        ├── test_algo_batch.py
        ├── test_arbitrage.py
        ├── test_config.py
        ├── test_decision.py
        ├── test_errors.py
        ├── test_execute.py
        ├── test_grid.py
        ├── test_learning.py
        ├── test_mode.py
        ├── test_okx_client.py
        ├── test_position_risk.py
        ├── test_private_ws.py
        ├── test_report.py
        ├── test_snapshot.py
        ├── test_trend.py
        └── test_ws_client.py
```

### Memory Files (runtime data)

| File | Purpose |
|---|---|
| `memory/okx-trading-preferences.json` | Risk parameters, strategy config, trading mode |
| `memory/okx-trading-state.json` | Runtime state (daily trade count, last scan time) |
| `memory/okx-trading-journal.json` | Monitor trade log (auto SL/TP closures) |
| `memory/okx-trade-journal.json` | Learning system trade log (signal analysis) |
| `memory/okx-learning-model.json` | Learning model (win rates by coin/regime, optimal params) |
| `memory/okx-monitor-snapshots.json` | Account snapshot history (up to 48 entries) |
| `memory/okx-grid-{inst_id}.json` | Grid state per instrument |

---

## Security

- API keys are stored in `~/.openclaw/workspace/.env` — never published to ClawHub
- Live and demo keys are fully isolated; switching mode never mixes credentials
- Order confirmation is on by default (`require_confirm=true`); set `auto_trade=true` for automation
- Daily trade limit (`max_daily_trades`) prevents runaway auto-trading

---

## Run Tests

```bash
cd scripts
python3 tests/run_all.py
# 371 tests, 0 failures
```

---

## Requirements

- Python 3.9+
- `requests >= 2.31.0`
- OKX account + V5 API Key ([apply here](https://www.okx.com/account/my-api))

---

**English** | [中文](./README_ZH.md)


path: README_ZH.md
content: [English](./README.md) | **中文**

---

# OKX Exchange Skill

> OKX 量化交易 Agent，覆盖现货、永续合约和交割合约的全流程自动化。
>
> A quantitative trading agent for OKX exchange — full automation across spot, perpetual swap, and futures.

---

## 能力概览 · Capabilities

| 类别 · Category | 功能 · Features |
|---|---|
| 市场数据 Market Data | 价格、K线、行情、技术指标 (MA / RSI / MACD) |
| 账户管理 Account | 余额、持仓、挂单、历史成交 |
| 订单执行 Orders | 市价/限价、双向持仓、TP/SL、批量下单、OCO条件单 |
| 策略 Strategies | 趋势跟踪、网格交易、现货合约套利 |
| 风控 Risk | 强平预警、日交易限额、价格冲击检查、止损止盈自动执行 |
| 监控 Monitoring | 账户快照（含历史追踪）、cron 定时任务、SL/TP 自动平仓 |
| 绩效报告 Reports | 日/周/全期盈亏、胜率、最佳/最差交易、分币种统计 |
| 学习系统 Learning | 自动归因历史交易、识别成功/失败模式、优化参数 |
| 模式切换 Mode | 模拟盘 / 实盘一键切换，独立 API Key 隔离 |

---

## 快速开始 · Quick Start

### 1. 配置 API Key · Configure credentials

```bash
cat >> ~/.openclaw/workspace/.env << 'EOF'
# Demo / paper trading (safe default)
OKX_API_KEY=your_demo_key
OKX_SECRET_KEY=your_demo_secret
OKX_PASSPHRASE=your_demo_passphrase
OKX_SIMULATED=1

# Live trading (optional, used only when mode=live)
OKX_API_KEY_LIVE=your_live_key
OKX_SECRET_KEY_LIVE=your_live_secret
OKX_PASSPHRASE_LIVE=your_live_passphrase
EOF
```

> `OKX_SIMULATED=1` = 模拟盘（纸面交易，安全）。上实盘前务必在此验证所有功能。
>
> `OKX_SIMULATED=1` enables paper trading. Validate everything here before going live.

### 2. 安装依赖 · Install dependencies

```bash
pip install -r requirements.txt
```

### 3. 初始化 · Initialize

```bash
cd ~/.openclaw/workspace/skills/okx-exchange/scripts
python3 setup.py
```

---

## 使用方式 · Usage

所有命令通过统一入口 `okx.py` 调用。

All commands go through the unified entry point `okx.py`.

```bash
cd ~/.openclaw/workspace/skills/okx-exchange/scripts
source ~/.openclaw/workspace/.env
```

### 账户 · Account

```bash
python3 okx.py account               # 完整持仓概览 / Full portfolio summary
python3 okx.py account balance USDT  # 指定币种余额 / Specific currency balance
python3 okx.py account positions      # 合约持仓 / Open positions
python3 okx.py account orders         # 挂单 / Pending orders
python3 okx.py account history SWAP   # 历史成交 / Order history
```

### 下单 · Order Execution

```bash
# 现货市价买 / Spot market buy
python3 okx.py buy BTC-USDT market 0.01

# 合约做多，带止盈止损 / Perpetual long with TP/SL
python3 okx.py buy BTC-USDT-SWAP market 1 --td cross --pos long --tp 55000 --sl 42000

# 减仓平多 / Close long (reduce-only)
python3 okx.py sell BTC-USDT-SWAP market 1 --td cross --pos long --reduce

# OCO 条件单（独立止盈止损）/ Standalone OCO algo order
python3 okx.py algo oco BTC-USDT-SWAP 1 --tp 55000 --sl 45000 --td cross --pos long --reduce
```

### 策略 · Strategies

```bash
# 趋势分析 / Trend analysis
python3 okx.py trend analyze BTC-USDT-SWAP --bar 1H

# 网格交易 / Grid trading
python3 okx.py grid setup BTC-USDT 40000 50000 10 1000
python3 okx.py grid check BTC-USDT

# 套利扫描 / Arbitrage scan
python3 okx.py arb scan
python3 okx.py arb basis BTC-USDT BTC-USDT-SWAP
```

### 快照与监控 · Snapshot & Monitoring

```bash
# 实时账户快照（含历史追踪表）/ Live snapshot with historical tracking
python3 okx.py snapshot

# 手动监控 / Manual monitor
python3 okx.py monitor sl-tp      # 止损止盈检查 / SL/TP check
python3 okx.py monitor liq-risk   # 强平风险预警 / Liquidation risk alert

# 定时任务 / Cron automation
bash scripts/cron_setup.sh setup          # 默认间隔 / Default intervals
bash scripts/cron_setup.sh setup 1m       # SL/TP 每 1 分钟
bash scripts/cron_setup.sh setup 10m 1h   # SL/TP 10m, scan 1h
bash scripts/cron_setup.sh teardown       # 停止所有 / Stop all
```

### 绩效报告 · Performance Report

```bash
python3 okx.py report daily    # 今日盈亏 / Today's P&L
python3 okx.py report weekly   # 本周 / This week
python3 okx.py report all      # 全期 / All time
```

### 模式切换 · Trading Mode

```bash
python3 okx.py mode           # 查看当前模式 / Show current mode
python3 okx.py mode demo      # 切换模拟盘 / Switch to paper trading
python3 okx.py mode live      # 切换实盘（需确认）/ Switch to live (requires confirmation)
```

### 配置 · Preferences

```bash
python3 okx.py prefs show
python3 okx.py prefs set stop_loss_pct 3.0
python3 okx.py prefs set auto_trade true
python3 okx.py prefs set watchlist BTC-USDT-SWAP,ETH-USDT-SWAP,SOL-USDT-SWAP
```

---

## 目录结构 · File Structure

```
okx-exchange/
├── SKILL.md                        # Agent 指令 / Agent instructions
├── README.md                       # 本文件 / This file
├── requirements.txt                # Python 依赖 (requests)
├── .env.example                    # 环境变量模板 / Credentials template
│
├── docs/
│   ├── trading-rules.md            # 交易原则与决策规范 / Trading rules & decision principles
│   ├── decision-engine-guide.md    # 决策引擎使用说明 / Decision engine guide
│   └── learning-system-data-management.md  # 学习系统数据管理
│
└── scripts/
    ├── okx.py                      # 统一 CLI 入口 / Unified CLI entry point
    ├── okx_client.py               # OKX REST API 封装 / REST API wrapper
    ├── okx_ws_client.py            # WebSocket 实时数据 / WebSocket feed
    ├── okx_decision.py             # 决策引擎 / Decision engine
    ├── okx_learning.py             # 学习系统（模式识别）/ Learning system
    ├── monitor.py                  # 自动监控（SL/TP, scan, liq-risk）
    ├── account.py                  # 账户 & 持仓展示
    ├── execute.py                  # 订单执行 & 安全检查
    ├── report.py                   # 绩效报告生成
    ├── config.py                   # 配置 & 状态持久化
    ├── errors.py                   # 错误类型定义
    ├── logger.py                   # 结构化日志
    ├── setup.py                    # 首次初始化
    ├── cron_setup.sh               # Cron 定时任务管理
    │
    ├── strategies/
    │   ├── trend.py                # 趋势跟踪（MA + RSI + MACD）
    │   ├── grid.py                 # 网格交易
    │   └── arbitrage.py            # 现货合约套利
    │
    └── tests/                      # 单元测试（371 个）
        ├── run_all.py
        ├── test_account.py
        ├── test_algo_batch.py
        ├── test_arbitrage.py
        ├── test_config.py
        ├── test_decision.py
        ├── test_errors.py
        ├── test_execute.py
        ├── test_grid.py
        ├── test_learning.py
        ├── test_mode.py
        ├── test_okx_client.py
        ├── test_position_risk.py
        ├── test_private_ws.py
        ├── test_report.py
        ├── test_snapshot.py
        ├── test_trend.py
        └── test_ws_client.py
```

### Memory Files（运行时数据）

| 文件 · File | 用途 · Purpose |
|---|---|
| `memory/okx-trading-preferences.json` | 风控参数、策略配置、交易模式 |
| `memory/okx-trading-state.json` | 运行时状态（日交易计数、上次扫描时间）|
| `memory/okx-trading-journal.json` | 监控系统交易日志（SL/TP 自动平仓记录）|
| `memory/okx-trade-journal.json` | 学习系统交易日志（信号分析记录）|
| `memory/okx-learning-model.json` | 学习模型（各币种/市场状态胜率、最优参数）|
| `memory/okx-monitor-snapshots.json` | 账户快照历史（最多 48 条）|
| `memory/okx-grid-{inst_id}.json` | 各标的网格状态 |

---

## 安全说明 · Security

- API Key 存储于 `~/.openclaw/workspace/.env`，不会被发布到 ClawHub
- 实盘与模拟盘使用独立的 Key，互不影响
- 默认开启下单确认（`require_confirm=true`），自动化需显式设置 `auto_trade=true`
- 设有日交易限额（`max_daily_trades`）防止失控

---

## 运行测试 · Run Tests

```bash
cd scripts
python3 tests/run_all.py
# 371 tests, 0 failures
```

---

## 依赖 · Requirements

- Python 3.9+
- `requests >= 2.31.0`
- OKX 账户 + V5 API Key（[申请地址](https://www.okx.com/account/my-api)）

---

[English](./README.md) | **中文**


path: docs/decision-engine-guide.md
content: # Decision Engine Guide

## Overview

The decision engine combines historical JSON learning data to generate **trade decisions, scenario projections, and risk evaluations**.

| Feature | Description |
|---|---|
| **Lesson matching** | Checks for known avoid conditions before entering a trade |
| **Pattern recognition** | Identifies reusable successful patterns |
| **Scenario simulation** | Projects outcomes based on historical trade data |
| **Decision generation** | buy / sell / wait + confidence score + parameter suggestions |
| **Decision log** | Records every decision for post-trade review |

---

## Commands

### 1. Generate a trade decision

```bash
python3 okx_decision.py decision <coin> <signal> <market_regime> <price> [rsi]
```

**Examples:**
```bash
# BTC ranging market, SELL signal, price 67361, RSI 50
python3 okx_decision.py decision BTC-USDT-SWAP SELL ranging 67361 50

# ETH strong bull market, BUY signal, price 2235, RSI 45
python3 okx_decision.py decision ETH-USDT-SWAP BUY strong_bull 2235 45
```

**Sample output:**
```
============================================================
Decision
============================================================
Coin:          BTC-USDT-SWAP
Signal:        SELL
Market regime: ranging
Price:         67361.0
RSI:           50.0
------------------------------------------------------------
Decision:      WAIT
Confidence:    0%
------------------------------------------------------------
Warning: Avoid — lesson#signal_divergence: MA/MACD produced 61 consecutive false SELL signals in ranging market

Success patterns (2):
  - ETH-USDT-SWAP_BUY_strong_bull: win rate 80%, avg profit 8.50%
  - BUY signal in strong_bull returned +12.3%

Reasons:
  Ranging market — confidence weight reduced 50%
  Confidence below 30% — recommend wait

Suggested parameters:
  Position: 50 USDT
  Leverage: 3x
  Stop loss: 3.0%
  Take profit: 15.0%

Scenario simulation:
  Sample size: 25 historical trades
  Historical win rate: 58.0%
  Expected value: +12.50 USDT
  Bull case  (+30% probability): +15.0%
  Base case  (+50% probability): +2.5%
  Bear case  (+20% probability): -3.0%
============================================================
```

---

### 2. View decision history

```bash
python3 okx_decision.py summary [limit]
```

**Example:**
```bash
python3 okx_decision.py summary 10
```

**Output:**
```
Last 10 decisions:

2026-02-27T21:15:00 | BTC-USDT-SWAP   | WAIT   | 0%    | Ranging market — weight reduced
2026-02-27T21:00:00 | ETH-USDT-SWAP   | BUY    | 70%   | RSI 28.5 < 30 (oversold), strong bull + BUY signal aligned
2026-02-27T20:45:00 | SOL-USDT-SWAP   | AVOID  | 0%    | Failed pattern: win rate 25.0%
```

---

### 3. Scenario simulation

```bash
python3 okx_decision.py simulate <coin> <direction> <price> <position_usdt> <leverage> <sl> <tp>
```

**Example:**
```bash
python3 okx_decision.py simulate ETH-USDT-SWAP long 2235 50 3 0.03 0.15
```

**Output:**
```json
{
  "status": "simulated",
  "sample_size": 25,
  "historical_win_rate": "58.0%",
  "avg_win": "6.50%",
  "avg_loss": "-2.80%",
  "scenarios": {
    "bull_case":  { "probability": 0.3, "outcome": "+15.0%", "pnl_usdt": 22.5 },
    "base_case":  { "probability": 0.5, "outcome": "+2.5%",  "pnl_usdt": 3.75 },
    "bear_case":  { "probability": 0.2, "outcome": "-3.0%",  "pnl_usdt": -4.5 }
  },
  "expected_value_usdt": "8.40"
}
```

---

### 4. Check avoid conditions

```bash
python3 okx_decision.py avoid <coin> <signal> <market_regime>
```

**Example:**
```bash
python3 okx_decision.py avoid BTC-USDT-SWAP SELL ranging
```

**Output:**
```
Avoid: lesson#signal_divergence — MA/MACD produced 61 consecutive false SELL signals in ranging market
```

---

## Decision Logic

### Flow

```
1. Load JSON data (lessons, patterns, journal)
   ↓
2. Check avoid conditions (lessons.json)
   → match found → Decision: AVOID
   ↓
3. Check success patterns (patterns.json)
   → match found → confidence +30%
   ↓
4. RSI extremes
   → RSI < 30 → Decision: BUY,  confidence +30%
   → RSI > 70 → Decision: SELL, confidence +30%
   ↓
5. Market regime + signal alignment
   → strong_bull + BUY  → Decision: BUY,  confidence +40%
   → strong_bear + SELL → Decision: SELL, confidence +40%
   → ranging             → confidence ×50%
   ↓
6. Suggest parameters (based on historical optimals)
   ↓
7. Run scenario simulation (based on historical trades)
   ↓
8. Confidence < 30% → Decision: WAIT
   ↓
9. Log decision
```

---

### Confidence Calculation

| Factor | Weight |
|---|---|
| RSI extreme (< 30 or > 70) | +30% |
| Market regime + signal alignment | +40% |
| Successful pattern match (each) | +10–30% |
| Ranging market | ×50% |
| Avoid condition match | → AVOID |

**Decision thresholds:**
- ≥ 70%: High confidence — recommended to execute
- 30–70%: Medium confidence — can execute with caution
- < 30%: Low confidence — wait

---

## Data Files

| File | Purpose | How the engine uses it |
|---|---|---|
| `okx-learning-model.json` | Core learning model | Reads optimal parameters |
| `okx-trade-journal.json` | Trade records | Scenario simulation samples |
| `okx-lessons.json` | Learned lessons | Avoid condition checks |
| `okx-patterns.json` | Trade patterns | Success pattern matching |
| `okx-monitoring-log.json` | Monitor sessions | Market regime inference |
| `okx-decision-log.json` | Decision log | Records all decisions |

---

## Practical Examples

### Case 1: BTC ranging market, consecutive SELL signals

```bash
python3 okx_decision.py decision BTC-USDT-SWAP SELL ranging 67361 50
```

**Output:**
```
Decision:   AVOID
Confidence: 0%
Warning:    lesson#signal_divergence — 61 consecutive SELL signals, price moved +476 pts against signal
```

---

### Case 2: ETH strong bull market, oversold RSI

```bash
python3 okx_decision.py decision ETH-USDT-SWAP BUY strong_bull 2235 28
```

**Output:**
```
Decision:   BUY
Confidence: 85%
  RSI 28.0 < 30 (oversold)
  Strong bull + BUY signal aligned
  Success pattern: ETH_BUY_strong_bull win rate 80%

Suggested parameters:
  Position:    75 USDT (pattern bonus)
  Leverage:    3x
  Stop loss:   3.0%
  Take profit: 18.0% (pattern bonus)

Scenario simulation:
  Historical win rate: 80.0%
  Expected value: +18.50 USDT
```

---

### Case 3: SOL failed pattern

```bash
python3 okx_decision.py decision SOL-USDT-SWAP SELL ranging 84 45
```

**Output:**
```
Decision:   AVOID
Confidence: 0%
Warning:    Failed pattern — SOL SELL in ranging market win rate 25%
```

---

## Integration

Calling the decision engine from `okx.py`:

```python
from okx_decision import DecisionEngine

engine = DecisionEngine()
decision = engine.generate_decision(
    coin="BTC-USDT-SWAP",
    signal="BUY",
    market_regime="strong_bull",
    current_price=67000,
    rsi=35
)

if decision["decision"] == "buy" and decision["confidence"] > 0.7:
    execute_trade(decision["parameters"])
elif decision["decision"] == "avoid":
    print(f"Avoid: {decision['avoid_warning']}")
```

---

## Roadmap

1. **Backtesting** — validate decisions against historical price data
2. **A/B testing** — compare parameter combinations
3. **Auto-tuning** — adjust confidence weights based on decision accuracy
4. **Multi-timeframe** — combine 1H / 4H / 1D signals
5. **Sentiment overlay** — integrate external sentiment indicators


path: docs/learning-system-data-management.md
content: # OKX Learning System — Data Management

## Data File Overview

| File | Purpose | Size Control |
|------|---------|--------------|
| `okx-learning-model.json` | Core learning model | Fixed structure, ~2 KB |
| `okx-trade-journal.json` | Trade records | Up to 1,000 entries; older entries compressed |
| `okx-monitoring-log.json` | Monitor sessions | Up to 100 sessions |
| `okx-lessons.json` | Learned lessons | Grows continuously, ~1 KB per entry |
| `okx-patterns.json` | Trade patterns | Keyed by coin × signal × market regime |

---

## Data Compression Strategy

### 1. Trade Record Compression

```
Raw entry (per trade):
{
  "id": "abc123",
  "coin": "BTC-USDT-SWAP",
  "entry_price": 67000,
  "exit_price": 67500,
  "pnl_pct": 0.75,
  "pnl_usdt": 50,
  "hold_time_hours": 2.5,
  ...
}

Compressed (statistical summary):
{
  "period_start": "2026-02-27T20:00:00",
  "period_end": "2026-02-28T20:00:00",
  "total_trades": 50,
  "winning_trades": 32,
  "losing_trades": 18,
  "total_pnl_usdt": 234.5,
  "avg_win_pct": 4.2,
  "avg_loss_pct": -2.1,
  "compressed_at": "2026-03-06T20:00:00"
}
```

**Compression rules:**
- Keep the most recent **1,000 trades** in full detail
- When the count exceeds 1,000 → compress older entries into statistical summaries
- Raw data is deleted after compression; only summaries are retained

### 2. Monitor Session Compression

```
Each session records:
- Signal counts (BUY / SELL / HOLD)
- Price range (high / low / open / close)
- Average RSI
- Detected market regime
- Strategy effectiveness evaluation

Retention policy:
- Keep the most recent 100 sessions in full detail
- Beyond 100 → merge into daily summaries
```

### 3. Time-Based Cleanup

| Data Age | Action |
|----------|--------|
| 0–7 days | Keep full detail |
| 7–30 days | Compress to statistical summary |
| 30–90 days | Retain core patterns only |
| 90+ days | Delete |

---

## Lesson Extraction

### 1. Automatic Lesson Capture

**Trigger conditions:**
```python
# Large loss (> 5%)
if pnl_pct < -5:
    lesson = {
        "type": "large_loss",
        "lesson": f"Lost {pnl_pct:.1f}% using {signal} signal in {market_regime} market",
        "action": f"Tighten stop-loss for {coin} or reduce weight for this signal",
        "avoid_condition": {"coin": coin, "signal": signal, "market_regime": market_regime}
    }

# Large win (> 10%)
if pnl_pct > 10:
    lesson = {
        "type": "large_win",
        "lesson": f"Gained {pnl_pct:.1f}% using {signal} signal in {market_regime} market",
        "action": f"Increase position size for {coin} in this market regime",
        "replicate_condition": {...}
    }

# Quick win (< 1h hold, > 5% gain)
if hold_time < 1 and pnl_pct > 5:
    lesson = {
        "type": "quick_win",
        "lesson": f"{coin} achieved quick +{pnl_pct:.1f}% gain",
        "action": "This coin is well-suited for short-term strategies"
    }
```

### 2. Pattern Recognition

**Recognition dimensions:**
```
Pattern key = coin × signal type × market regime
Example: "BTC-USDT-SWAP_BUY_strong_bull"

Tracked metrics:
- Trade count
- Win rate
- Average P&L
- Max consecutive wins / losses

Pattern classifications:
- successful: win rate >= 60%
- failed: win rate <= 30%
- neutral: 30% < win rate < 60%
```

### 3. Applying Lessons

**Pre-trade checks:**
```python
# Check whether to avoid
should_avoid, reason = should_avoid_trade(coin, signal, market_regime)
if should_avoid:
    return f"Avoid trade: {reason}"

# Check optimal conditions
suggestions = get_optimal_conditions(coin, signal, market_regime)
if suggestions:
    return f"Recommended trade: {suggestions}"
```

---

## Error Avoidance System

### 1. Real-Time Checks

Automatically applied before every trade:
```
1. Check the lesson library
   → Is there a recorded loss under these exact conditions?

2. Check failed patterns
   → Is the win rate for this coin + signal + regime below 30%?

3. Validate parameters
   → Are the current SL/TP levels consistent with historical optima?
```

### 2. Error Classification

| Error Type | Detection | Avoidance Strategy |
|------------|-----------|-------------------|
| Signal divergence | Consecutive same-direction signals with price moving opposite | Reduce signal weight |
| Overtrading | Multiple losses in a short window | Enforce a cooldown period |
| Bad parameters | Stop-loss triggers too frequently | Dynamically widen stop-loss |
| Regime misread | Trend strategy applied in a ranging market | Improve market regime detection |

### 3. Lessons Persisted as JSON

```json
{
  "lesson_id": "lesson_001",
  "type": "signal_divergence",
  "observation": "46 consecutive SELL signals; price moved +418 points against signal",
  "conclusion": "MA/MACD generates consecutive false signals in a ranging market",
  "action_taken": "Reduced MA/MACD weight (50% → 30%)",
  "avoid_condition": {
    "market_regime": "ranging",
    "signal": "SELL",
    "timeframe": "1H"
  },
  "created_at": "2026-02-27T21:00:00"
}
```

---

## Replicating Successful Trades

### 1. Successful Pattern Store

```json
{
  "pattern_id": "pattern_eth_bull_001",
  "coin": "ETH-USDT-SWAP",
  "signal": "BUY",
  "market_regime": "strong_bull",
  "trades": 15,
  "wins": 12,
  "losses": 3,
  "win_rate": 0.80,
  "avg_pnl": 8.5,
  "pattern_type": "successful",
  "replicate_when": {
    "rsi_range": [30, 60],
    "macd_positive": true,
    "volume_above_avg": true
  }
}
```

### 2. Pattern Activation

When market conditions match a successful pattern:
```
- Automatically increase position size (e.g. 30 USDT → 50 USDT)
- Widen take-profit target (e.g. 15% → 20%)
- Prioritize execution
```

### 3. Parameter Auto-Optimization

Based on historical performance:
```
If ETH win rate is 80% in bull markets:
  → Raise ETH position cap by 50%
  → Widen ETH stop-loss to 4%
  → Raise ETH take-profit to 20%

If LINK win rate is 25% in ranging markets:
  → Lower LINK position cap by 50%
  → Or avoid LINK trading entirely
```

---

## Learning System Workflow

```
Trade completes
    ↓
Record trade details
    ↓
Extract lessons → Update lesson library
    ↓
Update pattern stats → Identify successful / failed patterns
    ↓
Compress old data → Keep file sizes stable
    ↓
Before next trade: check lesson library and pattern store
    ↓
Adjust parameters and decisions based on historical experience
```

---

## Commands

```bash
# View learning status
python3 okx_learning.py status

# View recorded lessons
python3 okx_learning.py lessons

# View recognized patterns
python3 okx_learning.py patterns

# Check whether a trade should be avoided
python3 okx_learning.py check BTC-USDT-SWAP BUY strong_bull

# Analyze and suggest parameter optimizations
python3 okx_learning.py analyze

# Remove expired data
python3 okx_learning.py cleanup

# Manually compress old data
python3 okx_learning.py compress
```

---

## Storage Estimates

| File | Initial Size | Annual Growth | Est. After 5 Years |
|------|-------------|---------------|--------------------|
| learning-model.json | 2 KB | ~0 | 2 KB |
| trade-journal.json | 100 KB | ~500 KB (compressed) | 500 KB |
| monitoring-log.json | 50 KB | ~200 KB | 200 KB |
| lessons.json | 1 KB | ~50 KB (500 lessons) | 250 KB |
| patterns.json | 1 KB | ~20 KB (200 patterns) | 100 KB |
| **Total** | **~154 KB** | **~1 MB/year** | **~5 MB** |

With compression and cleanup, data growth is fully contained.

---

## Key Benefits

1. **No data bloat** — automatic compression and periodic cleanup
2. **Lessons accumulate** — lessons and patterns are retained permanently
3. **Errors are not repeated** — avoid conditions are checked before every trade
4. **Successes are replicated** — high-win-rate patterns are identified and reused
5. **Parameters self-optimize** — adjusted based on actual historical performance
6. **Fully auditable** — every decision is backed by historical evidence


path: docs/trading-rules.md
content: # Trading Rules

These are guiding principles for decision-making. They define **how to think**, not what to do.
The agent should observe the market, learn from outcomes, and continuously refine its own judgment.

---

## 1. Market Observation

Before any decision, understand the current market state:

- What is the trend across multiple timeframes (1H, 4H, 1D)? Are they aligned or conflicting?
- What are RSI, MACD, and moving averages indicating? Do they agree?
- What is the current trading volume relative to recent history?
- Is this a trending market or a ranging market? The answer changes how signals should be weighted.
- What is the funding rate for perpetual swaps? Extreme funding often precedes reversals.
- Are there signs of market exhaustion (e.g., strong trend with weakening volume)?

Observation is not about confirming a bias. It is about reading what the market is actually doing.

---

## 2. Decision Principles

- Act on evidence, not prediction. Do not assume what will happen; respond to what is happening.
- When signals across indicators and timeframes align, conviction is higher. When they conflict, reduce exposure or wait.
- A decision to wait is a valid decision. Forcing a trade when conditions are unclear is worse than doing nothing.
- Every trade should have a clear reason. If the reason cannot be articulated, the trade should not happen.
- Consider asymmetry: is the potential reward proportionate to the risk given current conditions?

---

## 3. Position Sizing

Size positions based on conviction derived from evidence, not from fixed rules:

- Higher alignment across signals and timeframes → can justify a larger position.
- Conflicting signals, uncertain regime, or recent consecutive losses → smaller position.
- The market will indicate how much confidence is warranted. Follow that, not a preset number.

---

## 4. Entry and Exit

- Enter when multiple indicators confirm the same direction. One signal alone is weak.
- Define an exit condition before entering. Know what would invalidate the trade thesis.
- Exit when the original reason for the trade no longer holds — not just because a price target is hit.
- If the market moves strongly against a position without a clear reason, that is information. Respond to it.

---

## 5. Self-Learning

After every trade — win or loss — ask:

- Was the entry based on sound reasoning at the time?
- What did the market do that was unexpected? Why?
- Did the exit happen for the right reason, or was it emotional?
- What would be done differently with the same setup?

Track recurring patterns in wins and losses. Over time, weight decisions toward conditions that have historically worked and away from conditions that have not.

---

## 6. Adaptation

- The market changes. A rule that worked in a trending market may fail in a ranging one.
- Do not rigidly apply past patterns. Use them as evidence, not as guarantees.
- If recent trades are consistently underperforming, do not increase size to recover. Step back, observe, and understand why before acting again.
- Be willing to change any assumption based on new evidence from the market.

---

## 7. Risk Awareness

Risk is not managed through fixed limits. It is managed through understanding:

- What is the realistic downside if this trade goes wrong?
- How does this position interact with existing positions? Does it concentrate or diversify risk?
- How liquid is this market? Can the position be exited quickly if needed?
- Is the current market environment (volatility, trend strength) suitable for the intended trade?

Risk awareness should be continuous, not a one-time check before entry.


path: requirements.txt
content: requests>=2.31.0


path: scripts/account.py
content: """Account & portfolio management"""
import json
from datetime import datetime, timezone
from logger import get_logger
from okx_client import OKXClient

log = get_logger("okx.account")


def get_balance(ccy: str = "") -> None:
    client = OKXClient()
    data = client.balance(ccy)
    if data.get("code") != "0":
        log.error(f"Error: {data.get('msg')}")
        return

    details = data["data"][0]["details"] if data["data"] else []
    total_usd = float(data["data"][0].get("totalEq", 0)) if data["data"] else 0

    log.info(f"\n{'='*50}")
    log.info(f"Total Equity: ${total_usd:,.2f} USD")
    log.info(f"{'='*50}")
    log.info(f"{'Currency':<12} {'Available':>14} {'Frozen':>14} {'USD Value':>14}")
    log.info("-" * 56)
    for d in details:
        if float(d.get("cashBal", 0)) > 0:
            log.info(f"{d['ccy']:<12} {float(d['cashBal']):>14.6f} "
                     f"{float(d.get('frozenBal','0')):>14.6f} "
                     f"${float(d.get('eqUsd','0')):>13.2f}")
    log.info("")


def get_positions(inst_type: str = "", inst_id: str = "") -> None:
    client = OKXClient()
    data = client.positions(inst_type, inst_id)
    if data.get("code") != "0":
        log.error(f"Error: {data.get('msg')}")
        return

    positions = data.get("data", [])
    if not positions:
        log.info("No open positions.")
        return

    log.info(f"\n{'='*70}")
    log.info("Open Positions")
    log.info(f"{'='*70}")
    log.info(f"{'Instrument':<20} {'Side':<8} {'Size':>10} {'Entry':>12} "
             f"{'Mark':>12} {'Unr.PnL':>12} {'Lev':>6}")
    log.info("-" * 82)
    for p in positions:
        side = p.get("posSide", p.get("side", "-"))
        def _f(val: str) -> float:
            return float(val) if val else 0.0
        log.info(f"{p['instId']:<20} {side:<8} "
                 f"{_f(p.get('pos','0')):>10.4f} "
                 f"{_f(p.get('avgPx','0')):>12.4f} "
                 f"{_f(p.get('markPx','0')):>12.4f} "
                 f"{_f(p.get('upl','0')):>12.4f} "
                 f"{p.get('lever',''):>6}x")
    log.info("")


def get_pending_orders(inst_id: str = "") -> None:
    client = OKXClient()
    data = client.pending_orders(inst_id)
    if data.get("code") != "0":
        log.error(f"Error: {data.get('msg')}")
        return

    orders = data.get("data", [])
    if not orders:
        log.info("No pending orders.")
        return

    log.info(f"\nPending Orders ({len(orders)})")
    log.info(f"{'Instrument':<20} {'Side':<6} {'Type':<8} {'Size':>10} {'Price':>12} {'Filled':>10}")
    log.info("-" * 72)
    for o in orders:
        log.info(f"{o['instId']:<20} {o['side']:<6} {o['ordType']:<8} "
                 f"{float(o['sz']):>10.4f} {float(o.get('px','0')):>12.4f} "
                 f"{float(o.get('fillSz','0')):>10.4f}")
    log.info("")


def get_order_history(inst_type: str = "SPOT", inst_id: str = "", limit: int = 20) -> None:
    client = OKXClient()
    data = client.order_history(inst_type, inst_id, limit)
    if data.get("code") != "0":
        log.error(f"Error: {data.get('msg')}")
        return

    orders = data.get("data", [])
    if not orders:
        log.info("No order history.")
        return

    log.info(f"\nOrder History — {inst_type} (last {len(orders)})")
    log.info(f"{'Time (UTC)':<16} {'Instrument':<20} {'Side':<6} {'Type':<8} "
             f"{'Size':>10} {'Avg Price':>12} {'Status':<10}")
    log.info("-" * 86)
    for o in orders:
        ts = o.get("uTime") or o.get("cTime", "0")
        try:
            dt = datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
        except (ValueError, OSError):
            dt = "?"
        avg_px = float(o.get("avgPx") or 0)
        log.info(f"{dt:<16} {o['instId']:<20} {o['side']:<6} {o['ordType']:<8} "
                 f"{float(o.get('sz','0')):>10.4f} {avg_px:>12.4f} {o.get('state','?'):<10}")
    log.info("")


def portfolio_summary() -> None:
    log.info("\n=== Portfolio Summary ===")
    get_balance()
    get_positions()
    get_pending_orders()


if __name__ == "__main__":
    import sys
    cmd = sys.argv[1] if len(sys.argv) > 1 else "summary"
    arg = sys.argv[2] if len(sys.argv) > 2 else ""

    if cmd == "balance":
        get_balance(arg)
    elif cmd == "positions":
        get_positions(arg)
    elif cmd == "orders":
        get_pending_orders(arg)
    elif cmd == "history":
        inst_type = arg.upper() if arg else "SPOT"
        inst_id = sys.argv[3] if len(sys.argv) > 3 else ""
        limit = int(sys.argv[4]) if len(sys.argv) > 4 else 20
        get_order_history(inst_type, inst_id, limit)
    else:
        portfolio_summary()


path: scripts/config.py
content: """OKX Exchange Skill — Unified configuration, state, and journal management"""
import json
import os

MEMORY_DIR = os.path.expanduser("~/.openclaw/workspace/memory")
PREFS_PATH = os.path.join(MEMORY_DIR, "okx-trading-preferences.json")
STATE_PATH = os.path.join(MEMORY_DIR, "okx-trading-state.json")
JOURNAL_PATH = os.path.join(MEMORY_DIR, "okx-trading-journal.json")

# Merged defaults from execute.py and monitor.py
DEFAULT_PREFS: dict = {
    # Order safety
    "max_order_usd": 100,
    "max_leverage": 10,
    "price_impact_warn": 0.005,
    "price_impact_abort": 0.01,
    "require_confirm": True,
    # Position management
    "stop_loss_pct": 5.0,
    "take_profit_pct": 10.0,
    "auto_trade": False,
    "max_position_usd": 100,
    "max_daily_trades": 10,
    # Strategy
    "strategies": ["trend"],
    "watchlist": ["BTC-USDT-SWAP", "ETH-USDT-SWAP"],
    "default_sz": "0.01",
}


def load_prefs() -> dict:
    """Load user preferences, merged over defaults so new keys are always present."""
    if os.path.exists(PREFS_PATH):
        with open(PREFS_PATH) as f:
            data = json.load(f)
        return {**DEFAULT_PREFS, **data}
    return dict(DEFAULT_PREFS)


def save_prefs(prefs: dict) -> None:
    os.makedirs(MEMORY_DIR, exist_ok=True)
    with open(PREFS_PATH, "w") as f:
        json.dump(prefs, f, indent=2)


def load_state() -> dict:
    if os.path.exists(STATE_PATH):
        with open(STATE_PATH) as f:
            return json.load(f)
    return {"last_scan": None, "daily_trades": 0, "last_reset": None}


def save_state(state: dict) -> None:
    os.makedirs(MEMORY_DIR, exist_ok=True)
    with open(STATE_PATH, "w") as f:
        json.dump(state, f, indent=2)


def log_trade(entry: dict) -> None:
    os.makedirs(MEMORY_DIR, exist_ok=True)
    journal: dict = {"trades": []}
    if os.path.exists(JOURNAL_PATH):
        with open(JOURNAL_PATH) as f:
            journal = json.load(f)
    journal["trades"].append(entry)
    with open(JOURNAL_PATH, "w") as f:
        json.dump(journal, f, indent=2)


def grid_state_path(inst_id: str) -> str:
    """Return path for the grid state file of a given instrument."""
    return os.path.join(MEMORY_DIR, f"okx-grid-{inst_id.replace('/', '-')}.json")


SNAPSHOTS_PATH = os.path.join(MEMORY_DIR, "okx-monitor-snapshots.json")
_MAX_SNAPSHOTS = 48  # keep last 48 entries


def load_snapshots() -> dict:
    """Load persisted monitor snapshots."""
    if os.path.exists(SNAPSHOTS_PATH):
        with open(SNAPSHOTS_PATH) as f:
            return json.load(f)
    return {"initial_equity": None, "snapshots": []}


def save_snapshot(snapshot: dict) -> None:
    """Append a snapshot and persist. Sets initial_equity on first write."""
    os.makedirs(MEMORY_DIR, exist_ok=True)
    data = load_snapshots()
    if data["initial_equity"] is None:
        data["initial_equity"] = snapshot.get("equity_usd")
    data["snapshots"].append(snapshot)
    data["snapshots"] = data["snapshots"][-_MAX_SNAPSHOTS:]
    with open(SNAPSHOTS_PATH, "w") as f:
        json.dump(data, f, indent=2)


path: scripts/cron_setup.sh
content: #!/bin/bash
# OKX Exchange Skill — Cron job manager (openclaw cron)
#
# Usage:
#   bash cron_setup.sh setup [sl_tp_interval] [scan_interval]
#   bash cron_setup.sh teardown
#   bash cron_setup.sh status
#
# Examples:
#   bash cron_setup.sh setup          # defaults: sl-tp=5m, scan=30m
#   bash cron_setup.sh setup 1m       # sl-tp=1m, scan=30m
#   bash cron_setup.sh setup 10m 1h   # sl-tp=10m, scan=1h

CMD=${1:-"setup"}
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"

if [ "$CMD" = "setup" ]; then
    SLTP_INTERVAL=${2:-"5m"}
    SCAN_INTERVAL=${3:-"30m"}

    echo "Setting up OKX trading cron jobs..."
    echo "  okx-sl-tp : every ${SLTP_INTERVAL}"
    echo "  okx-scan  : every ${SCAN_INTERVAL}"

    # Job 1: Snapshot + Stop-loss / Take-profit check
    openclaw cron add \
      --every "${SLTP_INTERVAL}" \
      --name "okx-sl-tp" \
      --target isolated \
      --message "你是 OKX 交易监控 Agent。执行以下命令并将输出原文发送，不要增删任何内容：

source ~/.openclaw/workspace/.env
cd ${SCRIPTS_DIR}
python3 okx.py snapshot
python3 okx.py monitor sl-tp

将两条命令的实际输出合并后发送。如果命令报错，直接报告错误原文。不要主动调用任何发送工具。"

    # Job 2: Strategy scan
    openclaw cron add \
      --every "${SCAN_INTERVAL}" \
      --name "okx-scan" \
      --target isolated \
      --message "你是 OKX 策略扫描 Agent。执行以下命令并将输出原文发送：

source ~/.openclaw/workspace/.env
cd ${SCRIPTS_DIR}
python3 okx.py monitor scan

将命令的实际输出发送。如果没有信号则发送'无交易信号'。如果命令报错，直接报告错误原文。不要主动调用任何发送工具。"

    echo ""
    echo "✅ Cron jobs added:"
    openclaw cron list 2>/dev/null | grep okx

elif [ "$CMD" = "teardown" ]; then
    echo "Removing OKX cron jobs..."
    openclaw cron list 2>/dev/null | grep okx | awk '{print $1}' | while read id; do
        openclaw cron rm "$id" 2>/dev/null && echo "  Removed: $id"
    done
    echo "✅ Done."

elif [ "$CMD" = "status" ]; then
    echo "Active OKX cron jobs:"
    openclaw cron list 2>/dev/null | grep -E "okx|ID" || echo "  (none)"

else
    echo "Usage: $0 [setup|teardown|status] [sl_tp_interval] [scan_interval]"
    echo ""
    echo "Examples:"
    echo "  $0 setup           # defaults: sl-tp=5m, scan=30m"
    echo "  $0 setup 1m        # sl-tp=1m, scan=30m"
    echo "  $0 setup 10m 1h    # sl-tp=10m, scan=1h"
fi


path: scripts/errors.py
content: """OKX Exchange Skill — Unified error hierarchy"""
from enum import IntEnum


class OKXErrorCode(IntEnum):
    """Common OKX V5 API error codes"""
    INVALID_KEY = 50111
    WRONG_ENV = 50101
    EMPTY_KEY = 50103
    INVALID_SIGN = 50113
    TIMESTAMP_EXPIRED = 50114
    RATE_LIMIT = 50011
    INSUFFICIENT_FUNDS = 51008
    ORDER_NOT_FOUND = 51503


class OKXBaseError(Exception):
    """Base class for all OKX Skill errors"""


class NetworkError(OKXBaseError):
    """Connection-level errors: SSL, timeout, connection refused"""


class APIError(OKXBaseError):
    """HTTP-level or OKX code != 0 errors"""
    def __init__(self, message: str, code: str = "", status: int = 0):
        super().__init__(message)
        self.code = code
        self.status = status


class BusinessError(OKXBaseError):
    """Business-logic violations: insufficient funds, order not found, etc."""
    def __init__(self, message: str, code: str = ""):
        super().__init__(message)
        self.code = code


class ArbHedgeFailedError(BusinessError):
    """Arbitrage hedge leg failed to execute"""
    def __init__(self, leg: str, message: str):
        super().__init__(f"Hedge failed on {leg} leg: {message}", code="ARB_HEDGE_FAIL")
        self.leg = leg


path: scripts/execute.py
content: """Order execution with safety checks"""
import sys
from config import load_prefs
from logger import get_logger
from okx_client import OKXClient

log = get_logger("okx.execute")


def check_price_impact(client: OKXClient, inst_id: str, side: str, sz: float) -> tuple[float, bool]:
    book = client.orderbook(inst_id, sz=20)
    if book.get("code") != "0":
        return 0.0, False

    asks = book["data"][0]["asks"]  # [[price, qty, ...], ...]
    bids = book["data"][0]["bids"]

    orders = asks if side == "buy" else bids
    mid = (float(asks[0][0]) + float(bids[0][0])) / 2

    filled = 0.0
    cost = 0.0
    for price, qty, *_ in orders:
        take = min(float(qty), sz - filled)
        cost += take * float(price)
        filled += take
        if filled >= sz:
            break

    avg = cost / filled if filled > 0 else float(orders[0][0])
    impact = abs(avg - mid) / mid
    return impact, filled >= sz


def place_order(inst_id: str, side: str, ord_type: str, sz: str,
                td_mode: str = "cash", px: str = "",
                pos_side: str = "", reduce_only: bool = False,
                tp: str = "", sl: str = "", no_confirm: bool = False) -> None:
    prefs = load_prefs()
    client = OKXClient()

    # Max order USD check (spot only; derivatives rely on leverage limit)
    if td_mode == "cash":
        ticker = client.ticker(inst_id)
        if ticker.get("code") == "0":
            price = float(ticker["data"][0].get("last", 0))
            order_usd = float(sz) * price
            max_usd = prefs.get("max_order_usd", 100)
            if order_usd > max_usd:
                log.error(f"Aborted: order value ${order_usd:.2f} exceeds max_order_usd=${max_usd}")
                return

    # Price impact check for market orders
    if ord_type == "market":
        impact, fillable = check_price_impact(client, inst_id, side, float(sz))
        warn = prefs["price_impact_warn"]
        abort = prefs["price_impact_abort"]

        if impact > abort:
            log.error(f"Aborted: price impact {impact*100:.2f}% > {abort*100:.1f}% limit")
            return
        if impact > warn:
            log.warning(f"High price impact: {impact*100:.2f}%")

    # Confirmation (if required)
    if prefs.get("require_confirm") and not no_confirm:
        log.info(f"\nOrder Preview:")
        log.info(f"  Instrument : {inst_id}")
        log.info(f"  Side       : {side.upper()}")
        log.info(f"  Type       : {ord_type}")
        log.info(f"  Size       : {sz}")
        if px:
            log.info(f"  Price      : {px}")
        if tp:
            log.info(f"  Take Profit: {tp}")
        if sl:
            log.info(f"  Stop Loss  : {sl}")
        confirm = input("\nConfirm? [y/N]: ").strip().lower()
        if confirm != "y":
            log.info("Cancelled.")
            return

    result = client.place_order(
        inst_id=inst_id,
        td_mode=td_mode,
        side=side,
        ord_type=ord_type,
        sz=sz,
        px=px,
        reduce_only=reduce_only,
        pos_side=pos_side,
        tp_trigger_px=tp,
        sl_trigger_px=sl,
    )

    if result.get("code") == "0":
        ord_id = result["data"][0]["ordId"]
        log.info(f"✅ Order placed: {ord_id}")
    else:
        log.error(f"Error: {result.get('msg')} — {result.get('data', [{}])[0].get('sMsg', '')}")


def cancel_order(inst_id: str, ord_id: str) -> None:
    client = OKXClient()
    result = client.cancel_order(inst_id, ord_id)
    if result.get("code") == "0":
        log.info(f"✅ Cancelled: {ord_id}")
    else:
        log.error(f"Error: {result.get('msg')}")


def cancel_all(inst_id: str) -> None:
    client = OKXClient()
    result = client.cancel_all_orders(inst_id)
    failed = result.get("failed", 0)
    log.info(f"✅ Cancelled {result['cancelled']} orders for {inst_id}" +
             (f" ({failed} failed)" if failed else ""))


def set_leverage(inst_id: str, lever: int, mg_mode: str = "cross") -> None:
    prefs = load_prefs()
    max_lev = prefs.get("max_leverage", 10)
    if lever > max_lev:
        log.error(f"Aborted: leverage {lever}x exceeds max_leverage={max_lev}x")
        return
    client = OKXClient()
    result = client.set_leverage(inst_id, lever, mg_mode)
    if result.get("code") == "0":
        log.info(f"✅ Leverage set to {lever}x for {inst_id}")
    else:
        log.error(f"Error: {result.get('msg')}")


_ACCT_NAMES = {"6": "Funding", "18": "Trading"}


def transfer_funds(ccy: str, amt: str, from_acct: str, to_acct: str) -> None:
    """Transfer funds between OKX sub-accounts. from/to: 6=Funding, 18=Trading."""
    client = OKXClient()
    result = client.transfer(ccy, amt, from_acct, to_acct)
    if result.get("code") == "0":
        trans_id = result["data"][0].get("transId", "?")
        from_name = _ACCT_NAMES.get(from_acct, f"Acct {from_acct}")
        to_name = _ACCT_NAMES.get(to_acct, f"Acct {to_acct}")
        log.info(f"✅ Transfer submitted: {amt} {ccy} | {from_name} → {to_name} | ID: {trans_id}")
    else:
        log.error(f"Transfer failed: {result.get('msg')}")


if __name__ == "__main__":
    """
    Usage:
      python execute.py buy BTC-USDT market 0.01
      python execute.py buy BTC-USDT-SWAP market 1 --td isolated --pos long --sl 40000 --tp 50000
      python execute.py sell BTC-USDT limit 0.01 --px 45000
      python execute.py cancel BTC-USDT <ord_id>
      python execute.py cancel-all BTC-USDT
      python execute.py leverage BTC-USDT-SWAP 10
    """
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["buy", "sell", "cancel", "cancel-all", "leverage"])
    parser.add_argument("inst_id")
    parser.add_argument("extra", nargs="*")
    parser.add_argument("--td", default="cash", help="tdMode: cash/cross/isolated")
    parser.add_argument("--pos", default="", help="posSide: long/short/net")
    parser.add_argument("--px", default="", help="Limit price")
    parser.add_argument("--tp", default="", help="Take profit trigger price")
    parser.add_argument("--sl", default="", help="Stop loss trigger price")
    parser.add_argument("--reduce", action="store_true")
    parser.add_argument("--no-confirm", action="store_true")
    args = parser.parse_args()

    if args.action in ("buy", "sell"):
        if len(args.extra) < 2:
            log.error("Usage: execute.py buy/sell <inst_id> <ord_type> <sz> [options]")
            sys.exit(1)
        place_order(
            inst_id=args.inst_id,
            side=args.action,
            ord_type=args.extra[0],
            sz=args.extra[1],
            td_mode=args.td,
            px=args.px,
            pos_side=args.pos,
            reduce_only=args.reduce,
            tp=args.tp,
            sl=args.sl,
            no_confirm=args.no_confirm,
        )
    elif args.action == "cancel":
        cancel_order(args.inst_id, args.extra[0])
    elif args.action == "cancel-all":
        cancel_all(args.inst_id)
    elif args.action == "leverage":
        set_leverage(args.inst_id, int(args.extra[0]), args.td)


path: scripts/logger.py
content: """OKX Exchange Skill — Structured logging

Environment variables:
  OKX_LOG_LEVEL  : DEBUG | INFO | WARNING | ERROR  (default: INFO)
  OKX_LOG_FORMAT : text | json                     (default: text)
  OKX_CRON_MODE  : 1 to suppress INFO output       (default: 0)
"""
import json
import logging
import os
import sys
from datetime import datetime, timezone

_LOG_LEVELS = {
    "DEBUG": logging.DEBUG,
    "INFO": logging.INFO,
    "WARNING": logging.WARNING,
    "ERROR": logging.ERROR,
}

_level = _LOG_LEVELS.get(os.getenv("OKX_LOG_LEVEL", "INFO").upper(), logging.INFO)
_json_mode = os.getenv("OKX_LOG_FORMAT", "text").lower() == "json"
_cron_mode = os.getenv("OKX_CRON_MODE", "0") == "1"


class _OKXFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        msg = record.getMessage()
        if _json_mode:
            return json.dumps({
                "ts": datetime.now(timezone.utc).isoformat(),
                "level": record.levelname,
                "msg": msg,
            })
        if record.levelno == logging.DEBUG:
            return f"[DBG] {msg}"
        if record.levelno == logging.WARNING:
            return f"⚠️  {msg}"
        if record.levelno >= logging.ERROR:
            return f"❌ {msg}"
        return msg  # INFO: pass through unchanged (preserves table formatting)


def get_logger(name: str = "okx") -> logging.Logger:
    logger = logging.getLogger(name)
    if logger.handlers:
        return logger

    logger.setLevel(logging.DEBUG)

    # In cron mode: only WARNING+ to stderr; otherwise respect OKX_LOG_LEVEL to stdout
    if _cron_mode:
        handler = logging.StreamHandler(sys.stderr)
        handler.setLevel(logging.WARNING)
    else:
        handler = logging.StreamHandler(sys.stdout)
        handler.setLevel(_level)

    handler.setFormatter(_OKXFormatter())
    logger.addHandler(handler)
    logger.propagate = False
    return logger


path: scripts/monitor.py
content: """Automated monitor: stop-loss/take-profit + strategy scan (for cron)"""
import os
import sys
from datetime import datetime

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import load_prefs, load_state, save_state, log_trade
from logger import get_logger
from okx_client import OKXClient
from okx_learning import record_trade, should_avoid_trade
from okx_ws_client import get_feed

log = get_logger("okx.monitor")


def _ws_warmup(watchlist: list, bar: str = "1H") -> None:
    """Subscribe WS feed to watchlist and wait briefly for connection."""
    feed = get_feed()
    if not feed:
        return
    feed.subscribe_tickers(watchlist)
    feed.subscribe_candles(watchlist, bar)
    if not feed._connected.is_set():
        feed.wait_ready(timeout=3)
    log.info(f"WS feed active — subscribed {len(watchlist)} instruments")


def check_stop_loss_take_profit() -> None:
    """Check all positions against SL/TP thresholds."""
    prefs = load_prefs()
    state = load_state()
    _ws_warmup(prefs.get("watchlist", []))
    client = OKXClient()

    positions_data = client.positions()
    if positions_data.get("code") != "0":
        log.error(f"Error fetching positions: {positions_data.get('msg')}")
        return

    positions = positions_data.get("data", [])
    if not positions:
        return

    today = datetime.utcnow().strftime("%Y-%m-%d")
    if state.get("last_reset") != today:
        state["daily_trades"] = 0
        state["last_reset"] = today

    actions_taken = []

    for pos in positions:
        inst_id = pos["instId"]
        upl_ratio = float(pos.get("uplRatio", 0)) * 100  # %
        side = pos.get("posSide", "net")
        sz = pos.get("pos", "0")

        if upl_ratio <= -prefs["stop_loss_pct"]:
            action = "stop_loss"
            close_side = "sell" if side in ("long", "net") else "buy"
        elif upl_ratio >= prefs["take_profit_pct"]:
            action = "take_profit"
            close_side = "sell" if side in ("long", "net") else "buy"
        else:
            continue

        msg = f"{'🛑 Stop-loss' if action == 'stop_loss' else '🎯 Take-profit'}: {inst_id} | PnL: {upl_ratio:.2f}%"
        log.info(msg)

        if prefs.get("auto_trade") and state["daily_trades"] < prefs["max_daily_trades"]:
            result = client.place_order(
                inst_id=inst_id,
                td_mode="cross",
                side=close_side,
                ord_type="market",
                sz=sz,
                reduce_only=True,
                pos_side=side if side not in ("net",) else "",
            )
            if result.get("code") == "0":
                state["daily_trades"] += 1
                actions_taken.append({"inst_id": inst_id, "action": action, "upl_pct": upl_ratio})
                log_trade({
                    "ts": datetime.utcnow().isoformat(),
                    "inst_id": inst_id,
                    "action": action,
                    "upl_pct": upl_ratio,
                    "ord_id": result["data"][0]["ordId"],
                })
                record_trade({
                    "coin": inst_id,
                    "direction": "long" if side in ("long", "net") else "short",
                    "signal_type": action,
                    "market_regime": "unknown",
                    "pnl_pct": upl_ratio,
                    "pnl_usdt": 0,
                    "hold_time_hours": 0,
                })
        else:
            log.warning(f"auto_trade=false or daily limit reached — manual action needed")

    save_state(state)
    if actions_taken:
        log.info(f"\nActions taken: {len(actions_taken)}")
    else:
        log.info("No SL/TP triggered.")


def scan_opportunities(inst_id: str = "") -> None:
    """Run strategy analysis on watchlist (or a single inst_id) and optionally execute."""
    prefs = load_prefs()
    state = load_state()
    watchlist = [inst_id] if inst_id else prefs.get("watchlist", [])
    strategies = prefs.get("strategies", ["trend"])

    _ws_warmup(watchlist)
    log.info(f"\n=== Opportunity Scan [{datetime.utcnow().strftime('%H:%M UTC')}] ===")

    for inst_id in watchlist:
        log.info(f"\n{inst_id}:")

        if "trend" in strategies:
            from strategies.trend import analyze
            result = analyze(inst_id)
            signal = result.get("signal", "hold")
            log.info(f"  Trend: {signal.upper()} | RSI:{result.get('rsi','?')} | "
                     f"MACD:{result.get('macd_histogram','?')}")

            if signal != "hold" and prefs.get("auto_trade"):
                today = datetime.utcnow().strftime("%Y-%m-%d")
                if state.get("last_reset") != today:
                    state["daily_trades"] = 0
                    state["last_reset"] = today
                if state["daily_trades"] >= prefs["max_daily_trades"]:
                    log.warning(f"Daily trade limit reached ({prefs['max_daily_trades']})")
                    continue

                market_regime = result.get("market_regime", "unknown")
                should_avoid, avoid_reason = should_avoid_trade(inst_id, signal.upper(), market_regime)
                if should_avoid:
                    log.warning(f"  ⚠️ Learning system skip: {avoid_reason}")
                    continue

                from execute import place_order
                sz = str(prefs.get("default_sz", "0.01"))
                td_mode = "cross" if "SWAP" in inst_id else "cash"
                current = result["current_price"]
                tp_pct = prefs.get("take_profit_pct", 5) / 100
                sl_pct = prefs.get("stop_loss_pct", 3) / 100

                tp = str(round(current * (1 + tp_pct if signal == "buy" else 1 - tp_pct), 4))
                sl = str(round(current * (1 - sl_pct if signal == "buy" else 1 + sl_pct), 4))

                pos_side = "long" if signal == "buy" else "short"
                log.info(f"  → Auto-{signal.upper()} sz={sz} pos={pos_side} TP={tp} SL={sl} [{market_regime}]")
                place_order(inst_id, signal, "market", sz, td_mode,
                            pos_side=pos_side, tp=tp, sl=sl, no_confirm=True)
                state["daily_trades"] += 1
                log_trade({
                    "ts": datetime.utcnow().isoformat(),
                    "inst_id": inst_id,
                    "action": f"auto_{signal}",
                    "signal_source": "trend",
                    "price": current,
                })
                record_trade({
                    "coin": inst_id,
                    "direction": "long" if signal == "buy" else "short",
                    "signal_type": f"trend_{signal}",
                    "market_regime": market_regime,
                    "pnl_pct": 0,
                    "pnl_usdt": 0,
                    "hold_time_hours": 0,
                })

        if "arbitrage" in strategies:
            if "SWAP" not in inst_id:
                continue
            from strategies.arbitrage import basis
            spot_id = inst_id.replace("-SWAP", "")
            info = basis(spot_id, inst_id)
            log.info(f"  Arb basis: {info.get('basis_pct','?')}% | Signal: {info.get('signal','?')}")

    state["last_scan"] = datetime.utcnow().isoformat()
    save_state(state)


def check_liquidation_risk(threshold_pct: float = 10.0) -> None:
    """Alert when mark price is within threshold_pct% of the liquidation price."""
    client = OKXClient()
    risk_data = client.position_risk()
    if risk_data.get("code") != "0":
        log.error(f"Error fetching position risk: {risk_data.get('msg')}")
        return

    alerts = []
    for item in risk_data.get("data", []):
        for pos in item.get("posData", []):
            inst_id = pos.get("instId", "")
            liq_px = pos.get("liqPx", "")
            mark_px = pos.get("markPx", "")
            pos_side = pos.get("posSide", "net")
            if not liq_px or not mark_px or liq_px == "0" or mark_px == "0":
                continue
            try:
                liq = float(liq_px)
                mark = float(mark_px)
            except ValueError:
                continue

            distance_pct = abs(mark - liq) / mark * 100
            if distance_pct <= threshold_pct:
                alerts.append({
                    "inst_id": inst_id,
                    "pos_side": pos_side,
                    "mark_px": mark,
                    "liq_px": liq,
                    "distance_pct": distance_pct,
                })
                log.warning(
                    f"⚠️ Liquidation risk: {inst_id} [{pos_side}] "
                    f"mark={mark} liq={liq} distance={distance_pct:.1f}%"
                )

    if not alerts:
        log.info(f"No liquidation risk (threshold={threshold_pct}%).")
    return alerts


if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else "all"

    inst_id = sys.argv[2] if len(sys.argv) > 2 else ""
    if cmd == "sl-tp":
        check_stop_loss_take_profit()
    elif cmd == "scan":
        scan_opportunities(inst_id)
    elif cmd == "liq-risk":
        threshold = float(sys.argv[2]) if len(sys.argv) > 2 else 10.0
        check_liquidation_risk(threshold)
    else:
        check_stop_loss_take_profit()
        scan_opportunities()
        check_liquidation_risk()


path: scripts/okx.py
content: #!/usr/bin/env python3
"""OKX Exchange Skill — Unified CLI dispatcher

Usage:
  python okx.py account [balance|positions|orders] [CCY]
  python okx.py buy|sell <inst_id> <type> <sz> [options]
  python okx.py cancel <inst_id> <ord_id>
  python okx.py cancel-all <inst_id>
  python okx.py leverage <inst_id> <lever> [--td cross]
  python okx.py trend analyze|run <inst_id> [options]
  python okx.py grid setup|check|stop <inst_id> [options]
  python okx.py arb scan|basis|open|close [options]
  python okx.py monitor [sl-tp|scan]
  python okx.py prefs [show|set <key> <value>]
  python okx.py setup
"""
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "strategies"))

from logger import get_logger

log = get_logger("okx")


def resolve_inst_id(name: str) -> str:
    """Resolve a short coin name (e.g. 'BTC') to a full instrument ID.

    If the input already contains '-', it is returned as-is (backward compatible).
    Otherwise the watchlist is searched first; on multiple matches a SWAP contract
    is preferred.  Falls back to '<COIN>-USDT-SWAP' when nothing is found.
    """
    if not name or "-" in name:
        return name  # already a full ID or empty (means "all")

    coin = name.upper()
    from config import load_prefs
    watchlist = load_prefs().get("watchlist", [])

    matches = [w for w in watchlist if w.upper().startswith(coin + "-")]
    if len(matches) == 1:
        resolved = matches[0]
    elif len(matches) > 1:
        swaps = [m for m in matches if "SWAP" in m.upper()]
        resolved = swaps[0] if swaps else matches[0]
    else:
        resolved = f"{coin}-USDT-SWAP"

    if resolved != name:
        log.info(f"  ↳ '{name}' resolved to '{resolved}'")
    return resolved

HELP = """
OKX Exchange Skill — Unified CLI

Account:
  python okx.py account                             Portfolio overview
  python okx.py account balance [CCY]               Balance by currency
  python okx.py account positions [INST_ID]         Open positions
  python okx.py account orders [INST_ID]            Pending orders
  python okx.py account history [SPOT|SWAP] 
  Filled order history

Market Data:
  python okx.py instruments [SPOT|SWAP|FUTURES] [COIN]  List instruments (filterable)
  python okx.py ticker <inst_id|COIN>                   Single ticker
  python okx.py ticker [SPOT|SWAP|FUTURES] [COIN]       Batch tickers by type
  python okx.py ticker watchlist                         Tickers for all watchlist coins
  python okx.py candles [INST_ID|COIN] [--bar 1H] [--limit 5]  Single K-line
  python okx.py candles --all [--bar 1H] [--limit 5]    K-lines for all watchlist coins

Trading:
  python okx.py buy  <inst_id> <type> <sz> [--td cash] [--pos long] [--px P] [--tp P] [--sl P] [--no-confirm]
  python okx.py sell <inst_id> <type> <sz> [--td cash] [--pos short] [--reduce]
  python okx.py cancel <inst_id> <ord_id>
  python okx.py cancel-all <inst_id>
  python okx.py leverage <inst_id> <lever> [--td cross]
  python okx.py transfer <ccy> <amount> <from> <to>  (from/to: funding or trading)

Strategies:
  python okx.py trend analyze <inst_id> [--bar 1H]
  python okx.py trend run <inst_id> <sz> [--bar 4H --tp 0.05 --sl 0.03 --td cross --pos long --dry]
  python okx.py grid setup <inst_id> <lower> <upper> <grids> <usdt> [--td cash]
  python okx.py grid check|stop <inst_id>
  python okx.py arb scan
  python okx.py arb basis <spot_id> <swap_id>
  python okx.py arb open <spot_id> <swap_id> <usdt_sz> [--min-basis 0.1]
  python okx.py arb close <spot_id> <swap_id> <spot_sz> <swap_sz> [--max-basis 0.02]

Algo Orders (standalone TP/SL — works on existing positions):
  python okx.py algo list [INST_ID]                    Pending algo orders
  python okx.py algo oco  <inst_id> <sz> --tp P --sl P [--td cross --pos long --reduce]
  python okx.py algo stop <inst_id> <sz> --sl P        Single stop-loss trigger
  python okx.py algo cancel <inst_id> <algo_id>        Cancel one algo order

Monitor:
  python okx.py monitor              SL/TP check, strategy scan, and liquidation risk
  python okx.py monitor sl-tp        Stop-loss / take-profit check only
  python okx.py monitor scan         Strategy scan — full watchlist
  python okx.py monitor scan <COIN>  Strategy scan — single coin only
  python okx.py monitor liq-risk [%] Liquidation price warning (default threshold: 10%)

Preferences:
  python okx.py prefs show           Show all preferences
  python okx.py prefs set <key> <v>  Update a preference value

Environment:
  OKX_SIMULATED=1     Paper trading mode (safe for testing)
  OKX_LOG_LEVEL       DEBUG | INFO | WARNING | ERROR  (default: INFO)
  OKX_LOG_FORMAT      text | json                     (default: text)
  OKX_CRON_MODE=1     Suppress INFO output (for cron)
""".strip()


# ── Account ───────────────────────────────────────────────────────────────────

def cmd_account(args: list) -> None:
    import account
    sub = args[0] if args else "summary"
    arg = args[1] if len(args) > 1 else ""
    if sub == "balance":
        account.get_balance(arg)
    elif sub == "positions":
        account.get_positions(inst_id=resolve_inst_id(arg))
    elif sub == "orders":
        account.get_pending_orders(resolve_inst_id(arg))
    elif sub == "history":
        inst_type = arg.upper() if arg else "SPOT"
        inst_id = resolve_inst_id(args[2]) if len(args) > 2 else ""
        limit = int(args[3]) if len(args) > 3 else 20
        account.get_order_history(inst_type, inst_id, limit)
    else:
        account.portfolio_summary()


# ── Trading ───────────────────────────────────────────────────────────────────

def cmd_trade(side: str, args: list) -> None:
    import argparse
    import execute
    p = argparse.ArgumentParser(prog=f"okx.py {side}")
    p.add_argument("inst_id")
    p.add_argument("ord_type")
    p.add_argument("sz")
    p.add_argument("--td", default="cash")
    p.add_argument("--pos", default="")
    p.add_argument("--px", default="")
    p.add_argument("--tp", default="")
    p.add_argument("--sl", default="")
    p.add_argument("--reduce", action="store_true")
    p.add_argument("--no-confirm", action="store_true")
    a = p.parse_args(args)
    execute.place_order(
        inst_id=resolve_inst_id(a.inst_id), side=side, ord_type=a.ord_type, sz=a.sz,
        td_mode=a.td, px=a.px, pos_side=a.pos, reduce_only=a.reduce,
        tp=a.tp, sl=a.sl, no_confirm=a.no_confirm,
    )


def cmd_cancel(args: list) -> None:
    import execute
    if len(args) < 2:
        log.error("Usage: okx.py cancel <inst_id> <ord_id>")
        sys.exit(1)
    execute.cancel_order(resolve_inst_id(args[0]), args[1])


def cmd_cancel_all(args: list) -> None:
    import execute
    if not args:
        log.error("Usage: okx.py cancel-all <inst_id>")
        sys.exit(1)
    execute.cancel_all(resolve_inst_id(args[0]))


def cmd_leverage(args: list) -> None:
    import argparse
    import execute
    p = argparse.ArgumentParser(prog="okx.py leverage")
    p.add_argument("inst_id")
    p.add_argument("lever", type=int)
    p.add_argument("--td", default="cross")
    a = p.parse_args(args)
    execute.set_leverage(resolve_inst_id(a.inst_id), a.lever, a.td)


def cmd_transfer(args: list) -> None:
    import execute
    # Aliases: funding/fund/6 → "6",  trading/trade/18 → "18"
    _code = {"funding": "6", "fund": "6", "6": "6", "trading": "18", "trade": "18", "18": "18"}
    if len(args) < 4:
        log.error("Usage: okx.py transfer <ccy> <amount> <from> <to>")
        log.info("  from/to: funding (6) or trading (18)")
        log.info("  Example: okx.py transfer USDT 100 funding trading")
        sys.exit(1)
    ccy, amt = args[0], args[1]
    from_acct = _code.get(args[2].lower(), args[2])
    to_acct = _code.get(args[3].lower(), args[3])
    execute.transfer_funds(ccy, amt, from_acct, to_acct)


# ── Strategies ────────────────────────────────────────────────────────────────

def cmd_trend(args: list) -> None:
    import argparse
    import trend
    p = argparse.ArgumentParser(prog="okx.py trend")
    p.add_argument("sub", choices=["analyze", "run"])
    p.add_argument("inst_id")
    p.add_argument("sz", nargs="?", default="0.01")
    p.add_argument("--bar", default="1H")
    p.add_argument("--td", default="cash")
    p.add_argument("--pos", default="")
    p.add_argument("--tp", type=float, default=0.05)
    p.add_argument("--sl", type=float, default=0.03)
    p.add_argument("--dry", action="store_true")
    a = p.parse_args(args)
    inst_id = resolve_inst_id(a.inst_id)
    if a.sub == "analyze":
        result = trend.analyze(inst_id, a.bar)
        print(json.dumps(result, indent=2))
    else:
        trend.run(inst_id, a.sz, a.td, a.pos, a.bar, a.tp, a.sl, a.dry)


def cmd_grid(args: list) -> None:
    import argparse
    import grid
    p = argparse.ArgumentParser(prog="okx.py grid")
    p.add_argument("sub", choices=["setup", "check", "stop"])
    p.add_argument("inst_id")
    p.add_argument("lower", nargs="?", type=float)
    p.add_argument("upper", nargs="?", type=float)
    p.add_argument("grids", nargs="?", type=int)
    p.add_argument("usdt", nargs="?", type=float)
    p.add_argument("--td", default="cash")
    a = p.parse_args(args)
    inst_id = resolve_inst_id(a.inst_id)
    if a.sub == "setup":
        if None in (a.lower, a.upper, a.grids, a.usdt):
            log.error("Usage: okx.py grid setup <inst_id> <lower> <upper> <grids> <usdt>")
            sys.exit(1)
        grid.setup_grid(inst_id, a.lower, a.upper, a.grids, a.usdt, a.td)
    elif a.sub == "check":
        grid.check_grid(inst_id)
    else:
        grid.stop_grid(inst_id)


def cmd_arb(args: list) -> None:
    import argparse
    import arbitrage
    from errors import ArbHedgeFailedError
    p = argparse.ArgumentParser(prog="okx.py arb")
    p.add_argument("sub", choices=["scan", "basis", "open", "close"])
    p.add_argument("rest", nargs="*")
    p.add_argument("--min-basis", type=float, default=0.1)
    p.add_argument("--max-basis", type=float, default=0.02)
    a = p.parse_args(args)
    if a.sub == "scan":
        arbitrage.scan()
    elif a.sub == "basis":
        if len(a.rest) < 2:
            log.error("Usage: okx.py arb basis <spot_id> <swap_id>")
            sys.exit(1)
        print(json.dumps(arbitrage.basis(resolve_inst_id(a.rest[0]), resolve_inst_id(a.rest[1])), indent=2))
    elif a.sub == "open":
        if len(a.rest) < 3:
            log.error("Usage: okx.py arb open <spot_id> <swap_id> <usdt_sz>")
            sys.exit(1)
        try:
            arbitrage.open_arb(resolve_inst_id(a.rest[0]), resolve_inst_id(a.rest[1]), float(a.rest[2]), a.min_basis)
        except ArbHedgeFailedError as e:
            log.error(f"HEDGE FAILURE — {e}. Spot is filled but swap is not. Close spot manually!")
            sys.exit(1)
    elif a.sub == "close":
        if len(a.rest) < 4:
            log.error("Usage: okx.py arb close <spot_id> <swap_id> <spot_sz> <swap_sz>")
            sys.exit(1)
        arbitrage.close_arb(resolve_inst_id(a.rest[0]), resolve_inst_id(a.rest[1]), a.rest[2], a.rest[3], a.max_basis)


# ── Algo Orders ───────────────────────────────────────────────────────────────

def cmd_algo(args: list) -> None:
    import argparse
    from okx_client import OKXClient
    client = OKXClient()
    sub = args[0] if args else "list"

    if sub == "list":
        inst_id = resolve_inst_id(args[1]) if len(args) > 1 else ""
        data = client.pending_algo_orders(inst_id)
        if data.get("code") != "0":
            log.error(f"Error: {data.get('msg')}")
            return
        orders = data.get("data", [])
        if not orders:
            log.info("No pending algo orders.")
            return
        log.info(f"\n{'Algo ID':<20} {'Instrument':<25} {'Type':<12} {'Side':<6} "
                 f"{'Size':>8} {'TP Trigger':>12} {'SL Trigger':>12}")
        log.info("-" * 100)
        for o in orders:
            log.info(f"  {o.get('algoId',''):<18} {o.get('instId',''):<23} "
                     f"{o.get('ordType',''):<12} {o.get('side',''):<6} "
                     f"{float(o.get('sz','0')):>8.4f} "
                     f"{o.get('tpTriggerPx','-'):>12} "
                     f"{o.get('slTriggerPx','-'):>12}")
        return

    if sub in ("oco", "stop"):
        p = argparse.ArgumentParser(prog=f"okx.py algo {sub}")
        p.add_argument("inst_id")
        p.add_argument("sz")
        p.add_argument("--side", default="sell")
        p.add_argument("--td", default="cross")
        p.add_argument("--pos", default="")
        p.add_argument("--tp", default="")
        p.add_argument("--sl", default="")
        p.add_argument("--reduce", action="store_true")
        p.add_argument("--no-confirm", action="store_true")
        a = p.parse_args(args[1:])
        inst_id = resolve_inst_id(a.inst_id)

        if sub == "oco" and (not a.tp or not a.sl):
            log.error("oco requires both --tp and --sl")
            sys.exit(1)
        if sub == "stop" and not a.sl:
            log.error("stop requires --sl")
            sys.exit(1)

        ord_type = "oco" if sub == "oco" else "conditional"
        log.info(f"\nAlgo Order Preview:")
        log.info(f"  Instrument : {inst_id}")
        log.info(f"  Type       : {ord_type.upper()}")
        log.info(f"  Side       : {a.side.upper()}")
        log.info(f"  Size       : {a.sz}")
        if a.tp:
            log.info(f"  TP Trigger : {a.tp}")
        if a.sl:
            log.info(f"  SL Trigger : {a.sl}")

        if not a.no_confirm:
            confirm = input("Confirm? [y/N] ").strip().lower()
            if confirm != "y":
                log.info("Cancelled.")
                return

        result = client.place_algo_order(
            inst_id=inst_id, td_mode=a.td, side=a.side,
            ord_type=ord_type, sz=a.sz,
            tp_trigger_px=a.tp, sl_trigger_px=a.sl,
            pos_side=a.pos, reduce_only=a.reduce,
        )
        if result.get("code") == "0":
            algo_id = result["data"][0].get("algoId", "")
            log.info(f"✅ Algo order placed: {algo_id}")
        else:
            log.error(f"❌ Error: {result.get('msg')} (code={result.get('code')})")
        return

    if sub == "cancel":
        if len(args) < 3:
            log.error("Usage: okx.py algo cancel <inst_id> <algo_id>")
            sys.exit(1)
        inst_id = resolve_inst_id(args[1])
        algo_id = args[2]
        result = client.cancel_algo_order(inst_id, algo_id)
        if result.get("code") == "0":
            log.info(f"✅ Algo order {algo_id} cancelled")
        else:
            log.error(f"❌ Error: {result.get('msg')}")
        return

    log.error(f"Unknown algo sub-command: {sub}")
    log.info("Usage: okx.py algo [list|oco|stop|cancel]")
    sys.exit(1)


# ── Monitor ───────────────────────────────────────────────────────────────────

def cmd_instruments(args: list) -> None:
    """List instruments by type, with optional coin filter."""
    from okx_client import OKXClient
    inst_type = args[0].upper() if args else "SWAP"
    coin_filter = args[1].upper() if len(args) > 1 else ""
    data = OKXClient().instruments(inst_type)
    if data.get("code") != "0":
        log.error(f"Error: {data.get('msg')}")
        return
    items = data.get("data", [])
    if coin_filter:
        items = [i for i in items if coin_filter in i["instId"].upper()]
    log.info(f"\n{inst_type} Instruments ({len(items)} matched):")
    for i in items:
        log.info(f"  {i['instId']:<25} ctVal={i.get('ctVal','-')} minSz={i.get('minSz','-')}")


def cmd_ticker(args: list) -> None:
    """Fetch ticker(s): single inst_id or all by inst_type or watchlist."""
    import json
    from okx_client import OKXClient
    from config import load_prefs
    client = OKXClient()

    if not args or args[0].lower() == "watchlist":
        # All tickers for watchlist coins
        watchlist = load_prefs().get("watchlist", [])
        log.info(f"\n{'Instrument':<25} {'Last':>12} {'24h%':>8} {'Vol(USDT)':>16}")
        log.info("-" * 65)
        for inst_id in watchlist:
            d = client.ticker(inst_id)
            if d.get("code") == "0" and d["data"]:
                t = d["data"][0]
                chg = float(t.get("sodUtc8", t.get("last", 0)) or 0)
                last = float(t.get("last", 0))
                pct = ((last - chg) / chg * 100) if chg else 0.0
                vol = float(t.get("volCcy24h", 0))
                log.info(f"  {inst_id:<23} {last:>12.4f} {pct:>+7.2f}% {vol:>16,.0f}")
    elif args[0].upper() in ("SPOT", "SWAP", "FUTURES", "OPTION"):
        # Batch fetch all by inst_type
        inst_type = args[0].upper()
        coin_filter = args[1].upper() if len(args) > 1 else ""
        d = client.tickers(inst_type)
        if d.get("code") != "0":
            log.error(f"Error: {d.get('msg')}")
            return
        items = d.get("data", [])
        if coin_filter:
            items = [t for t in items if coin_filter in t["instId"].upper()]
        items.sort(key=lambda t: float(t.get("volCcy24h", 0)), reverse=True)
        log.info(f"\n{inst_type} Tickers ({len(items)} shown):")
        log.info(f"  {'Instrument':<25} {'Last':>12} {'Vol(USDT)':>16}")
        log.info("  " + "-" * 57)
        for t in items[:50]:  # cap at 50 rows
            log.info(f"  {t['instId']:<25} {float(t.get('last',0)):>12.4f} "
                     f"{float(t.get('volCcy24h',0)):>16,.0f}")
    else:
        # Single inst_id
        inst_id = resolve_inst_id(args[0])
        d = client.ticker(inst_id)
        if d.get("code") != "0":
            log.error(f"Error: {d.get('msg')}")
            return
        print(json.dumps(d["data"][0], indent=2))


def cmd_candles(args: list) -> None:
    """Fetch K-line data: single coin or all watchlist coins."""
    import argparse
    from okx_client import OKXClient
    from config import load_prefs
    p = argparse.ArgumentParser(prog="okx.py candles")
    p.add_argument("inst_id", nargs="?", default="")
    p.add_argument("--bar", default="1H")
    p.add_argument("--limit", type=int, default=5)
    p.add_argument("--all", dest="all_watchlist", action="store_true")
    a = p.parse_args(args)

    client = OKXClient()
    targets = load_prefs().get("watchlist", []) if a.all_watchlist else [resolve_inst_id(a.inst_id)]

    for inst_id in targets:
        d = client.candles(inst_id, a.bar, a.limit)
        if d.get("code") != "0":
            log.error(f"{inst_id}: {d.get('msg')}")
            continue
        rows = d.get("data", [])
        log.info(f"\n{inst_id} [{a.bar}] — last {len(rows)} candles:")
        log.info(f"  {'Time':<22} {'Open':>10} {'High':>10} {'Low':>10} {'Close':>10} {'Vol':>14}")
        log.info("  " + "-" * 80)
        for r in reversed(rows):  # OKX returns newest first
            from datetime import datetime, timezone
            ts = datetime.fromtimestamp(int(r[0]) / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
            log.info(f"  {ts:<22} {float(r[1]):>10.4f} {float(r[2]):>10.4f} "
                     f"{float(r[3]):>10.4f} {float(r[4]):>10.4f} {float(r[5]):>14,.2f}")


def cmd_monitor(args: list) -> None:
    import monitor
    sub = args[0] if args else "all"
    inst_id = resolve_inst_id(args[1]) if len(args) > 1 else ""
    if sub == "sl-tp":
        monitor.check_stop_loss_take_profit()
    elif sub == "scan":
        monitor.scan_opportunities(inst_id)
    elif sub == "liq-risk":
        threshold = float(args[1]) if len(args) > 1 else 10.0
        monitor.check_liquidation_risk(threshold)
    else:
        monitor.check_stop_loss_take_profit()
        monitor.scan_opportunities()
        monitor.check_liquidation_risk()


# ── Preferences ───────────────────────────────────────────────────────────────

def cmd_prefs(args: list) -> None:
    from config import load_prefs, save_prefs
    sub = args[0] if args else "show"

    if sub == "show":
        log.info(json.dumps(load_prefs(), indent=2))
        return

    if sub == "set":
        if len(args) < 3:
            log.error("Usage: okx.py prefs set <key> <value>")
            sys.exit(1)
        key, raw = args[1], args[2]
        prefs = load_prefs()
        if key not in prefs:
            log.error(f"Unknown key: {key}. Valid keys: {', '.join(prefs.keys())}")
            sys.exit(1)
        # Coerce to current value's type
        current = prefs[key]
        if isinstance(current, bool):
            prefs[key] = raw.lower() in ("true", "1", "yes")
        elif isinstance(current, int):
            prefs[key] = int(raw)
        elif isinstance(current, float):
            prefs[key] = float(raw)
        elif isinstance(current, list):
            prefs[key] = [v.strip() for v in raw.split(",")]
        else:
            prefs[key] = raw
        save_prefs(prefs)
        log.info(f"✅ {key} = {prefs[key]}")
        return

    log.error(f"Unknown prefs sub-command: {sub}")
    log.info("Usage: okx.py prefs [show|set <key> <value>]")
    sys.exit(1)


def cmd_mode(args: list) -> None:
    """Switch between live and demo (simulated) trading."""
    from config import load_prefs, save_prefs
    prefs = load_prefs()
    current = prefs.get("mode", "demo")

    if not args:
        mode_label = f"{'🔴 LIVE' if current == 'live' else '🟢 DEMO'} ({current.upper()})"
        log.info(f"Current trading mode: {mode_label}")
        return

    new_mode = args[0].lower()
    if new_mode not in ("live", "demo"):
        log.error("Usage: okx.py mode [live|demo]")
        sys.exit(1)

    if new_mode == current:
        log.info(f"Already in {new_mode.upper()} mode.")
        return

    if new_mode == "live":
        log.info("⚠️  WARNING: Switching to LIVE trading mode — real money at risk!")
        log.info("   Live credentials: OKX_API_KEY_LIVE / OKX_SECRET_KEY_LIVE")
        confirm = input("   Type 'yes' to confirm: ").strip().lower()
        if confirm != "yes":
            log.info("Cancelled.")
            return

    prefs["mode"] = new_mode
    save_prefs(prefs)
    icon = "🔴" if new_mode == "live" else "🟢"
    log.info(f"✅ Mode switched to {icon} {new_mode.upper()}")


def cmd_snapshot(args: list) -> None:
    """Fetch live data, persist snapshot, print formatted cron report with real history."""
    from okx_client import OKXClient
    from config import load_snapshots, save_snapshot
    from datetime import datetime, timezone
    import zoneinfo

    client = OKXClient()
    tz = zoneinfo.ZoneInfo("Asia/Shanghai")
    now = datetime.now(tz)
    ts_label = now.strftime("%m-%d %H:%M")

    # ── 1. Fetch balance ──────────────────────────────────────────────────────
    bal_data = client.balance()
    equity_usd = 0.0
    avail_usdt = 0.0
    top_holdings = []
    if bal_data.get("code") == "0":
        for detail in bal_data["data"][0].get("details", []):
            eq = float(detail.get("eqUsd", 0) or 0)
            if eq < 0.01:
                continue
            equity_usd += eq
            top_holdings.append((detail.get("ccy", ""), eq,
                                  float(detail.get("availBal", 0) or 0)))
            if detail.get("ccy") == "USDT":
                avail_usdt = float(detail.get("availBal", 0) or 0)
    else:
        log.error(f"Balance fetch error: {bal_data.get('msg')}")
        sys.exit(1)

    top_holdings.sort(key=lambda x: x[1], reverse=True)

    # ── 2. Fetch positions ────────────────────────────────────────────────────
    pos_data = client.positions()
    positions = []
    total_upl = 0.0
    total_realized = 0.0
    if pos_data.get("code") == "0":
        for p in pos_data["data"]:
            if not p.get("instId") or float(p.get("pos", 0) or 0) == 0:
                continue
            upl = float(p.get("upl", 0) or 0)
            realized = float(p.get("realizedPnl", 0) or 0)
            mark_px = float(p.get("markPx", 0) or 0)
            positions.append({
                "inst_id": p["instId"],
                "pos_side": p.get("posSide", "net"),
                "sz": p.get("pos", "0"),
                "entry_px": float(p.get("avgPx", 0) or 0),
                "mark_px": mark_px,
                "upl": upl,
                "realized": realized,
                "liq_px": float(p.get("liqPx", 0) or 0),
                "lever": p.get("lever", ""),
            })
            total_upl += upl
            total_realized += realized
    else:
        log.error(f"Positions fetch error: {pos_data.get('msg')}")
        sys.exit(1)

    # ── 3. Persist snapshot ───────────────────────────────────────────────────
    snapshot = {
        "ts": now.isoformat(),
        "ts_label": ts_label,
        "equity_usd": round(equity_usd, 2),
        "avail_usdt": round(avail_usdt, 2),
        "total_upl": round(total_upl, 2),
        "total_realized": round(total_realized, 2),
        "positions": positions,
    }
    save_snapshot(snapshot)

    # ── 4. Load history ───────────────────────────────────────────────────────
    history = load_snapshots()
    initial_equity = history["initial_equity"] or equity_usd
    equity_change = equity_usd - initial_equity
    equity_change_pct = equity_change / initial_equity * 100 if initial_equity else 0.0
    snapshots = history["snapshots"]

    # ── 5. Build report ───────────────────────────────────────────────────────
    lines = []
    sep = "─" * 38

    lines.append(f"📊 OKX 交易监控报告 ({ts_label})")
    lines.append(sep)

    # Account overview
    lines.append("💰 账户总览")
    lines.append(f"  总权益   : ${equity_usd:>12,.2f}")
    lines.append(f"  初始资金 : ${initial_equity:>12,.2f}")
    change_icon = "📈" if equity_change >= 0 else "📉"
    lines.append(f"  资金变化 : {change_icon} {equity_change:+.2f} ({equity_change_pct:+.2f}%)")
    lines.append(f"  可用USDT : ${avail_usdt:>12,.2f}")

    # Top holdings (up to 5, skip USDT if avail is the same)
    if top_holdings:
        lines.append(sep)
        lines.append("🏦 主要持仓")
        for ccy, eq, avail in top_holdings[:5]:
            lines.append(f"  {ccy:<8} ${eq:>12,.2f}")

    # Open positions
    if positions:
        lines.append(sep)
        lines.append("📈 合约持仓")
        for p in positions:
            direction = "📉 空" if p["pos_side"] == "short" else "📈 多"
            upl_icon = "✅" if p["upl"] >= 0 else "❌"
            lines.append(
                f"  {p['inst_id']} {direction} x{p['lever']}"
            )
            lines.append(
                f"    入场 {p['entry_px']:,.1f} → 标记 {p['mark_px']:,.1f}"
            )
            lines.append(
                f"    未实现盈亏 {p['upl']:+.4f} USDT {upl_icon}"
                + (f"  | 已实现 {p['realized']:+.4f}" if abs(p["realized"]) > 0.001 else "")
            )
            if p["liq_px"] and 0 < p["liq_px"] < p["mark_px"] * 50:
                dist = abs(p["mark_px"] - p["liq_px"]) / p["mark_px"] * 100
                lines.append(f"    强平价 {p['liq_px']:,.1f}  距离 {dist:.1f}%")
        lines.append(f"  净未实现盈亏: {total_upl:+.4f} USDT")
    else:
        lines.append(sep)
        lines.append("📈 合约持仓: 无")

    # Historical tracking (last 5 real snapshots)
    if len(snapshots) >= 2:
        lines.append(sep)
        lines.append("📉 历史追踪（真实数据）")
        lines.append(f"  {'时间':<8} {'总权益':>14} {'未实现盈亏':>12} {'资金变化':>12}")
        for s in snapshots[-5:]:
            eq = s.get("equity_usd", 0)
            upl = s.get("total_upl", 0)
            chg = eq - initial_equity
            upl_icon = "✅" if upl >= 0 else "❌"
            chg_icon = "📈" if chg >= 0 else "📉"
            lines.append(
                f"  {s['ts_label']:<8} ${eq:>12,.2f}  {upl:>+10.4f}{upl_icon}"
                f"  {chg:>+10.2f}{chg_icon}"
            )

    lines.append(sep)
    print("\n".join(lines))


# ── Dispatch ──────────────────────────────────────────────────────────────────

_COMMANDS = {
    "account": cmd_account,
    "instruments": cmd_instruments,
    "ticker": cmd_ticker,
    "candles": cmd_candles,
    "trend": cmd_trend,
    "grid": cmd_grid,
    "arb": cmd_arb,
    "algo": cmd_algo,
    "monitor": cmd_monitor,
    "prefs": cmd_prefs,
    "mode": cmd_mode,
    "snapshot": cmd_snapshot,
    "report": lambda args: __import__("report").main(args),
    "transfer": cmd_transfer,
}

if __name__ == "__main__":
    argv = sys.argv[1:]
    if not argv or argv[0] in ("-h", "--help", "help"):
        print(HELP)
        sys.exit(0)

    cmd = argv[0]
    rest = argv[1:]

    if cmd in ("buy", "sell"):
        cmd_trade(cmd, rest)
    elif cmd == "cancel":
        cmd_cancel(rest)
    elif cmd == "cancel-all":
        cmd_cancel_all(rest)
    elif cmd == "leverage":
        cmd_leverage(rest)
    elif cmd == "setup":
        import setup as s
        s.check_deps()
        if s.check_env() and s.validate_api():
            s.create_default_prefs()
            log.info("\n✅ Setup complete. Run `python okx.py help` to see all commands.")
    elif cmd in _COMMANDS:
        _COMMANDS[cmd](rest)
    else:
        log.error(f"Unknown command: {cmd}")
        print(HELP)
        sys.exit(1)


path: scripts/okx_client.py
content: """OKX V5 API Client"""
import hmac
import base64
import hashlib
import json
import os
import time
from datetime import datetime, timezone
from typing import Any
import requests

from config import load_prefs
from errors import NetworkError, APIError
from okx_ws_client import get_feed, get_private_feed

def _load_env() -> None:
    env_file = os.path.expanduser("~/.openclaw/workspace/.env")
    if os.path.exists(env_file):
        with open(env_file) as f:
            for line in f:
                line = line.strip()
                if "=" in line and not line.startswith("#"):
                    k, v = line.split("=", 1)
                    os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

_load_env()

BASE_URL = os.getenv("OKX_API_URL", "https://www.okx.com")

_time_offset: float = 0.0
_last_sync: float = 0.0


def _sync_time() -> None:
    """Fetch OKX server time and calculate offset. Cached for 5 minutes."""
    global _time_offset, _last_sync
    if time.time() - _last_sync < 300:
        return
    try:
        r = requests.get(f"{BASE_URL}/api/v5/public/time", timeout=5)
        server_ms = int(r.json()["data"][0]["ts"])
        _time_offset = server_ms / 1000 - time.time()
        _last_sync = time.time()
    except Exception:
        _time_offset = 0.0


def _timestamp() -> str:
    t = datetime.fromtimestamp(time.time() + _time_offset, tz=timezone.utc)
    return t.strftime("%Y-%m-%dT%H:%M:%S.") + str(t.microsecond // 1000).zfill(3) + "Z"


def _sign(ts: str, method: str, path: str, body: str, secret: str) -> str:
    msg = ts + method.upper() + path + body
    return base64.b64encode(
        hmac.new(secret.encode(), msg.encode(), hashlib.sha256).digest()
    ).decode()


class OKXClient:
    def __init__(self):
        mode = load_prefs().get("mode", "demo")
        if mode == "live":
            self.[REDACTED_SECRET]"OKX_API_KEY_LIVE", "")
            self.[REDACTED_SECRET]"OKX_SECRET_KEY_LIVE", "")
            self.passphrase = os.getenv("OKX_PASSPHRASE_LIVE", "")
            self.simulated = False
        else:
            self.[REDACTED_SECRET]"OKX_API_KEY", "")
            self.[REDACTED_SECRET]"OKX_SECRET_KEY", "")
            self.passphrase = os.getenv("OKX_PASSPHRASE", "")
            self.simulated = os.getenv("OKX_SIMULATED", "0") == "1"
        _sync_time()

    def _headers(self, method: str, path: str, body: str = "") -> dict:
        ts = _timestamp()
        return {
            "OK-ACCESS-KEY": self.api_key,
            "OK-ACCESS-SIGN": _sign(ts, method, path, body, self.secret),
            "OK-ACCESS-TIMESTAMP": ts,
            "OK-ACCESS-PASSPHRASE": self.passphrase,
            "OK-ACCESS-SBE": "0",
            "x-simulated-trading": "1" if self.simulated else "0",
            "Content-Type": "application/json",
        }

    def _request(self, method: str, full_path: str, data: str = None) -> dict:
        url = BASE_URL + full_path
        _retryable = (
            requests.exceptions.SSLError,
            requests.exceptions.ConnectionError,
            requests.exceptions.Timeout,
        )
        for attempt in range(3):
            try:
                headers = self._headers(method, full_path, data or "")
                if method == "GET":
                    r = requests.get(url, headers=headers, timeout=10)
                else:
                    r = requests.post(url, headers=headers, data=data, timeout=10)
                if not r.ok:
                    try:
                        err = r.json()
                        raise APIError(
                            f"OKX {r.status_code}: code={err.get('code')} msg={err.get('msg')}",
                            code=str(err.get("code", "")),
                            status=r.status_code,
                        )
                    except (ValueError, KeyError):
                        raise APIError(f"HTTP {r.status_code}", status=r.status_code)
                return r.json()
            except _retryable as e:
                if attempt == 2:
                    raise NetworkError(str(e)) from e
                time.sleep(0.5 * (attempt + 1))

    def get(self, path: str, params: dict = None) -> dict:
        qs = ("?" + "&".join(f"{k}={v}" for k, v in params.items())) if params else ""
        return self._request("GET", path + qs)

    def post(self, path: str, body: dict) -> dict:
        return self._request("POST", path, json.dumps(body))

    # ── Market Data (no auth needed) ──────────────────────────────────────────

    def ticker(self, inst_id: str) -> dict:
        feed = get_feed()
        if feed:
            feed.subscribe_tickers([inst_id])
            cached = feed.get_ticker(inst_id)
            if cached:
                return {"code": "0", "data": [cached]}
        return self.get("/api/v5/market/ticker", {"instId": inst_id})

    def tickers(self, inst_type: str) -> dict:
        """Fetch all tickers for a given instrument type (SPOT/SWAP/FUTURES)."""
        return self.get("/api/v5/market/tickers", {"instType": inst_type.upper()})

    def candles(self, inst_id: str, bar: str = "1H", limit: int = 100) -> dict:
        feed = get_feed()
        if feed:
            feed.subscribe_candles([inst_id], bar)
            cached = feed.get_candles(inst_id, bar)
            if cached and len(cached) >= limit:
                return {"code": "0", "data": cached[:limit]}
        # REST fallback — also seeds the WS cache so future calls hit the cache
        result = self.get("/api/v5/market/candles", {"instId": inst_id, "bar": bar, "limit": limit})
        if feed and result.get("code") == "0":
            for row in reversed(result["data"]):  # oldest→newest so deque order is correct
                feed._update_candle(inst_id, bar, row)
        return result

    def orderbook(self, inst_id: str, sz: int = 20) -> dict:
        return self.get("/api/v5/market/books", {"instId": inst_id, "sz": sz})

    def instruments(self, inst_type: str) -> dict:
        return self.get("/api/v5/public/instruments", {"instType": inst_type})

    def funding_rate(self, inst_id: str) -> dict:
        return self.get("/api/v5/public/funding-rate", {"instId": inst_id})

    # ── Account ──────────────────────────────────────────────────────────────

    def balance(self, ccy: str = "") -> dict:
        pfeed = get_private_feed()
        if pfeed:
            cached = pfeed.get_account()
            if cached:
                details = [v for v in cached.values() if not ccy or v.get("ccy") == ccy]
                if details:
                    return {"code": "0", "data": [{"details": details}]}
        p = {"ccy": ccy} if ccy else {}
        return self.get("/api/v5/account/balance", p)

    def positions(self, inst_type: str = "", inst_id: str = "") -> dict:
        pfeed = get_private_feed()
        if pfeed:
            cached = pfeed.get_positions()
            if cached:
                positions = list(cached.values())
                if inst_id:
                    positions = [p for p in positions if p.get("instId") == inst_id]
                if inst_type:
                    positions = [p for p in positions if p.get("instType") == inst_type]
                return {"code": "0", "data": positions}
        p = {}
        if inst_type:
            p["instType"] = inst_type
        if inst_id:
            p["instId"] = inst_id
        return self.get("/api/v5/account/positions", p)

    def account_config(self) -> dict:
        return self.get("/api/v5/account/config")

    # ── Trading ──────────────────────────────────────────────────────────────

    def place_order(self, inst_id: str, td_mode: str, side: str, ord_type: str,
                    sz: str, px: str = "", reduce_only: bool = False,
                    pos_side: str = "", tp_trigger_px: str = "",
                    tp_ord_px: str = "", sl_trigger_px: str = "",
                    sl_ord_px: str = "") -> dict:
        body: dict[str, Any] = {
            "instId": inst_id,
            "tdMode": td_mode,
            "side": side,
            "ordType": ord_type,
            "sz": sz,
        }
        if px:
            body["px"] = px
        if reduce_only:
            body["reduceOnly"] = "true"
        if pos_side:
            body["posSide"] = pos_side
        if tp_trigger_px or sl_trigger_px:
            algo: dict[str, Any] = {}
            if tp_trigger_px:
                algo["tpTriggerPx"] = tp_trigger_px
                algo["tpOrdPx"] = tp_ord_px or "-1"
                algo["tpTriggerPxType"] = "last"
            if sl_trigger_px:
                algo["slTriggerPx"] = sl_trigger_px
                algo["slOrdPx"] = sl_ord_px or "-1"
                algo["slTriggerPxType"] = "last"
            body["attachAlgoOrds"] = [algo]
        return self.post("/api/v5/trade/order", body)

    def cancel_order(self, inst_id: str, ord_id: str) -> dict:
        return self.post("/api/v5/trade/cancel-order", {"instId": inst_id, "ordId": ord_id})

    def cancel_all_orders(self, inst_id: str) -> dict:
        pending = self.pending_orders(inst_id)
        results = []
        for o in pending.get("data", []):
            results.append(self.cancel_order(inst_id, o["ordId"]))
        succeeded = sum(1 for r in results if r.get("code") == "0")
        failed = len(results) - succeeded
        return {"cancelled": succeeded, "failed": failed, "results": results}

    def amend_order(self, inst_id: str, ord_id: str, new_sz: str = "", new_px: str = "") -> dict:
        body: dict[str, Any] = {"instId": inst_id, "ordId": ord_id}
        if new_sz:
            body["newSz"] = new_sz
        if new_px:
            body["newPx"] = new_px
        return self.post("/api/v5/trade/amend-order", body)

    def pending_orders(self, inst_id: str = "", inst_type: str = "") -> dict:
        p = {}
        if inst_id:
            p["instId"] = inst_id
        if inst_type:
            p["instType"] = inst_type
        return self.get("/api/v5/trade/orders-pending", p)

    def order_history(self, inst_type: str, inst_id: str = "", limit: int = 20) -> dict:
        p: dict[str, Any] = {"instType": inst_type, "limit": limit}
        if inst_id:
            p["instId"] = inst_id
        return self.get("/api/v5/trade/orders-history", p)

    def set_leverage(self, inst_id: str, lever: int, mg_mode: str = "cross", pos_side: str = "") -> dict:
        body: dict[str, Any] = {"instId": inst_id, "lever": str(lever), "mgnMode": mg_mode}
        if pos_side:
            body["posSide"] = pos_side
        return self.post("/api/v5/account/set-leverage", body)

    def position_risk(self, inst_type: str = "") -> dict:
        """Fetch position risk data including liquidation price per position."""
        p = {"instType": inst_type} if inst_type else {}
        return self.get("/api/v5/account/position-risk", p)

    # ── Algo Orders ───────────────────────────────────────────────────────────

    def place_algo_order(self, inst_id: str, td_mode: str, side: str,
                         ord_type: str, sz: str,
                         tp_trigger_px: str = "", tp_ord_px: str = "",
                         sl_trigger_px: str = "", sl_ord_px: str = "",
                         pos_side: str = "", reduce_only: bool = False) -> dict:
        """Place a standalone algo order (conditional / oco).

        ord_type:
          'conditional' — single TP or SL trigger
          'oco'         — TP + SL together; whichever triggers first cancels the other
        """
        body: dict[str, Any] = {
            "instId": inst_id,
            "tdMode": td_mode,
            "side": side,
            "ordType": ord_type,
            "sz": sz,
        }
        if pos_side:
            body["posSide"] = pos_side
        if reduce_only:
            body["reduceOnly"] = "true"
        if tp_trigger_px:
            body["tpTriggerPx"] = tp_trigger_px
            body["tpOrdPx"] = tp_ord_px or "-1"
            body["tpTriggerPxType"] = "last"
        if sl_trigger_px:
            body["slTriggerPx"] = sl_trigger_px
            body["slOrdPx"] = sl_ord_px or "-1"
            body["slTriggerPxType"] = "last"
        return self.post("/api/v5/trade/order-algo", body)

    def cancel_algo_order(self, inst_id: str, algo_id: str) -> dict:
        """Cancel a pending algo order by algoId."""
        return self.post("/api/v5/trade/cancel-algos",
                         [{"instId": inst_id, "algoId": algo_id}])

    def pending_algo_orders(self, inst_id: str = "", ord_type: str = "") -> dict:
        """Fetch pending algo orders (conditional / oco / trigger / iceberg / twap)."""
        p: dict[str, Any] = {"ordType": ord_type} if ord_type else {}
        if inst_id:
            p["instId"] = inst_id
        return self.get("/api/v5/trade/orders-algo-pending", p)

    # ── Batch Orders ──────────────────────────────────────────────────────────

    def batch_orders(self, orders: list[dict]) -> dict:
        """Place up to 20 orders in a single request.

        Each element of `orders` is a dict with the same fields as place_order().
        Returns a list of results, one per order.
        """
        return self.post("/api/v5/trade/batch-orders", orders)

    def transfer(self, ccy: str, amt: str, from_: str, to: str) -> dict:
        """from/to: 6=funding, 18=trading"""
        return self.post("/api/v5/asset/transfer", {
            "ccy": ccy, "amt": amt, "from": from_, "to": to, "type": "0"
        })


path: scripts/okx_decision.py
content: #!/usr/bin/env python3
"""
OKX Trading Decision Engine v1.0
结合 JSON 数据进行推演、规划、买卖决策
"""

import json
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple

MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"

# 数据文件
LEARNING_MODEL_FILE = MEMORY_DIR / "okx-learning-model.json"
TRADE_JOURNAL_FILE = MEMORY_DIR / "okx-trade-journal.json"
LESSONS_FILE = MEMORY_DIR / "okx-lessons.json"
PATTERNS_FILE = MEMORY_DIR / "okx-patterns.json"
MONITORING_LOG_FILE = MEMORY_DIR / "okx-monitoring-log.json"
DECISION_LOG_FILE = MEMORY_DIR / "okx-decision-log.json"


def load_json(filepath: Path) -> dict:
    try:
        with open(filepath, 'r') as f:
            return json.load(f)
    except FileNotFoundError:
        return {}


def save_json(filepath: Path, data: dict):
    with open(filepath, 'w') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


class DecisionEngine:
    """交易决策引擎"""
    
    def __init__(self):
        self.model = load_json(LEARNING_MODEL_FILE)
        self.lessons = load_json(LESSONS_FILE)
        self.patterns = load_json(PATTERNS_FILE)
        self.journal = load_json(TRADE_JOURNAL_FILE)
        self.monitoring = load_json(MONITORING_LOG_FILE)
        self.decision_log = load_json(DECISION_LOG_FILE)
    
    def check_avoid_conditions(self, coin: str, signal: str, market_regime: str) -> Tuple[bool, str]:
        """检查是否应避免某类交易"""
        for lesson in self.lessons.get("lessons", []):
            avoid = lesson.get("avoid_condition", {})
            if (avoid.get("coin") == coin and 
                avoid.get("signal") == signal and 
                avoid.get("market_regime") == market_regime):
                return True, f"教训#{lesson.get('type', 'unknown')}: {lesson.get('lesson', '')}"
        
        for pattern in self.patterns.get("patterns", []):
            if (pattern.get("coin") == coin and 
                pattern.get("signal") == signal and 
                pattern.get("market_regime") == market_regime and
                pattern.get("pattern_type") == "failed"):
                return True, f"失败模式：胜率{pattern.get('win_rate', 0)*100:.1f}%"
        
        return False, ""
    
    def check_success_patterns(self, coin: str, signal: str, market_regime: str) -> List[dict]:
        """检查是否有成功模式可复用"""
        suggestions = []
        
        for pattern in self.patterns.get("patterns", []):
            if (pattern.get("coin") == coin and 
                pattern.get("signal") == signal and 
                pattern.get("market_regime") == market_regime and
                pattern.get("pattern_type") == "successful"):
                suggestions.append({
                    "pattern": f"{coin}_{signal}_{market_regime}",
                    "win_rate": f"{pattern.get('win_rate', 0)*100:.1f}%",
                    "avg_pnl": f"{pattern.get('total_pnl', 0)/max(1, pattern.get('trades', 1)):.2f}%",
                    "trades": pattern.get("trades", 0)
                })
        
        for lesson in self.lessons.get("lessons", []):
            replicate = lesson.get("replicate_condition", {})
            if (replicate.get("coin") == coin and 
                replicate.get("signal") == signal and 
                replicate.get("market_regime") == market_regime):
                suggestions.append({
                    "lesson": lesson.get("lesson", ""),
                    "action": lesson.get("action", "")
                })
        
        return suggestions
    
    def simulate_scenario(self, coin: str, direction: str, entry_price: float, 
                          position_usdt: float, leverage: int, 
                          stop_loss_pct: float, take_profit_pct: float) -> dict:
        """推演交易情景"""
        # 基于历史数据模拟
        recent_trades = self.journal.get("trades", [])[-50:]  # 最近 50 笔
        
        coin_trades = [t for t in recent_trades if t.get("coin", "").startswith(coin.split("-")[0])]
        
        if not coin_trades:
            return {
                "status": "insufficient_data",
                "message": "该币种历史交易数据不足"
            }
        
        # 统计类似交易的表现
        similar_trades = [
            t for t in coin_trades 
            if t.get("direction", "") == direction
        ]
        
        if not similar_trades:
            similar_trades = coin_trades  #  fallback
        
        win_count = sum(1 for t in similar_trades if t.get("pnl_pct", 0) > 0)
        total_pnl = sum(t.get("pnl_pct", 0) for t in similar_trades)
        avg_win = sum(t.get("pnl_pct", 0) for t in similar_trades if t.get("pnl_pct", 0) > 0) / max(1, win_count)
        avg_loss = sum(t.get("pnl_pct", 0) for t in similar_trades if t.get("pnl_pct", 0) < 0) / max(1, len(similar_trades) - win_count)
        
        # 情景推演
        scenarios = {
            "bull_case": {
                "probability": 0.3,
                "outcome": f"+{take_profit_pct*100:.1f}%",
                "pnl_usdt": position_usdt * leverage * take_profit_pct
            },
            "base_case": {
                "probability": 0.5,
                "outcome": f"{total_pnl/max(1, len(similar_trades)):+.1f}%",
                "pnl_usdt": position_usdt * leverage * (total_pnl/max(1, len(similar_trades)))
            },
            "bear_case": {
                "probability": 0.2,
                "outcome": f"-{stop_loss_pct*100:.1f}%",
                "pnl_usdt": -position_usdt * leverage * stop_loss_pct
            }
        }
        
        expected_value = (
            scenarios["bull_case"]["pnl_usdt"] * scenarios["bull_case"]["probability"] +
            scenarios["base_case"]["pnl_usdt"] * scenarios["base_case"]["probability"] +
            scenarios["bear_case"]["pnl_usdt"] * scenarios["bear_case"]["probability"]
        )
        
        return {
            "status": "simulated",
            "sample_size": len(similar_trades),
            "historical_win_rate": f"{win_count/max(1, len(similar_trades))*100:.1f}%",
            "avg_win": f"{avg_win:.2f}%",
            "avg_loss": f"{avg_loss:.2f}%",
            "scenarios": scenarios,
            "expected_value_usdt": f"{expected_value:.2f}"
        }
    
    def generate_decision(self, coin: str, signal: str, market_regime: str, 
                          current_price: float, rsi: float = 50) -> dict:
        """生成买卖决策"""
        decision = {
            "timestamp": datetime.now().isoformat(),
            "coin": coin,
            "signal": signal,
            "market_regime": market_regime,
            "current_price": current_price,
            "rsi": rsi,
            "decision": "wait",  # buy / sell / wait
            "confidence": 0.0,
            "reasons": [],
            "parameters": {},
            "simulation": {},
            "avoid_warning": None,
            "success_patterns": []
        }
        
        # 1. 检查规避条件
        should_avoid, avoid_reason = self.check_avoid_conditions(coin, signal, market_regime)
        if should_avoid:
            decision["decision"] = "avoid"
            decision["avoid_warning"] = avoid_reason
            decision["reasons"].append(f"⚠️ {avoid_reason}")
            decision["confidence"] = 0.0
            return decision
        
        # 2. 检查成功模式
        success_patterns = self.check_success_patterns(coin, signal, market_regime)
        if success_patterns:
            decision["success_patterns"] = success_patterns
            decision["reasons"].append(f"✅ 发现{len(success_patterns)}个成功模式")
            decision["confidence"] += 0.3 * min(len(success_patterns), 3)
        
        # 3. RSI 极端值判断
        if rsi < 30:
            decision["decision"] = "buy"
            decision["reasons"].append(f"✅ RSI {rsi:.1f} < 30 (超卖)")
            decision["confidence"] += 0.3
        elif rsi > 70:
            decision["decision"] = "sell"
            decision["reasons"].append(f"✅ RSI {rsi:.1f} > 70 (超买)")
            decision["confidence"] += 0.3
        
        # 4. 市场状态判断
        if market_regime == "strong_bull":
            if signal == "BUY":
                decision["decision"] = "buy"
                decision["reasons"].append("✅ 强牛市 + BUY 信号共振")
                decision["confidence"] += 0.4
        elif market_regime == "strong_bear":
            if signal == "SELL":
                decision["decision"] = "sell"
                decision["reasons"].append("✅ 强熊市 + SELL 信号共振")
                decision["confidence"] += 0.4
        elif market_regime == "ranging":
            decision["reasons"].append("⚠️ 震荡市，降低权重")
            decision["confidence"] *= 0.5
        
        # 5. 参数建议
        if decision["decision"] in ["buy", "sell"]:
            # 基于历史最优参数
            optimal = self.model.get("optimal_parameters", {})
            
            # 根据成功模式调整
            if success_patterns:
                position_size = min(80, optimal.get("position_size_usdt", 50) * 1.5)
                take_profit = min(20, optimal.get("take_profit_pct", 15) * 1.2)
            else:
                position_size = optimal.get("position_size_usdt", 50)
                take_profit = optimal.get("take_profit_pct", 15)
            
            decision["parameters"] = {
                "position_usdt": position_size,
                "leverage": optimal.get("leverage", 3),
                "stop_loss_pct": optimal.get("stop_loss_pct", 3.0),
                "take_profit_pct": take_profit
            }
            
            # 情景推演
            direction = "long" if decision["decision"] == "buy" else "short"
            decision["simulation"] = self.simulate_scenario(
                coin=coin,
                direction=direction,
                entry_price=current_price,
                position_usdt=position_size,
                leverage=optimal.get("leverage", 3),
                stop_loss_pct=optimal.get("stop_loss_pct", 3.0),
                take_profit_pct=take_profit
            )
        
        # 6. 置信度封顶
        decision["confidence"] = min(1.0, decision["confidence"])
        
        # 7. 决策阈值
        if decision["confidence"] < 0.3:
            decision["decision"] = "wait"
            decision["reasons"].append("⚠️ 置信度不足 30%，建议观望")
        
        return decision
    
    def log_decision(self, decision: dict):
        """记录决策到日志"""
        if "decisions" not in self.decision_log:
            self.decision_log["decisions"] = []
        
        self.decision_log["decisions"].append(decision)
        
        # 限制日志大小
        if len(self.decision_log["decisions"]) > 500:
            self.decision_log["decisions"] = self.decision_log["decisions"][-500:]
        
        self.decision_log["last_updated"] = datetime.now().isoformat()
        save_json(DECISION_LOG_FILE, self.decision_log)
    
    def get_decision_summary(self, limit: int = 10) -> List[dict]:
        """获取最近决策摘要"""
        decisions = self.decision_log.get("decisions", [])[-limit:]
        
        summary = []
        for d in decisions:
            summary.append({
                "time": d.get("timestamp", "")[:19],
                "coin": d.get("coin", ""),
                "decision": d.get("decision", ""),
                "confidence": f"{d.get('confidence', 0)*100:.0f}%",
                "reasons": d.get("reasons", [])[:2]
            })
        
        return summary


def main():
    import sys
    
    engine = DecisionEngine()
    
    if len(sys.argv) < 2:
        print("""
OKX Decision Engine v1.0

Usage:
  python okx_decision.py decision <coin> <signal> <market_regime> <price> [rsi]
  python okx_decision.py summary [limit]
  python okx_decision.py simulate <coin> <direction> <price> <position> <leverage> <sl> <tp>
  python okx_decision.py avoid <coin> <signal> <market_regime>
        """)
        return
    
    cmd = sys.argv[1]
    
    if cmd == "decision":
        if len(sys.argv) < 6:
            print("Usage: python okx_decision.py decision <coin> <signal> <market_regime> <price> [rsi]")
            return
        
        coin = sys.argv[2]
        signal = sys.argv[3]
        regime = sys.argv[4]
        price = float(sys.argv[5])
        rsi = float(sys.argv[6]) if len(sys.argv) > 6 else 50
        
        decision = engine.generate_decision(coin, signal, regime, price, rsi)
        engine.log_decision(decision)
        
        print("\n" + "="*60)
        print("🎯 交易决策")
        print("="*60)
        print(f"币种：{decision['coin']}")
        print(f"信号：{decision['signal']}")
        print(f"市场状态：{decision['market_regime']}")
        print(f"当前价格：{decision['current_price']}")
        print(f"RSI: {decision['rsi']}")
        print("-"*60)
        print(f"决策：{decision['decision'].upper()}")
        print(f"置信度：{decision['confidence']*100:.0f}%")
        print("-"*60)
        
        if decision['avoid_warning']:
            print(f"⚠️ 规避警告：{decision['avoid_warning']}")
        
        if decision['success_patterns']:
            print(f"\n✅ 成功模式 ({len(decision['success_patterns'])}个):")
            for p in decision['success_patterns'][:3]:
                if 'win_rate' in p:
                    print(f"   - {p.get('pattern', '')}: 胜率{p['win_rate']}, 平均盈利{p['avg_pnl']}")
                else:
                    print(f"   - {p.get('lesson', '')}")
        
        print(f"\n📋 理由:")
        for r in decision['reasons']:
            print(f"   {r}")
        
        if decision['parameters']:
            print(f"\n💰 建议参数:")
            print(f"   仓位：{decision['parameters'].get('position_usdt', 0)} USDT")
            print(f"   杠杆：{decision['parameters'].get('leverage', 0)}x")
            print(f"   止损：{decision['parameters'].get('stop_loss_pct', 0)}%")
            print(f"   止盈：{decision['parameters'].get('take_profit_pct', 0)}%")
        
        if decision.get('simulation', {}).get('status') == 'simulated':
            sim = decision['simulation']
            print(f"\n🔮 情景推演:")
            print(f"   样本数：{sim.get('sample_size', 0)} 笔历史交易")
            print(f"   历史胜率：{sim.get('historical_win_rate', 'N/A')}")
            print(f"   期望值：{sim.get('expected_value_usdt', 'N/A')} USDT")
            print(f"   乐观情景 (+30%): {sim['scenarios']['bull_case']['outcome']}")
            print(f"   基准情景 (+50%): {sim['scenarios']['base_case']['outcome']}")
            print(f"   悲观情景 (+20%): {sim['scenarios']['bear_case']['outcome']}")
        
        print("="*60 + "\n")
    
    elif cmd == "summary":
        limit = int(sys.argv[2]) if len(sys.argv) > 2 else 10
        summary = engine.get_decision_summary(limit)
        
        print(f"\n最近{limit}笔决策:\n")
        for s in summary:
            print(f"{s['time']} | {s['coin'][:15]:15} | {s['decision']:6} | {s['confidence']:6} | {', '.join(s['reasons'])[:50]}")
        print()
    
    elif cmd == "simulate":
        if len(sys.argv) < 9:
            print("Usage: python okx_decision.py simulate <coin> <direction> <price> <position> <leverage> <sl> <tp>")
            return
        
        result = engine.simulate_scenario(
            coin=sys.argv[2],
            direction=sys.argv[3],
            entry_price=float(sys.argv[4]),
            position_usdt=float(sys.argv[5]),
            leverage=int(sys.argv[6]),
            stop_loss_pct=float(sys.argv[7]),
            take_profit_pct=float(sys.argv[8])
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif cmd == "avoid":
        if len(sys.argv) < 5:
            print("Usage: python okx_decision.py avoid <coin> <signal> <market_regime>")
            return
        
        should_avoid, reason = engine.check_avoid_conditions(sys.argv[2], sys.argv[3], sys.argv[4])
        if should_avoid:
            print(f"⚠️ 避免交易：{reason}")
        else:
            print("✅ 可以交易")


if __name__ == "__main__":
    main()


path: scripts/okx_learning.py
content: #!/usr/bin/env python3
"""
OKX Trading Learning System v2.0
自我学习、参数优化、市场状态识别、数据压缩、经验提炼
"""

import json
import os
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict
import hashlib

MEMORY_DIR = Path.home() / ".openclaw" / "workspace" / "memory"
LEARNING_MODEL_FILE = MEMORY_DIR / "okx-learning-model.json"
TRADE_JOURNAL_FILE = MEMORY_DIR / "okx-trade-journal.json"
MONITORING_LOG_FILE = MEMORY_DIR / "okx-monitoring-log.json"
LESSONS_FILE = MEMORY_DIR / "okx-lessons.json"
PATTERNS_FILE = MEMORY_DIR / "okx-patterns.json"
PREFERENCES_FILE = MEMORY_DIR / "okx-trading-preferences.json"

# 数据保留策略
MAX_TRADES_IN_MEMORY = 1000  # 内存中保留最多 1000 笔交易
MAX_MONITORING_SESSIONS = 100  # 最多 100 个监控会话
COMPRESS_AFTER_DAYS = 7  # 7 天后压缩归档
DELETE_AFTER_DAYS = 90  # 90 天后删除

# 经验提炼阈值
MIN_TRADES_FOR_PATTERN = 10  # 至少 10 笔交易才识别模式
MIN_WIN_RATE_FOR_PATTERN = 0.6  # 胜率 60%+ 才视为成功模式
MAX_LOSS_RATE_FOR_PATTERN = 0.3  # 亏损率 30%-才视为失败模式


def load_json(filepath):
    try:
        with open(filepath, 'r') as f:
            return json.load(f)
    except FileNotFoundError:
        return {}


def save_json(filepath, data):
    with open(filepath, 'w') as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


def _init_model(model: dict) -> dict:
    """Initialize model structure if missing (first run or new file)."""
    model.setdefault("performance_stats", {
        "total_trades": 0, "winning_trades": 0, "losing_trades": 0,
        "win_rate": 0.0, "total_pnl_usdt": 0.0,
    })
    model.setdefault("coin_performance", {})
    model.setdefault("lessons_learned", [])
    model.setdefault("optimal_parameters", {
        "stop_loss_pct": 3.0, "take_profit_pct": 15.0,
        "position_size_usdt": 50, "leverage": 3,
    })
    return model


def record_trade(trade_data):
    """记录交易并更新学习模型（带数据压缩）"""
    journal = load_json(TRADE_JOURNAL_FILE)
    model = _init_model(load_json(LEARNING_MODEL_FILE))
    lessons = load_json(LESSONS_FILE)
    patterns = load_json(PATTERNS_FILE)
    
    # 添加交易记录
    trade_record = {
        "id": generate_trade_id(trade_data),
        "timestamp": datetime.now().isoformat(),
        **trade_data
    }
    
    if "trades" not in journal:
        journal["trades"] = []
    journal["trades"].append(trade_record)
    journal["last_updated"] = datetime.now().isoformat()
    
    # 数据压缩检查
    journal = compress_trade_journal(journal)
    save_json(TRADE_JOURNAL_FILE, journal)
    
    # 更新统计数据
    update_model_stats(model, trade_data)
    save_json(LEARNING_MODEL_FILE, model)
    
    # 提炼经验教训
    lesson = extract_lesson(trade_data)
    if lesson:
        if "lessons" not in lessons:
            lessons["lessons"] = []
        lessons["lessons"].append(lesson)
        lessons["last_updated"] = datetime.now().isoformat()
        save_json(LESSONS_FILE, lessons)
    
    # 更新模式识别
    update_patterns(patterns, trade_data)
    save_json(PATTERNS_FILE, patterns)
    
    return model


def generate_trade_id(trade_data):
    """生成唯一交易 ID"""
    content = f"{trade_data.get('coin', '')}{trade_data.get('timestamp', '')}{trade_data.get('entry_price', 0)}"
    return hashlib.md5(content.encode()).hexdigest()[:12]


def compress_trade_journal(journal):
    """压缩交易记录，保留最近 N 笔，归档旧数据"""
    trades = journal.get("trades", [])
    
    if len(trades) <= MAX_TRADES_IN_MEMORY:
        return journal
    
    # 分离新旧数据
    recent_trades = trades[-MAX_TRADES_IN_MEMORY:]
    old_trades = trades[:-MAX_TRADES_IN_MEMORY]
    
    # 压缩旧数据为统计摘要
    if "compressed_stats" not in journal:
        journal["compressed_stats"] = []
    
    compressed = {
        "period_start": old_trades[0].get("timestamp"),
        "period_end": old_trades[-1].get("timestamp"),
        "total_trades": len(old_trades),
        "winning_trades": sum(1 for t in old_trades if t.get("pnl_pct", 0) > 0),
        "losing_trades": sum(1 for t in old_trades if t.get("pnl_pct", 0) < 0),
        "total_pnl_usdt": sum(t.get("pnl_usdt", 0) for t in old_trades),
        "avg_win_pct": sum(t.get("pnl_pct", 0) for t in old_trades if t.get("pnl_pct", 0) > 0) / max(1, sum(1 for t in old_trades if t.get("pnl_pct", 0) > 0)),
        "avg_loss_pct": sum(t.get("pnl_pct", 0) for t in old_trades if t.get("pnl_pct", 0) < 0) / max(1, sum(1 for t in old_trades if t.get("pnl_pct", 0) < 0)),
        "compressed_at": datetime.now().isoformat()
    }
    journal["compressed_stats"].append(compressed)
    
    # 保留最近交易
    journal["trades"] = recent_trades
    
    return journal


def extract_lesson(trade_data):
    """从单笔交易提炼经验教训"""
    pnl_pct = trade_data.get("pnl_pct", 0)
    coin = trade_data.get("coin", "UNKNOWN")
    hold_time = trade_data.get("hold_time_hours", 0)
    signal_type = trade_data.get("signal_type", "unknown")
    market_regime = trade_data.get("market_regime", "unknown")
    
    lesson = None
    
    # 大额亏损教训
    if pnl_pct < -5:
        lesson = {
            "type": "large_loss",
            "severity": "high" if pnl_pct < -10 else "medium",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "signal_type": signal_type,
            "market_regime": market_regime,
            "timestamp": datetime.now().isoformat(),
            "lesson": f"在{market_regime}市场使用{signal_type}信号亏损{pnl_pct:.1f}%",
            "action": f"收紧{coin}止损或减少该信号权重",
            "avoid_condition": {
                "coin": coin,
                "signal": signal_type,
                "market_regime": market_regime
            }
        }
    
    # 大额盈利经验
    elif pnl_pct > 10:
        lesson = {
            "type": "large_win",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "signal_type": signal_type,
            "market_regime": market_regime,
            "timestamp": datetime.now().isoformat(),
            "lesson": f"在{market_regime}市场使用{signal_type}信号盈利{pnl_pct:.1f}%",
            "action": f"增加{coin}在该市场状态下的仓位",
            "replicate_condition": {
                "coin": coin,
                "signal": signal_type,
                "market_regime": market_regime
            }
        }
    
    # 快速止盈经验
    elif hold_time < 1 and pnl_pct > 5:
        lesson = {
            "type": "quick_win",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "hold_time": hold_time,
            "timestamp": datetime.now().isoformat(),
            "lesson": f"{coin} 快速盈利{pnl_pct:.1f}% (持仓{hold_time:.1f}h)",
            "action": "该币种适合短线策略"
        }
    
    # 止损正确经验
    elif -3.5 < pnl_pct < -2.5:
        lesson = {
            "type": "good_stop",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "timestamp": datetime.now().isoformat(),
            "lesson": f"{coin} 止损执行正确 (亏损{pnl_pct:.1f}%)",
            "action": "止损参数合理，继续保持"
        }
    
    return lesson


def update_patterns(patterns, trade_data):
    """更新交易模式识别"""
    if "patterns" not in patterns:
        patterns["patterns"] = []
    
    coin = trade_data.get("coin", "UNKNOWN")
    signal = trade_data.get("signal_type", "unknown")
    regime = trade_data.get("market_regime", "unknown")
    pnl = trade_data.get("pnl_pct", 0)
    
    # 查找或创建模式
    pattern_key = f"{coin}_{signal}_{regime}"
    existing_pattern = None
    for p in patterns["patterns"]:
        if p.get("key") == pattern_key:
            existing_pattern = p
            break
    
    if not existing_pattern:
        existing_pattern = {
            "key": pattern_key,
            "coin": coin,
            "signal": signal,
            "market_regime": regime,
            "trades": 0,
            "wins": 0,
            "losses": 0,
            "total_pnl": 0,
            "created_at": datetime.now().isoformat()
        }
        patterns["patterns"].append(existing_pattern)
    
    # 更新模式统计
    existing_pattern["trades"] += 1
    existing_pattern["total_pnl"] += pnl
    if pnl > 0:
        existing_pattern["wins"] += 1
    elif pnl < 0:
        existing_pattern["losses"] += 1
    
    # 计算胜率
    existing_pattern["win_rate"] = existing_pattern["wins"] / existing_pattern["trades"]
    
    # 标记模式类型
    if existing_pattern["trades"] >= MIN_TRADES_FOR_PATTERN:
        if existing_pattern["win_rate"] >= MIN_WIN_RATE_FOR_PATTERN:
            existing_pattern["pattern_type"] = "successful"
        elif existing_pattern["win_rate"] <= MAX_LOSS_RATE_FOR_PATTERN:
            existing_pattern["pattern_type"] = "failed"
        else:
            existing_pattern["pattern_type"] = "neutral"
    
    patterns["last_updated"] = datetime.now().isoformat()


def update_model_stats(model, trade):
    """更新模型统计"""
    stats = model["performance_stats"]
    pnl_pct = trade.get("pnl_pct", 0)
    coin = trade.get("coin", "UNKNOWN")
    
    stats["total_trades"] += 1
    
    if pnl_pct > 0:
        stats["winning_trades"] += 1
    elif pnl_pct < 0:
        stats["losing_trades"] += 1
    
    # 胜率
    if stats["total_trades"] > 0:
        stats["win_rate"] = stats["winning_trades"] / stats["total_trades"] * 100
    
    # 平均盈亏
    stats["total_pnl_usdt"] += trade.get("pnl_usdt", 0)
    
    # 更新币种表现
    if coin not in model["coin_performance"]:
        model["coin_performance"][coin] = {
            "trades": 0, "wins": 0, "losses": 0, "total_pnl": 0
        }
    model["coin_performance"][coin]["trades"] += 1
    if pnl_pct > 0:
        model["coin_performance"][coin]["wins"] += 1
    elif pnl_pct < 0:
        model["coin_performance"][coin]["losses"] += 1
    model["coin_performance"][coin]["total_pnl"] += pnl_pct
    
    # 学习教训
    if pnl_pct < -5:  # 大额亏损
        lesson = {
            "type": "large_loss",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "timestamp": datetime.now().isoformat(),
            "suggestion": f"考虑收紧 {coin} 的止损或降低仓位"
        }
        model["lessons_learned"].append(lesson)
    elif pnl_pct > 10:  # 大额盈利
        lesson = {
            "type": "large_win",
            "coin": coin,
            "pnl_pct": pnl_pct,
            "timestamp": datetime.now().isoformat(),
            "suggestion": f"{coin} 策略有效，可适当增加仓位"
        }
        model["lessons_learned"].append(lesson)


def get_lessons_summary():
    """获取经验教训总结"""
    lessons = load_json(LESSONS_FILE)
    patterns = load_json(PATTERNS_FILE)
    
    summary = {
        "total_lessons": len(lessons.get("lessons", [])),
        "avoid_conditions": [],
        "replicate_conditions": [],
        "successful_patterns": [],
        "failed_patterns": []
    }
    
    # 提取规避条件
    for lesson in lessons.get("lessons", []):
        if lesson.get("type") == "large_loss" and lesson.get("avoid_condition"):
            summary["avoid_conditions"].append({
                "condition": lesson["avoid_condition"],
                "reason": lesson["lesson"],
                "action": lesson["action"]
            })
        
        # 提取复制条件
        if lesson.get("type") == "large_win" and lesson.get("replicate_condition"):
            summary["replicate_conditions"].append({
                "condition": lesson["replicate_condition"],
                "reason": lesson["lesson"],
                "action": lesson["action"]
            })
    
    # 提取成功/失败模式
    for pattern in patterns.get("patterns", []):
        if pattern.get("pattern_type") == "successful":
            summary["successful_patterns"].append({
                "coin": pattern["coin"],
                "signal": pattern["signal"],
                "market_regime": pattern["market_regime"],
                "win_rate": f"{pattern['win_rate']*100:.1f}%",
                "trades": pattern["trades"],
                "avg_pnl": f"{pattern['total_pnl']/pattern['trades']:.2f}%"
            })
        elif pattern.get("pattern_type") == "failed":
            summary["failed_patterns"].append({
                "coin": pattern["coin"],
                "signal": pattern["signal"],
                "market_regime": pattern["market_regime"],
                "win_rate": f"{pattern['win_rate']*100:.1f}%",
                "trades": pattern["trades"],
                "avg_pnl": f"{pattern['total_pnl']/pattern['trades']:.2f}%"
            })
    
    return summary


def should_avoid_trade(coin, signal, market_regime):
    """检查是否应该避免某类交易"""
    lessons = load_json(LESSONS_FILE)
    patterns = load_json(PATTERNS_FILE)
    
    # 检查规避条件
    for lesson in lessons.get("lessons", []):
        avoid = lesson.get("avoid_condition", {})
        if (avoid.get("coin") == coin and
            avoid.get("signal") == signal and
            avoid.get("market_regime") == market_regime):
            return True, f"避免：{lesson['lesson']}"
    
    # 检查失败模式
    for pattern in patterns.get("patterns", []):
        if (pattern.get("coin") == coin and
            pattern.get("signal") == signal and
            pattern.get("market_regime") == market_regime and
            pattern.get("pattern_type") == "failed"):
            return True, f"失败模式：胜率{pattern.get('win_rate', 0)*100:.1f}%"
    
    return False, None


def get_optimal_conditions(coin, signal, market_regime):
    """获取最佳交易条件"""
    lessons = load_json(LESSONS_FILE)
    patterns = load_json(PATTERNS_FILE)
    
    suggestions = []
    
    # 检查复制条件
    for lesson in lessons.get("lessons", []):
        replicate = lesson.get("replicate_condition", {})
        if (replicate.get("coin") == coin and
            replicate.get("signal") == signal and
            replicate.get("market_regime") == market_regime):
            suggestions.append({
                "type": "replicate",
                "reason": lesson["lesson"],
                "action": lesson["action"]
            })
    
    # 检查成功模式
    for pattern in patterns.get("patterns", []):
        if (pattern.get("coin") == coin and
            pattern.get("signal") == signal and
            pattern.get("market_regime") == market_regime and
            pattern.get("pattern_type") == "successful"):
            suggestions.append({
                "type": "successful_pattern",
                "win_rate": f"{pattern['win_rate']*100:.1f}%",
                "avg_pnl": f"{pattern['total_pnl']/pattern['trades']:.2f}%",
                "trades": pattern["trades"]
            })
    
    return suggestions


def cleanup_old_data():
    """清理过期数据"""
    cutoff_date = datetime.now() - timedelta(days=DELETE_AFTER_DAYS)
    
    # 清理交易日志
    journal = load_json(TRADE_JOURNAL_FILE)
    if "trades" in journal:
        original_count = len(journal["trades"])
        journal["trades"] = [
            t for t in journal["trades"]
            if datetime.fromisoformat(t.get("timestamp", "2000-01-01")) > cutoff_date
        ]
        if len(journal["trades"]) < original_count:
            save_json(TRADE_JOURNAL_FILE, journal)
            return f"清理了 {original_count - len(journal['trades'])} 笔过期交易记录"
    
    return "无需清理"


def analyze_and_optimize():
    """分析历史数据并优化参数"""
    journal = load_json(TRADE_JOURNAL_FILE)
    model = load_json(LEARNING_MODEL_FILE)
    lessons = load_json(LESSONS_FILE)
    patterns = load_json(PATTERNS_FILE)
    
    trades = journal.get("trades", [])
    if len(trades) < 5:
        return {
            "status": "insufficient_data",
            "message": "需要至少 5 笔交易才能优化参数",
            "current_trades": len(trades)
        }
    
    # 分析最佳参数组合
    winning_trades = [t for t in trades if t.get("pnl_pct", 0) > 0]
    losing_trades = [t for t in trades if t.get("pnl_pct", 0) < 0]
    
    # 计算平均持仓时间
    if winning_trades:
        avg_hold = sum(t.get("hold_time_hours", 0) for t in winning_trades) / len(winning_trades)
    else:
        avg_hold = 0
    
    # 参数优化建议
    suggestions = []
    
    # 胜率过低 → 收紧止损
    if model["performance_stats"]["win_rate"] < 40:
        suggestions.append({
            "param": "stop_loss_pct",
            "current": model["optimal_parameters"]["stop_loss_pct"],
            "suggested": 2.0,
            "reason": f"胜率仅{model['performance_stats']['win_rate']:.1f}%，建议收紧止损"
        })
    
    # 平均盈利高 → 放宽止盈
    if winning_trades:
        avg_win = sum(t.get("pnl_pct", 0) for t in winning_trades) / len(winning_trades)
        if avg_win > 8:
            suggestions.append({
                "param": "take_profit_pct",
                "current": model["optimal_parameters"]["take_profit_pct"],
                "suggested": 20.0,
                "reason": f"平均盈利{avg_win:.1f}%，可尝试更高止盈"
            })
    
    # 币种表现分析
    worst_coin = None
    worst_rate = 100
    for coin, perf in model["coin_performance"].items():
        if perf["trades"] >= 3:
            rate = perf["wins"] / perf["trades"] * 100
            if rate < worst_rate:
                worst_rate = rate
                worst_coin = coin
    
    if worst_coin and worst_rate < 30:
        suggestions.append({
            "param": f"avoid_coin",
            "value": worst_coin,
            "reason": f"{worst_coin} 胜率仅{worst_rate:.1f}%，建议减少交易"
        })
    
    return {
        "status": "optimized",
        "total_trades": len(trades),
        "win_rate": model["performance_stats"]["win_rate"],
        "suggestions": suggestions
    }


def identify_market_regime(monitoring_data):
    """识别当前市场状态"""
    if not monitoring_data:
        return "unknown"
    
    # 分析最近的信号
    recent_signals = monitoring_data[-20:] if len(monitoring_data) >= 20 else monitoring_data
    
    sell_count = sum(1 for d in recent_signals if d.get("signal") == "SELL")
    buy_count = sum(1 for d in recent_signals if d.get("signal") == "BUY")
    
    sell_ratio = sell_count / len(recent_signals) if recent_signals else 0
    
    if sell_ratio > 0.8:
        return "strong_bear"  # 强烈看跌
    elif sell_ratio > 0.6:
        return "weak_bear"    # 弱势看跌
    elif sell_ratio < 0.2:
        return "strong_bull"  # 强烈看涨
    elif sell_ratio < 0.4:
        return "weak_bull"    # 弱势看涨
    else:
        return "ranging"      # 震荡


def get_adaptive_parameters(market_regime):
    """根据市场状态返回自适应参数"""
    base_params = {
        "strong_bull": {
            "position_size": 80, "leverage": 5, "stop_loss": 4.0, "take_profit": 20.0,
            "strategy": "trend_follow_long"
        },
        "weak_bull": {
            "position_size": 50, "leverage": 3, "stop_loss": 3.0, "take_profit": 15.0,
            "strategy": "cautious_long"
        },
        "ranging": {
            "position_size": 30, "leverage": 2, "stop_loss": 2.0, "take_profit": 8.0,
            "strategy": "mean_reversion"
        },
        "weak_bear": {
            "position_size": 40, "leverage": 3, "stop_loss": 3.0, "take_profit": 12.0,
            "strategy": "cautious_short"
        },
        "strong_bear": {
            "position_size": 60, "leverage": 4, "stop_loss": 4.0, "take_profit": 18.0,
            "strategy": "trend_follow_short"
        }
    }
    
    return base_params.get(market_regime, base_params["ranging"])


if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 2:
        print("Usage: python okx_learning.py <command> [args]")
        print("""
Commands:
  record <json>      - 记录交易
  status             - 显示学习状态
  analyze            - 分析并优化参数
  lessons            - 显示经验教训总结
  patterns           - 显示识别的交易模式
  check <coin> <signal> <regime> - 检查是否应避免某类交易
  cleanup            - 清理过期数据
  compress           - 手动压缩数据
        """)
        sys.exit(1)
    
    cmd = sys.argv[1]
    
    if cmd == "record":
        trade_data = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
        result = record_trade(trade_data)
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif cmd == "status":
        model = load_json(LEARNING_MODEL_FILE)
        print(json.dumps(model.get("performance_stats", {}), indent=2, ensure_ascii=False))
    
    elif cmd == "analyze":
        result = analyze_and_optimize()
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif cmd == "lessons":
        result = get_lessons_summary()
        print(json.dumps(result, indent=2, ensure_ascii=False))
    
    elif cmd == "patterns":
        patterns = load_json(PATTERNS_FILE)
        print(json.dumps(patterns.get("patterns", []), indent=2, ensure_ascii=False))
    
    elif cmd == "check":
        if len(sys.argv) < 5:
            print("Usage: python okx_learning.py check <coin> <signal> <market_regime>")
            sys.exit(1)
        coin, signal, regime = sys.argv[2], sys.argv[3], sys.argv[4]
        should_avoid, reason = should_avoid_trade(coin, signal, regime)
        if should_avoid:
            print(f"⚠️ 避免交易：{reason}")
        else:
            print("✅ 可以交易")
            suggestions = get_optimal_conditions(coin, signal, regime)
            if suggestions:
                print("建议:")
                for s in suggestions:
                    print(f"  - {s}")
    
    elif cmd == "cleanup":
        result = cleanup_old_data()
        print(result)
    
    elif cmd == "compress":
        journal = load_json(TRADE_JOURNAL_FILE)
        journal = compress_trade_journal(journal)
        save_json(TRADE_JOURNAL_FILE, journal)
        print("数据压缩完成")
        print(json.dumps(journal.get("compressed_stats", []), indent=2, ensure_ascii=False))


path: scripts/okx_ws_client.py
content: """OKX WebSocket feed — runs in background thread, maintains a local data cache.

Activated via OKX_WS=1 environment variable.
OKXClient.ticker() and OKXClient.candles() read from this cache transparently.

Private feed (OKXPrivateWebSocketFeed) streams real-time account/positions/orders
and is activated when API credentials are available.
"""
import base64
import hashlib
import hmac
import json
import os
import threading
import time
from collections import deque
from typing import Optional

import websocket

from logger import get_logger

log = get_logger("okx.ws")

_WS_PUBLIC = "wss://ws.okx.com:8443/ws/v5/public"
_WS_PUBLIC_SIM = "wss://wspap.okx.com:8443/ws/v5/public?brokerId=9999"
_WS_PRIVATE = "wss://ws.okx.com:8443/ws/v5/private"
_WS_PRIVATE_SIM = "wss://wspap.okx.com:8443/ws/v5/private?brokerId=9999"

_PING_INTERVAL = 25  # OKX requires ping every 30s; send at 25s to be safe


class OKXWebSocketFeed:
    """Singleton background WebSocket feed.

    Subscribes to ticker and candle channels for a given instrument list.
    Data is stored in thread-safe caches and read by OKXClient methods.
    """

    def __init__(self):
        self._lock = threading.Lock()
        self._ticker_cache: dict = {}       # inst_id → latest ticker dict
        self._candle_cache: dict = {}       # (inst_id, bar) → deque of candle rows
        self._subscriptions: list = []      # list of {"channel":…, "instId":…}
        self._ws: Optional[websocket.WebSocketApp] = None
        self._thread: Optional[threading.Thread] = None
        self._connected = threading.Event()
        self._running = False
        self._simulated = os.getenv("OKX_SIMULATED", "0") == "1"

    # ── Public API ────────────────────────────────────────────────────────────

    def subscribe_tickers(self, inst_ids: list[str]) -> None:
        for inst_id in inst_ids:
            arg = {"channel": "tickers", "instId": inst_id}
            if arg not in self._subscriptions:
                self._subscriptions.append(arg)
                if self._connected.is_set():
                    self._send({"op": "subscribe", "args": [arg]})

    def subscribe_candles(self, inst_ids: list[str], bar: str = "1H") -> None:
        # Simulated trading WS does not support candle channels
        if self._simulated:
            return
        channel = f"candle{bar}"
        for inst_id in inst_ids:
            arg = {"channel": channel, "instId": inst_id}
            if arg not in self._subscriptions:
                self._subscriptions.append(arg)
                if self._connected.is_set():
                    self._send({"op": "subscribe", "args": [arg]})

    def get_ticker(self, inst_id: str) -> Optional[dict]:
        with self._lock:
            return self._ticker_cache.get(inst_id)

    def get_candles(self, inst_id: str, bar: str = "1H") -> Optional[list]:
        """Return candle rows in OKX REST format (newest first), or None if not cached."""
        with self._lock:
            key = (inst_id, bar)
            dq = self._candle_cache.get(key)
            if dq:
                return list(dq)  # newest first (same order as REST API)
        return None

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()
        # Give it a moment to connect before callers proceed
        self._connected.wait(timeout=5)

    def stop(self) -> None:
        self._running = False
        if self._ws:
            self._ws.close()

    def wait_ready(self, timeout: float = 5.0) -> bool:
        return self._connected.wait(timeout=timeout)

    # ── Internal ──────────────────────────────────────────────────────────────

    def _url(self) -> str:
        return _WS_PUBLIC_SIM if self._simulated else _WS_PUBLIC

    def _run_loop(self) -> None:
        while self._running:
            try:
                self._connect()
            except Exception as e:
                log.warning(f"WS error: {e}. Reconnecting in 5s…")
            if self._running:
                time.sleep(5)

    def _connect(self) -> None:
        self._connected.clear()
        self._ws = websocket.WebSocketApp(
            self._url(),
            on_open=self._on_open,
            on_message=self._on_message,
            on_error=self._on_error,
            on_close=self._on_close,
        )
        self._ws.run_forever(ping_interval=_PING_INTERVAL, ping_timeout=10)

    def _on_open(self, ws) -> None:
        log.info(f"WS connected ({'simulated' if self._simulated else 'live'})")
        self._connected.set()
        if self._subscriptions:
            self._send({"op": "subscribe", "args": self._subscriptions})

    def _on_message(self, ws, raw: str) -> None:
        if raw == "pong":
            return
        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            return

        event = msg.get("event")
        if event == "subscribe":
            log.debug(f"WS subscribed: {msg.get('arg')}")
            return
        if event == "error":
            log.warning(f"WS error event: {msg.get('msg')}")
            return

        arg = msg.get("arg", {})
        data = msg.get("data", [])
        channel = arg.get("channel", "")
        inst_id = arg.get("instId", "")

        if channel == "tickers" and data:
            self._update_ticker(inst_id, data[0])
        elif channel.startswith("candle") and data:
            bar = channel[len("candle"):]
            self._update_candle(inst_id, bar, data[0])

    def _on_error(self, ws, error) -> None:
        log.warning(f"WS error: {error}")
        self._connected.clear()

    def _on_close(self, ws, code, msg) -> None:
        log.info(f"WS closed (code={code})")
        self._connected.clear()

    def _send(self, payload: dict) -> None:
        try:
            if self._ws:
                self._ws.send(json.dumps(payload))
        except Exception as e:
            log.warning(f"WS send failed: {e}")

    def _update_ticker(self, inst_id: str, data: dict) -> None:
        with self._lock:
            self._ticker_cache[inst_id] = data

    def _update_candle(self, inst_id: str, bar: str, row: list) -> None:
        """OKX candle row: [ts, open, high, low, close, vol, volCcy, …]
        Stored in a deque (newest first, max 300 rows per symbol/bar).
        """
        key = (inst_id, bar)
        with self._lock:
            if key not in self._candle_cache:
                self._candle_cache[key] = deque(maxlen=300)
            dq = self._candle_cache[key]
            ts = row[0]
            # Replace the most-recent (in-progress) candle if same timestamp
            if dq and dq[0][0] == ts:
                dq[0] = row
            else:
                dq.appendleft(row)


# ── Public feed singleton ─────────────────────────────────────────────────────

_feed: Optional[OKXWebSocketFeed] = None
_feed_lock = threading.Lock()


def get_feed() -> Optional[OKXWebSocketFeed]:
    """Return the running WS feed, or None if OKX_WS is not enabled."""
    if os.getenv("OKX_WS", "0") != "1":
        return None
    global _feed
    with _feed_lock:
        if _feed is None:
            _feed = OKXWebSocketFeed()
            _feed.start()
    return _feed


# ── Private WebSocket Feed ────────────────────────────────────────────────────

class OKXPrivateWebSocketFeed:
    """Authenticated private WebSocket feed for real-time account/positions/orders.

    Requires OKX_WS=1 plus valid API credentials.
    Channels: account, positions, orders.
    """

    def __init__(self, api_key: str, secret: str, passphrase: str, simulated: bool = False):
        self._api_key = api_key
        self._secret = secret
        self._passphrase = passphrase
        self._simulated = simulated
        self._lock = threading.Lock()
        self._account_cache: dict = {}          # ccy → balance dict
        self._positions_cache: dict = {}        # inst_id → position dict
        self._orders_cache: dict = {}           # ord_id → order dict (last 50)
        self._ws: Optional[websocket.WebSocketApp] = None
        self._thread: Optional[threading.Thread] = None
        self._connected = threading.Event()
        self._logged_in = threading.Event()
        self._running = False

    # ── Public API ────────────────────────────────────────────────────────────

    def get_positions(self) -> dict:
        """Return cached positions dict {inst_id: position_data}."""
        with self._lock:
            return dict(self._positions_cache)

    def get_account(self) -> dict:
        """Return cached account balances {ccy: balance_data}."""
        with self._lock:
            return dict(self._account_cache)

    def get_orders(self) -> dict:
        """Return cached recent orders {ord_id: order_data}."""
        with self._lock:
            return dict(self._orders_cache)

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()
        self._logged_in.wait(timeout=8)

    def stop(self) -> None:
        self._running = False
        if self._ws:
            self._ws.close()

    def wait_ready(self, timeout: float = 8.0) -> bool:
        return self._logged_in.wait(timeout=timeout)

    # ── Internal ──────────────────────────────────────────────────────────────

    def _url(self) -> str:
        return _WS_PRIVATE_SIM if self._simulated else _WS_PRIVATE

    def _login_payload(self) -> dict:
        ts = str(int(time.time()))
        msg = ts + "GET" + "/users/self/verify"
        sign = base64.b64encode(
            hmac.new(self._secret.encode(), msg.encode(), hashlib.sha256).digest()
        ).decode()
        return {
            "op": "login",
            "args": [{"apiKey": self._api_key, "passphrase": self._passphrase,
                       "timestamp": ts, "sign": sign}],
        }

    def _run_loop(self) -> None:
        while self._running:
            try:
                self._connect()
            except Exception as e:
                log.warning(f"Private WS error: {e}. Reconnecting in 5s…")
            if self._running:
                time.sleep(5)

    def _connect(self) -> None:
        self._connected.clear()
        self._logged_in.clear()
        self._ws = websocket.WebSocketApp(
            self._url(),
            on_open=self._on_open,
            on_message=self._on_message,
            on_error=self._on_error,
            on_close=self._on_close,
        )
        self._ws.run_forever(ping_interval=_PING_INTERVAL, ping_timeout=10)

    def _on_open(self, ws) -> None:
        log.info(f"Private WS connected ({'simulated' if self._simulated else 'live'})")
        self._connected.set()
        self._send(self._login_payload())

    def _on_message(self, ws, raw: str) -> None:
        if raw == "pong":
            return
        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            return

        event = msg.get("event")
        if event == "login":
            if msg.get("code") == "0":
                log.info("Private WS login successful")
                self._logged_in.set()
                self._send({"op": "subscribe", "args": [
                    {"channel": "account"},
                    {"channel": "positions", "instType": "ANY"},
                    {"channel": "orders", "instType": "ANY"},
                ]})
            else:
                log.warning(f"Private WS login failed: {msg.get('msg')}")
            return
        if event == "subscribe":
            log.debug(f"Private WS subscribed: {msg.get('arg')}")
            return
        if event == "error":
            log.warning(f"Private WS error event: {msg.get('msg')}")
            return

        arg = msg.get("arg", {})
        data = msg.get("data", [])
        channel = arg.get("channel", "")

        if channel == "account" and data:
            self._update_account(data[0])
        elif channel == "positions" and data:
            for pos in data:
                self._update_position(pos)
        elif channel == "orders" and data:
            for order in data:
                self._update_order(order)

    def _on_error(self, ws, error) -> None:
        log.warning(f"Private WS error: {error}")
        self._connected.clear()

    def _on_close(self, ws, code, msg) -> None:
        log.info(f"Private WS closed (code={code})")
        self._connected.clear()
        self._logged_in.clear()

    def _send(self, payload: dict) -> None:
        try:
            if self._ws:
                self._ws.send(json.dumps(payload))
        except Exception as e:
            log.warning(f"Private WS send failed: {e}")

    def _update_account(self, data: dict) -> None:
        with self._lock:
            for detail in data.get("details", []):
                ccy = detail.get("ccy", "")
                if ccy:
                    self._account_cache[ccy] = detail

    def _update_position(self, pos: dict) -> None:
        with self._lock:
            inst_id = pos.get("instId", "")
            pos_side = pos.get("posSide", "net")
            key = f"{inst_id}:{pos_side}"
            if pos.get("pos", "0") == "0":
                self._positions_cache.pop(key, None)  # closed position
            else:
                self._positions_cache[key] = pos

    def _update_order(self, order: dict) -> None:
        with self._lock:
            ord_id = order.get("ordId", "")
            if ord_id:
                self._orders_cache[ord_id] = order
                # Keep only last 50 orders
                if len(self._orders_cache) > 50:
                    oldest = next(iter(self._orders_cache))
                    del self._orders_cache[oldest]


# ── Private feed singleton ────────────────────────────────────────────────────

_private_feed: Optional[OKXPrivateWebSocketFeed] = None
_private_feed_lock = threading.Lock()


def get_private_feed() -> Optional[OKXPrivateWebSocketFeed]:
    """Return the running private WS feed, or None if OKX_WS is not enabled or no credentials."""
    if os.getenv("OKX_WS", "0") != "1":
        return None
    from config import load_prefs
    prefs = load_prefs()
    mode = prefs.get("mode", "demo")
    if mode == "live":
        [REDACTED_SECRET]"OKX_API_KEY_LIVE", "")
        [REDACTED_SECRET]"OKX_SECRET_KEY_LIVE", "")
        passphrase = os.getenv("OKX_PASSPHRASE_LIVE", "")
        simulated = False
    else:
        [REDACTED_SECRET]"OKX_API_KEY", "")
        [REDACTED_SECRET]"OKX_SECRET_KEY", "")
        passphrase = os.getenv("OKX_PASSPHRASE", "")
        simulated = os.getenv("OKX_SIMULATED", "0") == "1"
    if not [REDACTED_SECRET] None
    global _private_feed
    with _private_feed_lock:
        if _private_feed is None:
            _private_feed = OKXPrivateWebSocketFeed(api_key, secret, passphrase, simulated)
            _private_feed.start()
    return _private_feed


path: scripts/report.py
content: """OKX Performance Report — daily/weekly P&L summary from trade journal."""
import json
import os
import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import JOURNAL_PATH, MEMORY_DIR
from logger import get_logger

log = get_logger("okx.report")

# Learning system journal (from okx_learning.record_trade)
LEARNING_JOURNAL = os.path.join(MEMORY_DIR, "okx-trade-journal.json")


def _load_journal() -> list:
    """Merge both journal sources into a unified list of trade dicts."""
    trades = []

    # Primary journal (from config.log_trade)
    if os.path.exists(JOURNAL_PATH):
        with open(JOURNAL_PATH) as f:
            data = json.load(f)
        for t in data.get("trades", []):
            trades.append({
                "ts": t.get("ts", ""),
                "inst_id": t.get("inst_id", ""),
                "action": t.get("action", ""),
                "pnl_pct": t.get("upl_pct", 0.0),
                "source": "monitor",
            })

    # Learning journal (from okx_learning.record_trade)
    if os.path.exists(LEARNING_JOURNAL):
        with open(LEARNING_JOURNAL) as f:
            data = json.load(f)
        for t in data.get("trades", []):
            trades.append({
                "ts": t.get("timestamp", ""),
                "inst_id": t.get("coin", ""),
                "action": t.get("signal_type", ""),
                "pnl_pct": t.get("pnl_pct", 0.0),
                "source": "learning",
            })

    return sorted(trades, key=lambda t: t.get("ts", ""))


def _date_of(ts: str) -> str:
    return ts[:10] if ts else ""


def _period_trades(trades: list, days: int) -> list:
    cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
    return [t for t in trades if _date_of(t["ts"]) >= cutoff]


def _stats(trades: list) -> dict:
    if not trades:
        return {"count": 0, "wins": 0, "losses": 0, "win_rate": 0.0,
                "total_pnl": 0.0, "avg_pnl": 0.0, "best": 0.0, "worst": 0.0}
    pnls = [t["pnl_pct"] for t in trades if t.get("pnl_pct") not in (None, 0)]
    wins = [p for p in pnls if p > 0]
    losses = [p for p in pnls if p < 0]
    total = sum(pnls)
    return {
        "count": len(trades),
        "wins": len(wins),
        "losses": len(losses),
        "win_rate": len(wins) / len(pnls) * 100 if pnls else 0.0,
        "total_pnl": total,
        "avg_pnl": total / len(pnls) if pnls else 0.0,
        "best": max(pnls) if pnls else 0.0,
        "worst": min(pnls) if pnls else 0.0,
    }


def _coin_breakdown(trades: list) -> dict:
    by_coin = defaultdict(list)
    for t in trades:
        coin = t["inst_id"].split("-")[0]
        by_coin[coin].append(t["pnl_pct"])
    result = {}
    for coin, pnls in by_coin.items():
        pnls = [p for p in pnls if p not in (None, 0)]
        if not pnls:
            continue
        result[coin] = {
            "trades": len(pnls),
            "total_pnl": sum(pnls),
            "win_rate": len([p for p in pnls if p > 0]) / len(pnls) * 100,
        }
    return dict(sorted(result.items(), key=lambda x: x[1]["total_pnl"], reverse=True))


def generate_report(period: str = "daily") -> str:
    """Generate a formatted text report for the given period (daily/weekly/all)."""
    all_trades = _load_journal()
    days = {"daily": 1, "weekly": 7, "all": 9999}.get(period, 1)
    trades = _period_trades(all_trades, days)
    s = _stats(trades)
    coins = _coin_breakdown(trades)

    now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    period_label = {"daily": "今日", "weekly": "本周", "all": "全部"}.get(period, period)

    lines = [
        f"## 📊 OKX 交易绩效报告 — {period_label}",
        f"生成时间：{now}",
        "",
        "### 概览",
        f"| 项目 | 数值 |",
        f"|------|------|",
        f"| 总交易次数 | {s['count']} |",
        f"| 盈利次数 | {s['wins']} |",
        f"| 亏损次数 | {s['losses']} |",
        f"| 胜率 | {s['win_rate']:.1f}% |",
        f"| 总盈亏 | {s['total_pnl']:+.2f}% |",
        f"| 平均盈亏 | {s['avg_pnl']:+.2f}% |",
        f"| 最佳交易 | {s['best']:+.2f}% |",
        f"| 最差交易 | {s['worst']:+.2f}% |",
    ]

    if coins:
        lines += ["", "### 币种明细"]
        for coin, info in list(coins.items())[:5]:
            lines.append(
                f"- **{coin}**: {info['trades']}笔 | "
                f"总盈亏 {info['total_pnl']:+.2f}% | "
                f"胜率 {info['win_rate']:.0f}%"
            )

    if s["count"] == 0:
        lines.append("\n_暂无交易记录_")

    return "\n".join(lines)


def main(args: list) -> None:
    period = args[0] if args else "daily"
    if period not in ("daily", "weekly", "all"):
        log.error(f"Unknown period: {period}. Use: daily | weekly | all")
        sys.exit(1)
    print(generate_report(period))


if __name__ == "__main__":
    main(sys.argv[1:])


path: scripts/setup.py
content: """First-time setup: check dependencies and validate API keys."""
import os
import sys
import subprocess


def check_deps():
    required = ["requests", "websocket-client"]
    missing = []
    for pkg in required:
        try:
            __import__(pkg)
        except ImportError:
            missing.append(pkg)
    if missing:
        print(f"Installing: {', '.join(missing)}")
        subprocess.check_call([sys.executable, "-m", "pip", "install"] + missing, stdout=subprocess.DEVNULL)
        print("Done.")


def check_env():
    required = ["OKX_API_KEY", "OKX_SECRET_KEY", "OKX_PASSPHRASE"]
    env_file = os.path.expanduser("~/.openclaw/workspace/.env")

    # Load .env if exists
    if os.path.exists(env_file):
        with open(env_file) as f:
            for line in f:
                line = line.strip()
                if "=" in line and not line.startswith("#"):
                    k, v = line.split("=", 1)
                    os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

    missing = [k for k in required if not os.getenv(k)]
    if missing:
        print(f"\n⚠️  Missing environment variables: {', '.join(missing)}")
        print(f"\nAdd to {env_file}:")
        for k in missing:
            print(f"  {k}=your_value_here")
        print("\nGet API keys from: https://www.okx.com/account/my-api")
        return False
    return True


def validate_api():
    sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
    from okx_client import OKXClient
    client = OKXClient()

    print("\nValidating API connection...")
    result = client.account_config()
    if result.get("code") == "0":
        data = result["data"][0]
        print(f"  ✅ Connected as UID: {data.get('uid')}")
        print(f"  Account type: {data.get('acctLv','?')} | Mode: {data.get('posMode','?')}")
        simulated = os.getenv("OKX_SIMULATED", "0") == "1"
        print(f"  {'⚠️  SIMULATED trading mode' if simulated else '🔴 LIVE trading mode'}")
        return True
    else:
        print(f"  ❌ API error: {result.get('msg')}")
        return False


def create_default_prefs():
    from config import PREFS_PATH, DEFAULT_PREFS, save_prefs
    if not os.path.exists(PREFS_PATH):
        save_prefs(DEFAULT_PREFS)
        print(f"\n  ✅ Created default preferences: {PREFS_PATH}")


if __name__ == "__main__":
    print("OKX Exchange Skill — Setup")
    print("=" * 40)

    check_deps()

    if not check_env():
        sys.exit(1)

    if not validate_api():
        sys.exit(1)

    create_default_prefs()

    print("\n✅ Setup complete. Ready to trade.")
    print("\nQuick start (all via unified CLI):")
    print("  python3 okx.py account                          # Portfolio overview")
    print("  python3 okx.py trend analyze BTC-USDT-SWAP      # Trend signal")
    print("  python3 okx.py arb scan                          # Arbitrage scan")
    print("  python3 okx.py grid setup BTC-USDT 40000 50000 10 1000")
    print("  python3 okx.py prefs show                        # View all settings")
    print("\nTip: python3 okx.py help   — see all commands")
    print("To enable auto-trading:  python3 okx.py prefs set auto_trade true")
    print("To use simulated mode:   OKX_SIMULATED=1 python3 okx.py monitor")


path: scripts/strategies/arbitrage.py
content: """Spot-Futures Arbitrage (Cash & Carry)"""
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from errors import ArbHedgeFailedError
from logger import get_logger
from okx_client import OKXClient

log = get_logger("okx.arb")


def basis(spot_id: str, swap_id: str) -> dict:
    """Calculate current basis between spot and perpetual swap."""
    client = OKXClient()

    spot_data = client.ticker(spot_id)
    swap_data = client.ticker(swap_id)
    funding_data = client.funding_rate(swap_id)

    if spot_data.get("code") != "0" or swap_data.get("code") != "0":
        return {"error": "Failed to fetch prices"}

    spot_px = float(spot_data["data"][0]["last"])
    swap_px = float(swap_data["data"][0]["last"])
    funding_rate = float(funding_data["data"][0].get("fundingRate", 0)) if funding_data.get("code") == "0" else 0

    basis_val = swap_px - spot_px
    basis_pct = basis_val / spot_px * 100
    annual_funding = funding_rate * 3 * 365 * 100  # 3x daily (8h intervals)

    return {
        "spot_id": spot_id,
        "swap_id": swap_id,
        "spot_price": spot_px,
        "swap_price": swap_px,
        "basis": round(basis_val, 4),
        "basis_pct": round(basis_pct, 4),
        "funding_rate": round(funding_rate * 100, 6),
        "annual_funding_pct": round(annual_funding, 2),
        "signal": "open" if basis_pct > 0.1 else "close" if basis_pct < 0.02 else "wait",
    }


def open_arb(spot_id: str, swap_id: str, usdt_sz: float,
             min_basis_pct: float = 0.1) -> None:
    """Open arbitrage: buy spot + short swap when basis > threshold."""
    client = OKXClient()
    info = basis(spot_id, swap_id)

    log.info(f"\nArbitrage Analysis:")
    log.info(f"  Spot:    {info['spot_price']:.4f}")
    log.info(f"  Swap:    {info['swap_price']:.4f}")
    log.info(f"  Basis:   {info['basis_pct']:.4f}% | Funding: {info['annual_funding_pct']:.2f}%/yr")

    if info["basis_pct"] < min_basis_pct:
        log.info(f"  ⚪ Basis {info['basis_pct']:.4f}% < threshold {min_basis_pct}% — no trade")
        return

    spot_sz = str(round(usdt_sz / info["spot_price"], 6))
    swap_sz = str(int(usdt_sz / info["swap_price"]))  # contracts

    log.info(f"\n  Opening arb: buy {spot_sz} {spot_id} + short {swap_sz} {swap_id}")
    confirm = input("  Confirm? [y/N]: ").strip().lower()
    if confirm != "y":
        log.info("  Cancelled.")
        return

    # Buy spot
    r1 = client.place_order(spot_id, "cash", "buy", "market", spot_sz)
    if r1.get("code") == "0":
        log.info(f"  ✅ Spot buy: {r1['data'][0]['ordId']}")
    else:
        log.error(f"Spot buy failed: {r1.get('msg')}")
        return

    # Short swap — if this fails, spot is already filled: critical hedge failure
    r2 = client.place_order(swap_id, "cross", "sell", "market", swap_sz, pos_side="short")
    if r2.get("code") == "0":
        log.info(f"  ✅ Swap short: {r2['data'][0]['ordId']}")
    else:
        raise ArbHedgeFailedError("swap-short", r2.get("msg", "unknown error"))


def close_arb(spot_id: str, swap_id: str, spot_sz: str, swap_sz: str,
              max_basis_pct: float = 0.02) -> None:
    """Close arbitrage: sell spot + close short when basis converges."""
    client = OKXClient()
    info = basis(spot_id, swap_id)

    log.info(f"\n  Current basis: {info['basis_pct']:.4f}%")
    if info["basis_pct"] > max_basis_pct:
        log.info(f"  ⚪ Basis {info['basis_pct']:.4f}% > close threshold {max_basis_pct}% — hold")
        return

    log.info(f"  Closing arb: sell {spot_sz} {spot_id} + close {swap_sz} {swap_id}")
    confirm = input("  Confirm? [y/N]: ").strip().lower()
    if confirm != "y":
        log.info("  Cancelled.")
        return

    r1 = client.place_order(spot_id, "cash", "sell", "market", spot_sz)
    if r1.get("code") == "0":
        log.info(f"  ✅ Spot sell: {r1['data'][0]['ordId']}")
    else:
        log.error(f"Spot sell failed: {r1.get('msg')}")

    r2 = client.place_order(swap_id, "cross", "buy", "market", swap_sz,
                             reduce_only=True, pos_side="short")
    if r2.get("code") == "0":
        log.info(f"  ✅ Swap close: {r2['data'][0]['ordId']}")
    else:
        log.error(f"Swap close failed: {r2.get('msg')}")


def scan(pairs: list = None) -> None:
    """Scan multiple pairs for arbitrage opportunities."""
    if not pairs:
        pairs = [
            ("BTC-USDT", "BTC-USDT-SWAP"),
            ("ETH-USDT", "ETH-USDT-SWAP"),
            ("SOL-USDT", "SOL-USDT-SWAP"),
        ]

    log.info(f"\n{'Pair':<20} {'Spot':>12} {'Swap':>12} {'Basis%':>8} {'Funding%/yr':>12} {'Signal':>8}")
    log.info("-" * 76)
    for spot_id, swap_id in pairs:
        info = basis(spot_id, swap_id)
        if "error" in info:
            log.warning(f"{spot_id}: {info['error']}")
            continue
        signal_icon = "🟢" if info["signal"] == "open" else "🔴" if info["signal"] == "close" else "⚪"
        log.info(f"{spot_id:<20} {info['spot_price']:>12.4f} {info['swap_price']:>12.4f} "
                 f"{info['basis_pct']:>8.4f} {info['annual_funding_pct']:>12.2f} "
                 f"{signal_icon} {info['signal']:>6}")


if __name__ == "__main__":
    """
    Usage:
      python arbitrage.py scan
      python arbitrage.py basis BTC-USDT BTC-USDT-SWAP
      python arbitrage.py open BTC-USDT BTC-USDT-SWAP 1000 --min-basis 0.1
      python arbitrage.py close BTC-USDT BTC-USDT-SWAP 0.01 1 --max-basis 0.02
    """
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("cmd", choices=["scan", "basis", "open", "close"])
    parser.add_argument("args", nargs="*")
    parser.add_argument("--min-basis", type=float, default=0.1)
    parser.add_argument("--max-basis", type=float, default=0.02)
    a = parser.parse_args()

    if a.cmd == "scan":
        scan()
    elif a.cmd == "basis" and len(a.args) >= 2:
        log.info(json.dumps(basis(a.args[0], a.args[1]), indent=2))
    elif a.cmd == "open" and len(a.args) >= 3:
        try:
            open_arb(a.args[0], a.args[1], float(a.args[2]), a.min_basis)
        except ArbHedgeFailedError as e:
            log.error(f"HEDGE FAILURE — {e}. Spot is filled but swap is not. Close spot manually!")
            sys.exit(1)
    elif a.cmd == "close" and len(a.args) >= 4:
        close_arb(a.args[0], a.args[1], a.args[2], a.args[3], a.max_basis)
    else:
        parser.print_help()


path: scripts/strategies/grid.py
content: """Grid Trading Strategy"""
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import MEMORY_DIR, grid_state_path
from logger import get_logger
from okx_client import OKXClient

log = get_logger("okx.grid")


def load_state(inst_id: str) -> dict:
    path = grid_state_path(inst_id)
    if os.path.exists(path):
        with open(path) as f:
            return json.load(f)
    return {}


def save_state(inst_id: str, state: dict) -> None:
    os.makedirs(MEMORY_DIR, exist_ok=True)
    with open(grid_state_path(inst_id), "w") as f:
        json.dump(state, f, indent=2)


def setup_grid(inst_id: str, lower: float, upper: float, grids: int,
               total_usdt: float, td_mode: str = "cash") -> None:
    """Initialize grid: place buy orders at each grid level below current price."""
    client = OKXClient()
    ticker = client.ticker(inst_id)
    if ticker.get("code") != "0":
        log.error(f"Error fetching price: {ticker.get('msg')}")
        return

    current = float(ticker["data"][0]["last"])
    step = (upper - lower) / grids
    levels = [lower + i * step for i in range(grids + 1)]
    order_usdt = total_usdt / grids

    log.info(f"\nGrid Setup: {inst_id}")
    log.info(f"Range: {lower} — {upper} | Grids: {grids} | Step: {step:.4f}")
    log.info(f"Current Price: {current:.4f}")
    log.info(f"USDT per grid: {order_usdt:.2f}\n")

    state = {
        "inst_id": inst_id,
        "lower": lower,
        "upper": upper,
        "grids": grids,
        "step": step,
        "total_usdt": total_usdt,
        "td_mode": td_mode,
        "levels": levels,
        "orders": {},
        "pnl": 0.0,
    }

    placed = 0
    for level in levels:
        if level >= current:
            continue  # Will place sell orders when price rises
        sz = str(round(order_usdt / level, 6))
        result = client.place_order(
            inst_id=inst_id,
            td_mode=td_mode,
            side="buy",
            ord_type="limit",
            sz=sz,
            px=str(round(level, 4)),
        )
        if result.get("code") == "0":
            ord_id = result["data"][0]["ordId"]
            state["orders"][str(round(level, 4))] = {"ordId": ord_id, "side": "buy", "sz": sz}
            placed += 1
            log.info(f"  ✅ Buy @ {level:.4f} | sz={sz}")
        else:
            log.error(f"Failed @ {level:.4f}: {result.get('msg')}")

    save_state(inst_id, state)
    log.info(f"\nGrid active: {placed} buy orders placed.")


def check_grid(inst_id: str) -> None:
    """Check filled orders and place opposite side orders."""
    client = OKXClient()
    state = load_state(inst_id)
    if not state:
        log.warning(f"No active grid for {inst_id}")
        return

    ticker = client.ticker(inst_id)
    current = float(ticker["data"][0]["last"])
    step = state["step"]
    td_mode = state["td_mode"]
    order_usdt = state["total_usdt"] / state["grids"]

    history = client.order_history("SPOT" if "SWAP" not in inst_id else "SWAP", inst_id, limit=50)
    filled_ids = {o["ordId"]: o for o in history.get("data", []) if o["state"] == "filled"}

    for px_str, order in list(state["orders"].items()):
        if order["ordId"] not in filled_ids:
            continue

        filled = filled_ids[order["ordId"]]
        fill_px = float(filled["avgPx"])
        sz = filled["accFillSz"]

        # Place opposite order
        if order["side"] == "buy":
            sell_px = round(fill_px + step, 4)
            result = client.place_order(
                inst_id=inst_id,
                td_mode=td_mode,
                side="sell",
                ord_type="limit",
                sz=sz,
                px=str(sell_px),
            )
            if result.get("code") == "0":
                ord_id = result["data"][0]["ordId"]
                state["orders"][str(sell_px)] = {"ordId": ord_id, "side": "sell", "sz": sz}
                del state["orders"][px_str]
                log.info(f"  🔄 Filled buy @ {fill_px:.4f} → placed sell @ {sell_px:.4f}")
        else:
            trade_profit = float(sz) * step  # profit = size × grid step
            state["pnl"] += trade_profit
            buy_px = round(fill_px - step, 4)
            new_sz = str(round(order_usdt / buy_px, 6))
            result = client.place_order(
                inst_id=inst_id,
                td_mode=td_mode,
                side="buy",
                ord_type="limit",
                sz=new_sz,
                px=str(buy_px),
            )
            if result.get("code") == "0":
                ord_id = result["data"][0]["ordId"]
                state["orders"][str(buy_px)] = {"ordId": ord_id, "side": "buy", "sz": new_sz}
                del state["orders"][px_str]
                log.info(f"  🔄 Filled sell @ {fill_px:.4f} → placed buy @ {buy_px:.4f} | trade profit: {trade_profit:.4f}")

    save_state(inst_id, state)
    log.info(f"\nCurrent: {current:.4f} | Total PnL: {state['pnl']:.4f} USDT")


def stop_grid(inst_id: str) -> None:
    """Cancel all grid orders."""
    client = OKXClient()
    state = load_state(inst_id)
    if not state:
        log.warning(f"No active grid for {inst_id}")
        return

    cancelled = 0
    for px_str, order in state["orders"].items():
        result = client.cancel_order(inst_id, order["ordId"])
        if result.get("code") == "0":
            cancelled += 1

    state["orders"] = {}
    save_state(inst_id, state)
    log.info(f"✅ Grid stopped. Cancelled {cancelled} orders. Total PnL: {state['pnl']:.4f} USDT")


if __name__ == "__main__":
    """
    Usage:
      python grid.py setup BTC-USDT 40000 50000 10 1000
      python grid.py check BTC-USDT
      python grid.py stop BTC-USDT
    """
    cmd = sys.argv[1] if len(sys.argv) > 1 else "help"
    inst = sys.argv[2] if len(sys.argv) > 2 else ""

    if cmd == "setup" and len(sys.argv) >= 7:
        setup_grid(inst, float(sys.argv[3]), float(sys.argv[4]),
                   int(sys.argv[5]), float(sys.argv[6]),
                   sys.argv[7] if len(sys.argv) > 7 else "cash")
    elif cmd == "check":
        check_grid(inst)
    elif cmd == "stop":
        stop_grid(inst)
    else:
        log.info("Usage: grid.py setup <inst_id> <lower> <upper> <grids> <usdt> [td_mode]")
        log.info("       grid.py check <inst_id>")
        log.info("       grid.py stop <inst_id>")


path: scripts/strategies/trend.py
content: """Trend Following Strategy (MA Crossover + RSI + MACD)"""
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from logger import get_logger
from okx_client import OKXClient

log = get_logger("okx.trend")


def sma(prices: list, n: int) -> float:
    return sum(prices[-n:]) / n if len(prices) >= n else 0.0


def ema(prices: list, n: int) -> float:
    if len(prices) < n:
        return 0.0
    k = 2 / (n + 1)
    result = sum(prices[:n]) / n
    for p in prices[n:]:
        result = p * k + result * (1 - k)
    return result


def rsi(prices: list, n: int = 14) -> float:
    if len(prices) < n + 1:
        return 50.0
    deltas = [prices[i] - prices[i - 1] for i in range(1, len(prices))]
    gains = [max(d, 0) for d in deltas]
    losses = [max(-d, 0) for d in deltas]
    avg_gain = sum(gains[:n]) / n
    avg_loss = sum(losses[:n]) / n
    for g, l in zip(gains[n:], losses[n:]):
        avg_gain = (avg_gain * (n - 1) + g) / n
        avg_loss = (avg_loss * (n - 1) + l) / n
    if avg_loss == 0:
        return 100.0
    return 100 - (100 / (1 + avg_gain / avg_loss))


def macd(prices: list, fast: int = 12, slow: int = 26, signal: int = 9) -> tuple[float, float, float]:
    if len(prices) < slow:
        return 0.0, 0.0, 0.0
    kf = 2 / (fast + 1)
    ks = 2 / (slow + 1)
    # Seed fast EMA with SMA of first `fast` bars, then advance to position `slow`
    ema_fast = sum(prices[:fast]) / fast
    for p in prices[fast:slow]:
        ema_fast = p * kf + ema_fast * (1 - kf)
    # Seed slow EMA with SMA of first `slow` bars
    ema_slow = sum(prices[:slow]) / slow
    # Build MACD line history incrementally — O(n) instead of O(n²)
    macd_history = [ema_fast - ema_slow]
    for p in prices[slow:]:
        ema_fast = p * kf + ema_fast * (1 - kf)
        ema_slow = p * ks + ema_slow * (1 - ks)
        macd_history.append(ema_fast - ema_slow)
    macd_line = ema_fast - ema_slow
    signal_line = ema(macd_history, signal) if len(macd_history) >= signal else macd_line
    histogram = macd_line - signal_line
    return macd_line, signal_line, histogram


def analyze(inst_id: str, bar: str = "1H", fast_ma: int = 20,
            slow_ma: int = 50, rsi_period: int = 14) -> dict:
    client = OKXClient()
    candles_data = client.candles(inst_id, bar=bar, limit=100)
    if candles_data.get("code") != "0":
        return {"error": candles_data.get("msg")}

    # OKX candles: [ts, open, high, low, close, vol, volCcy, volCcyQuote, confirm]
    candles = candles_data["data"]
    prices = [float(c[4]) for c in reversed(candles)]  # close prices, oldest first

    current = prices[-1]
    fast = sma(prices, fast_ma)
    slow = sma(prices, slow_ma)
    rsi_val = rsi(prices, rsi_period)
    macd_line, signal_line, histogram = macd(prices)

    # Signal logic
    signal = "hold"
    reasons = []

    if fast > slow:
        reasons.append(f"MA{fast_ma}({fast:.2f}) > MA{slow_ma}({slow:.2f}) ✅")
        if rsi_val < 70:
            reasons.append(f"RSI({rsi_val:.1f}) not overbought ✅")
            if histogram > 0:
                reasons.append(f"MACD histogram positive ✅")
                signal = "buy"
            else:
                reasons.append(f"MACD histogram negative ⚠️")
        else:
            reasons.append(f"RSI({rsi_val:.1f}) overbought ⚠️")
    elif fast < slow:
        reasons.append(f"MA{fast_ma}({fast:.2f}) < MA{slow_ma}({slow:.2f}) ❌")
        if rsi_val > 30:
            reasons.append(f"RSI({rsi_val:.1f}) not oversold ✅")
            if histogram < 0:
                reasons.append(f"MACD histogram negative ✅")
                signal = "sell"

    if signal == "buy":
        market_regime = "strong_bull" if rsi_val < 40 and histogram > 0 else "weak_bull"
    elif signal == "sell":
        market_regime = "strong_bear" if rsi_val > 60 and histogram < 0 else "weak_bear"
    else:
        market_regime = "ranging"

    result = {
        "inst_id": inst_id,
        "bar": bar,
        "current_price": current,
        "ma_fast": round(fast, 4),
        "ma_slow": round(slow, 4),
        "rsi": round(rsi_val, 2),
        "macd_line": round(macd_line, 4),
        "macd_signal": round(signal_line, 4),
        "macd_histogram": round(histogram, 4),
        "signal": signal,
        "market_regime": market_regime,
        "reasons": reasons,
    }
    return result


def run(inst_id: str, sz: str, td_mode: str = "cash", pos_side: str = "",
        bar: str = "1H", tp_pct: float = 0.05, sl_pct: float = 0.03,
        dry_run: bool = False) -> None:
    result = analyze(inst_id, bar)
    if "error" in result:
        log.error(f"Error: {result['error']}")
        return

    log.info(f"\n{'='*50}")
    log.info(f"Trend Analysis: {inst_id} [{bar}]")
    log.info(f"Price: {result['current_price']:.4f}")
    log.info(f"MA20: {result['ma_fast']:.4f} | MA50: {result['ma_slow']:.4f}")
    log.info(f"RSI: {result['rsi']:.2f} | MACD: {result['macd_histogram']:.4f}")
    log.info(f"\nSignal: {'🟢 BUY' if result['signal']=='buy' else '🔴 SELL' if result['signal']=='sell' else '⚪ HOLD'}")
    for r in result["reasons"]:
        log.info(f"  {r}")

    if dry_run or result["signal"] == "hold":
        return

    from execute import place_order
    current = result["current_price"]
    if result["signal"] == "buy":
        tp = str(round(current * (1 + tp_pct), 4))
        sl = str(round(current * (1 - sl_pct), 4))
        log.info(f"\n→ Placing BUY | TP: {tp} | SL: {sl}")
        place_order(inst_id, "buy", "market", sz, td_mode, pos_side=pos_side, tp=tp, sl=sl)
    elif result["signal"] == "sell":
        tp = str(round(current * (1 - tp_pct), 4))
        sl = str(round(current * (1 + sl_pct), 4))
        log.info(f"\n→ Placing SELL | TP: {tp} | SL: {sl}")
        place_order(inst_id, "sell", "market", sz, td_mode, pos_side=pos_side, tp=tp, sl=sl)


if __name__ == "__main__":
    """
    Usage:
      python trend.py analyze BTC-USDT --bar 1H
      python trend.py run BTC-USDT 0.01 --bar 4H --tp 0.05 --sl 0.03
      python trend.py run BTC-USDT-SWAP 1 --td cross --pos long --bar 1H --dry
    """
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("cmd", choices=["analyze", "run"])
    parser.add_argument("inst_id")
    parser.add_argument("sz", nargs="?", default="0.01")
    parser.add_argument("--bar", default="1H")
    parser.add_argument("--td", default="cash")
    parser.add_argument("--pos", default="")
    parser.add_argument("--tp", type=float, default=0.05)
    parser.add_argument("--sl", type=float, default=0.03)
    parser.add_argument("--dry", action="store_true")
    args = parser.parse_args()

    if args.cmd == "analyze":
        result = analyze(args.inst_id, args.bar)
        print(json.dumps(result, indent=2))
    else:
        run(args.inst_id, args.sz, args.td, args.pos, args.bar, args.tp, args.sl, args.dry)


path: scripts/tests/bench_indicators.py
content: """Benchmark tests for CPU-intensive functions.

Run:  python tests/bench_indicators.py
"""
import sys, os, timeit, random, json, time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "strategies"))

from strategies.trend import sma, ema, rsi, macd
from config import load_prefs, save_prefs, load_state, save_state
import tempfile, shutil


# ── Helpers ───────────────────────────────────────────────────────────────────

def _prices(n: int, seed: int = 42) -> list:
    random.seed(seed)
    price = 67000.0
    prices = []
    for _ in range(n):
        price += random.uniform(-500, 500)
        prices.append(price)
    return prices


def _bench(label: str, fn, *args, repeat: int = 5, number: int = 200) -> None:
    times = timeit.repeat(lambda: fn(*args), repeat=repeat, number=number)
    best_ms = min(times) / number * 1000
    avg_ms = (sum(times) / len(times)) / number * 1000
    print(f"  {label:<40}  best={best_ms:.4f}ms  avg={avg_ms:.4f}ms  (×{number})")


# ── Indicator Benchmarks ──────────────────────────────────────────────────────

def bench_indicators():
    print("\n── Technical Indicators ──────────────────────────────────────────")
    for n in (100, 1_000, 10_000):
        prices = _prices(n)
        print(f"\n  Dataset size: {n:,} prices")
        _bench(f"sma(n={n}, period=20)", sma, prices, 20)
        _bench(f"ema(n={n}, period=20)", ema, prices, 20)
        _bench(f"rsi(n={n}, period=14)", rsi, prices, 14)
        _bench(f"macd(n={n})", macd, prices)


# ── Config I/O Benchmarks ─────────────────────────────────────────────────────

def bench_config():
    print("\n── Config I/O ────────────────────────────────────────────────────")
    tmp = tempfile.mkdtemp()
    try:
        import config as cfg
        cfg.PREFS_PATH = os.path.join(tmp, "prefs.json")
        cfg.STATE_PATH = os.path.join(tmp, "state.json")
        cfg.JOURNAL_PATH = os.path.join(tmp, "journal.json")

        # Pre-create files
        save_prefs(load_prefs())
        save_state(load_state())

        _bench("load_prefs()", load_prefs, repeat=5, number=500)
        _bench("save_prefs()", save_prefs, load_prefs(), repeat=5, number=500)
        _bench("load_state()", load_state, repeat=5, number=500)
        _bench("save_state()", save_state, load_state(), repeat=5, number=500)
    finally:
        shutil.rmtree(tmp)


# ── OKXClient Signing Benchmark ───────────────────────────────────────────────

def bench_signing():
    print("\n── Cryptographic Signing ─────────────────────────────────────────")
    from okx_client import _sign, _timestamp

    ts = _timestamp()
    _bench("_timestamp()", _timestamp, repeat=5, number=1000)
    _bench("_sign() per request", _sign,
           ts, "GET", "/api/v5/account/balance", "", "SECRETKEY12345",
           repeat=5, number=1000)


# ── MACD Complexity Verification ──────────────────────────────────────────────

def bench_macd_scaling():
    """Verify O(n) scaling: doubling n should roughly double time."""
    print("\n── MACD O(n) Scaling Verification ───────────────────────────────")
    sizes = [500, 1000, 2000, 4000]
    times = []
    for n in sizes:
        prices = _prices(n)
        t = timeit.timeit(lambda: macd(prices), number=100)
        times.append(t / 100 * 1000)
        print(f"  n={n:5d}  avg={times[-1]:.4f}ms")

    # Check scaling ratios
    print("\n  Scaling ratios (should be ~2.0 for O(n)):")
    for i in range(1, len(times)):
        ratio = times[i] / times[i - 1]
        ok = "✅" if 1.5 <= ratio <= 3.0 else "⚠️ "
        print(f"  {sizes[i-1]}→{sizes[i]}: ratio={ratio:.2f}  {ok}")


# ── Grid Level Calculation ────────────────────────────────────────────────────

def bench_grid():
    print("\n── Grid Level Calculation ────────────────────────────────────────")
    def calc_levels(lower, upper, grids):
        step = (upper - lower) / grids
        return [lower + i * step for i in range(grids + 1)]

    _bench("grid levels (10 grids)",  calc_levels, 40000, 50000, 10, number=10000)
    _bench("grid levels (100 grids)", calc_levels, 40000, 50000, 100, number=10000)
    _bench("grid levels (500 grids)", calc_levels, 40000, 50000, 500, number=1000)


# ── WebSocket Cache Benchmarks ────────────────────────────────────────────────

def bench_ws_cache():
    print("\n── WebSocket Cache ───────────────────────────────────────────────")
    import threading
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    from okx_ws_client import OKXWebSocketFeed

    feed = OKXWebSocketFeed()

    # Pre-populate: 10 instruments × 300 candles each
    inst_ids = [f"COIN{i}-USDT-SWAP" for i in range(10)]
    for inst_id in inst_ids:
        for ts in range(300):
            feed._update_candle(inst_id, "1H", [str(ts), "100", "110", "90", "105", "1000"])
        feed._update_ticker(inst_id, {"instId": inst_id, "last": "67000", "vol24h": "100"})

    print(f"\n  Cache populated: {len(inst_ids)} instruments × 300 candles")

    # Read benchmarks
    _bench("ticker cache read (1 inst)",
           feed.get_ticker, "COIN0-USDT-SWAP", number=50_000)
    _bench("candle cache read (300 rows)",
           feed.get_candles, "COIN0-USDT-SWAP", "1H", number=10_000)

    # Write benchmarks
    _bench("ticker cache write",
           feed._update_ticker, "COIN0-USDT-SWAP",
           {"instId": "COIN0-USDT-SWAP", "last": "68000"},
           number=20_000)
    _bench("candle cache write (new ts)",
           feed._update_candle, "COIN0-USDT-SWAP", "1H",
           ["9999999", "100", "110", "90", "105", "1000"],
           number=10_000)

    # Message parse benchmark
    import json as _json
    sample_msg = _json.dumps({
        "arg": {"channel": "tickers", "instId": "BTC-USDT-SWAP"},
        "data": [{"instId": "BTC-USDT-SWAP", "last": "67000", "vol24h": "100"}],
    })
    _bench("_on_message() parse + store",
           feed._on_message, None, sample_msg, number=10_000)

    # Concurrent throughput
    print("\n  Concurrent read throughput (4 threads × 5000 reads):")
    stop = threading.Event()
    counts = [0] * 4

    def reader(idx):
        while not stop.is_set():
            feed.get_ticker("COIN0-USDT-SWAP")
            counts[idx] += 1

    threads = [threading.Thread(target=reader, args=(i,)) for i in range(4)]
    for t in threads:
        t.start()
    time.sleep(0.5)
    stop.set()
    for t in threads:
        t.join()
    total = sum(counts)
    print(f"  Total reads in 0.5s: {total:,}  ({total * 2:,} reads/sec)")


# ── Private WebSocket Cache Benchmarks ───────────────────────────────────────

def bench_private_ws_cache():
    print("\n── Private WebSocket Cache ───────────────────────────────────────")
    from okx_ws_client import OKXPrivateWebSocketFeed

    feed = OKXPrivateWebSocketFeed("key", "secret", "pass", simulated=False)

    # Pre-populate caches
    ccys = [f"CCY{i}" for i in range(20)]
    for ccy in ccys:
        feed._update_account({"details": [{"ccy": ccy, "cashBal": str(1000 * (ccys.index(ccy) + 1))}]})

    inst_ids = [f"COIN{i}-USDT-SWAP" for i in range(10)]
    for inst_id in inst_ids:
        feed._update_position({"instId": inst_id, "posSide": "long", "pos": "1", "uplRatio": "0.05"})

    for i in range(50):
        feed._update_order({"ordId": str(i), "instId": "BTC-USDT-SWAP", "state": "filled"})

    print(f"\n  Cache: {len(ccys)} balances, {len(inst_ids)} positions, 50 orders")

    _bench("get_account() (20 ccys)", feed.get_account, number=50_000)
    _bench("get_positions() (10 positions)", feed.get_positions, number=50_000)
    _bench("get_orders() (50 orders)", feed.get_orders, number=20_000)

    # Write benchmarks
    _bench("_update_account() single ccy",
           feed._update_account, {"details": [{"ccy": "USDT", "cashBal": "9999"}]},
           number=20_000)
    _bench("_update_position() upsert",
           feed._update_position, {"instId": "BTC-USDT-SWAP", "posSide": "long", "pos": "2"},
           number=20_000)
    _bench("_update_order() upsert",
           feed._update_order, {"ordId": "99999", "state": "live"},
           number=20_000)


# ── Main ──────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print("=" * 65)
    print("OKX Exchange Skill — Benchmark Report")
    print("=" * 65)

    bench_indicators()
    bench_macd_scaling()
    bench_config()
    bench_signing()
    bench_grid()
    bench_ws_cache()
    bench_private_ws_cache()

    print("\n" + "=" * 65)
    print("Done.")


path: scripts/tests/run_all.py
content: """Run all unit tests + benchmarks.

Usage:
  python tests/run_all.py           # unit tests only
  python tests/run_all.py --bench   # unit tests + benchmarks
"""
import sys, os, unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def run_unit_tests() -> bool:
    loader = unittest.TestLoader()
    suite = loader.discover(os.path.dirname(os.path.abspath(__file__)), pattern="test_*.py")
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)
    return result.wasSuccessful()


def run_benchmarks():
    import bench_indicators
    bench_indicators.bench_indicators()
    bench_indicators.bench_macd_scaling()
    bench_indicators.bench_config()
    bench_indicators.bench_signing()
    bench_indicators.bench_grid()
    bench_indicators.bench_ws_cache()
    bench_indicators.bench_private_ws_cache()


if __name__ == "__main__":
    print("=" * 65)
    print("OKX Exchange Skill — Test Suite")
    print("=" * 65)

    ok = run_unit_tests()

    if "--bench" in sys.argv:
        print("\n" + "=" * 65)
        print("Benchmarks")
        print("=" * 65)
        run_benchmarks()

    sys.exit(0 if ok else 1)


path: scripts/tests/test_account.py
content: """Unit tests for account.py (client mocked)"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import unittest
from unittest.mock import patch, MagicMock
from io import StringIO


def _balance_data(balances: dict) -> dict:
    """Build a mock balance response."""
    details = [
        {"ccy": ccy, "availBal": str(amt), "frozenBal": "0", "usdVal": str(amt)}
        for ccy, amt in balances.items()
    ]
    total_eq = sum(balances.values())
    return {
        "code": "0",
        "data": [{"totalEq": str(total_eq), "details": details}]
    }


class TestGetBalance(unittest.TestCase):
    def test_specific_currency(self):
        import account
        client = MagicMock()
        client.balance.return_value = _balance_data({"USDT": 1000.0})

        with patch("account.OKXClient", return_value=client):
            account.get_balance("USDT")

        client.balance.assert_called_once_with("USDT")

    def test_all_currencies(self):
        import account
        client = MagicMock()
        client.balance.return_value = _balance_data({
            "USDT": 1000.0, "BTC": 0.5, "ETH": 2.0
        })

        with patch("account.OKXClient", return_value=client):
            account.get_balance("")

        client.balance.assert_called_once_with("")

    def test_api_error_handled(self):
        import account
        client = MagicMock()
        client.balance.return_value = {"code": "1", "msg": "timeout"}

        with patch("account.OKXClient", return_value=client):
            account.get_balance("")  # should not raise


class TestGetPositions(unittest.TestCase):
    def test_no_positions(self):
        import account
        client = MagicMock()
        client.positions.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            account.get_positions("")  # should not raise

    def test_with_positions(self):
        import account
        client = MagicMock()
        client.positions.return_value = {"code": "0", "data": [
            {
                "instId": "BTC-USDT-SWAP",
                "posSide": "long",
                "pos": "1",
                "avgPx": "67000",
                "markPx": "67500",
                "upl": "5",
                "uplRatio": "0.0746",
                "lever": "5",
            }
        ]}

        with patch("account.OKXClient", return_value=client):
            account.get_positions("")  # should not raise

    def test_positions_filtered_by_inst_id(self):
        import account
        client = MagicMock()
        client.positions.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            # get_positions(inst_type, inst_id) — pass "" as inst_type
            account.get_positions("", "BTC-USDT-SWAP")

        client.positions.assert_called_once_with("", "BTC-USDT-SWAP")


class TestGetPendingOrders(unittest.TestCase):
    def test_no_pending_orders(self):
        import account
        client = MagicMock()
        client.pending_orders.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            account.get_pending_orders("")

    def test_with_pending_orders(self):
        import account
        client = MagicMock()
        client.pending_orders.return_value = {"code": "0", "data": [
            {
                "instId": "BTC-USDT",
                "ordId": "12345",
                "side": "buy",
                "ordType": "limit",
                "sz": "0.01",
                "px": "65000",
                "state": "live",
                "cTime": "1700000000000",
            }
        ]}

        with patch("account.OKXClient", return_value=client):
            account.get_pending_orders("")


class TestGetOrderHistory(unittest.TestCase):
    def test_order_history_spot(self):
        import account
        client = MagicMock()
        client.order_history.return_value = {"code": "0", "data": [
            {
                "instId": "BTC-USDT",
                "ordId": "111",
                "side": "sell",
                "ordType": "market",
                "sz": "0.001",
                "avgPx": "67000",
                "accFillSz": "0.001",
                "state": "filled",
                "uTime": "1700000000000",
                "pnl": "0",
            }
        ]}

        with patch("account.OKXClient", return_value=client):
            account.get_order_history("SPOT", "", 10)

        client.order_history.assert_called_once_with("SPOT", "", 10)

    def test_order_history_swap(self):
        import account
        client = MagicMock()
        client.order_history.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            account.get_order_history("SWAP", "BTC-USDT-SWAP", 20)

        client.order_history.assert_called_once_with("SWAP", "BTC-USDT-SWAP", 20)


class TestPortfolioSummary(unittest.TestCase):
    def test_portfolio_summary(self):
        import account
        client = MagicMock()
        client.balance.return_value = _balance_data({
            "USDT": 1000.0, "BTC": 0.5,
        })
        client.positions.return_value = {"code": "0", "data": []}
        client.pending_orders.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            account.portfolio_summary()  # should not raise

    def test_portfolio_summary_with_positions(self):
        import account
        client = MagicMock()
        client.balance.return_value = _balance_data({"USDT": 500.0})
        client.positions.return_value = {"code": "0", "data": [
            {
                "instId": "BTC-USDT-SWAP",
                "posSide": "long",
                "pos": "1",
                "avgPx": "67000",
                "markPx": "67500",
                "upl": "5",
                "uplRatio": "0.0746",
                "lever": "5",
            }
        ]}
        client.pending_orders.return_value = {"code": "0", "data": []}

        with patch("account.OKXClient", return_value=client):
            account.portfolio_summary()


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_algo_batch.py
content: """Unit tests for algo orders and batch orders."""
import sys, os, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import unittest
from unittest.mock import patch, MagicMock


def _client():
    with patch("okx_client._sync_time"), patch("okx_client._load_env"):
        from okx_client import OKXClient
        with patch.dict(os.environ, {
            "OKX_API_KEY": "key", "OKX_SECRET_KEY": "secret",
            "OKX_PASSPHRASE": "pass", "OKX_SIMULATED": "1",
        }):
            return OKXClient()


# ── place_algo_order() ────────────────────────────────────────────────────────

class TestPlaceAlgoOrder(unittest.TestCase):

    def test_oco_body_has_tp_and_sl(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "algo123", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="oco", sz="1",
                tp_trigger_px="55000", sl_trigger_px="45000",
            )
            body = m.call_args[0][1]
        self.assertEqual(body["ordType"], "oco")
        self.assertEqual(body["tpTriggerPx"], "55000")
        self.assertEqual(body["slTriggerPx"], "45000")
        self.assertEqual(body["tpOrdPx"], "-1")   # market execution
        self.assertEqual(body["slOrdPx"], "-1")
        self.assertEqual(body["tpTriggerPxType"], "last")
        self.assertEqual(body["slTriggerPxType"], "last")

    def test_conditional_body_sl_only(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "algo456", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="conditional", sz="1", sl_trigger_px="45000",
            )
            body = m.call_args[0][1]
        self.assertEqual(body["ordType"], "conditional")
        self.assertIn("slTriggerPx", body)
        self.assertNotIn("tpTriggerPx", body)

    def test_conditional_body_tp_only(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "algo789", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="conditional", sz="1", tp_trigger_px="55000",
            )
            body = m.call_args[0][1]
        self.assertIn("tpTriggerPx", body)
        self.assertNotIn("slTriggerPx", body)

    def test_custom_ord_px_used_when_provided(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "x", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="conditional", sz="1",
                sl_trigger_px="45000", sl_ord_px="44500",
            )
            body = m.call_args[0][1]
        self.assertEqual(body["slOrdPx"], "44500")

    def test_reduce_only_flag(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "x", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="conditional", sz="1", sl_trigger_px="45000",
                reduce_only=True,
            )
            body = m.call_args[0][1]
        self.assertEqual(body["reduceOnly"], "true")

    def test_pos_side_included_when_set(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "x", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order(
                inst_id="BTC-USDT-SWAP", td_mode="cross", side="sell",
                ord_type="oco", sz="1",
                tp_trigger_px="55000", sl_trigger_px="45000",
                pos_side="long",
            )
            body = m.call_args[0][1]
        self.assertEqual(body["posSide"], "long")

    def test_posts_to_correct_endpoint(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "x", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.place_algo_order("BTC-USDT-SWAP", "cross", "sell", "oco", "1",
                                    tp_trigger_px="55000", sl_trigger_px="45000")
            endpoint = m.call_args[0][0]
        self.assertEqual(endpoint, "/api/v5/trade/order-algo")


# ── cancel_algo_order() ───────────────────────────────────────────────────────

class TestCancelAlgoOrder(unittest.TestCase):

    def test_cancel_posts_list_payload(self):
        client = _client()
        ok = {"code": "0", "data": [{"algoId": "algo123", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            client.cancel_algo_order("BTC-USDT-SWAP", "algo123")
            endpoint, body = m.call_args[0]
        self.assertEqual(endpoint, "/api/v5/trade/cancel-algos")
        self.assertIsInstance(body, list)
        self.assertEqual(body[0]["instId"], "BTC-USDT-SWAP")
        self.assertEqual(body[0]["algoId"], "algo123")


# ── pending_algo_orders() ─────────────────────────────────────────────────────

class TestPendingAlgoOrders(unittest.TestCase):

    def test_no_params_empty_dict(self):
        client = _client()
        ok = {"code": "0", "data": []}
        with patch.object(client, "get", return_value=ok) as m:
            client.pending_algo_orders()
            params = m.call_args[0][1]
        self.assertEqual(params, {})

    def test_with_inst_id(self):
        client = _client()
        ok = {"code": "0", "data": []}
        with patch.object(client, "get", return_value=ok) as m:
            client.pending_algo_orders(inst_id="BTC-USDT-SWAP")
            params = m.call_args[0][1]
        self.assertEqual(params["instId"], "BTC-USDT-SWAP")

    def test_with_ord_type(self):
        client = _client()
        ok = {"code": "0", "data": []}
        with patch.object(client, "get", return_value=ok) as m:
            client.pending_algo_orders(ord_type="oco")
            params = m.call_args[0][1]
        self.assertEqual(params["ordType"], "oco")

    def test_posts_to_correct_endpoint(self):
        client = _client()
        ok = {"code": "0", "data": []}
        with patch.object(client, "get", return_value=ok) as m:
            client.pending_algo_orders()
            endpoint = m.call_args[0][0]
        self.assertEqual(endpoint, "/api/v5/trade/orders-algo-pending")


# ── batch_orders() ────────────────────────────────────────────────────────────

class TestBatchOrders(unittest.TestCase):

    def _sample_orders(self, n: int = 3) -> list:
        return [
            {"instId": f"COIN{i}-USDT-SWAP", "tdMode": "cross",
             "side": "buy", "ordType": "market", "sz": "1"}
            for i in range(n)
        ]

    def test_batch_posts_list(self):
        client = _client()
        ok = {"code": "0", "data": [{"ordId": str(i), "sCode": "0"} for i in range(3)]}
        with patch.object(client, "post", return_value=ok) as m:
            orders = self._sample_orders(3)
            client.batch_orders(orders)
            endpoint, body = m.call_args[0]
        self.assertEqual(endpoint, "/api/v5/trade/batch-orders")
        self.assertIsInstance(body, list)
        self.assertEqual(len(body), 3)

    def test_batch_preserves_order_fields(self):
        client = _client()
        ok = {"code": "0", "data": [{"ordId": "1", "sCode": "0"}]}
        with patch.object(client, "post", return_value=ok) as m:
            orders = [{"instId": "BTC-USDT-SWAP", "tdMode": "cross",
                       "side": "sell", "ordType": "limit", "sz": "0.5", "px": "60000"}]
            client.batch_orders(orders)
            body = m.call_args[0][1]
        self.assertEqual(body[0]["px"], "60000")
        self.assertEqual(body[0]["ordType"], "limit")

    def test_batch_returns_response(self):
        client = _client()
        ok = {"code": "0", "data": [
            {"ordId": "1", "sCode": "0"},
            {"ordId": "", "sCode": "51008", "sMsg": "Insufficient balance"},
        ]}
        with patch.object(client, "post", return_value=ok):
            result = client.batch_orders(self._sample_orders(2))
        self.assertEqual(result["code"], "0")
        self.assertEqual(len(result["data"]), 2)

    def test_empty_batch_still_posts(self):
        client = _client()
        ok = {"code": "0", "data": []}
        with patch.object(client, "post", return_value=ok) as m:
            client.batch_orders([])
        m.assert_called_once()


# ── cmd_algo() integration ────────────────────────────────────────────────────

class TestCmdAlgo(unittest.TestCase):

    def _mock_client(self, algo_orders=None):
        mock = MagicMock()
        mock.pending_algo_orders.return_value = {
            "code": "0",
            "data": algo_orders or [],
        }
        mock.place_algo_order.return_value = {
            "code": "0", "data": [{"algoId": "algo999", "sCode": "0"}],
        }
        mock.cancel_algo_order.return_value = {
            "code": "0", "data": [{"algoId": "algo999", "sCode": "0"}],
        }
        return mock

    def test_list_no_orders(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            okx.cmd_algo(["list"])  # should not raise

    def test_list_with_orders(self):
        import okx
        orders = [{"algoId": "a1", "instId": "BTC-USDT-SWAP",
                   "ordType": "oco", "side": "sell", "sz": "1",
                   "tpTriggerPx": "55000", "slTriggerPx": "45000"}]
        with patch("okx_client.OKXClient", return_value=self._mock_client(orders)):
            okx.cmd_algo(["list"])  # should not raise

    def test_oco_missing_tp_exits(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            with self.assertRaises(SystemExit):
                okx.cmd_algo(["oco", "BTC-USDT-SWAP", "1", "--sl", "45000"])

    def test_oco_missing_sl_exits(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            with self.assertRaises(SystemExit):
                okx.cmd_algo(["oco", "BTC-USDT-SWAP", "1", "--tp", "55000"])

    def test_stop_missing_sl_exits(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            with self.assertRaises(SystemExit):
                okx.cmd_algo(["stop", "BTC-USDT-SWAP", "1"])

    def test_oco_no_confirm_places_order(self):
        import okx
        mock = self._mock_client()
        with patch("okx_client.OKXClient", return_value=mock):
            okx.cmd_algo(["oco", "BTC-USDT-SWAP", "1",
                          "--tp", "55000", "--sl", "45000", "--no-confirm"])
        mock.place_algo_order.assert_called_once()
        _, kwargs = mock.place_algo_order.call_args
        self.assertEqual(mock.place_algo_order.call_args[1].get("ord_type") or
                         mock.place_algo_order.call_args[0][3], "oco")

    def test_cancel_requires_algo_id(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            with self.assertRaises(SystemExit):
                okx.cmd_algo(["cancel", "BTC-USDT-SWAP"])  # missing algo_id

    def test_cancel_calls_client(self):
        import okx
        mock = self._mock_client()
        with patch("okx_client.OKXClient", return_value=mock):
            okx.cmd_algo(["cancel", "BTC-USDT-SWAP", "algo999"])
        mock.cancel_algo_order.assert_called_once_with("BTC-USDT-SWAP", "algo999")

    def test_unknown_sub_exits(self):
        import okx
        with patch("okx_client.OKXClient", return_value=self._mock_client()):
            with self.assertRaises(SystemExit):
                okx.cmd_algo(["invalid"])


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_arbitrage.py
content: """Unit tests for arbitrage.py"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "strategies"))

import unittest
from unittest.mock import patch, MagicMock
from errors import ArbHedgeFailedError


def _make_client(spot_px="67000", swap_px="67200", funding="0.0001"):
    client = MagicMock()
    client.ticker.side_effect = lambda inst_id: {
        "code": "0",
        "data": [{"last": spot_px if "SWAP" not in inst_id else swap_px}],
    }
    client.funding_rate.return_value = {
        "code": "0",
        "data": [{"fundingRate": funding}],
    }
    return client


class TestBasis(unittest.TestCase):
    def test_basis_calculation(self):
        from strategies.arbitrage import basis
        client = _make_client(spot_px="67000", swap_px="67200")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")

        self.assertAlmostEqual(result["basis"], 200.0)
        self.assertAlmostEqual(result["basis_pct"], round(200 / 67000 * 100, 4))

    def test_signal_open_when_basis_above_threshold(self):
        from strategies.arbitrage import basis
        # basis_pct = 200/67000*100 ≈ 0.299% > 0.1 → "open"
        client = _make_client(spot_px="67000", swap_px="67200")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertEqual(result["signal"], "open")

    def test_signal_close_when_basis_below_threshold(self):
        from strategies.arbitrage import basis
        # basis_pct = 10/67000*100 ≈ 0.015% < 0.02 → "close"
        client = _make_client(spot_px="67000", swap_px="67010")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertEqual(result["signal"], "close")

    def test_signal_wait_in_between(self):
        from strategies.arbitrage import basis
        # basis_pct = 50/67000*100 ≈ 0.075% → between 0.02 and 0.1 → "wait"
        client = _make_client(spot_px="67000", swap_px="67050")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertEqual(result["signal"], "wait")

    def test_annual_funding_calculation(self):
        from strategies.arbitrage import basis
        # funding_rate=0.0001 → 0.0001 * 3 * 365 * 100 = 10.95%/yr
        client = _make_client(funding="0.0001")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertAlmostEqual(result["annual_funding_pct"], 10.95, places=1)

    def test_api_error_returns_error_dict(self):
        from strategies.arbitrage import basis
        client = MagicMock()
        client.ticker.return_value = {"code": "1", "msg": "timeout"}
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertIn("error", result)

    def test_negative_basis_when_spot_above_swap(self):
        from strategies.arbitrage import basis
        client = _make_client(spot_px="67200", swap_px="67000")
        with patch("strategies.arbitrage.OKXClient", return_value=client):
            result = basis("BTC-USDT", "BTC-USDT-SWAP")
        self.assertLess(result["basis"], 0)
        self.assertLess(result["basis_pct"], 0)


class TestOpenArb(unittest.TestCase):
    def test_hedge_failure_raises_arb_error(self):
        """If swap short fails after spot buy succeeds, raise ArbHedgeFailedError."""
        from strategies.arbitrage import open_arb
        client = MagicMock()
        client.ticker.side_effect = lambda inst_id: {
            "code": "0",
            "data": [{"last": "67000" if "SWAP" not in inst_id else "67200"}]
        }
        client.funding_rate.return_value = {
            "code": "0", "data": [{"fundingRate": "0.0001"}]
        }
        # Spot buy succeeds, swap short fails
        client.place_order.side_effect = [
            {"code": "0", "data": [{"ordId": "spot123"}]},  # spot buy OK
            {"code": "1", "msg": "Insufficient margin"},     # swap short FAIL
        ]

        with patch("strategies.arbitrage.OKXClient", return_value=client), \
             patch("builtins.input", return_value="y"):
            with self.assertRaises(ArbHedgeFailedError) as ctx:
                open_arb("BTC-USDT", "BTC-USDT-SWAP", 100, min_basis_pct=0.0)

        self.assertIn("swap-short", str(ctx.exception))

    def test_no_trade_when_basis_below_threshold(self):
        """No order placed if basis < min_basis_pct."""
        from strategies.arbitrage import open_arb
        client = _make_client(spot_px="67000", swap_px="67010")  # basis ≈ 0.015%

        with patch("strategies.arbitrage.OKXClient", return_value=client):
            open_arb("BTC-USDT", "BTC-USDT-SWAP", 100, min_basis_pct=0.1)

        client.place_order.assert_not_called()

    def test_cancel_at_prompt_skips_order(self):
        """User typing 'n' at confirm prompt should skip order placement."""
        from strategies.arbitrage import open_arb
        client = _make_client(spot_px="67000", swap_px="67300")

        with patch("strategies.arbitrage.OKXClient", return_value=client), \
             patch("builtins.input", return_value="n"):
            open_arb("BTC-USDT", "BTC-USDT-SWAP", 100, min_basis_pct=0.0)

        client.place_order.assert_not_called()


class TestScan(unittest.TestCase):
    def test_scan_handles_api_errors_gracefully(self):
        """scan() should not raise even if some pairs fail."""
        from strategies.arbitrage import scan
        client = MagicMock()
        client.ticker.return_value = {"code": "1", "msg": "timeout"}
        client.funding_rate.return_value = {"code": "1"}

        with patch("strategies.arbitrage.OKXClient", return_value=client):
            scan([("BTC-USDT", "BTC-USDT-SWAP")])  # should not raise

    def test_scan_processes_all_pairs(self):
        """scan() should call ticker for each pair."""
        from strategies.arbitrage import scan
        client = _make_client()

        with patch("strategies.arbitrage.OKXClient", return_value=client):
            scan([
                ("BTC-USDT", "BTC-USDT-SWAP"),
                ("ETH-USDT", "ETH-USDT-SWAP"),
            ])

        # 2 tickers per pair (spot + swap) × 2 pairs = 4 calls
        self.assertEqual(client.ticker.call_count, 4)


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_config.py
content: """Unit tests for config.py"""
import sys, os, json, tempfile, shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import unittest
from unittest.mock import patch


class TestConfig(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp()
        # Redirect MEMORY_DIR to a temp directory for all tests
        self._patcher = patch("config.MEMORY_DIR", self.tmp)
        self._patcher.start()
        # Also patch the path constants that were already bound at import time
        import config
        config.PREFS_PATH = os.path.join(self.tmp, "okx-trading-preferences.json")
        config.STATE_PATH = os.path.join(self.tmp, "okx-trading-state.json")
        config.JOURNAL_PATH = os.path.join(self.tmp, "okx-trading-journal.json")

    def tearDown(self):
        self._patcher.stop()
        shutil.rmtree(self.tmp)

    def test_load_prefs_returns_defaults_when_no_file(self):
        import config
        prefs = config.load_prefs()
        self.assertIn("max_order_usd", prefs)
        self.assertIn("stop_loss_pct", prefs)
        self.assertIn("watchlist", prefs)

    def test_save_and_load_prefs_roundtrip(self):
        import config
        prefs = config.load_prefs()
        prefs["max_order_usd"] = 999
        config.save_prefs(prefs)
        loaded = config.load_prefs()
        self.assertEqual(loaded["max_order_usd"], 999)

    def test_load_prefs_merges_defaults_for_missing_keys(self):
        import config
        # Write partial prefs (missing some keys)
        partial = {"max_order_usd": 50}
        with open(config.PREFS_PATH, "w") as f:
            json.dump(partial, f)
        prefs = config.load_prefs()
        # All default keys should still be present
        self.assertEqual(prefs["max_order_usd"], 50)
        self.assertIn("stop_loss_pct", prefs)
        self.assertIn("auto_trade", prefs)

    def test_load_state_returns_defaults(self):
        import config
        state = config.load_state()
        self.assertIn("daily_trades", state)
        self.assertEqual(state["daily_trades"], 0)

    def test_save_and_load_state_roundtrip(self):
        import config
        state = config.load_state()
        state["daily_trades"] = 5
        config.save_state(state)
        loaded = config.load_state()
        self.assertEqual(loaded["daily_trades"], 5)

    def test_log_trade_appends(self):
        import config
        config.log_trade({"inst_id": "BTC-USDT", "action": "buy"})
        config.log_trade({"inst_id": "ETH-USDT", "action": "sell"})
        with open(config.JOURNAL_PATH) as f:
            data = json.load(f)
        trades = data["trades"]  # journal is {"trades": [...]}
        self.assertEqual(len(trades), 2)
        self.assertEqual(trades[0]["inst_id"], "BTC-USDT")
        self.assertEqual(trades[1]["action"], "sell")

    def test_log_trade_appends_to_existing(self):
        import config
        config.log_trade({"inst_id": "BTC-USDT", "action": "buy"})
        config.log_trade({"inst_id": "ETH-USDT", "action": "sell"})
        config.log_trade({"inst_id": "SOL-USDT", "action": "buy"})
        with open(config.JOURNAL_PATH) as f:
            data = json.load(f)
        self.assertEqual(len(data["trades"]), 3)

    def test_grid_state_path(self):
        import config
        path = config.grid_state_path("BTC-USDT")
        self.assertIn("BTC-USDT", path)
        self.assertTrue(path.endswith(".json"))

    def test_default_prefs_types(self):
        import config
        prefs = config.DEFAULT_PREFS
        self.assertIsInstance(prefs["max_order_usd"], (int, float))
        self.assertIsInstance(prefs["require_confirm"], bool)
        self.assertIsInstance(prefs["watchlist"], list)
        self.assertIsInstance(prefs["strategies"], list)


class TestSnapshots(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp()
        import config
        config.SNAPSHOTS_PATH = os.path.join(self.tmp, "okx-monitor-snapshots.json")

    def tearDown(self):
        shutil.rmtree(self.tmp)

    def test_load_snapshots_returns_defaults_when_no_file(self):
        import config
        data = config.load_snapshots()
        self.assertIsNone(data["initial_equity"])
        self.assertEqual(data["snapshots"], [])

    def test_save_snapshot_sets_initial_equity_on_first_write(self):
        import config
        config.save_snapshot({"equity_usd": 1000.0, "ts_label": "01:00"})
        data = config.load_snapshots()
        self.assertEqual(data["initial_equity"], 1000.0)

    def test_save_snapshot_does_not_overwrite_initial_equity(self):
        import config
        config.save_snapshot({"equity_usd": 1000.0, "ts_label": "01:00"})
        config.save_snapshot({"equity_usd": 1200.0, "ts_label": "01:01"})
        data = config.load_snapshots()
        self.assertEqual(data["initial_equity"], 1000.0)  # unchanged

    def test_save_snapshot_appends_entries(self):
        import config
        config.save_snapshot({"equity_usd": 1000.0, "ts_label": "01:00"})
        config.save_snapshot({"equity_usd": 1010.0, "ts_label": "01:01"})
        data = config.load_snapshots()
        self.assertEqual(len(data["snapshots"]), 2)
        self.assertEqual(data["snapshots"][1]["equity_usd"], 1010.0)

    def test_save_snapshot_caps_at_48(self):
        import config
        for i in range(50):
            config.save_snapshot({"equity_usd": float(i), "ts_label": f"{i:02d}:00"})
        data = config.load_snapshots()
        self.assertEqual(len(data["snapshots"]), 48)
        # Oldest entries are dropped; last entry is the most recent
        self.assertEqual(data["snapshots"][-1]["equity_usd"], 49.0)

    def test_save_and_load_roundtrip(self):
        import config
        snap = {"equity_usd": 5000.0, "ts_label": "02:30",
                "avail_usdt": 116.5, "total_upl": 0.75}
        config.save_snapshot(snap)
        data = config.load_snapshots()
        self.assertEqual(data["snapshots"][0]["avail_usdt"], 116.5)
        self.assertEqual(data["snapshots"][0]["total_upl"], 0.75)


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_decision.py
content: """Unit tests for okx_decision.py — DecisionEngine"""
import sys, os, json, tempfile, shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock


def _engine(tmp, lessons=None, patterns=None, journal=None, model=None):
    """Build a DecisionEngine with in-memory data, no real file I/O."""
    from okx_decision import DecisionEngine
    engine = DecisionEngine.__new__(DecisionEngine)
    engine.lessons       = lessons  or {}
    engine.patterns      = patterns or {}
    engine.journal       = journal  or {}
    engine.model         = model    or {"optimal_parameters": {
        "stop_loss_pct": 3.0, "take_profit_pct": 15.0,
        "position_size_usdt": 50, "leverage": 3,
    }, "performance_stats": {"win_rate": 50.0}}
    engine.monitoring    = {}
    engine.decision_log  = {}
    return engine


# ── check_avoid_conditions ───────────────────────────────────────────────────

class TestCheckAvoidConditions(unittest.TestCase):

    def test_no_avoid_when_empty(self):
        engine = _engine(None)
        avoid, reason = engine.check_avoid_conditions("BTC", "BUY", "bull")
        self.assertFalse(avoid)
        self.assertEqual(reason, "")

    def test_avoids_on_matching_lesson(self):
        lessons = {"lessons": [{
            "type": "large_loss",
            "lesson": "BTC 大亏",
            "avoid_condition": {"coin": "BTC", "signal": "BUY", "market_regime": "bear"}
        }]}
        engine = _engine(None, lessons=lessons)
        avoid, reason = engine.check_avoid_conditions("BTC", "BUY", "bear")
        self.assertTrue(avoid)
        self.assertIn("BTC 大亏", reason)

    def test_avoids_on_failed_pattern(self):
        patterns = {"patterns": [{
            "coin": "ETH", "signal": "SELL", "market_regime": "bull",
            "pattern_type": "failed", "win_rate": 0.15
        }]}
        engine = _engine(None, patterns=patterns)
        avoid, reason = engine.check_avoid_conditions("ETH", "SELL", "bull")
        self.assertTrue(avoid)

    def test_no_avoid_on_different_regime(self):
        lessons = {"lessons": [{
            "avoid_condition": {"coin": "BTC", "signal": "BUY", "market_regime": "bear"}
        }]}
        engine = _engine(None, lessons=lessons)
        avoid, _ = engine.check_avoid_conditions("BTC", "BUY", "bull")
        self.assertFalse(avoid)


# ── check_success_patterns ───────────────────────────────────────────────────

class TestCheckSuccessPatterns(unittest.TestCase):

    def test_returns_empty_when_none(self):
        engine = _engine(None)
        self.assertEqual(engine.check_success_patterns("BTC", "BUY", "bull"), [])

    def test_returns_successful_pattern(self):
        patterns = {"patterns": [{
            "coin": "BTC", "signal": "BUY", "market_regime": "bull",
            "pattern_type": "successful", "win_rate": 0.75,
            "total_pnl": 30.0, "trades": 10
        }]}
        engine = _engine(None, patterns=patterns)
        result = engine.check_success_patterns("BTC", "BUY", "bull")
        self.assertEqual(len(result), 1)
        self.assertIn("win_rate", result[0])

    def test_does_not_return_failed_pattern(self):
        patterns = {"patterns": [{
            "coin": "BTC", "signal": "BUY", "market_regime": "bull",
            "pattern_type": "failed", "win_rate": 0.2
        }]}
        engine = _engine(None, patterns=patterns)
        result = engine.check_success_patterns("BTC", "BUY", "bull")
        self.assertEqual(result, [])

    def test_returns_replicate_lesson(self):
        lessons = {"lessons": [{
            "type": "large_win",
            "lesson": "BTC 大赚",
            "action": "加仓",
            "replicate_condition": {"coin": "BTC", "signal": "BUY", "market_regime": "bull"}
        }]}
        engine = _engine(None, lessons=lessons)
        result = engine.check_success_patterns("BTC", "BUY", "bull")
        self.assertEqual(len(result), 1)
        self.assertIn("lesson", result[0])


# ── generate_decision ────────────────────────────────────────────────────────

class TestGenerateDecision(unittest.TestCase):

    def test_returns_avoid_when_lesson_matches(self):
        lessons = {"lessons": [{
            "type": "large_loss",
            "lesson": "bad trade",
            "avoid_condition": {"coin": "BTC", "signal": "BUY", "market_regime": "bear"}
        }]}
        engine = _engine(None, lessons=lessons)
        decision = engine.generate_decision("BTC", "BUY", "bear", 66000)
        self.assertEqual(decision["decision"], "avoid")
        self.assertIsNotNone(decision["avoid_warning"])

    def test_strong_bull_buy_signal_returns_buy(self):
        engine = _engine(None)
        decision = engine.generate_decision("BTC", "BUY", "strong_bull", 66000, rsi=50)
        self.assertEqual(decision["decision"], "buy")

    def test_strong_bear_sell_signal_returns_sell(self):
        engine = _engine(None)
        decision = engine.generate_decision("BTC", "SELL", "strong_bear", 66000, rsi=50)
        self.assertEqual(decision["decision"], "sell")

    def test_oversold_rsi_returns_buy(self):
        # ranging applies *0.5 penalty → confidence 0.15 < 0.3 threshold → wait
        # use "bull" which has no penalty so RSI confidence 0.3 meets threshold
        engine = _engine(None)
        decision = engine.generate_decision("BTC", "BUY", "bull", 66000, rsi=25)
        self.assertEqual(decision["decision"], "buy")

    def test_overbought_rsi_returns_sell(self):
        # same reason: use "bear" to avoid the ranging 0.5 penalty
        engine = _engine(None)
        decision = engine.generate_decision("BTC", "SELL", "bear", 66000, rsi=75)
        self.assertEqual(decision["decision"], "sell")

    def test_low_confidence_returns_wait(self):
        engine = _engine(None)
        # ranging + no strong signal + rsi=50 → confidence too low
        decision = engine.generate_decision("BTC", "BUY", "ranging", 66000, rsi=50)
        self.assertEqual(decision["decision"], "wait")

    def test_decision_has_required_keys(self):
        engine = _engine(None)
        d = engine.generate_decision("BTC", "BUY", "strong_bull", 66000)
        for key in ("decision", "confidence", "reasons", "coin", "signal", "market_regime"):
            self.assertIn(key, d)

    def test_confidence_capped_at_1(self):
        patterns = {"patterns": [{
            "coin": "BTC", "signal": "BUY", "market_regime": "strong_bull",
            "pattern_type": "successful", "win_rate": 0.9,
            "total_pnl": 100.0, "trades": 20
        }] * 5}
        engine = _engine(None, patterns=patterns)
        d = engine.generate_decision("BTC", "BUY", "strong_bull", 66000, rsi=25)
        self.assertLessEqual(d["confidence"], 1.0)

    def test_buy_decision_includes_parameters(self):
        engine = _engine(None)
        d = engine.generate_decision("BTC", "BUY", "strong_bull", 66000)
        self.assertIn("position_usdt", d["parameters"])
        self.assertIn("stop_loss_pct", d["parameters"])


# ── log_decision ─────────────────────────────────────────────────────────────

class TestLogDecision(unittest.TestCase):

    def test_appends_decision(self):
        engine = _engine(None)
        with patch.object(engine, "decision_log", {}), \
             patch("okx_decision.save_json"):
            engine.log_decision({"decision": "buy", "coin": "BTC"})
            self.assertEqual(len(engine.decision_log["decisions"]), 1)

    def test_caps_at_500(self):
        engine = _engine(None)
        engine.decision_log = {"decisions": [{"d": i} for i in range(500)]}
        with patch("okx_decision.save_json"):
            engine.log_decision({"decision": "sell"})
        self.assertEqual(len(engine.decision_log["decisions"]), 500)
        self.assertEqual(engine.decision_log["decisions"][-1]["decision"], "sell")


# ── get_decision_summary ─────────────────────────────────────────────────────

class TestGetDecisionSummary(unittest.TestCase):

    def test_returns_last_n(self):
        engine = _engine(None)
        engine.decision_log = {"decisions": [
            {"timestamp": "2024-01-01T00:00:00", "coin": f"COIN{i}",
             "decision": "buy", "confidence": 0.8, "reasons": []}
            for i in range(20)
        ]}
        result = engine.get_decision_summary(5)
        self.assertEqual(len(result), 5)
        self.assertEqual(result[-1]["coin"], "COIN19")

    def test_empty_log_returns_empty(self):
        engine = _engine(None)
        self.assertEqual(engine.get_decision_summary(), [])

    def test_summary_contains_required_fields(self):
        engine = _engine(None)
        engine.decision_log = {"decisions": [{
            "timestamp": "2024-01-01T00:00:00", "coin": "BTC",
            "decision": "buy", "confidence": 0.7, "reasons": ["RSI 超卖"]
        }]}
        result = engine.get_decision_summary(1)
        self.assertIn("coin", result[0])
        self.assertIn("decision", result[0])
        self.assertIn("confidence", result[0])


# ── simulate_scenario ────────────────────────────────────────────────────────

class TestSimulateScenario(unittest.TestCase):

    def test_insufficient_data_when_no_trades(self):
        engine = _engine(None, journal={})
        result = engine.simulate_scenario("BTC", "long", 66000, 100, 3, 0.03, 0.15)
        self.assertEqual(result["status"], "insufficient_data")

    def test_simulated_when_trades_exist(self):
        journal = {"trades": [
            {"coin": "BTC-USDT-SWAP", "direction": "long",
             "pnl_pct": 5.0, "pnl_usdt": 10.0}
            for _ in range(5)
        ]}
        engine = _engine(None, journal=journal)
        result = engine.simulate_scenario("BTC-USDT-SWAP", "long", 66000, 100, 3, 0.03, 0.15)
        self.assertEqual(result["status"], "simulated")
        self.assertIn("scenarios", result)
        self.assertIn("expected_value_usdt", result)

    def test_scenarios_have_three_cases(self):
        journal = {"trades": [
            {"coin": "BTC", "direction": "long", "pnl_pct": 3.0, "pnl_usdt": 5.0}
        ]}
        engine = _engine(None, journal=journal)
        result = engine.simulate_scenario("BTC", "long", 66000, 100, 3, 0.03, 0.15)
        if result["status"] == "simulated":
            for case in ("bull_case", "base_case", "bear_case"):
                self.assertIn(case, result["scenarios"])


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_errors.py
content: """Unit tests for errors.py"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import unittest
from errors import (
    OKXBaseError, NetworkError, APIError, BusinessError, ArbHedgeFailedError,
    OKXErrorCode,
)


class TestOKXErrorCode(unittest.TestCase):
    def test_known_codes(self):
        self.assertEqual(OKXErrorCode.INVALID_KEY, 50111)
        self.assertEqual(OKXErrorCode.WRONG_ENV, 50101)

    def test_membership(self):
        self.assertIn(50111, OKXErrorCode.__members__.values())


class TestNetworkError(unittest.TestCase):
    def test_is_base(self):
        e = NetworkError("timeout")
        self.assertIsInstance(e, OKXBaseError)

    def test_message(self):
        self.assertEqual(str(NetworkError("timeout")), "timeout")


class TestAPIError(unittest.TestCase):
    def test_defaults(self):
        e = APIError("bad request")
        self.assertEqual(e.code, "")
        self.assertEqual(e.status, 0)

    def test_with_code_and_status(self):
        e = APIError("not found", code="404", status=404)
        self.assertEqual(e.code, "404")
        self.assertEqual(e.status, 404)

    def test_is_base(self):
        self.assertIsInstance(APIError("x"), OKXBaseError)


class TestBusinessError(unittest.TestCase):
    def test_is_base(self):
        self.assertIsInstance(BusinessError("x"), OKXBaseError)

    def test_code(self):
        e = BusinessError("insufficient margin", code="51020")
        self.assertEqual(e.code, "51020")


class TestArbHedgeFailedError(unittest.TestCase):
    def test_is_business(self):
        e = ArbHedgeFailedError("swap-short", "order failed")
        self.assertIsInstance(e, BusinessError)

    def test_leg_in_message(self):
        e = ArbHedgeFailedError("swap-short", "order failed")
        self.assertIn("swap-short", str(e))

    def test_raise_catch(self):
        with self.assertRaises(ArbHedgeFailedError):
            raise ArbHedgeFailedError("swap-short", "fail")

        with self.assertRaises(BusinessError):
            raise ArbHedgeFailedError("swap-short", "fail")


if __name__ == "__main__":
    unittest.main()


path: scripts/tests/test_grid.py
content: """Unit tests for grid.py"""
import sys, os, json, tempfile, shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "strategies"))

import unittest
from unittest.mock import patch, MagicMock


class TestGridLevels(unittest.TestCase):
    """Test grid level calculation without API calls."""

    def test_level_count(self):
        """n grids → n+1 levels."""
        lower, upper, grids = 40000, 50000, 10
        step = (upper - lower) / grids
        levels = [lower + i * step for i in range(grids + 1)]
        self.assertEqual(len(levels), 11)

    def test_level_bounds(self):
        lower, upper, grids = 40000, 50000, 10
        step = (upper - lower) / grids
        levels = [lower + i * step for i in range(grids + 1)]
        self.assertAlmostEqual(levels[0], 40000)
        self.assertAlmostEqual(levels[-1], 50000)

    def test_step_size(self):
        lower, upper, grids = 40000, 50000, 10
        step = (upper - lower) / grids
        self.assertAlmostEqual(step, 1000)

    def test_order_size_per_grid(self):
        total_usdt, grids = 1000, 10
        order_usdt = total_usdt / grids
        self.assertAlmostEqual(order_usdt, 100)

    def test_sz_calculation(self):
        """sz = order_usdt / price, correct to 6 decimals."""
        order_usdt, level = 100, 40000
        sz = round(order_usdt / level, 6)
        self.assertAlmostEqual(sz, 0.0025, places=6)


class TestGridPnL(unittest.TestCase):
    """Test per-trade PnL accumulation — verifies the double-count bug is fixed."""

    def test_pnl_per_trade_not_cumulative(self):
        """Each sell fill adds step * sz, not cumulative."""
        step = 1000
        state = {"pnl": 0.0}
        fills = [
            {"sz": "0.0025", "expected_profit": 0.0025 * 1000},
            {"sz": "0.0025", "expected_profit": 0.0025 * 1000},
        ]
        for fill in fills:
            trade_profit = float(fill["sz"]) * step
            state["pnl"] += trade_profit

        # After 2 fills, total PnL = 2 * 2.5 = 5.0 USDT
        self.assertAlmostEqual(state["pnl"], 5.0)

    def test_pnl_not_doubled_on_third_fill(self):
        """The OLD bug: state['pnl'] += profit (where profit was cumulative)."""
        step = 1000
        sz = 0.0025
        state = {"pnl": 0.0}

        # Simulate the FIXED code: trade_profit is per-trade only
        for _ in range(5):
            trade_profit = sz * step
            state["pnl"] += trade_profit

        self.assertAlmostEqual(state["pnl"], 5 * sz * step)  # 5 * 2.5 = 12.5


class TestGridSetup(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp()

    def tearDown(self):
        shutil.rmtree(self.tmp)

    def _mock_client(self, price="45000"):
        client = MagicMock()
        client.ticker.return_value = {"code": "0", "data": [{"last": price}]}
        client.place_order.return_value = {
            "code": "0", "data": [{"ordId": "order123", "sCode": "0", "sMsg": ""}]
        }
        return client

    def test_setup_places_only_buy_orders_below_price(self):
        """Only buy orders should be placed for levels below current price."""
        from strategies.grid import setup_grid
        client = self._mock_client(price="45000")

        with patch("strategies.grid.OKXClient", return_value=client), \
             patch("strategies.grid.MEMORY_DIR", self.tmp), \
             patch("strategies.grid.grid_state_path",
                   return_value=os.path.join(self.tmp, "grid.json")):
            setup_grid("BTC-USDT", 40000, 50000, 10, 1000)

        # Levels: 40000, 41000, 42000, 43000, 44000, 45000(=current), 46000, 47000...
        # Only 40000-44000 should get buy orders (5 levels below current)
        calls = client.place_order.call_args_list
        sides = [c[1]["side"] for c in calls]
        self.assertTrue(all(s == "buy" for s in sides))
        self.assertEqual(len(sides), 5)  # 40k,41k,42k,43k,44k < 45k current

    def test_state_saved_with_correct_structure(self):
        from strategies.grid import setup_grid
        client = self._mock_client(price="45000")
        state_path = os.path.join(self.tmp, "grid.json")

        with patch("strategies.grid.OKXClient", return_value=client), \
             patch("strategies.grid.MEMORY_DIR", self.tmp), \
             patch("strategies.grid.grid_state_path", return_value=state_path):
            setup_grid("BTC-USDT", 40000, 50000, 10, 1000)

        with open(state_path) as f:
            state = json.load(f)

        self.assertEqual(state["inst_id"], "BTC-USDT")
        self.assertEqual(state["lower"], 40000)
        self.assertEqual(state["upper"], 50000)
        self.assertIn("orders", state)
        self.assertEqual(state["pnl"], 0.0)


class TestGridStop(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.mkdtemp()
        self.state_path = os.path.join(self.tmp, "grid.json")

    def tearDown(self):
        shutil.rmtree(self.tmp)

    def _write_state(self, orders: dict):
        state = {
            "inst_id": "BTC-USDT", "lower": 40000, "upper": 50000,
            "grids": 10, "step": 1000, "total_usdt": 1000, "td_mode": "cash",
            "levels": [], "orders": orders, "pnl": 2.5,
        }
        with open(self.state_path, "w") as f:
            json.dump(state, f)

    def test_stop_grid_cancels_all_orders(self):
        from strategies.grid import stop_grid
        self._write_state({
            "40000.0": {"ordId": "ord1", "side": "buy", "sz": "0.0025"},
            "41000.0": {"ordId": "ord2", "side": "buy", "sz": "0.0025"},
        })
        client = MagicMock()
        client.cancel_order.return_value = {"code": "0"}

        with patch("strategies.grid.OKXClient", return_value=client), \
             patch("strategies.grid.grid_state_path", return_value=self.state_path):
            stop_grid("BTC-USDT")

        self.assertEqual(client.cancel_order.call_count, 2)

    def test_stop_grid_clears_orders_in_state(self):
        from strategies.grid import stop_grid
        self._write_state({"40000.0": {"ordId": "ord1", "side": "buy", "sz": "0.0025"}})
        client = MagicMock()
        client.cancel_order.return_value = {"code": "0"}

        with patch("strategies.grid.OKXClient", return_value=client), \
             patch("strategies.grid.grid_state_path", return_value=self.state_path):
            stop_grid("BTC-USDT")

        with open(self.state_path) as f:
            state = json.load(f)
        self.assertEqual(state["orders"], {})


if __name__ == "__main__":
    unittest.main()


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