# Raydium LP Dashboard 公共 API

公共 API 提供当前监控池目录（Raydium，可选 Orca/Meteora 24h 榜单）、Raydium/Orca 分钟级成交量与实际手续费、单池历史成交量、CLMM 建池 WebSocket 及按创建者地址订阅开仓、加仓、减仓和关仓的 WebSocket。所有接口只读、无需 `ADMIN_PASSWORD`，并开放跨域 GET 请求。

以下示例使用：

```text
https://your-dashboard.example.com
```

替换为实际部署域名即可。HTTP 成交量金额单位为 USD，时间使用 UTC ISO 8601；建池事件的 Unix 秒/毫秒字段见第 4 节，仓位原始数量与时间字段见第 5 节。

## 1. 获取当前监控的池子

```http
GET /api/public/pools?dex={dex}&excludeMeme={boolean}
```

| 参数 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `dex` | 否 | `raydium` | 池子来源：`raydium`、`orca`、`meteora` |
| `excludeMeme` | 否 | `true` | 是否排除 Meme 池；对 `dex=raydium` 和 `dex=orca` 生效。支持 `true`/`false`（也兼容 `1`/`0`） |

- `raydium`（默认，与不传 `dex` 行为一致）：返回 Raydium RWA 池目录与手工添加池合并后的当前监控目录。默认 `excludeMeme=true`，只保留包含 USDC 的池，以及 xStocks/Backpack 股票代币与 USDC、USDG、SOL 或 WSOL 的池；传 `excludeMeme=false` 可返回全部监控池。`manuallyAdded` 表示该池是否在 `/ops/pool-registry` 中手工登记；手工池同样遵循 Meme 过滤参数。
- `orca`：返回 Orca RWA 榜单，与交易量监控使用同一个 `categories=security` TOP 50 接口，按 24h 手续费降序；默认只保留 xStocks / Backpack Securities 与 USDC、USDG、SOL 或 WSOL 组成的池，传 `excludeMeme=false` 可返回该接口的完整结果。
- `meteora`：返回 Meteora RWA 榜单，与 `/meteora` 菜单页同源，24h 统计窗口、按手续费降序 TOP 50。

`excludeMeme` 不过滤 Meteora，该 DEX 的榜单始终保持原有返回口径。

```bash
curl 'https://your-dashboard.example.com/api/public/pools'
curl 'https://your-dashboard.example.com/api/public/pools?dex=raydium&excludeMeme=false'
curl 'https://your-dashboard.example.com/api/public/pools?dex=orca'
```

响应示例（`dex=raydium`）：

```json
{
  "data": [
    {
      "id": "POOL_ADDRESS",
      "dex": "raydium",
      "pair": "TOKEN/USDC",
      "baseToken": {
        "symbol": "TOKEN",
        "mint": "BASE_MINT_ADDRESS",
        "decimals": 6,
        "logoURI": "https://example.com/token-logo.png"
      },
      "quoteToken": {
        "symbol": "USDC",
        "mint": "QUOTE_MINT_ADDRESS",
        "decimals": 6,
        "logoURI": "https://example.com/usdc-logo.png"
      },
      "feeRate": 0.0025,
      "feeBps": 25,
      "tvlUsd": 1250000.5,
      "volume24hUsd": null,
      "fees24hUsd": null,
      "manuallyAdded": true,
      "addedAt": "2026-07-15T02:30:00.000Z"
    }
  ],
  "meta": {
    "count": 1,
    "generatedAt": "2026-07-15T03:00:00.000Z"
  }
}
```

`baseToken.logoURI` 和 `quoteToken.logoURI` 来自对应 DEX 的 token metadata；元数据未提供图片地址时返回 `null`。

`dex=orca` / `dex=meteora` 时返回榜单条目，差异如下：

- `manuallyAdded` 固定为 `false`，`addedAt` 固定为 `null`。
- `decimals` 来自上游 token metadata；上游未提供时为 `null`。
- `volume24hUsd` 和 `fees24hUsd` 为 24h 窗口的交易量与手续费（USD）；`dex=raydium` 时这两个字段固定为 `null`。
- 排序保持上游榜单顺序（24h 手续费降序）。
- 上游不可用时返回 `502`。

```json
{
  "data": [
    {
      "id": "POOL_ADDRESS",
      "dex": "orca",
      "pair": "SOL/USDC",
      "baseToken": { "symbol": "SOL", "mint": "BASE_MINT_ADDRESS", "decimals": 9, "logoURI": "https://example.com/sol-logo.png" },
      "quoteToken": { "symbol": "USDC", "mint": "QUOTE_MINT_ADDRESS", "decimals": 6, "logoURI": "https://example.com/usdc-logo.png" },
      "feeRate": 0.0004,
      "feeBps": 4,
      "tvlUsd": 52300000.12,
      "volume24hUsd": 18200000.45,
      "fees24hUsd": 72800.18,
      "manuallyAdded": false,
      "addedAt": null
    }
  ],
  "meta": {
    "count": 1,
    "generatedAt": "2026-07-15T03:00:00.000Z"
  }
}
```

## 2. 一次查询全部池成交量

```http
GET /api/public/pools/volume?dex={dex}&interval={interval}&limit={limit}&excludeMeme={boolean}
```

一次返回当前全部 Raydium 或 Orca 监控池的成交量和手续费时间序列。参数与单池接口一致：

| 参数 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `dex` | 否 | `raydium` | 成交量来源：`raydium` 或 `orca`；`meteora` 暂无链上成交量监控 |
| `interval` | 否 | `1h` | 聚合时间段：`1m`、`5m`、`1h`、`24h` |
| `limit` | 否 | `24` | 每个池返回最近多少个 `interval`，正整数 |
| `excludeMeme` | 否 | `true` | 是否排除 Meme 池；`dex=orca` 时只保留 xStocks / Backpack Securities 与 USDC、USDG、SOL 或 WSOL 组成的池 |

```bash
curl 'https://your-dashboard.example.com/api/public/pools/volume?interval=1h&limit=24'
curl 'https://your-dashboard.example.com/api/public/pools/volume?dex=orca&interval=1m&limit=60'
curl 'https://your-dashboard.example.com/api/public/pools/volume?interval=5m&limit=12&excludeMeme=false'
```

响应中的顶层 `totals` 是全部返回池在查询窗口内的合计；每个池也有自己的 `totals` 和固定 `limit` 个时间桶。`data` 按窗口交易量从高到低排列，交易量相同时按交易对名称排列。

```json
{
  "query": {
    "dex": "raydium",
    "interval": "1h",
    "limit": 24,
    "excludeMeme": true,
    "windowStart": "2026-07-14T04:00:00.000Z",
    "windowEnd": "2026-07-15T03:03:20.000Z"
  },
  "totals": {
    "volumeUsd": 128300.5,
    "feesUsd": 320.75,
    "buyVolumeUsd": 71400.25,
    "sellVolumeUsd": 56900.25,
    "trades": 241,
    "pools": 18
  },
  "data": [
    {
      "pool": {
        "id": "POOL_ADDRESS",
        "dex": "raydium",
        "pair": "TOKEN/USDC",
        "baseToken": { "symbol": "TOKEN", "mint": "BASE_MINT_ADDRESS", "decimals": 6, "logoURI": null },
        "quoteToken": { "symbol": "USDC", "mint": "QUOTE_MINT_ADDRESS", "decimals": 6, "logoURI": null },
        "feeRate": 0.0025,
        "feeBps": 25,
        "tvlUsd": 1250000.5,
        "volume24hUsd": null,
        "fees24hUsd": null,
        "manuallyAdded": false,
        "addedAt": null
      },
      "totals": {
        "volumeUsd": 42150.75,
        "feesUsd": 105.38,
        "buyVolumeUsd": 23700.25,
        "sellVolumeUsd": 18450.5,
        "trades": 86
      },
      "data": [
        {
          "startTime": "2026-07-14T04:00:00.000Z",
          "endTime": "2026-07-14T05:00:00.000Z",
          "complete": true,
          "volumeUsd": 3120.5,
          "feesUsd": 7.8,
          "buyVolumeUsd": 1870.25,
          "sellVolumeUsd": 1250.25,
          "trades": 7
        }
      ]
    }
  ]
}
```

该接口只返回本服务捕获并持久化的链上成交，不代理上游榜单统计。`dex=orca` 使用当前 Orca RWA TOP 50 监控目录；`dex=meteora` 返回 `400`。即使某个当前监控池在窗口内没有成交，也会返回该池，并以零填充所有时间桶。数据库聚合在一次查询中完成，不会按池逐个查询。

## 3. 按池 ID 查询成交量

```http
GET /api/public/pools/{poolId}/volume?dex={dex}&interval={interval}&limit={limit}&excludeMeme={boolean}
```

查询当前监控池的成交量时间序列。

| 参数 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `poolId` | 是 | - | Solana 池地址，且必须位于当前监控目录 |
| `dex` | 否 | `raydium` | 池来源：`raydium` 或 `orca` |
| `interval` | 否 | `1h` | 聚合时间段：`1m`、`5m`、`1h`、`24h` |
| `limit` | 否 | `24` | 返回最近多少个 `interval`，正整数 |
| `excludeMeme` | 否 | `true` | 是否排除 Meme 池；`dex=orca` 时只保留发行方可识别的证券池 |

例如，以 5 分钟为一段，查询最近 12 段（约 1 小时）：

```bash
curl 'https://your-dashboard.example.com/api/public/pools/POOL_ADDRESS/volume?interval=5m&limit=12'
curl 'https://your-dashboard.example.com/api/public/pools/ORCA_POOL_ADDRESS/volume?dex=orca&interval=1m&limit=60'
curl 'https://your-dashboard.example.com/api/public/pools/MEME_POOL_ADDRESS/volume?interval=5m&limit=12&excludeMeme=false'
```

响应示例：

```json
{
  "pool": {
    "id": "POOL_ADDRESS",
    "dex": "raydium",
    "pair": "TOKEN/USDC",
    "baseToken": { "symbol": "TOKEN", "mint": "BASE_MINT_ADDRESS", "decimals": 6, "logoURI": "https://example.com/token-logo.png" },
    "quoteToken": { "symbol": "USDC", "mint": "QUOTE_MINT_ADDRESS", "decimals": 6, "logoURI": "https://example.com/usdc-logo.png" },
    "feeRate": 0.0025,
    "feeBps": 25,
    "tvlUsd": 1250000.5,
    "volume24hUsd": null,
    "fees24hUsd": null,
    "manuallyAdded": true,
    "addedAt": "2026-07-15T02:30:00.000Z"
  },
  "query": {
    "dex": "raydium",
    "interval": "5m",
    "limit": 12,
    "excludeMeme": true,
    "windowStart": "2026-07-15T02:05:00.000Z",
    "windowEnd": "2026-07-15T03:03:20.000Z"
  },
  "totals": {
    "volumeUsd": 42150.75,
    "feesUsd": 105.38,
    "buyVolumeUsd": 23700.25,
    "sellVolumeUsd": 18450.5,
    "trades": 86
  },
  "data": [
    {
      "startTime": "2026-07-15T02:05:00.000Z",
      "endTime": "2026-07-15T02:10:00.000Z",
      "complete": true,
      "volumeUsd": 3120.5,
      "feesUsd": 7.8,
      "buyVolumeUsd": 1870.25,
      "sellVolumeUsd": 1250.25,
      "trades": 7
    }
  ]
}
```

### 时间和数据口径

- 时间段按 UTC 自然边界对齐。例如 `5m` 使用 `10:00–10:05`、`10:05–10:10`；`24h` 使用 UTC 自然日。
- 返回数组固定包含 `limit` 个时间段，无成交的时间段补零。
- 最后一段通常仍在进行，此时 `complete` 为 `false`。`query.windowEnd` 是服务器实际查询时间。
- `volumeUsd` 来自监控服务捕获的 Swap，以 1 分钟蜡烛聚合；加池和撤池不计入成交量。
- `feesUsd` 是新捕获成交的链上实际手续费。Orca 使用 `Traded` 事件的 `lp_fee + protocol_fee`，动态费率池也可按实际值统计；历史 Raydium 蜡烛未回填实际手续费时可能为 `0`。
- `dex=raydium` 默认不返回被 Meme 规则排除的池，显式传 `excludeMeme=false` 后可查询；`dex=orca` 默认只返回 xStocks / Backpack Securities 证券池，传 `excludeMeme=false` 后可查询其他当前监控池。
- `dex=orca` 从服务启动后开始累积本地成交，不扫描上线前历史；退榜池返回 `404`。
- 服务默认保留 90 天蜡烛数据，可通过 `CANDLE_RETENTION_DAYS` 修改。单次最多返回 1000 段，并且不能超过当前保留期；超出时返回 `400`。

## 错误响应

```json
{
  "error": {
    "message": "interval must be one of: 1m, 5m, 1h, 24h",
    "parameter": "interval"
  }
}
```

| HTTP 状态码 | 含义 |
| --- | --- |
| `400` | 池地址或查询参数无效 |
| `404` | 池不在当前监控目录，或被默认 Meme 过滤规则排除（可传 `excludeMeme=false` 查询） |
| `500` | 服务内部错误 |
| `502` | `dex=orca` / `dex=meteora` 上游数据服务不可用 |

OpenAPI 3.1 机器可读定义见 [`docs/openapi.yaml`](/docs/openapi.yaml)。

## 4. WebSocket：订阅 Raydium CLMM 新建池

```text
wss://your-dashboard.example.com/api/public/pool-creations/ws
wss://your-dashboard.example.com/api/public/pool-creations/ws?after=0
```

这是标准 WebSocket（RFC 6455），不是 SSE 或 Solana `logsSubscribe`。与其他公共 API 一样，只读、无需鉴权，可从浏览器跨域连接。不要传 `ADMIN_PASSWORD`，此接口不需要签名密钥。

- 只发布本项目捕获的成功 **Raydium CLMM** 建池事件，不包含 CPMM、Orca、Meteora 或失败交易。
- 新池发现独立于 RWA / 手工池目录，包含普通代币新池；本接口不提供 `excludeMeme` 或 `dex` 参数。
- 发送时过滤已拉黑的池及已知黑名单创建者。已发送的事件不会因之后拉黑而撤回；创建者未知时为 `null`。
- 复用现有链上订阅及 SQLite 事件，不为每个客户端增加 RPC 或链上 WebSocket。元数据在服务端统一补充；失败最多尝试 3 次后发布可用字段，名称、创建者等可能为 `null`。
- 事件正式发布后分配持久化游标，后续不再更新同一事件的 payload。元数据补充较慢的池可能晚于其他池发布，因此游标顺序不等同于 slot 或建池时间顺序。
- 建池事件仅通过 WebSocket 分享。byreal-copy 使用已有 `RAYDIUM_DASHBOARD_API_BASE` 自动订阅此接口，首次 `after=0`，后续按持久化游标续传。

### 连接参数与补发

| 参数 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- |
| `after` | 否 | 连接时的最新游标 | 十进制非负整数，以字符串保存，最大 `9007199254740991`。从该游标**之后**开始补发，再持续推送新事件 |

不传 `after` 只接收之后发布的事件；`after=0` 从数据库保存的第一条可见事件开始补发。每个连接每秒最多扫描 100 条记录，积压会逐批追上。没有订阅指令，握手成功即开始推送；不要发送 JSON subscribe 消息。

游标与本实例的 SQLite 数据库绑定，重启后仍有效。持久化卷丢失、还原旧备份或改连另一份数据库时，应重新确定起点。游标大于数据库当前最大值会在握手阶段返回 `409`，不会静默忽略。

服务端目前不按时间清理建池 feed。黑名单会使游标出现跳号。客户端应在成功处理事件后保存游标，并按 `data.eventId` 幂等入库；重连补发可能重复已接收但未提交游标的事件。此机制只能重放已经捕获并持久化的事件，不能补齐链上 WS 断线期间从未收到的新池。

### 服务端消息

**1. ready：** 握手后的第一条消息。

```json
{"type":"ready","version":1,"cursor":"120","latestCursor":"150","heartbeatSeconds":25}
```

`cursor` 是本次读取起点，`latestCursor` 是握手时的最新位置。请求 `after=120` 时仍需处理 121～150，**不能直接保存 `latestCursor` 跳过补发**。不传 `after` 时两者相同，可保存 `cursor` 作为初始重连起点。

**2. pool.created：** WebSocket 外层消息包裹 v1 建池事件。

```json
{
  "type": "pool.created",
  "cursor": "121",
  "data": {
    "version": 1,
    "type": "raydium.pool.created",
    "eventId": "SIGNATURE:pool:POOL_ADDRESS",
    "signature": "SIGNATURE",
    "slot": 350000000,
    "blockTime": null,
    "detectedAt": 1789084800000,
    "programId": "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK",
    "poolType": "clmm",
    "poolAddress": "POOL_ADDRESS",
    "creator": null,
    "ammConfig": null,
    "sqrtPriceX64": "18446744073709551616",
    "tick": 0,
    "tickSpacing": 60,
    "vaultA": "VAULT_A",
    "vaultB": "VAULT_B",
    "tokenA": {"mint":"MINT_A","symbol":"AAPLx","name":"Apple xStock","icon":null,"decimals":6},
    "tokenB": {"mint":"MINT_B","symbol":"USDC","name":"USD Coin","icon":null,"decimals":6}
  }
}
```

| 字段 | 类型 / 含义 |
| --- | --- |
| 外层 `cursor` | 持久化发布序号，十进制字符串，不是 slot |
| `version` / `type` | 固定为 `1` / `raydium.pool.created` |
| `eventId` | `${signature}:pool:${poolAddress}`；一笔交易多个池有不同 ID |
| `signature` / `slot` | 交易签名与确认时所在 slot；commitment 为 confirmed |
| `blockTime` | Unix **秒**或 `null`；实时日志没有精确区块时间，通常为 `null` |
| `detectedAt` | 本项目发现事件的 Unix **毫秒**，不是池开盘时间 |
| `programId` / `poolType` | Raydium CLMM 程序地址 / `clmm` |
| `poolAddress` | 新池地址 |
| `creator` / `ammConfig` | 从池账户补充的创建者 / 配置地址；不可用时为 `null` |
| `sqrtPriceX64` | 创建时链上 Q64.64 平方根价格，u128 十进制字符串；不得转换为 JS Number 后再持久化 |
| `tick` / `tickSpacing` | 初始 tick / 池 tick 间距 |
| `vaultA` / `vaultB` | 两侧 token vault 地址 |
| `tokenA` / `tokenB` | 保持链上 token0 / token1 顺序；含 mint、symbol、name、icon、decimals，除 mint 外均可为 `null` |

该事件不包含初始加池金额、TVL 或实际开仓：CLMM 创建池与添加流动性是不同操作。价格参数保持链上原始口径，未应用 Token-2022 ScaledUiAmount 倍率。

**3. checkpoint：** 一批扫描完成，可在前面的事件都处理成功后保存此游标。它也会跨过被黑名单隐藏的记录。

```json
{"type":"checkpoint","cursor":"150"}
```

### JavaScript 重连示例

```javascript
let cursor = localStorage.getItem('raydium-pool-cursor');
let retryMs = 1000;

function connect() {
  const url = new URL('wss://your-dashboard.example.com/api/public/pool-creations/ws');
  if (cursor !== null) url.searchParams.set('after', cursor);
  const ws = new WebSocket(url);
  ws.onopen = () => { retryMs = 1000; };
  ws.onmessage = ({ data }) => {
    const message = JSON.parse(data);
    if (message.type === 'pool.created') {
      // 示例同步处理；生产消费者按 eventId 幂等提交后才推进游标。
      console.log(message.data);
    }
    if (['ready', 'pool.created', 'checkpoint'].includes(message.type)) {
      cursor = message.cursor; // ready 用 cursor，不能用 latestCursor
      localStorage.setItem('raydium-pool-cursor', cursor);
    }
  };
  ws.onclose = () => {
    const wait = retryMs + Math.random() * 500;
    retryMs = Math.min(retryMs * 2, 30000);
    setTimeout(connect, wait);
  };
}
connect();
```

如果处理是异步的，须按收到的顺序串行完成事件和 checkpoint，不能让后续 checkpoint 先提交。数据库被重置导致 `409` 时，人工选择清除本地游标（从实时开始）或改用 `after=0`（全部重放），不要无限重试旧游标。Node 客户端可使用 `ws` 包，浏览器使用内置 WebSocket。

### 心跳、错误与部署

- 每 25 秒发送 WebSocket 协议 ping；浏览器和 `ws` 默认自动 pong，无须发送 JSON 心跳。下一次检查仍未 pong 时终止连接。
- 默认每个进程最多 200 个连接，可通过 `POOL_CREATION_WS_MAX_CONNECTIONS` 调整；超过上限返回 `503`。
- 握手 `400`：非法、重复或不支持的参数；`409`：游标超前；`503`：存储尚未准备好、mock 模式或连接已满。普通 HTTP GET 返回 `426 Upgrade Required`。
- 连接后关闭码 `1008`：发送了应用消息或数据库回退导致游标失效；`1011`：存储不可用。慢客户端发送缓冲超过 1 MiB 时终止连接，客户端通常看到 `1006`，应从最后成功处理的游标重连。
- 启动必须使用 `pnpm start`（`node server.mjs`），开发使用 `pnpm dev`。直接运行 `next start` 不提供此 WebSocket。Dockerfile 已同步复制入口及 transport，监听与网页相同的 3000 端口。
- EasyPanel / Nginx 必须转发 `Upgrade` 和 `Connection`；公网用 HTTPS / WSS。代理空闲超时建议大于 60 秒。多个实例必须各自使用一致的事件存储和游标范围；不要把本地 SQLite 游标用于另一份独立数据库。

Nginx 示例（按实际 upstream 调整）：

```nginx
location /api/public/pool-creations/ws {
    proxy_pass http://dashboard:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 75s;
}
```

部署方式参考 [Next.js Custom Server](https://nextjs.org/docs/app/guides/custom-server)，协议处理使用 [ws](https://github.com/websockets/ws)。OpenAPI 文件描述握手及 `x-websocket` 消息协议；普通 OpenAPI HTTP 客户端生成器不会自动生成 WebSocket 订阅代码。

## 5. WebSocket：按创建者地址订阅新建仓位与关仓

```text
wss://your-dashboard.example.com/api/public/position-openings/ws?creator=CREATOR_ADDRESS
wss://your-dashboard.example.com/api/public/position-openings/ws?creator=CREATOR_ADDRESS&dex=orca&after=120
wss://your-dashboard.example.com/api/public/position-openings/ws?creator=ADDRESS_A&creator=ADDRESS_B&dex=all
```

标准只读 WebSocket，无需密码；连接后自动订阅，不发送 subscribe 消息。支持 **Raydium CLMM / Orca Whirlpool**，范围为本服务已有监控池（Raydium RWA/手工池目录、Orca RWA TOP 50），不是该钱包的全链交易监听。增加客户端不会增加链上 RPC 或链上订阅。

| 参数 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- |
| `creator` | 是 | — | 开仓时仓位持有人的 Solana 地址，不是出资人或手续费支付人。可重复传入 1–100 个参数，如 `creator=A&creator=B`；每个值必须是一个有效完整地址，不接受逗号分隔。重复地址去重，只推送一次；数量限制按去重前参数个数计算 |
| `dex` | 否 | `all` | `all`、`raydium` 或 `orca` |
| `after` | 否 | 连接时最新发布游标 | 非负十进制整数字符串，最大 `9007199254740991`；从此游标之后续传。`0` 补发仍保留的历史事件 |

一个连接合并所有订阅地址的开仓、加仓、减仓与关仓事件，按全局发布游标升序推送，`data.creator` 标明事件所属地址。`dex` 统一作用于整组地址。只有一条 `after` / checkpoint 游标，无需为组内各地址分别续传；单地址 URL 保持兼容。

**发布条件：** 已捕获创建事件、已首次注入非零代币数量、已知创建者，并且保存了原始 tick/金额。Raydium 直接使用开仓日志；Orca 等开仓交易中的持有人补齐后再发布，空仓创建和首次入金可以是两笔交易。后续加仓发布为 `position.increased`，不重复发布新开仓。创建者补齐失败、旧版记录缺少原始参数时不发布，不能从展示价格猜测 tick。

事件先与仓位一起持久化，再分配独立发布游标；同一开仓事件只发布一次。延迟补齐的 Orca 创建者获得新的发布游标，因此不会被已经推进的订阅游标漏掉。按当前池/创建者黑名单过滤。

### 消息与跟单参数

第一条消息：

```json
{"type":"ready","version":1,"creator":"CREATOR_ADDRESS","creators":["CREATOR_ADDRESS"],"dex":"all","scope":"monitored-pools","retentionDays":10,"cursor":"120","latestCursor":"125","heartbeatSeconds":25}
```

`ready.creators` 总是返回按字典序排序、去重后的完整地址数组；`ready.creator` 为兼容旧客户端保留，只有一个地址时为该地址，多地址时为 `null`。事件中的 `data.creator` 始终是该事件的单个原始持有人地址。

多地址 ready 示例：

```json
{"type":"ready","version":1,"creator":null,"creators":["ADDRESS_A","ADDRESS_B"],"dex":"all","scope":"monitored-pools","retentionDays":10,"cursor":"120","latestCursor":"125","heartbeatSeconds":25}
```

`cursor` 是本次续传起点；不要把 `latestCursor` 保存为已处理游标。`after=0` 时 `cursor` 可能是已经清理的历史边界。

新开仓消息（以下地址为占位示例）：

```json
{
  "type": "position.opened",
  "cursor": "121",
  "data": {
    "version": 1,
    "type": "position.opened",
    "eventId": "orca:OPEN_SIGNATURE:open:0",
    "dex": "orca",
    "programId": "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc",
    "poolAddress": "POOL_ADDRESS",
    "creator": "CREATOR_ADDRESS",
    "signature": "OPEN_SIGNATURE",
    "eventIndex": 0,
    "slot": 454600000,
    "openedAt": 1791504000000,
    "fundingSignature": "FUNDING_SIGNATURE",
    "fundingSlot": 454600001,
    "fundedAt": 1791504000400,
    "publishedAt": 1791504002000,
    "commitment": "confirmed",
    "positionAddress": "POSITION_PDA",
    "nftMint": "POSITION_NFT_MINT",
    "tickLower": -120,
    "tickUpper": 240,
    "tickSpacing": 60,
    "tokenA": {"mint":"MINT_A","symbol":"TOKEN","decimals":6,"amountRaw":"1000000"},
    "tokenB": {"mint":"MINT_B","symbol":"USDC","decimals":6,"amountRaw":"100000000"},
    "amountUsd": 200,
    "display": {"baseMint":"MINT_A","quoteMint":"MINT_B","priceLower":0.9880723,"priceUpper":1.0242891}
  }
}
```

| 字段 | 用途与口径 |
| --- | --- |
| `eventId` | `${dex}:${signature}:open:${eventIndex}`，幂等键；同一交易多个仓位分别发布 |
| `dex` / `programId` / `poolAddress` | 选择 DEX SDK、程序和目标池 |
| `creator` | 原始开仓持有人；Orca 组合仓位从该交易的组合 NFT token balance 识别，不把代理权限地址当成持有人 |
| `tickLower` / `tickUpper` | **链上原始整数 tick**，保持 token A/B 方向；展示交易对反转也不改变这两个值，不从价格反推 |
| `tickSpacing` | Orca 池目录中的 tick 间距；Raydium 当前未保存该元数据，返回 `null`，消费者可随池状态读取 |
| `tokenA` / `tokenB` | **链上 token0/token1 或 Whirlpool A/B 顺序**，含 mint、symbol、decimals、首次注入的原始数量 `amountRaw` |
| `amountRaw` | u64 最小单位十进制字符串，应使用 BigInt/BN；没有 ScaledUiAmount 展示倍率，也不是已含滑点的 maximum amount。转账费按对应 DEX/Token-2022 规则处理 |
| `amountUsd` | 初次入金数量乘当时缓存价格的 USD 估值；缺少价格可能为 0，不是下单预算或保证成交金额 |
| `display` | 仅供显示的 base/quote 价格区间，可能反向且已应用 ScaledUiAmount；不要用于还原链上 tick |
| `signature` / `slot` | 创建仓位交易；`eventIndex` 为该 DEX 解码事件中的序号，不是 instruction index |
| `fundingSignature` / `fundingSlot` | 首次入金交易；Raydium 与开仓一致，Orca 可能不同 |
| `openedAt` / `fundedAt` | Unix 毫秒；实时路径使用本服务收到日志的时间，补漏路径使用区块时间，并非精确成交时间 |
| `publishedAt` | 本服务正式发布的 Unix 毫秒，可能晚于开仓/首次入金 |
| `positionAddress` / `nftMint` | 原始仓位身份，Raydium 日志缺失时为 `null`；跟单者创建自己的仓位/NFT，不复用原仓位身份 |
| `commitment` | `confirmed`；事件不是 finalized 通知，也不发送链回滚撤销事件 |

消费者可以按原始数量或自定比例/预算跟单，使用返回的目标池与原始 tick 区间。执行前仍需通过对应 DEX SDK 读取当前池状态，计算当前所需数量、滑点上限、账户与 Token-2022 转账费；接口提供开仓信号和原始条件，不返回已签名交易，也不代替消费者下单。

### 加仓与减仓消息

同一订阅连接新增两种消息：`position.increased`（后续增加流动性）和 `position.decreased`（减少流动性）。订阅地址、DEX 过滤、单一游标、幂等与十天回放规则不变。旧客户端需要处理这两种类型后再提交后续 checkpoint。

- 第一次入金记为 `position.opened`；Orca 的空仓创建后第一次入金不重复算加仓，同笔交易之后再次增加流动性才算加仓。
- 只有流动性变化量大于零才发布；纯领取手续费/奖励不是减仓。减到零但账户尚未关闭仍只是减仓，后续账户关闭单独发布 `position.closed`。同笔交易内同一仓位先减仓再关仓，只发布一次 `position.closed`，不再发布该仓位的 `position.decreased`；同笔交易中其他未关闭仓位的减仓仍正常推送。识别到关仓日志时，减仓会等待现有关仓交易查询确认对应 PDA，复用查询结果，不为此新增 RPC。
- `creator` 始终是原始开仓持有人，不是本次操作签名者；NFT 转手后仍按原持有人订阅。`openingEventId` 用于关联自己的跟单仓位，不能仅按地址/池匹配。
- 仅覆盖已有监控池内、能关联到仍保留开仓快照且已知创建者/原始参数的仓位；不会扫描全链或补造上线前缺失的开仓。未知创建者的 Orca 变动先持久化，待身份补齐后发布。
- 事件来自成功程序调用；失败交易和被上层回滚的 CPI 不发布。同笔交易多次变动各有不同 ID，WS 与补漏重放去重。金额保持事件原始整数，不改变开仓快照为“当前余额”，LP 变动也不计入 Swap 成交量。

```json
{
  "type": "position.decreased",
  "cursor": "123",
  "data": {
    "version": 1,
    "type": "position.decreased",
    "eventId": "orca:CHANGE_SIGNATURE:liquidity:0",
    "dex": "orca",
    "programId": "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc",
    "poolAddress": "POOL_ADDRESS",
    "creator": "CREATOR_ADDRESS",
    "positionAddress": "POSITION_PDA",
    "nftMint": "POSITION_NFT_MINT",
    "openingEventId": "orca:OPEN_SIGNATURE:open:0",
    "openingSignature": "OPEN_SIGNATURE",
    "signature": "CHANGE_SIGNATURE",
    "eventIndex": 0,
    "slot": 454600100,
    "occurredAt": 1791504100000,
    "publishedAt": 1791504101000,
    "commitment": "confirmed",
    "liquidityDelta": "12345678901234567890",
    "tickLower": -120,
    "tickUpper": 240,
    "tickSpacing": 60,
    "tokenA": {"mint":"MINT_A","symbol":"TOKEN","decimals":6,"amountRaw":"500000","feeAmountRaw":"0","transferFeeRaw":"0"},
    "tokenB": {"mint":"MINT_B","symbol":"USDC","decimals":6,"amountRaw":"50000000","feeAmountRaw":"0","transferFeeRaw":"0"}
  }
}
```

加仓消息字段相同，内外层 `type` 均为 `position.increased`。

| 字段 | 含义 |
| --- | --- |
| `eventId` | `${dex}:${signature}:liquidity:${eventIndex}`，按事件幂等；不是开仓事件 ID |
| `eventIndex` | 当前 DEX 交易日志中加/减流动性事件的序号，从 0 开始；Orca 的首次入金也占序号，但不作为加仓发布。不同于开仓序号与 instruction index |
| `liquidityDelta` | u128 十进制字符串，非零、无符号的流动性变动绝对值；方向由消息类型决定。不是剩余流动性或增减仓比例，不能据此推断“已全部减仓” |
| `tokenA/B.amountRaw` | 链上事件的本次流动性对应原始代币数量，u64 字符串。Raydium 减仓的独立手续费字段不并入该数量；不是滑点上限或保证净到账量 |
| `tokenA/B.feeAmountRaw` | Raydium 减仓事件单独报告的已领取手续费；其他事件为 `"0"`。不包含奖励；Orca 独立领取手续费/奖励指令不在此事件内 |
| `tokenA/B.transferFeeRaw` | 事件报告的 Token-2022 转账费，旧版 Raydium 日志没有该字段时为 `null`。Raydium 减仓转账费可能对应本金与手续费合并转出，不能直接当作本金净额扣除 |
| `tickLower/Upper` | 原始链上 token A/B 方向；Orca 使用本次事件的 tick，Raydium 使用关联开仓快照的 tick |
| `occurredAt` | Unix 毫秒；实时接收时间或补漏交易区块时间 |
| `slot` | 变动事件所在 slot（实时日志 context 或完整交易 slot） |
| `openingEventId` / `openingSignature` | 原开仓事件 ID 与签名；用于精确关联 |

跟单程序应读取当前目标仓位/池状态，自行计算加减仓比例、数量、滑点和转账费。`liquidityDelta`、原始数量不能当成当前仓位余额；不要重复使用 `openedAt`/开仓金额表示本次变动。本消息不提供保证成交报价或盈亏。

Orca 复用现有日志及已保存仓位；Raydium 变动日志只有 NFT 标识，未建立关联时会异步查询已识别的变动交易，先过滤监控池，再沿该 PDA 历史精确关联仍保留的开仓。关联和非监控池结果会缓存；重试最多 8 次，新增订阅客户端不增加 RPC。监控范围仍为现有池和十天仓位保留期。

事件字段依据 [Raydium 仓位事件定义](https://github.com/raydium-io/raydium-clmm/blob/master/programs/amm/src/states/personal_position.rs) 与 [Orca 事件定义](https://github.com/orca-so/whirlpools/blob/main/programs/whirlpool/src/events.rs)。

### 关仓消息

同一连接推送 `position.opened`、`position.increased`、`position.decreased` 和 `position.closed`，使用同一条发布游标；接口路径与 `creator` / `dex` / `after` 参数不变。订阅程序通过 `openingEventId` 查找自己记录的跟单仓位映射。

```json
{
  "type": "position.closed",
  "cursor": "122",
  "data": {
    "version": 1,
    "type": "position.closed",
    "eventId": "orca:CLOSE_SIGNATURE:close:POSITION_PDA",
    "dex": "orca",
    "programId": "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc",
    "poolAddress": "POOL_ADDRESS",
    "creator": "CREATOR_ADDRESS",
    "signature": "CLOSE_SIGNATURE",
    "slot": 454601000,
    "closedAt": 1791504400000,
    "publishedAt": 1791504401500,
    "commitment": "confirmed",
    "positionAddress": "POSITION_PDA",
    "nftMint": "POSITION_NFT_MINT",
    "openingEventId": "orca:OPEN_SIGNATURE:open:0",
    "openingSignature": "OPEN_SIGNATURE"
  }
}
```

- `eventId` 为 `${dex}:${关闭交易签名}:close:${仓位PDA}`，重试和断线重放按此幂等；同一交易关闭多个仓位分别发布。
- `openingEventId` 与开仓消息的 `eventId` 一致；`openingSignature` 是原开仓签名。旧版仅有 PDA 的快照无法还原开仓 ID 时，这两个字段为 `null`，可使用 `positionAddress` 匹配。不要只按创建者/池地址关掉所有跟单仓位。
- `creator` 保持原始开仓持有人，因此 NFT 后来转手、由其他地址关闭时，原订阅仍按同一地址接收；该字段不是关仓签名者。
- `signature` / `slot` 属于关仓交易；`closedAt` 为该交易区块时间的 Unix 毫秒，区块时间不可用时取本地处理时间。`publishedAt` 为发布时刻。
- 只对成功的 Raydium `ClosePosition`、Orca `ClosePosition` / `ClosePositionWithTokenExtensions` / `CloseBundledPosition` 指令发布。部分撤流动性、把流动性降为零但未关闭账户、本地保留期清理都不属于关仓消息。
- 复用现有关仓日志/补漏和持久化重试，不新增客户端专属 RPC。Raydium 通过原有 PDA 创建历史解析精确开仓 ID；Orca 按 PDA 和 slot 防止旧关仓误删后来复用的组合仓位。
- 关仓发布与删除原仓位在同一个数据库事务中完成，发布失败会保留仓位待重试。必须能关联本服务仍保留的仓位快照（开仓快照保留十天）且已知原创建者；不扫描全链历史、不补发升级前已删除的仓位，也不凭空猜测未知创建者。
- 关仓消息不返回撤回金额或盈亏；关闭账户指令本身不提供这些数量，不能把原开仓金额当成实际赎回金额。

### 续传、保留与示例程序

```json
{"type":"checkpoint","cursor":"125"}
```

只在前序事件处理成功后持久化 checkpoint。服务按创建者索引读取，每连接每秒最多处理整组地址合计 100 条候选记录（不是每个地址 100 条）；checkpoint 可直接跨过其他地址、DEX 或黑名单记录，**不能用游标差判断漏单数量**。

- 发布记录保留十天（启动/每小时清理）。记录是历史开仓/关仓信号，关闭后保留原开仓消息并追加关仓消息；旧开仓消息不表示此刻仍持仓；消费者应检查事件新鲜度、需要时确认原仓位状态。
- 不传 `after` 从实时开始；`after=0` 会重放历史，消费者必须按 `eventId` 幂等，避免重连重复跟单。API 上线前缺少原始参数的仓位不回填。
- 游标与数据库及订阅条件绑定。游标必须绑定同一地址集合、`dex` 和部署实例；调整地址顺序或重复同一地址不改变订阅集合。增加、删除地址或更换 `dex` 时需重连，并使用新的游标文件/订阅状态：不传 `after` 从实时开始，或显式 `after=0` 重放仍保留的历史并按 `eventId` 去重。不能直接取多个旧单地址游标的最大值合并，否则会跳过其他地址尚未处理的事件。不支持连接内动态发送增删地址指令。
- `400`：地址无效、缺少地址、`creator` 参数超过 100 个、DEX/游标格式错误、重复 `dex` / `after` 或未知参数；`409`：游标超过当前数据库最大值；`410`：游标已超出保留期，需要调用者选择新起点；`426`：普通 HTTP GET；`503`：数据库未就绪、mock 模式或连接已满。
- 连接后游标回退/过期或收到应用消息关闭码 `1008`；存储异常 `1011`；慢客户端缓冲超过 1 MiB 会被终止。每 25 秒协议 ping，未回复 pong 的连接被清理。
- 默认最多 200 个开仓订阅连接，可通过 `POSITION_OPENING_WS_MAX_CONNECTIONS` 修改。使用既有 `server.mjs` / `pnpm start`，端口与网页相同；反向代理对该路径启用 WebSocket Upgrade，超时大于 60 秒。

仓库提供可运行示例 [`examples/subscribe-position-openings.mjs`](/docs/examples/subscribe-position-openings.mjs)，串行处理事件、原子保存游标、断线退避重连，并区分订阅条件。示例只打印信号；将 `handleOpening` / `handleLiquidityChange` / `handleClose` 替换为按 `eventId` 幂等的持久化处理函数；先保存开仓事件到自有仓位的映射，关仓时按 `openingEventId` 查询该映射。

```bash
CURSOR_FILE=./my-wallet-orca-cursor.json node examples/subscribe-position-openings.mjs \
  https://your-dashboard.example.com CREATOR_ADDRESS orca

# 示例程序的命令行支持逗号分隔，内部会转换为重复 creator 参数
CURSOR_FILE=./my-wallets-cursor.json node examples/subscribe-position-openings.mjs \
  https://your-dashboard.example.com ADDRESS_A,ADDRESS_B all
```
