Skill: cengsin/tickflow-realtime (version 0.1.0)

--- name: tickflow-realtime description: 使用 TickFlow 数据中心查询实时行情和日K数据。适用于用户想查单个或多个标的的最新价格、涨跌幅、成交量、交易时段，或查询单标的/多标的的日K、最近N根K线、复权K线时。 --- # TickFlow 实时行情与日K 这个 skill 用 TickFlow HTTP API 查询两类数据： - 实时行情：最新价、涨跌幅、成交量、交易时段 - K 线数据：默认日 K，也可按周期查询最近 N 根 K 线 ## 何时使用 在这些场景触发： - 用户要查某只股票、ETF、美股、港股的最新行情 - 用户要批量比较多个代码的实时价格或涨跌幅 - 用户要查某个标的池 `universes` 的实时行情 - 用户要查某个代码的日 K、周 K、月 K - 用户要查多只标的最近一根或最近几根 K 线 ## 工作流 1. 判断用户是要实时行情还是 K 线。 2. 从环境变量 `TICKFLOW_API_KEY` 读取 API Key。 3. 实时行情优先使用 `GET /v1/quotes`；标的较多时可以切到 `POST /v1/quotes`。 4. K 线单标的使用 `GET /v1/klines`；多标的使用 `GET /v1/klines/batch`。 5. 校验响应结构。 6. 返回简洁摘要；如果用户明确要原始数据，再返回 JSON。 ## API Key - 统一从环境变量 `TICKFLOW_API_KEY` 读取 - 不要把 API Key 写入文件、日志或输出内容。 ## 脚本 - 实时行情脚本：`scripts/query_quotes.py` - K 线脚本：`scripts/query_klines.py` - 共享工具：`scripts/tickflow_common.py` ## 参考文档 - API 结构与字段说明：`references/api.md` - 对外输出约定：`references/output-contract.md` ## 使用约定 - 百分比字段需要按 TickFlow 语义换算：`0.01 -> 1%` - `timestamp` 是毫秒时间戳 - `region` 枚举目前是 `CN | US | HK` - `session` 枚举见 `references/api.md` - K 线接口返回紧凑列式结构；做摘要或表格前先解压 ## 示例 查询单个标的实时行情： ```bash python3 scripts/query_quotes.py --symbols 600519.SH --format summary ``` 批量查询多个代码实时行情： ```bash python3 scripts/query_quotes.py --symbols 600519.SH,000001.SZ,AAPL.US --format table ``` 查询单个标的日 K： ```bash python3 scripts/query_klines.py --symbol 600519.SH --period 1d --count 20 --format table ``` 查询多个标的最近一根日 K： ```bash python3 scripts/query_klines.py --symbols 600519.SH,000001.SZ --period 1d --count 1 --format summary ```

path: README.md
content: # tickflow-realtime

A Codex skill for querying real-time quotes and daily K-lines from TickFlow.

## What It Does

This skill wraps the TickFlow HTTP API and provides two main capabilities:

- Real-time quotes for one or more symbols
- K-line queries, with `1d` as the default period

It supports:

- Single-symbol quote lookup
- Multi-symbol quote comparison
- Quote lookup by `universes`
- Single-symbol K-line queries
- Batch K-line queries for multiple symbols

## Project Structure

```text
.
├── SKILL.md
├── README.md
├── TODO.md
├── references/
│   ├── api.md
│   └── output-contract.md
└── scripts/
    ├── query_klines.py
    ├── query_quotes.py
    └── tickflow_common.py
```

## Requirements

- Python 3
- A valid TickFlow API key

## API Key

This project reads the API key from the environment variable:

```bash
export TICKFLOW_API_KEY=your_key_here
```

The key is sent as the `x-api-key` header.

## Usage

### Real-Time Quotes

Single symbol:

```bash
python3 scripts/query_quotes.py --symbols 600519.SH --format summary
```

Multiple symbols:

```bash
python3 scripts/query_quotes.py --symbols 600519.SH,000001.SZ,AAPL.US --format table
```

Universe query:

```bash
python3 scripts/query_quotes.py --universes my-universe-id --format json --pretty
```

### K-Lines

Single symbol daily K:

```bash
python3 scripts/query_klines.py --symbol 600519.SH --period 1d --count 20 --format table
```

Batch latest daily K:

```bash
python3 scripts/query_klines.py --symbols 600519.SH,000001.SZ --period 1d --count 1 --format summary
```

Adjusted K-lines:

```bash
python3 scripts/query_klines.py --symbol 600519.SH --period 1d --count 60 --adjust forward --format table
```

## Output Modes

Both scripts support:

- `summary`
- `table`
- `json`

For JSON output, add `--pretty` for formatted output.

## Notes

- TickFlow percentage fields use the convention `0.01 -> 1%`
- Timestamps are epoch milliseconds
- K-line responses are returned in compact columnar form and are expanded by the script before summary/table rendering

## References

- Skill definition: [SKILL.md](./SKILL.md)
- API notes: [references/api.md](./references/api.md)
- Output contract: [references/output-contract.md](./references/output-contract.md)

## Publish Checklist

Before pushing to GitHub:

- Confirm `TICKFLOW_API_KEY` is not committed anywhere
- Review `TODO.md` and decide whether to keep it public
- Remove local-only helper files if they are not meant for the repository
- Run:

```bash
python3 -m py_compile scripts/tickflow_common.py scripts/query_quotes.py scripts/query_klines.py
```


path: references/api.md
content: # TickFlow API Notes

本文件只保留实现这个 skill 需要的最小事实，来源是：

- 官网文档页
- `https://api.tickflow.org/openapi.json`

## 认证

- 所有接口通过请求头 `x-api-key` 认证
- 本 skill 统一从环境变量 `TICKFLOW_API_KEY` 读取 API Key

## 实时行情

### GET `/v1/quotes`

- 参数：
  - `symbols`: 逗号分隔字符串
  - `universes`: 逗号分隔字符串
- 适合少量查询

### POST `/v1/quotes`

- Body schema: `QuotesRequest`
- 字段：
  - `symbols: string[] | null`
  - `universes: string[] | null`
- 适合大量标的，避免 URL 过长

### `QuotesResponse`

- 顶层：`data: Quote[]`

### `Quote`

- 必填核心字段：
  - `symbol`
  - `region`
  - `last_price`
  - `prev_close`
  - `open`
  - `high`
  - `low`
  - `volume`
  - `amount`
  - `timestamp`
- 可选字段：
  - `session`
  - `ext`

### 区域与交易时段

- `region`: `CN | US | HK`
- `session`:
  - `pre_market`
  - `regular`
  - `after_hours`
  - `closed`
  - `halted`
  - `lunch_break`

### `ext`

`ext` 是按市场区分的联合类型：

- `cn_equity`
  - `name`
  - `change_amount`
  - `change_pct`
  - `amplitude`
  - `turnover_rate`
- `us_equity`
  - `name`
  - `pre_market_price`
  - `pre_market_change_pct`
  - `after_hours_price`
  - `after_hours_change_pct`
- `hk_equity`
  - `name`

### 百分比字段

TickFlow 的百分比说明是：

- `0.01 -> 1%`

因此展示时应乘以 `100` 再加 `%`。

## K 线

### GET `/v1/klines`

- 单标的
- 参数：
  - `symbol` 必填
  - `period` 可选
  - `count` 可选
  - `start_time` 可选，毫秒时间戳
  - `end_time` 可选，毫秒时间戳
  - `adjust` 可选

### GET `/v1/klines/batch`

- 多标的
- 参数：
  - `symbols` 必填，逗号分隔字符串
  - 其余参数与单标的一致

### `Period`

- `1m`
- `5m`
- `10m`
- `15m`
- `30m`
- `60m`
- `1d`
- `1w`
- `1M`
- `1Q`
- `1Y`

对于这个 skill，默认优先 `1d`。

### `AdjustType`

- `forward`
- `backward`
- `forward_additive`
- `backward_additive`
- `none`

### `KlinesResponse`

- 顶层：`data: CompactKlineData`

### `KlinesBatchResponse`

- 顶层：`data: { [symbol: string]: CompactKlineData }`

### `CompactKlineData`

K 线不是逐根对象数组，而是列式结构：

- `timestamp: int64[]`
- `open: number[]`
- `high: number[]`
- `low: number[]`
- `close: number[]`
- `volume: int64[]`
- `amount: number[]`
- 可选：
  - `prev_close`
  - `open_interest`
  - `settlement_price`

做摘要或表格前，先按索引解压成逐根 K 线对象。

## 错误

错误响应 schema: `ApiError`

- `code`
- `message`
- `details` 可选

## 相关标的池接口

- `GET /v1/universes`
- `POST /v1/universes/batch`
- `GET /v1/universes/{id}`

当用户给的是 `universes` 时，实时行情可以直接透传给 `/v1/quotes`；如果后续要做校验或增强展示，可以再接入这些接口。


path: references/output-contract.md
content: # Output Contract

## 实时行情

### 单标的 `summary`

输出一段简明摘要，至少包含：

- `symbol`
- `name` 或 `-`
- `region`
- `last_price`
- `change_amount`
- `change_pct`
- `volume`
- `amount`
- `session`
- `timestamp`

### 多标的 `table`

列建议：

- `symbol`
- `name`
- `region`
- `last`
- `chg`
- `chg%`
- `volume`
- `session`
- `time`

### `json`

- 原样输出接口 JSON
- `--pretty` 时使用缩进格式化

## K 线

### 单标的 `summary`

至少包含：

- `symbol`
- `period`
- `bars`
- 最新一根 K 线的 `timestamp/open/high/low/close/volume/amount`

### 单标的 `table`

按时间顺序输出最近若干根 K 线：

- `time`
- `open`
- `high`
- `low`
- `close`
- `volume`
- `amount`

### 多标的 `summary`

每个标的只展示最新一根：

- `symbol`
- `period`
- `time`
- `open`
- `high`
- `low`
- `close`
- `volume`

### `json`

- 原样输出接口 JSON

## 空结果

- 不伪造默认值
- 输出 `No data returned.`

## 错误

- 优先输出 API 的 `code` 和 `message`
- 如果有 HTTP 状态码，一并显示
- 不输出 API Key


path: scripts/query_klines.py
content: #!/usr/bin/env python3

import argparse

from tickflow_common import (
    DEFAULT_BASE_URL,
    TickFlowAPIError,
    TickFlowError,
    compact_kline_to_rows,
    ensure_dict,
    format_epoch_ms,
    format_number,
    join_csv,
    parse_csv_arg,
    render_table,
    request_json,
    resolve_api_key,
    to_pretty_json,
)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Query TickFlow K-lines.")
    parser.add_argument("--symbol", help="Single symbol")
    parser.add_argument("--symbols", help="Comma-separated symbols for batch query")
    parser.add_argument("--period", default="1d", help="K-line period, default: 1d")
    parser.add_argument("--count", type=int, help="Number of bars")
    parser.add_argument("--start-time", type=int, help="Start time in epoch milliseconds")
    parser.add_argument("--end-time", type=int, help="End time in epoch milliseconds")
    parser.add_argument("--adjust", help="Adjust type")
    parser.add_argument("--rows", type=int, default=10, help="Rows to show in table format")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="TickFlow API base URL")
    parser.add_argument("--timeout", type=float, default=10.0, help="Request timeout in seconds")
    parser.add_argument("--format", choices=["json", "summary", "table"], default="summary")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
    return parser


def fetch_single(args: argparse.Namespace, api_key: str) -> dict:
    params = {
        "symbol": args.symbol,
        "period": args.period,
        "count": args.count,
        "start_time": args.start_time,
        "end_time": args.end_time,
        "adjust": args.adjust,
    }
    payload = request_json(
        "GET",
        "/v1/klines",
        api_key,
        base_url=args.base_url,
        params=params,
        timeout=args.timeout,
    )
    payload = ensure_dict(payload, name="response")
    payload["data"] = compact_kline_to_rows(payload.get("data"))
    return payload


def fetch_batch(args: argparse.Namespace, api_key: str, symbols: list[str]) -> dict:
    params = {
        "symbols": join_csv(symbols),
        "period": args.period,
        "count": args.count,
        "start_time": args.start_time,
        "end_time": args.end_time,
        "adjust": args.adjust,
    }
    payload = request_json(
        "GET",
        "/v1/klines/batch",
        api_key,
        base_url=args.base_url,
        params=params,
        timeout=args.timeout,
    )
    payload = ensure_dict(payload, name="response")
    raw_data = ensure_dict(payload.get("data"), name="response.data")
    payload["data"] = {symbol: compact_kline_to_rows(value) for symbol, value in raw_data.items()}
    return payload


def kline_row(symbol: str, period: str, item: dict) -> dict[str, str]:
    return {
        "symbol": symbol,
        "period": period,
        "time": format_epoch_ms(item.get("timestamp")),
        "open": format_number(item.get("open")),
        "high": format_number(item.get("high")),
        "low": format_number(item.get("low")),
        "close": format_number(item.get("close")),
        "volume": format_number(item.get("volume"), 0),
        "amount": format_number(item.get("amount")),
    }


def render_single_summary(symbol: str, period: str, rows: list[dict]) -> str:
    if not rows:
        return "No data returned."
    latest = kline_row(symbol, period, rows[-1])
    return "\n".join(
        [
            f"{symbol} period={period} bars={len(rows)}",
            f"time={latest['time']} open={latest['open']} high={latest['high']} low={latest['low']} close={latest['close']}",
            f"volume={latest['volume']} amount={latest['amount']}",
        ]
    )


def render_batch_summary(period: str, payload: dict[str, list[dict]]) -> str:
    if not payload:
        return "No data returned."
    blocks = []
    for symbol, rows in payload.items():
        if not rows:
            blocks.append(f"{symbol} period={period} No data returned.")
            continue
        latest = kline_row(symbol, period, rows[-1])
        blocks.append(
            "\n".join(
                [
                    f"{symbol} period={period}",
                    f"time={latest['time']} open={latest['open']} high={latest['high']} low={latest['low']} close={latest['close']}",
                    f"volume={latest['volume']} amount={latest['amount']}",
                ]
            )
        )
    return "\n\n".join(blocks)


def render_single_table(symbol: str, period: str, rows: list[dict], limit: int) -> str:
    selected = rows[-limit:] if limit > 0 else rows
    table_rows = [kline_row(symbol, period, item) for item in selected]
    columns = [
        ("time", "time"),
        ("open", "open"),
        ("high", "high"),
        ("low", "low"),
        ("close", "close"),
        ("volume", "volume"),
        ("amount", "amount"),
    ]
    return render_table(table_rows, columns)


def render_batch_table(period: str, payload: dict[str, list[dict]]) -> str:
    rows = []
    for symbol, items in payload.items():
        if not items:
            rows.append(
                {
                    "symbol": symbol,
                    "period": period,
                    "time": "-",
                    "open": "-",
                    "high": "-",
                    "low": "-",
                    "close": "-",
                    "volume": "-",
                }
            )
            continue
        rows.append(kline_row(symbol, period, items[-1]))
    columns = [
        ("symbol", "symbol"),
        ("period", "period"),
        ("time", "time"),
        ("open", "open"),
        ("high", "high"),
        ("low", "low"),
        ("close", "close"),
        ("volume", "volume"),
    ]
    return render_table(rows, columns)


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    if bool(args.symbol) == bool(args.symbols):
        raise SystemExit("Provide exactly one of --symbol or --symbols.")

    [REDACTED_SECRET])

    try:
        if args.symbol:
            payload = fetch_single(args, api_key)
            if args.format == "json":
                print(to_pretty_json(payload, args.pretty))
                return
            if args.format == "table":
                print(render_single_table(args.symbol, args.period, payload["data"], args.rows))
                return
            print(render_single_summary(args.symbol, args.period, payload["data"]))
            return

        symbols = parse_csv_arg(args.symbols)
        if not symbols:
            raise TickFlowError("No symbols provided.")

        payload = fetch_batch(args, api_key, symbols)
        if args.format == "json":
            print(to_pretty_json(payload, args.pretty))
            return
        if args.format == "table":
            print(render_batch_table(args.period, payload["data"]))
            return
        print(render_batch_summary(args.period, payload["data"]))
    except TickFlowAPIError as exc:
        raise SystemExit(str(exc))
    except TickFlowError as exc:
        raise SystemExit(str(exc))


if __name__ == "__main__":
    main()


path: scripts/query_quotes.py
content: #!/usr/bin/env python3

import argparse

from tickflow_common import (
    DEFAULT_BASE_URL,
    TickFlowAPIError,
    TickFlowError,
    ensure_dict,
    ensure_list,
    format_epoch_ms,
    format_number,
    format_percent,
    join_csv,
    parse_csv_arg,
    render_table,
    request_json,
    resolve_api_key,
    to_pretty_json,
)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Query TickFlow real-time quotes.")
    parser.add_argument("--symbols", help="Comma-separated symbols, for example 600519.SH,AAPL.US")
    parser.add_argument("--universes", help="Comma-separated universe ids")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="TickFlow API base URL")
    parser.add_argument("--timeout", type=float, default=10.0, help="Request timeout in seconds")
    parser.add_argument("--format", choices=["json", "summary", "table"], default="summary")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
    parser.add_argument("--force-post", action="store_true", help="Use POST /v1/quotes")
    return parser


def choose_method(symbols: list[str], universes: list[str], force_post: bool) -> str:
    if force_post:
        return "POST"

    total_items = len(symbols) + len(universes)
    joined_length = len(",".join(symbols)) + len(",".join(universes))
    if total_items > 20 or joined_length > 800:
        return "POST"
    return "GET"


def fetch_quotes(args: argparse.Namespace) -> dict:
    symbols = parse_csv_arg(args.symbols)
    universes = parse_csv_arg(args.universes)
    if not symbols and not universes:
        raise TickFlowError("Provide at least one of --symbols or --universes.")

    [REDACTED_SECRET])
    method = choose_method(symbols, universes, args.force_post)

    if method == "POST":
        body = {
            "symbols": symbols or None,
            "universes": universes or None,
        }
        payload = request_json(
            "POST",
            "/v1/quotes",
            api_key,
            base_url=args.base_url,
            json_body=body,
            timeout=args.timeout,
        )
    else:
        params = {
            "symbols": join_csv(symbols) or None,
            "universes": join_csv(universes) or None,
        }
        payload = request_json(
            "GET",
            "/v1/quotes",
            api_key,
            base_url=args.base_url,
            params=params,
            timeout=args.timeout,
        )

    payload = ensure_dict(payload, name="response")
    payload["data"] = ensure_list(payload.get("data"), name="response.data")
    return payload


def quote_to_row(item: dict) -> dict[str, str]:
    ext = item.get("ext") if isinstance(item.get("ext"), dict) else {}
    return {
        "symbol": str(item.get("symbol", "-")),
        "name": str(ext.get("name") or "-"),
        "region": str(item.get("region", "-")),
        "last": format_number(item.get("last_price")),
        "chg": format_number(ext.get("change_amount")),
        "chg_pct": format_percent(ext.get("change_pct")),
        "volume": format_number(item.get("volume"), 0),
        "session": str(item.get("session") or "-"),
        "time": format_epoch_ms(item.get("timestamp")),
        "amount": format_number(item.get("amount")),
        "open": format_number(item.get("open")),
        "high": format_number(item.get("high")),
        "low": format_number(item.get("low")),
        "prev_close": format_number(item.get("prev_close")),
        "ext_type": str(ext.get("type") or "-"),
    }


def render_summary(data: list[dict]) -> str:
    if not data:
        return "No data returned."

    blocks = []
    for item in data:
        row = quote_to_row(item)
        blocks.append(
            "\n".join(
                [
                    f"{row['symbol']} {row['name']}",
                    f"region={row['region']} last={row['last']} change={row['chg']} ({row['chg_pct']})",
                    f"open={row['open']} high={row['high']} low={row['low']} prev_close={row['prev_close']}",
                    f"volume={row['volume']} amount={row['amount']} session={row['session']}",
                    f"time={row['time']} ext={row['ext_type']}",
                ]
            )
        )
    return "\n\n".join(blocks)


def render_quotes_table(data: list[dict]) -> str:
    rows = [quote_to_row(item) for item in data]
    columns = [
        ("symbol", "symbol"),
        ("name", "name"),
        ("region", "region"),
        ("last", "last"),
        ("chg", "chg"),
        ("chg_pct", "chg%"),
        ("volume", "volume"),
        ("session", "session"),
        ("time", "time"),
    ]
    return render_table(rows, columns)


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    try:
        payload = fetch_quotes(args)
        if args.format == "json":
            print(to_pretty_json(payload, args.pretty))
            return

        data = payload["data"]
        if args.format == "table":
            print(render_quotes_table(data))
            return

        print(render_summary(data))
    except TickFlowAPIError as exc:
        raise SystemExit(str(exc))
    except TickFlowError as exc:
        raise SystemExit(str(exc))


if __name__ == "__main__":
    main()


path: scripts/tickflow_common.py
content: #!/usr/bin/env python3

import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any, Iterable


DEFAULT_BASE_URL = "https://api.tickflow.org"
DEFAULT_API_KEY_ENV = "TICKFLOW_API_KEY"
DEFAULT_TIMEOUT = 10.0
USER_AGENT = "tickflow-realtime-skill/1.0"


class TickFlowError(Exception):
    pass


class TickFlowAPIError(TickFlowError):
    def __init__(self, status: int, code: str | None, message: str, details: Any = None):
        self.status = status
        self.code = code
        self.message = message
        self.details = details
        super().__init__(self.__str__())

    def __str__(self) -> str:
        parts = [f"HTTP {self.status}"]
        if self.code:
            parts.append(self.code)
        parts.append(self.message)
        return " - ".join(parts)


def eprint(message: str) -> None:
    print(message, file=sys.stderr)


def die(message: str, exit_code: int = 1) -> "NoReturn":
    eprint(message)
    raise SystemExit(exit_code)


def parse_csv_arg(value: str | None) -> list[str]:
    if not value:
        return []
    return [item.strip() for item in value.split(",") if item.strip()]


def resolve_api_key() -> str:
    value = os.environ.get(DEFAULT_API_KEY_ENV)
    if value:
        return value

    raise TickFlowError(f"Missing API key. Export {DEFAULT_API_KEY_ENV} before running the script.")


def build_url(base_url: str, path: str, params: dict[str, Any] | None = None) -> str:
    base = base_url.rstrip("/")
    url = f"{base}{path}"
    if not params:
        return url

    filtered = {key: value for key, value in params.items() if value is not None}
    query = urllib.parse.urlencode(filtered, doseq=True)
    return f"{url}?{query}" if query else url


def request_json(
    method: str,
    path: str,
    api_key: str,
    *,
    base_url: str = DEFAULT_BASE_URL,
    params: dict[str, Any] | None = None,
    json_body: dict[str, Any] | None = None,
    timeout: float = DEFAULT_TIMEOUT,
) -> Any:
    headers = {
        "Accept": "application/json",
        "User-Agent": USER_AGENT,
        "x-api-key": api_key,
    }
    data = None
    if json_body is not None:
        headers["Content-Type"] = "application/json"
        data = json.dumps(json_body).encode("utf-8")

    request = urllib.request.Request(
        build_url(base_url, path, params),
        method=method.upper(),
        headers=headers,
        data=data,
    )

    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            payload = response.read().decode("utf-8")
    except urllib.error.HTTPError as exc:
        payload = exc.read().decode("utf-8", errors="replace")
        parsed = _safe_json_loads(payload)
        if isinstance(parsed, dict):
            raise TickFlowAPIError(exc.code, parsed.get("code"), parsed.get("message", payload), parsed.get("details"))
        raise TickFlowAPIError(exc.code, None, payload.strip() or "Request failed")
    except urllib.error.URLError as exc:
        raise TickFlowError(f"Network error: {exc.reason}") from exc

    parsed = _safe_json_loads(payload)
    if parsed is None:
        raise TickFlowError("Response is not valid JSON.")
    return parsed


def _safe_json_loads(text: str) -> Any:
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return None


def ensure_dict(value: Any, *, name: str) -> dict[str, Any]:
    if not isinstance(value, dict):
        raise TickFlowError(f"{name} must be an object.")
    return value


def ensure_list(value: Any, *, name: str) -> list[Any]:
    if not isinstance(value, list):
        raise TickFlowError(f"{name} must be an array.")
    return value


def format_epoch_ms(value: Any) -> str:
    if value in (None, ""):
        return "-"
    try:
        dt = datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc)
    except (TypeError, ValueError, OSError):
        return str(value)
    return dt.isoformat().replace("+00:00", "Z")


def format_number(value: Any, decimals: int = 2) -> str:
    if value is None:
        return "-"
    if isinstance(value, bool):
        return str(value)
    if isinstance(value, int):
        return str(value)
    if isinstance(value, float):
        text = f"{value:.{decimals}f}"
        return text.rstrip("0").rstrip(".")
    return str(value)


def format_percent(value: Any, decimals: int = 2) -> str:
    if value is None:
        return "-"
    try:
        pct = float(value) * 100
    except (TypeError, ValueError):
        return str(value)
    return f"{pct:.{decimals}f}%"


def pad(value: Any, width: int) -> str:
    text = str(value)
    return text if len(text) >= width else text + (" " * (width - len(text)))


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    if not rows:
        return "No data returned."

    widths: dict[str, int] = {}
    for key, title in columns:
        widths[key] = len(title)
    for row in rows:
        for key, _ in columns:
            widths[key] = max(widths[key], len(str(row.get(key, "-"))))

    header = "  ".join(pad(title, widths[key]) for key, title in columns)
    divider = "  ".join("-" * widths[key] for key, _ in columns)
    body = []
    for row in rows:
        body.append("  ".join(pad(row.get(key, "-"), widths[key]) for key, _ in columns))
    return "\n".join([header, divider, *body])


def compact_kline_to_rows(data: dict[str, Any]) -> list[dict[str, Any]]:
    data = ensure_dict(data, name="kline data")
    timestamps = ensure_list(data.get("timestamp"), name="data.timestamp")
    row_count = len(timestamps)
    rows: list[dict[str, Any]] = []
    keys = [
        "timestamp",
        "open",
        "high",
        "low",
        "close",
        "volume",
        "amount",
        "prev_close",
        "open_interest",
        "settlement_price",
    ]

    for index in range(row_count):
        row: dict[str, Any] = {}
        for key in keys:
            column = data.get(key)
            if column is None:
                continue
            if not isinstance(column, list):
                raise TickFlowError(f"data.{key} must be an array.")
            if len(column) != row_count:
                raise TickFlowError(f"data.{key} length does not match data.timestamp.")
            row[key] = column[index]
        rows.append(row)
    return rows


def to_pretty_json(value: Any, pretty: bool) -> str:
    if pretty:
        return json.dumps(value, indent=2, ensure_ascii=False)
    return json.dumps(value, ensure_ascii=False)


def join_csv(items: Iterable[str]) -> str:
    return ",".join(item for item in items if item)


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