Skill: shouldnotappearcalm/a-share-paper-trading (version 1.0.1)

--- name: a-share-paper-trading description: A股模拟盘交易与回测技能。Use when 用户要启动模拟仓服务、创建多账户、下限价单/市价单、撤单、查询持仓资金、验证涨跌停成交逻辑或运行A股回测。 --- # A股模拟盘 独立的模拟盘 skill。交易服务、CLI、账户、撮合与行情适配都在本目录内，不依赖其他 skill 脚本。 ## 何时使用 - 启动或检查模拟盘服务 - 创建/重置账户 - 限价买卖、市价买卖、撤单 - 查询账户、持仓、订单、成交 - 验证涨跌停、T+1、收盘过期 - 跑简单回测 ## 启动 ```bash SKILL_DIR="<本skill绝对路径>" python3 "$SKILL_DIR/scripts/paper_trading_service.py" --host 127.0.0.1 --port 18765 ``` 默认监听 `http://127.0.0.1:18765`，默认数据库不再落在 skill 目录，而是落到用户级数据目录： - macOS: `~/Library/Application Support/a-share-paper-trading/paper_trading.db` - Linux: `${XDG_DATA_HOME:-~/.local/share}/a-share-paper-trading/paper_trading.db` 若本机该端口**已有**模拟盘进程在跑，**不要**再启动第二个实例：会报 `Address already in use`，且多进程可能争用同一 SQLite 库文件。启动前可先检查端口是否在监听，例如： ```bash lsof -iTCP:18765 -sTCP:LISTEN ``` 或向 `http://127.0.0.1:18765/accounts` 发 `GET`（CLI 默认 `--base-url` 与此一致）。已有服务时直接用 `paper_trade_cli.py` 即可。 更推荐使用控制脚本常驻运行： ```bash python3 "$SKILL_DIR/scripts/paper_trading_ctl.py" start python3 "$SKILL_DIR/scripts/paper_trading_ctl.py" status python3 "$SKILL_DIR/scripts/paper_trading_ctl.py" stop ``` 在 macOS 上，如需持续自启动，可安装 launchd： ```bash python3 "$SKILL_DIR/scripts/paper_trading_ctl.py" install-launchd ``` 服务会： - 交易时段定时撮合挂单 - 非交易时段停止撮合 - 收盘后让当日未成单过期 - 定时写账户净值快照 可用启动参数： - `--host` - `--port` - `--db-path` - `--match-interval` - `--valuation-interval` - `--idle-valuation-interval` 默认端口与 CLI 基址已改为 `18765`，避免和常见本地开发服务冲突。 ## CLI ```bash python3 "$SKILL_DIR/scripts/paper_trade_cli.py" create-account alpha --cash 500000 python3 "$SKILL_DIR/scripts/paper_trade_cli.py" list-accounts python3 "$SKILL_DIR/scripts/paper_trade_cli.py" show-default-account python3 "$SKILL_DIR/scripts/paper_trade_cli.py" set-default-account alpha python3 "$SKILL_DIR/scripts/paper_trade_cli.py" reset-account alpha --cash 300000 python3 "$SKILL_DIR/scripts/paper_trade_cli.py" add-cash alpha 50000 --note 入金 python3 "$SKILL_DIR/scripts/paper_trade_cli.py" deduct-cash alpha 10000 --note 出金 python3 "$SKILL_DIR/scripts/paper_trade_cli.py" buy alpha 600519 100 --market python3 "$SKILL_DIR/scripts/paper_trade_cli.py" sell alpha 600519 100 --price 1450 python3 "$SKILL_DIR/scripts/paper_trade_cli.py" orders alpha python3 "$SKILL_DIR/scripts/paper_trade_cli.py" positions alpha python3 "$SKILL_DIR/scripts/paper_trade_cli.py" show-account alpha python3 "$SKILL_DIR/scripts/paper_trade_cli.py" trades alpha python3 "$SKILL_DIR/scripts/paper_trade_cli.py" cancel <order_id> python3 "$SKILL_DIR/scripts/paper_trade_cli.py" process-orders python3 "$SKILL_DIR/scripts/paper_trade_cli.py" run-snapshots python3 "$SKILL_DIR/scripts/paper_trade_cli.py" backtest 600519 --strategy sma_cross --start 2025-01-01 --end 2026-03-31 --cash 200000 ``` 支持的 CLI 子命令： - `create-account` - `show-default-account` - `set-default-account` - `reset-account` - `list-accounts` - `show-account` - `positions` - `orders` - `trades` - `buy` - `sell` - `cancel` - `add-cash` - `deduct-cash` - `process-orders` - `run-snapshots` - `backtest` ## Agent 执行规则（账户路由） - 首次创建模拟账户前，必须先向用户确认初始资金金额。 - 若系统存在多个账户且用户未指明账户，先反问“本次操作哪个账户”再继续。 - 若仅有一个账户且该账户已存在，默认直接使用该账户。 - 若存在默认账户（`show-default-account` 可查），且用户未指定账户，优先使用默认账户。 - 需要切换默认账户时，使用 `set-default-account`。 ### 首次初始化资金规则（默认 100w） - 当用户首次要进行交易但尚未初始化账户资金时，先反问确认，不直接执行。 - 默认建议初始资金为 `1000000`（100w），提问模板建议为： - “当前未初始化模拟账户，默认按 100w 启动，是否确认？如需修改请直接给出金额。” - 仅在用户明确确认后，才执行创建默认账户。 - 若用户给出其他金额，按用户金额创建并继续后续操作。 ## CLI 参数约束（Agent 必读） - `buy/sell`： - 限价单必须带 `--price`。 - 市价单使用 `--market`，不应再传 `--price`。 - `orders --status` 可用值：`open` / `filled` / `cancelled` / `expired` / `rejected`。 - `add-cash` / `deduct-cash` 的 `amount` 必须为正数，单位为人民币元。 - 默认优先使用 `--json` 输出，便于后续结构化解析与决策。 ## 执行前检查（下单前固定三步） 1. 先确认账户路由：`show-default-account` 或 `list-accounts`。 2. 再确认资金：`show-account <account_id>`。 3. 下单前确认持仓与未完成单：`positions <account_id>` 与 `orders <account_id> --status open`。 ## 资金调整规则（加减可用资金） - 入金使用 `add-cash <account_id> <amount>`，出金使用 `deduct-cash <account_id> <amount>`。 - 入金/出金金额必须为正数。 - 出金只能减少可用资金，不得穿透冻结资金；可用不足时应先告知用户再调整。 ## 常见错误与处理动作 - `Address already in use`：不要重复启动服务，直接使用现有服务地址。 - `default account not set`：触发首次初始化反问流程，确认金额后创建默认账户。 - `insufficient available cash`：提示当前可用资金，建议入金或降低下单数量/价格。 - `insufficient sellable qty`：提示可卖数量，说明可能受 T+1 影响或持仓不足。 - `order is not open`：先查询订单状态（可能已成交/已撤/已过期），再决定下一步。 - `market orders are only accepted during trading hours`：改用限价单或等待交易时段。 ## 规则摘要 - 只支持 A 股 long-only - 买入数量必须是 100 股整数倍 - 卖出遵守 T+1 - 限价单价格必须符合 `0.01` 元最小报价单位 - 板块涨跌幅兜底规则： - 主板普通股 `10%` - 创业板/科创板 `20%` - 主板 ST `5%` - 限价单价格不能超出当日涨跌停 - 一字涨停默认买不进 - 一字跌停默认卖不出 - 市价单仅在连续竞价时段接受 - 集合竞价与午休时段不做自动撮合 - 行情时间戳不是当日时，视为陈旧行情，不执行市价成交 - 费用模型包含最低佣金、卖出印花税、沪市过户费 - 当日单收盘后过期 ## 结构 - `scripts/paper_trading_service.py`: 启动 HTTP 服务 - `scripts/paper_trade_cli.py`: CLI - `scripts/paper_trading_ctl.py`: 启停、状态、launchd 安装 - `scripts/paper_trading_runtime.py`: 运行时默认目录、端口、路径 - `scripts/paper_trading/`: 账户、撮合、估值、数据适配 ## 服务接口 默认暴露这些 HTTP 路由： - `GET /accounts` - `GET /accounts/default` - `POST /accounts` - `POST /accounts/default` - `GET /accounts/{account_id}` - `POST /accounts/{account_id}/reset` - `POST /accounts/{account_id}/cash-adjust` - `GET /accounts/{account_id}/positions` - `GET /accounts/{account_id}/orders` - `GET /accounts/{account_id}/trades` - `POST /orders` - `POST /orders/{order_id}/cancel` - `POST /orders/process` - `POST /snapshots/run` - `POST /backtest` - `GET /health` ## 参考 - GitHub 项目地址：[https://github.com/shouldnotappearcalm/a-share-skill](https://github.com/shouldnotappearcalm/a-share-skill)

path: README.md
content: # a-share-paper-trading

独立的 A 股模拟盘 skill，和数据 skill 解耦，负责：

- 多账户模拟交易
- 限价单、市价单、撤单
- 持仓、订单、成交、账户净值
- A 股规则校验与定时撮合
- 回测

## 当前规则

- A 股 `long-only`
- 买入数量必须是 `100` 股整数倍
- 卖出遵守 `T+1`
- 限价单价格必须符合 `0.01` 元最小报价单位
- 超过当日涨跌停的限价单直接拒绝
- 一字涨停默认买不进
- 一字跌停默认卖不出
- 市价单仅在连续竞价时段接受
- 集合竞价与午休时段不做自动撮合
- 行情时间戳不是当日时，视为陈旧行情，不执行市价成交
- 费用模型包含最低佣金、卖出印花税、沪市过户费
- 当日未成交 `DAY` 单收盘后过期

## 板块涨跌幅兜底

- 主板普通股：`10%`
- 创业板/科创板：`20%`
- 主板 ST：`5%`

优先使用实时 quote 自带的 `limit_up/limit_down`；只有上游缺失时才走这套兜底规则。

## 启动

默认配置已经改成用户级运行目录，不再把数据库写进 skill 目录：

- macOS: `~/Library/Application Support/a-share-paper-trading/`
- Linux: `${XDG_DATA_HOME:-~/.local/share}/a-share-paper-trading/`

默认 SQLite 路径：

- `~/Library/Application Support/a-share-paper-trading/paper_trading.db`（macOS）

默认监听地址：

- `http://127.0.0.1:18765`

前台启动：

```bash
python3 /Users/yanyun/dev/git_repo/ai-stock/ai-stock-data-v2/a-share-skill/a-share-paper-trading/scripts/paper_trading_service.py --host 127.0.0.1 --port 18765
```

推荐用控制脚本后台常驻：

```bash
python3 /Users/yanyun/dev/git_repo/ai-stock/ai-stock-data-v2/a-share-skill/a-share-paper-trading/scripts/paper_trading_ctl.py start
python3 /Users/yanyun/dev/git_repo/ai-stock/ai-stock-data-v2/a-share-skill/a-share-paper-trading/scripts/paper_trading_ctl.py status
python3 /Users/yanyun/dev/git_repo/ai-stock/ai-stock-data-v2/a-share-skill/a-share-paper-trading/scripts/paper_trading_ctl.py stop
```

若希望开机自动拉起，可安装 launchd：

```bash
python3 /Users/yanyun/dev/git_repo/ai-stock/ai-stock-data-v2/a-share-skill/a-share-paper-trading/scripts/paper_trading_ctl.py install-launchd
```

## 验证脚本

- 规则回归：`scripts/rule_regression_check.py`
- 真实股票校验：`scripts/real_stock_rule_validation.py`

其中真实股票校验会用 22 只真实股票做 3 轮验证，覆盖主板、创业板、科创板、ST。


path: scripts/paper_trading_runtime.py
content: #!/usr/bin/env python3
"""Runtime defaults and paths for the paper trading service."""

from __future__ import annotations

import os
from pathlib import Path

DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 18765
APP_NAME = "a-share-paper-trading"
DB_FILENAME = "paper_trading.db"
LOG_FILENAME = "service.log"
PID_FILENAME = "service.pid"
LAUNCH_AGENT_LABEL = "ai.openclaw.a-share-paper-trading"


def get_app_data_dir() -> Path:
    custom = os.environ.get("A_SHARE_PAPER_TRADING_HOME")
    if custom:
        return Path(custom).expanduser()
    home = Path.home()
    if os.name == "posix" and "darwin" in os.uname().sysname.lower():
        return home / "Library" / "Application Support" / APP_NAME
    xdg = os.environ.get("XDG_DATA_HOME")
    if xdg:
        return Path(xdg).expanduser() / APP_NAME
    return home / ".local" / "share" / APP_NAME


def get_default_db_path() -> Path:
    return get_app_data_dir() / DB_FILENAME


def get_default_log_path() -> Path:
    return get_app_data_dir() / LOG_FILENAME


def get_default_pid_path() -> Path:
    return get_app_data_dir() / PID_FILENAME


def get_launch_agents_dir() -> Path:
    return Path.home() / "Library" / "LaunchAgents"


def ensure_runtime_dir(path: Path | None = None) -> Path:
    target = path or get_app_data_dir()
    target.mkdir(parents=True, exist_ok=True)
    return target


path: scripts/paper_trading_service.py
content: #!/usr/bin/env python3
"""Run the paper trading backend service."""

from __future__ import annotations

import argparse

from paper_trading.engine import PaperTradingEngine
from paper_trading.service import run_server
from paper_trading_runtime import DEFAULT_HOST, DEFAULT_PORT, ensure_runtime_dir, get_default_db_path


def main() -> None:
    parser = argparse.ArgumentParser(description="Paper trading backend service")
    parser.add_argument("--host", default=DEFAULT_HOST)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--db-path", default=str(get_default_db_path()))
    parser.add_argument("--match-interval", type=int, default=5)
    parser.add_argument("--valuation-interval", type=int, default=10)
    parser.add_argument("--idle-valuation-interval", type=int, default=300)
    args = parser.parse_args()
    ensure_runtime_dir()
    engine = PaperTradingEngine(db_path=args.db_path)
    print(f"paper trading service listening on http://{args.host}:{args.port} db={args.db_path}")
    run_server(args.host, args.port, engine, match_interval=args.match_interval, valuation_interval=args.valuation_interval, idle_valuation_interval=args.idle_valuation_interval)


if __name__ == "__main__":
    main()


path: scripts/full_function_smoke_check.py
content: #!/usr/bin/env python3
"""Offline smoke checks for core paper trading functionality."""

from __future__ import annotations

import json
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path

import pandas as pd


SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import paper_trading.engine as engine_module  # noqa: E402
from paper_trading.engine import OrderRequest, PaperTradingEngine  # noqa: E402
from paper_trading.market_data import Quote  # noqa: E402


class FakeMarketData:
    def __init__(self) -> None:
        self.quote = Quote(
            symbol="600519",
            name="贵州茅台",
            price=1450.0,
            open=1448.0,
            high=1452.0,
            low=1446.0,
            prev_close=1440.0,
            volume=100000,
            change_pct=0.69,
            timestamp="2026-04-11 10:00:00",
            source="fake",
            limit_up=1584.0,
            limit_down=1296.0,
        )

    def normalize_symbol(self, symbol: str) -> str:
        return symbol

    def get_quote(self, symbol: str) -> Quote:
        return self.quote

    def get_quotes(self, symbols):
        return {symbol: self.quote for symbol in symbols}

    def get_intraday_bars(self, symbol: str, freq: str = "1m", count: int = 240):
        return pd.DataFrame(
            [
                {"time": pd.Timestamp("2026-04-11 09:59:00"), "open": 1448.0, "high": 1450.0, "low": 1447.5, "close": 1449.8, "volume": 1000},
                {"time": pd.Timestamp("2026-04-11 10:00:00"), "open": 1449.5, "high": 1451.0, "low": 1448.5, "close": 1450.0, "volume": 1200},
            ]
        )

    def get_history(self, symbol: str, start: str | None = None, end: str | None = None, count: int = 240):
        dates = pd.date_range("2025-01-01", periods=120, freq="B")
        return pd.DataFrame(
            {
                "time": dates,
                "open": [100 + i * 0.1 for i in range(len(dates))],
                "high": [101 + i * 0.1 for i in range(len(dates))],
                "low": [99 + i * 0.1 for i in range(len(dates))],
                "close": [100 + i * 0.15 for i in range(len(dates))],
                "volume": [100000 + i * 10 for i in range(len(dates))],
            }
        ).tail(count).reset_index(drop=True)


def _assert(expr: bool, message: str) -> None:
    if not expr:
        raise AssertionError(message)


def _assert_raises(fn, contains: str) -> None:
    try:
        fn()
    except Exception as exc:  # noqa: BLE001
        if contains not in str(exc):
            raise AssertionError(f"expected {contains!r}, got {exc!r}") from exc
        return
    raise AssertionError(f"expected exception containing {contains!r}")


def _free_port() -> int:
    sock = socket.socket()
    sock.bind(("127.0.0.1", 0))
    port = sock.getsockname()[1]
    sock.close()
    return port


def run_engine_checks() -> None:
    fake_data = FakeMarketData()
    with tempfile.TemporaryDirectory() as tmp:
        db_path = str(Path(tmp) / "smoke.db")
        engine = PaperTradingEngine(db_path, market_data=fake_data)
        acct = engine.create_account("alpha", 500000)
        _assert(acct["cash"] == 500000.0, "create_account cash mismatch")
        _assert(engine.get_default_account_id() == "alpha", "default account mismatch")
        engine.adjust_cash("alpha", 10000, "deposit")
        _assert(engine.get_account("alpha")["cash"] == 510000.0, "add cash failed")
        engine.adjust_cash("alpha", -5000, "withdraw")
        _assert(engine.get_account("alpha")["cash"] == 505000.0, "deduct cash failed")
        _assert_raises(
            lambda: engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="limit", limit_price=1450.005)),
            "0.01",
        )
        buy_limit = engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="limit", limit_price=1449.5))
        _assert(buy_limit["status"] == "open", "limit order not open")
        frozen = engine.get_account("alpha")["frozen_cash"]
        _assert(frozen > 0, "frozen cash not reserved")
        engine.cancel_order(buy_limit["order_id"])
        _assert(engine.get_account("alpha")["frozen_cash"] == 0.0, "cancel did not release frozen cash")

        original_is_trading_time = engine_module.is_trading_time
        original_trade_date = engine_module.trade_date
        original_is_after_close = engine_module.is_after_close
        try:
            engine_module.is_trading_time = lambda dt=None: True
            engine_module.trade_date = lambda: "2026-04-11"
            market_buy = engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="market"))
            _assert(market_buy["status"] == "filled", "market buy not filled")
            positions = engine.get_positions("alpha")
            _assert(len(positions) == 1 and positions[0]["qty"] == 100, "position not created")
            _assert_raises(
                lambda: engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="sell", qty=100, order_type="limit", limit_price=1450.0)),
                "insufficient sellable qty",
            )
            with sqlite3.connect(db_path) as conn:
                conn.execute("UPDATE position_lots SET acquired_date = '2026-04-10' WHERE account_id = 'alpha'")
                conn.commit()
            sell_order = engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="sell", qty=100, order_type="limit", limit_price=1450.0))
            processed = engine.process_orders()
            _assert(processed["filled"] >= 1, "sell order not processed")
            _assert(engine.get_order(sell_order["order_id"])["status"] == "filled", "sell order not filled")
            snapshots = engine.snapshot_accounts()
            _assert(snapshots["snapshots"] >= 1, "snapshot missing")
            reset = engine.reset_account("alpha", 300000)
            _assert(reset["cash"] == 300000.0 and reset["order_count"] == 0, "reset failed")

            pending = engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="limit", limit_price=1400.0))
            engine_module.is_trading_time = lambda dt=None: False
            engine_module.is_after_close = lambda dt=None: True
            expired = engine.expire_day_orders()
            _assert(expired == 1, "expire_day_orders count mismatch")
            _assert(engine.get_order(pending["order_id"])["status"] == "expired", "order not expired")
        finally:
            engine_module.is_trading_time = original_is_trading_time
            engine_module.trade_date = original_trade_date
            engine_module.is_after_close = original_is_after_close


def run_service_cli_checks() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        db_path = str(Path(tmp) / "service.db")
        port = _free_port()
        base = f"http://127.0.0.1:{port}"
        service_cmd = [
            sys.executable,
            str(SCRIPT_DIR / "paper_trading_service.py"),
            "--host",
            "127.0.0.1",
            "--port",
            str(port),
            "--db-path",
            db_path,
            "--match-interval",
            "1",
            "--valuation-interval",
            "1",
            "--idle-valuation-interval",
            "1",
        ]
        proc = subprocess.Popen(service_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        try:
            deadline = time.time() + 15
            while time.time() < deadline:
                try:
                    with urllib.request.urlopen(f"{base}/health", timeout=2) as resp:
                        body = json.loads(resp.read().decode("utf-8"))
                        if body.get("status") == "ok":
                            break
                except Exception:
                    time.sleep(0.2)
            else:
                raise AssertionError("service did not start")

            cli = [sys.executable, str(SCRIPT_DIR / "paper_trade_cli.py"), "--base-url", base]
            subprocess.run(cli + ["create-account", "svc", "--cash", "200000"], check=True, capture_output=True, text=True)
            subprocess.run(cli + ["set-default-account", "svc"], check=True, capture_output=True, text=True)
            default_out = subprocess.run(cli + ["show-default-account"], check=True, capture_output=True, text=True).stdout
            _assert("svc" in default_out, "default account CLI failed")
            subprocess.run(cli + ["add-cash", "svc", "10000"], check=True, capture_output=True, text=True)
            account_out = subprocess.run(cli + ["show-account", "svc"], check=True, capture_output=True, text=True).stdout
            _assert("210000" in account_out.replace(",", ""), "show-account CLI failed")
            list_out = subprocess.run(cli + ["list-accounts"], check=True, capture_output=True, text=True).stdout
            _assert("svc" in list_out, "list-accounts CLI failed")
        finally:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=5)


def main() -> None:
    run_engine_checks()
    run_service_cli_checks()
    print("PASS full_function_smoke_check")


if __name__ == "__main__":
    main()


path: scripts/rule_regression_check.py
content: #!/usr/bin/env python3
"""Regression checks for core A-share trading rules."""

from __future__ import annotations

import tempfile
from datetime import datetime
from pathlib import Path
import sys


SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from paper_trading.engine import (  # noqa: E402
    OrderRequest,
    PaperTradingEngine,
    calc_commission,
    is_trading_time,
    market_phase,
)
from paper_trading.market_data import Quote  # noqa: E402


class FakeMarketData:
    def __init__(self, quote: Quote) -> None:
        self.quote = quote

    def normalize_symbol(self, symbol: str) -> str:
        return symbol

    def get_quote(self, symbol: str) -> Quote:
        return self.quote

    def get_quotes(self, symbols):
        return {symbol: self.quote for symbol in symbols}

    def get_intraday_bars(self, symbol: str, freq: str = "1m", count: int = 240):
        import pandas as pd

        return pd.DataFrame(columns=["time", "open", "high", "low", "close", "volume"])

    def get_history(self, symbol: str, start: str | None = None, end: str | None = None, count: int = 240):
        raise NotImplementedError


def assert_raises(fn, expected_text: str) -> None:
    try:
        fn()
    except Exception as exc:  # noqa: BLE001
        if expected_text not in str(exc):
            raise AssertionError(f"expected error containing {expected_text!r}, got {exc!r}") from exc
        return
    raise AssertionError(f"expected error containing {expected_text!r}")


def test_price_tick_validation() -> None:
    quote = Quote("600519", "贵州茅台", 1450.0, 1450.0, 1455.0, 1448.0, 1440.0, 1000, 0.5, "2026-04-11 10:00:00", "fake", 1584.0, 1296.0)
    with tempfile.TemporaryDirectory() as tmp:
        engine = PaperTradingEngine(str(Path(tmp) / "paper.db"), market_data=FakeMarketData(quote))
        engine.create_account("alpha", 500000)
        assert_raises(
            lambda: engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="limit", limit_price=1450.005)),
            "0.01",
        )


def test_market_phase_windows() -> None:
    monday = datetime(2026, 4, 13, 9, 20)
    assert market_phase(monday) == "opening_call_auction"
    assert is_trading_time(monday) is False
    assert market_phase(datetime(2026, 4, 13, 9, 31)) == "morning_continuous"
    assert is_trading_time(datetime(2026, 4, 13, 9, 31)) is True
    assert market_phase(datetime(2026, 4, 13, 12, 0)) == "lunch_break"
    assert market_phase(datetime(2026, 4, 13, 14, 58)) == "closing_call_auction"
    assert is_trading_time(datetime(2026, 4, 13, 14, 58)) is False


def test_stale_quote_market_order_rejected() -> None:
    stale_quote = Quote("600519", "贵州茅台", 1450.0, 1450.0, 1455.0, 1448.0, 1440.0, 1000, 0.5, "2026-04-10 15:00:00", "fake", 1584.0, 1296.0)
    with tempfile.TemporaryDirectory() as tmp:
        engine = PaperTradingEngine(str(Path(tmp) / "paper.db"), market_data=FakeMarketData(stale_quote))
        engine.create_account("alpha", 500000)
        import paper_trading.engine as engine_module

        original = engine_module.is_trading_time
        engine_module.is_trading_time = lambda dt=None: True
        try:
            assert_raises(
                lambda: engine.place_order(OrderRequest(account_id="alpha", symbol="600519", side="buy", qty=100, order_type="market")),
                "stale",
            )
        finally:
            engine_module.is_trading_time = original


def test_shanghai_transfer_fee_included() -> None:
    sh_fee = calc_commission(100000.0, "600519")
    sz_fee = calc_commission(100000.0, "000001")
    assert round(sh_fee - sz_fee, 2) == 1.0, (sh_fee, sz_fee)


if __name__ == "__main__":
    test_price_tick_validation()
    test_market_phase_windows()
    test_stale_quote_market_order_rejected()
    test_shanghai_transfer_fee_included()
    print("PASS rule_regression_check")


path: scripts/paper_trade_cli.py
content: #!/usr/bin/env python3
"""CLI entrypoint for the paper trading service."""

from __future__ import annotations

import argparse
import json
import sys
import urllib.error
import urllib.request
from typing import Any, Dict, Optional

from paper_trading_runtime import DEFAULT_HOST, DEFAULT_PORT


def request_json(base_url: str, method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
    req = urllib.request.Request(f"{base_url}{path}", data=data, headers={"Content-Type": "application/json"}, method=method)
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            return json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8") if exc.fp else ""
        try:
            return json.loads(body)
        except Exception as err:
            raise RuntimeError(f"HTTP {exc.code}: {body}") from err
    except urllib.error.URLError as exc:
        raise RuntimeError(
            "failed to reach service at "
            f"{base_url}: {exc}. "
            "Try starting it with: python3 scripts/paper_trading_ctl.py start"
        ) from exc


def print_result(result: Dict[str, Any], output_json: bool) -> None:
    if output_json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return
    if result.get("status") != "success":
        print(f"ERROR: {result.get('message')}")
        sys.exit(1)
    data = result.get("data")
    print(json.dumps(data, ensure_ascii=False, indent=2) if isinstance(data, (dict, list)) else data)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Paper trading CLI for a-share paper trading skill")
    parser.add_argument("--base-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}")
    parser.add_argument("--json", action="store_true")
    sub = parser.add_subparsers(dest="command", required=True)
    create = sub.add_parser("create-account")
    create.add_argument("account_id")
    create.add_argument("--cash", type=float, default=100000.0)
    reset = sub.add_parser("reset-account")
    reset.add_argument("account_id")
    reset.add_argument("--cash", type=float)
    sub.add_parser("list-accounts")
    sub.add_parser("show-default-account")
    set_default = sub.add_parser("set-default-account")
    set_default.add_argument("account_id")
    show = sub.add_parser("show-account")
    show.add_argument("account_id")
    positions = sub.add_parser("positions")
    positions.add_argument("account_id")
    orders = sub.add_parser("orders")
    orders.add_argument("account_id")
    orders.add_argument("--status")
    trades = sub.add_parser("trades")
    trades.add_argument("account_id")
    buy = sub.add_parser("buy")
    buy.add_argument("account_id")
    buy.add_argument("symbol")
    buy.add_argument("qty", type=int)
    buy.add_argument("--price", type=float)
    buy.add_argument("--market", action="store_true")
    buy.add_argument("--note", default="")
    sell = sub.add_parser("sell")
    sell.add_argument("account_id")
    sell.add_argument("symbol")
    sell.add_argument("qty", type=int)
    sell.add_argument("--price", type=float)
    sell.add_argument("--market", action="store_true")
    sell.add_argument("--note", default="")
    cancel = sub.add_parser("cancel")
    cancel.add_argument("order_id")
    add_cash = sub.add_parser("add-cash")
    add_cash.add_argument("account_id")
    add_cash.add_argument("amount", type=float)
    add_cash.add_argument("--note", default="")
    deduct_cash = sub.add_parser("deduct-cash")
    deduct_cash.add_argument("account_id")
    deduct_cash.add_argument("amount", type=float)
    deduct_cash.add_argument("--note", default="")
    sub.add_parser("process-orders")
    sub.add_parser("run-snapshots")
    backtest = sub.add_parser("backtest")
    backtest.add_argument("symbol")
    backtest.add_argument("--strategy", default="sma_cross", choices=["buy_and_hold", "sma_cross", "rsi_revert"])
    backtest.add_argument("--start", required=True)
    backtest.add_argument("--end", required=True)
    backtest.add_argument("--cash", type=float, default=100000.0)
    backtest.add_argument("--fast", type=int, default=5)
    backtest.add_argument("--slow", type=int, default=20)
    backtest.add_argument("--buy-rsi", type=float, default=30.0)
    backtest.add_argument("--sell-rsi", type=float, default=70.0)
    return parser


def main() -> None:
    argv = list(sys.argv[1:])
    output_json = False
    filtered_argv = []
    for token in argv:
        if token == "--json":
            output_json = True
            continue
        filtered_argv.append(token)

    parser = build_parser()
    args = parser.parse_args(filtered_argv)
    base_url = args.base_url.rstrip("/")
    cmd = args.command
    if cmd == "create-account":
        result = request_json(base_url, "POST", "/accounts", {"account_id": args.account_id, "initial_cash": args.cash})
    elif cmd == "reset-account":
        result = request_json(base_url, "POST", f"/accounts/{args.account_id}/reset", {"initial_cash": args.cash})
    elif cmd == "list-accounts":
        result = request_json(base_url, "GET", "/accounts")
    elif cmd == "show-default-account":
        result = request_json(base_url, "GET", "/accounts/default")
    elif cmd == "set-default-account":
        result = request_json(base_url, "POST", "/accounts/default", {"account_id": args.account_id})
    elif cmd == "show-account":
        result = request_json(base_url, "GET", f"/accounts/{args.account_id}")
    elif cmd == "positions":
        result = request_json(base_url, "GET", f"/accounts/{args.account_id}/positions")
    elif cmd == "orders":
        suffix = f"?status={args.status}" if args.status else ""
        result = request_json(base_url, "GET", f"/accounts/{args.account_id}/orders{suffix}")
    elif cmd == "trades":
        result = request_json(base_url, "GET", f"/accounts/{args.account_id}/trades")
    elif cmd in {"buy", "sell"}:
        if not args.market and args.price is None:
            parser.error("limit order requires --price, or use --market")
        result = request_json(
            base_url,
            "POST",
            "/orders",
            {
                "account_id": args.account_id,
                "symbol": args.symbol,
                "side": cmd,
                "qty": args.qty,
                "order_type": "market" if args.market else "limit",
                "limit_price": args.price,
                "note": args.note,
            },
        )
    elif cmd == "cancel":
        result = request_json(base_url, "POST", f"/orders/{args.order_id}/cancel", {})
    elif cmd == "add-cash":
        if args.amount <= 0:
            parser.error("amount must be positive")
        result = request_json(
            base_url,
            "POST",
            f"/accounts/{args.account_id}/cash-adjust",
            {"delta": args.amount, "note": args.note},
        )
    elif cmd == "deduct-cash":
        if args.amount <= 0:
            parser.error("amount must be positive")
        result = request_json(
            base_url,
            "POST",
            f"/accounts/{args.account_id}/cash-adjust",
            {"delta": -args.amount, "note": args.note},
        )
    elif cmd == "process-orders":
        result = request_json(base_url, "POST", "/orders/process", {})
    elif cmd == "run-snapshots":
        result = request_json(base_url, "POST", "/snapshots/run", {})
    elif cmd == "backtest":
        result = request_json(
            base_url,
            "POST",
            "/backtest",
            {
                "symbol": args.symbol,
                "strategy": args.strategy,
                "start": args.start,
                "end": args.end,
                "initial_cash": args.cash,
                "params": {"fast": args.fast, "slow": args.slow, "buy_rsi": args.buy_rsi, "sell_rsi": args.sell_rsi},
            },
        )
    else:
        parser.error(f"unknown command {cmd}")
        return
    print_result(result, output_json)


if __name__ == "__main__":
    main()


path: scripts/paper_trading_ctl.py
content: #!/usr/bin/env python3
"""Control helper for the paper trading backend service."""

from __future__ import annotations

import argparse
import os
import plistlib
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

from paper_trading_runtime import (
    APP_NAME,
    DEFAULT_HOST,
    DEFAULT_PORT,
    LAUNCH_AGENT_LABEL,
    ensure_runtime_dir,
    get_app_data_dir,
    get_default_db_path,
    get_default_log_path,
    get_default_pid_path,
    get_launch_agents_dir,
)

SCRIPT_DIR = Path(__file__).resolve().parent
SERVICE_SCRIPT = SCRIPT_DIR / "paper_trading_service.py"


def service_url(host: str, port: int) -> str:
    return f"http://{host}:{port}"


def healthcheck(host: str, port: int, timeout: float = 1.5) -> bool:
    try:
        with urllib.request.urlopen(f"{service_url(host, port)}/health", timeout=timeout) as resp:
            return resp.status == 200
    except Exception:
        return False


def read_pid(pid_path: Path) -> int | None:
    try:
        return int(pid_path.read_text().strip())
    except Exception:
        return None


def is_pid_alive(pid: int | None) -> bool:
    if not pid:
        return False
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False


def wait_until_healthy(host: str, port: int, timeout_seconds: float = 15) -> bool:
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        if healthcheck(host, port, timeout=1.0):
            return True
        time.sleep(0.3)
    return False


def start_service(host: str, port: int, db_path: Path, log_path: Path, pid_path: Path) -> int:
    ensure_runtime_dir(db_path.parent)
    if healthcheck(host, port):
        return 0
    existing_pid = read_pid(pid_path)
    if is_pid_alive(existing_pid):
        raise RuntimeError(f"service already appears running with pid={existing_pid}, but healthcheck failed")
    with log_path.open("a", encoding="utf-8") as log_file:
        proc = subprocess.Popen(
            [
                sys.executable,
                str(SERVICE_SCRIPT),
                "--host",
                host,
                "--port",
                str(port),
                "--db-path",
                str(db_path),
            ],
            stdout=log_file,
            stderr=subprocess.STDOUT,
            cwd=str(SCRIPT_DIR),
            start_new_session=True,
        )
    pid_path.write_text(str(proc.pid), encoding="utf-8")
    if not wait_until_healthy(host, port):
        raise RuntimeError(f"service failed to become healthy, inspect log: {log_path}")
    return proc.pid


def stop_service(host: str, port: int, pid_path: Path) -> bool:
    pid = read_pid(pid_path)
    stopped = False
    if pid and is_pid_alive(pid):
        os.kill(pid, signal.SIGTERM)
        deadline = time.time() + 8
        while time.time() < deadline and is_pid_alive(pid):
            time.sleep(0.2)
        if is_pid_alive(pid):
            os.kill(pid, signal.SIGKILL)
        stopped = True
    if pid_path.exists():
        pid_path.unlink()
    return stopped or not healthcheck(host, port)


def render_launch_agent(host: str, port: int, db_path: Path, log_path: Path) -> bytes:
    ensure_runtime_dir(db_path.parent)
    plist = {
        "Label": LAUNCH_AGENT_LABEL,
        "ProgramArguments": [
            sys.executable,
            str(SERVICE_SCRIPT),
            "--host",
            host,
            "--port",
            str(port),
            "--db-path",
            str(db_path),
        ],
        "WorkingDirectory": str(SCRIPT_DIR),
        "RunAtLoad": True,
        "KeepAlive": True,
        "StandardOutPath": str(log_path),
        "StandardErrorPath": str(log_path),
    }
    return plistlib.dumps(plist)


def install_launch_agent(host: str, port: int, db_path: Path, log_path: Path) -> Path:
    agents_dir = get_launch_agents_dir()
    agents_dir.mkdir(parents=True, exist_ok=True)
    plist_path = agents_dir / f"{LAUNCH_AGENT_LABEL}.plist"
    plist_path.write_bytes(render_launch_agent(host, port, db_path, log_path))
    subprocess.run(["launchctl", "unload", str(plist_path)], check=False, capture_output=True)
    subprocess.run(["launchctl", "load", str(plist_path)], check=True)
    return plist_path


def uninstall_launch_agent() -> Path:
    plist_path = get_launch_agents_dir() / f"{LAUNCH_AGENT_LABEL}.plist"
    if plist_path.exists():
        subprocess.run(["launchctl", "unload", str(plist_path)], check=False, capture_output=True)
        plist_path.unlink()
    return plist_path


def main() -> None:
    parser = argparse.ArgumentParser(description=f"Control {APP_NAME} service")
    parser.add_argument("action", choices=["start", "stop", "status", "install-launchd", "uninstall-launchd"])
    parser.add_argument("--host", default=DEFAULT_HOST)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--db-path", default=str(get_default_db_path()))
    parser.add_argument("--log-path", default=str(get_default_log_path()))
    parser.add_argument("--pid-path", default=str(get_default_pid_path()))
    args = parser.parse_args()

    db_path = Path(args.db_path).expanduser()
    log_path = Path(args.log_path).expanduser()
    pid_path = Path(args.pid_path).expanduser()

    if args.action == "start":
        pid = start_service(args.host, args.port, db_path, log_path, pid_path)
        print(f"started {APP_NAME} on {service_url(args.host, args.port)} pid={pid} db={db_path}")
        return
    if args.action == "stop":
        ok = stop_service(args.host, args.port, pid_path)
        print("stopped" if ok else "not running")
        return
    if args.action == "status":
        pid = read_pid(pid_path)
        print(
            {
                "app_dir": str(get_app_data_dir()),
                "db_path": str(db_path),
                "log_path": str(log_path),
                "pid_path": str(pid_path),
                "pid": pid,
                "pid_alive": is_pid_alive(pid),
                "healthy": healthcheck(args.host, args.port),
                "base_url": service_url(args.host, args.port),
            }
        )
        return
    if args.action == "install-launchd":
        plist_path = install_launch_agent(args.host, args.port, db_path, log_path)
        print(f"installed launchd agent: {plist_path}")
        return
    if args.action == "uninstall-launchd":
        plist_path = uninstall_launch_agent()
        print(f"removed launchd agent: {plist_path}")
        return


if __name__ == "__main__":
    main()


path: scripts/real_stock_rule_validation.py
content: #!/usr/bin/env python3
"""Validate A-share board limit rules against real stocks."""

from __future__ import annotations

import tempfile
from pathlib import Path
import sys

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from paper_trading.engine import OrderRequest, PaperTradingEngine  # noqa: E402
from paper_trading.market_data import MarketDataProvider, infer_limit_ratio  # noqa: E402


SAMPLES = [
    {"symbol": "600519", "bucket": "main"},
    {"symbol": "601318", "bucket": "main"},
    {"symbol": "600036", "bucket": "main"},
    {"symbol": "600276", "bucket": "main"},
    {"symbol": "000858", "bucket": "main"},
    {"symbol": "000333", "bucket": "main"},
    {"symbol": "002594", "bucket": "main"},
    {"symbol": "601398", "bucket": "main"},
    {"symbol": "300750", "bucket": "chinext"},
    {"symbol": "300059", "bucket": "chinext"},
    {"symbol": "300308", "bucket": "chinext"},
    {"symbol": "300274", "bucket": "chinext"},
    {"symbol": "300124", "bucket": "chinext"},
    {"symbol": "300760", "bucket": "chinext"},
    {"symbol": "688981", "bucket": "star"},
    {"symbol": "688041", "bucket": "star"},
    {"symbol": "688111", "bucket": "star"},
    {"symbol": "688256", "bucket": "star"},
    {"symbol": "300561", "bucket": "st"},
    {"symbol": "300555", "bucket": "st"},
    {"symbol": "300506", "bucket": "st"},
    {"symbol": "600243", "bucket": "st"},
]


def _assert_limit_ratio_ok(quote) -> None:
    expected = infer_limit_ratio(quote.symbol, quote.name)
    if quote.limit_up is None or quote.limit_down is None or quote.prev_close <= 0:
        raise AssertionError(f"{quote.symbol} missing limit prices")
    actual_up = (float(quote.limit_up) - float(quote.prev_close)) / float(quote.prev_close)
    actual_down = (float(quote.prev_close) - float(quote.limit_down)) / float(quote.prev_close)
    tolerance = 0.011
    if abs(actual_up - expected) > tolerance or abs(actual_down - expected) > tolerance:
        raise AssertionError(
            f"{quote.symbol} {quote.name} expected ratio {expected:.3f}, got up={actual_up:.4f}, down={actual_down:.4f}"
        )


def _assert_rejects_outside_limits(engine: PaperTradingEngine, symbol: str, quote) -> None:
    account_id = "verify"
    upper_bad = round(float(quote.limit_up) + 0.01, 2)
    lower_bad = round(float(quote.limit_down) - 0.01, 2)
    cases = [
        (upper_bad, "above daily limit up"),
        (lower_bad, "below daily limit down"),
    ]
    for bad_price, expected_text in cases:
        try:
            engine.place_order(OrderRequest(account_id=account_id, symbol=symbol, side="buy", qty=100, order_type="limit", limit_price=bad_price))
        except Exception as exc:  # noqa: BLE001
            if expected_text not in str(exc):
                raise AssertionError(f"{symbol} expected {expected_text!r}, got {exc!r}") from exc
        else:
            raise AssertionError(f"{symbol} unexpectedly accepted invalid price {bad_price}")


def main() -> None:
    provider = MarketDataProvider()
    for round_idx in range(1, 4):
        with tempfile.TemporaryDirectory() as tmp:
            engine = PaperTradingEngine(str(Path(tmp) / f"validation_{round_idx}.db"), market_data=provider)
            engine.create_account("verify", 100000000.0)
            for sample in SAMPLES:
                quote = provider.get_quote(sample["symbol"])
                _assert_limit_ratio_ok(quote)
                _assert_rejects_outside_limits(engine, sample["symbol"], quote)
        print(f"round {round_idx} ok for {len(SAMPLES)} stocks")
    print("PASS real_stock_rule_validation", [sample["symbol"] for sample in SAMPLES])


if __name__ == "__main__":
    main()


path: scripts/backtest_batch_validation.py
content: #!/usr/bin/env python3
"""Batch backtest validation across real A-share symbols."""

from __future__ import annotations

from pathlib import Path
import sys
import tempfile


SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from paper_trading.engine import PaperTradingEngine  # noqa: E402
from paper_trading.market_data import MarketDataProvider  # noqa: E402


SYMBOLS = [
    "600519",
    "601318",
    "600036",
    "600276",
    "000858",
    "000333",
    "002594",
    "601398",
    "300750",
    "300059",
    "300308",
    "300274",
    "300124",
    "300760",
    "688981",
    "688041",
    "688111",
    "688256",
    "600309",
    "002475",
]


def _assert(expr: bool, message: str) -> None:
    if not expr:
        raise AssertionError(message)


def main() -> None:
    provider = MarketDataProvider()
    for round_idx in range(1, 4):
        with tempfile.TemporaryDirectory() as tmp:
            engine = PaperTradingEngine(str(Path(tmp) / f"backtest_{round_idx}.db"), market_data=provider)
            for symbol in SYMBOLS:
                result = engine.run_backtest(
                    symbol=symbol,
                    strategy="sma_cross",
                    start="2025-01-01",
                    end="2026-03-31",
                    initial_cash=200000.0,
                    params={"fast": 5, "slow": 20},
                )
                _assert(result["symbol"] == symbol, f"{symbol} symbol mismatch")
                _assert(result["initial_capital"] == 200000.0, f"{symbol} initial capital mismatch")
                _assert(result["final_capital"] > 0, f"{symbol} final capital invalid")
                _assert(isinstance(result["trades"], list), f"{symbol} trades not list")
                _assert(isinstance(result["equity_curve"], list) and len(result["equity_curve"]) > 0, f"{symbol} missing equity curve")
        print(f"round {round_idx} ok for {len(SYMBOLS)} backtests")
    print("PASS backtest_batch_validation", SYMBOLS)


if __name__ == "__main__":
    main()


path: scripts/paper_trading/service.py
content: #!/usr/bin/env python3
"""HTTP service for the paper trading engine."""

from __future__ import annotations

import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Dict
from urllib.parse import parse_qs, urlparse

from .engine import OrderRequest, PaperTradingEngine, is_trading_time


class MatchingLoop(threading.Thread):
    def __init__(self, engine: PaperTradingEngine, interval_seconds: int) -> None:
        super().__init__(daemon=True)
        self.engine = engine
        self.interval_seconds = interval_seconds
        self._stop = threading.Event()

    def run(self) -> None:
        while not self._stop.is_set():
            try:
                self.engine.process_orders()
            except Exception:
                pass
            self._stop.wait(self.interval_seconds)

    def stop(self) -> None:
        self._stop.set()


class ValuationLoop(threading.Thread):
    def __init__(self, engine: PaperTradingEngine, trading_interval_seconds: int, idle_interval_seconds: int) -> None:
        super().__init__(daemon=True)
        self.engine = engine
        self.trading_interval_seconds = trading_interval_seconds
        self.idle_interval_seconds = idle_interval_seconds
        self._stop = threading.Event()

    def run(self) -> None:
        while not self._stop.is_set():
            try:
                self.engine.snapshot_accounts()
            except Exception:
                pass
            interval = self.trading_interval_seconds if is_trading_time() else self.idle_interval_seconds
            self._stop.wait(interval)

    def stop(self) -> None:
        self._stop.set()


class TradingRequestHandler(BaseHTTPRequestHandler):
    engine: PaperTradingEngine = None

    def do_GET(self) -> None:
        try:
            parsed = urlparse(self.path)
            path = parsed.path
            qs = parse_qs(parsed.query)
            if path == "/health":
                return self._json({"status": "ok"})
            if path == "/accounts":
                return self._json({"status": "success", "data": self.engine.list_accounts()})
            if path == "/accounts/default":
                default_account_id = self.engine.get_default_account_id()
                if not default_account_id:
                    return self._json({"status": "error", "message": "default account not set"}, code=404)
                return self._json({"status": "success", "data": {"account_id": default_account_id}})
            if path.startswith("/accounts/") and path.endswith("/positions"):
                return self._json({"status": "success", "data": self.engine.get_positions(path.split("/")[2])})
            if path.startswith("/accounts/") and path.endswith("/orders"):
                account_id = path.split("/")[2]
                return self._json({"status": "success", "data": self.engine.list_orders(account_id, status=qs.get("status", [None])[0])})
            if path.startswith("/accounts/") and path.endswith("/trades"):
                return self._json({"status": "success", "data": self.engine.list_trades(path.split("/")[2])})
            if path.startswith("/accounts/") and len(path.split("/")) == 3:
                return self._json({"status": "success", "data": self.engine.get_account(path.split("/")[2])})
            return self._json({"status": "error", "message": f"unknown route {path}"}, code=404)
        except Exception as exc:
            return self._json({"status": "error", "message": str(exc)}, code=400)

    def do_POST(self) -> None:
        try:
            payload = self._payload()
            path = urlparse(self.path).path
            if path == "/accounts":
                return self._json({"status": "success", "data": self.engine.create_account(payload["account_id"], float(payload.get("initial_cash", 100000.0)))}, code=201)
            if path.startswith("/accounts/") and path.endswith("/reset"):
                return self._json({"status": "success", "data": self.engine.reset_account(path.split("/")[2], payload.get("initial_cash"))})
            if path == "/accounts/default":
                return self._json({"status": "success", "data": self.engine.set_default_account(payload["account_id"])})
            if path.startswith("/accounts/") and path.endswith("/cash-adjust"):
                account_id = path.split("/")[2]
                return self._json(
                    {
                        "status": "success",
                        "data": self.engine.adjust_cash(account_id, float(payload["delta"]), payload.get("note", "")),
                    }
                )
            if path == "/orders":
                req = OrderRequest(
                    account_id=payload["account_id"],
                    symbol=payload["symbol"],
                    side=payload["side"],
                    qty=int(payload["qty"]),
                    order_type=payload.get("order_type", "limit"),
                    limit_price=float(payload["limit_price"]) if payload.get("limit_price") is not None else None,
                    note=payload.get("note", ""),
                )
                return self._json({"status": "success", "data": self.engine.place_order(req)}, code=201)
            if path.startswith("/orders/") and path.endswith("/cancel"):
                return self._json({"status": "success", "data": self.engine.cancel_order(path.split("/")[2])})
            if path == "/orders/process":
                return self._json({"status": "success", "data": self.engine.process_orders()})
            if path == "/snapshots/run":
                return self._json({"status": "success", "data": self.engine.snapshot_accounts()})
            if path == "/backtest":
                return self._json(
                    {
                        "status": "success",
                        "data": self.engine.run_backtest(
                            symbol=payload["symbol"],
                            strategy=payload.get("strategy", "sma_cross"),
                            start=payload["start"],
                            end=payload["end"],
                            initial_cash=float(payload.get("initial_cash", 100000.0)),
                            params=payload.get("params") or {},
                        ),
                    }
                )
            return self._json({"status": "error", "message": f"unknown route {path}"}, code=404)
        except Exception as exc:
            return self._json({"status": "error", "message": str(exc)}, code=400)

    def log_message(self, fmt: str, *args: Any) -> None:
        return

    def _payload(self) -> Dict[str, Any]:
        length = int(self.headers.get("Content-Length", "0") or "0")
        raw = self.rfile.read(length) if length else b"{}"
        return json.loads(raw.decode("utf-8")) if raw else {}

    def _json(self, data: Dict[str, Any], code: int = 200) -> None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


def run_server(host: str, port: int, engine: PaperTradingEngine, match_interval: int = 5, valuation_interval: int = 10, idle_valuation_interval: int = 300) -> None:
    TradingRequestHandler.engine = engine
    server = ThreadingHTTPServer((host, port), TradingRequestHandler)
    matching_loop = MatchingLoop(engine, match_interval)
    valuation_loop = ValuationLoop(engine, valuation_interval, idle_valuation_interval)
    matching_loop.start()
    valuation_loop.start()
    try:
        server.serve_forever()
    finally:
        matching_loop.stop()
        valuation_loop.stop()
        server.server_close()


path: scripts/paper_trading/__init__.py
content: """Paper trading service package for A-share paper trading skill."""


path: scripts/paper_trading/engine.py
content: #!/usr/bin/env python3
"""Core paper trading engine with SQLite persistence."""

from __future__ import annotations

import sqlite3
import threading
import uuid
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from datetime import datetime, time as time_type
from pathlib import Path
from typing import Dict, List, Optional

import pandas as pd

from .market_data import MarketDataProvider


def now_ts() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def trade_date() -> str:
    return datetime.now().strftime("%Y-%m-%d")


def market_phase(dt: Optional[datetime] = None) -> str:
    now = dt or datetime.now()
    if now.weekday() >= 5:
        return "closed"
    current = now.time()
    if time_type(9, 15) <= current < time_type(9, 25):
        return "opening_call_auction"
    if time_type(9, 25) <= current < time_type(9, 30):
        return "pre_open_break"
    if time_type(9, 30) <= current <= time_type(11, 30):
        return "morning_continuous"
    if time_type(11, 30) < current < time_type(13, 0):
        return "lunch_break"
    if time_type(13, 0) <= current < time_type(14, 57):
        return "afternoon_continuous"
    if time_type(14, 57) <= current <= time_type(15, 0):
        return "closing_call_auction"
    return "post_close"


def is_trading_time(dt: Optional[datetime] = None) -> bool:
    return market_phase(dt) in {"morning_continuous", "afternoon_continuous"}


def is_after_close(dt: Optional[datetime] = None) -> bool:
    now = dt or datetime.now()
    if now.weekday() >= 5:
        return True
    return now.time() > time_type(15, 0)


def _is_shanghai_symbol(symbol: Optional[str]) -> bool:
    if not symbol:
        return False
    value = str(symbol).strip().lower()
    return value.startswith("sh") or value.startswith("6")


def calc_transfer_fee(amount: float, symbol: Optional[str] = None) -> float:
    if not _is_shanghai_symbol(symbol):
        return 0.0
    return round(amount * 0.00001, 2)


def calc_commission(amount: float, symbol: Optional[str] = None) -> float:
    broker = max(5.0, round(amount * 0.0003, 2))
    return round(broker + calc_transfer_fee(amount, symbol), 2)


def calc_tax(side: str, amount: float) -> float:
    return 0.0 if side.lower() != "sell" else round(amount * 0.001, 2)


def validate_price_tick(price: float) -> float:
    try:
        price_decimal = Decimal(str(price))
    except (InvalidOperation, ValueError) as exc:
        raise ValueError(f"invalid price {price}") from exc
    normalized = price_decimal.quantize(Decimal("0.01"))
    if price_decimal != normalized:
        raise ValueError(f"order price must align with 0.01 tick size, got {price}")
    return float(normalized)


@dataclass
class OrderRequest:
    account_id: str
    symbol: str
    side: str
    qty: int
    order_type: str = "limit"
    limit_price: Optional[float] = None
    note: str = ""


class PaperTradingEngine:
    def __init__(self, db_path: str, market_data: Optional[MarketDataProvider] = None) -> None:
        self.db_path = db_path
        Path(db_path).parent.mkdir(parents=True, exist_ok=True)
        self.market_data = market_data or MarketDataProvider()
        self._lock = threading.RLock()
        self._init_db()

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.db_path, check_same_thread=False)
        conn.row_factory = sqlite3.Row
        return conn

    def _init_db(self) -> None:
        with self._connect() as conn:
            conn.executescript(
                """
                CREATE TABLE IF NOT EXISTS accounts (
                    account_id TEXT PRIMARY KEY,
                    initial_cash REAL NOT NULL,
                    cash REAL NOT NULL,
                    frozen_cash REAL NOT NULL DEFAULT 0,
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS orders (
                    order_id TEXT PRIMARY KEY,
                    account_id TEXT NOT NULL,
                    symbol TEXT NOT NULL,
                    side TEXT NOT NULL,
                    order_type TEXT NOT NULL,
                    limit_price REAL,
                    qty INTEGER NOT NULL,
                    reserved_cash REAL NOT NULL DEFAULT 0,
                    filled_qty INTEGER NOT NULL DEFAULT 0,
                    avg_fill_price REAL,
                    status TEXT NOT NULL,
                    note TEXT NOT NULL DEFAULT '',
                    last_checked_at TEXT,
                    validity TEXT NOT NULL DEFAULT 'day',
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS trades (
                    trade_id TEXT PRIMARY KEY,
                    order_id TEXT NOT NULL,
                    account_id TEXT NOT NULL,
                    symbol TEXT NOT NULL,
                    side TEXT NOT NULL,
                    price REAL NOT NULL,
                    qty INTEGER NOT NULL,
                    amount REAL NOT NULL,
                    commission REAL NOT NULL,
                    tax REAL NOT NULL,
                    created_at TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS position_lots (
                    lot_id TEXT PRIMARY KEY,
                    account_id TEXT NOT NULL,
                    symbol TEXT NOT NULL,
                    acquired_date TEXT NOT NULL,
                    qty INTEGER NOT NULL,
                    remaining_qty INTEGER NOT NULL,
                    cost_price REAL NOT NULL,
                    created_at TEXT NOT NULL
                );
                CREATE TABLE IF NOT EXISTS account_snapshots (
                    snapshot_id TEXT PRIMARY KEY,
                    account_id TEXT NOT NULL,
                    snapshot_time TEXT NOT NULL,
                    cash REAL NOT NULL,
                    frozen_cash REAL NOT NULL,
                    market_value REAL NOT NULL,
                    net_asset REAL NOT NULL,
                    position_count INTEGER NOT NULL
                );
                CREATE TABLE IF NOT EXISTS system_settings (
                    key TEXT PRIMARY KEY,
                    value TEXT NOT NULL
                );
                """
            )

    def create_account(self, account_id: str, initial_cash: float) -> Dict:
        with self._lock, self._connect() as conn:
            existing = conn.execute("SELECT account_id FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
            if existing:
                raise ValueError(f"account {account_id} already exists")
            account_count = conn.execute("SELECT COUNT(*) AS cnt FROM accounts").fetchone()["cnt"]
            ts = now_ts()
            conn.execute(
                "INSERT INTO accounts(account_id, initial_cash, cash, frozen_cash, created_at, updated_at) VALUES(?, ?, ?, 0, ?, ?)",
                (account_id, float(initial_cash), float(initial_cash), ts, ts),
            )
            if int(account_count or 0) == 0:
                conn.execute(
                    "INSERT OR REPLACE INTO system_settings(key, value) VALUES('default_account_id', ?)",
                    (account_id,),
                )
        return self.get_account(account_id)

    def list_accounts(self) -> List[Dict]:
        default_account_id = self.get_default_account_id()
        with self._lock, self._connect() as conn:
            rows = conn.execute("SELECT account_id FROM accounts ORDER BY account_id").fetchall()
        out = []
        for row in rows:
            item = self.get_account(row["account_id"])
            item["is_default"] = row["account_id"] == default_account_id
            out.append(item)
        return out

    def get_default_account_id(self) -> Optional[str]:
        with self._lock, self._connect() as conn:
            row = conn.execute("SELECT value FROM system_settings WHERE key = 'default_account_id'").fetchone()
            if not row:
                return None
            account = conn.execute("SELECT account_id FROM accounts WHERE account_id = ?", (row["value"],)).fetchone()
            return account["account_id"] if account else None

    def set_default_account(self, account_id: str) -> Dict:
        with self._lock, self._connect() as conn:
            account = conn.execute("SELECT account_id FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
            if not account:
                raise ValueError(f"account {account_id} not found")
            conn.execute(
                "INSERT OR REPLACE INTO system_settings(key, value) VALUES('default_account_id', ?)",
                (account_id,),
            )
        return {"default_account_id": account_id}

    def adjust_cash(self, account_id: str, delta: float, note: str = "") -> Dict:
        delta = round(float(delta), 2)
        if abs(delta) < 1e-9:
            raise ValueError("delta must not be zero")
        with self._lock, self._connect() as conn:
            account = conn.execute("SELECT * FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
            if not account:
                raise ValueError(f"account {account_id} not found")
            available_cash = float(account["cash"]) - float(account["frozen_cash"])
            if delta < 0 and available_cash + 1e-6 < abs(delta):
                raise ValueError(f"insufficient available cash, available={round(available_cash, 2)}")
            ts = now_ts()
            conn.execute(
                "UPDATE accounts SET initial_cash = initial_cash + ?, cash = cash + ?, updated_at = ? WHERE account_id = ?",
                (delta, delta, ts, account_id),
            )
        account_data = self.get_account(account_id)
        account_data["cash_adjustment"] = {"delta": delta, "note": note}
        return account_data

    def reset_account(self, account_id: str, initial_cash: Optional[float] = None) -> Dict:
        with self._lock, self._connect() as conn:
            account = conn.execute("SELECT * FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
            if not account:
                raise ValueError(f"account {account_id} not found")
            base_cash = float(initial_cash if initial_cash is not None else account["initial_cash"])
            ts = now_ts()
            conn.execute(
                "UPDATE accounts SET initial_cash = ?, cash = ?, frozen_cash = 0, updated_at = ? WHERE account_id = ?",
                (base_cash, base_cash, ts, account_id),
            )
            conn.execute("DELETE FROM orders WHERE account_id = ?", (account_id,))
            conn.execute("DELETE FROM trades WHERE account_id = ?", (account_id,))
            conn.execute("DELETE FROM position_lots WHERE account_id = ?", (account_id,))
            conn.execute("DELETE FROM account_snapshots WHERE account_id = ?", (account_id,))
        return self.get_account(account_id)

    def place_order(self, req: OrderRequest) -> Dict:
        req.symbol = self.market_data.normalize_symbol(req.symbol)
        req.side = req.side.lower()
        req.order_type = req.order_type.lower()
        if req.side not in {"buy", "sell"}:
            raise ValueError("side must be buy or sell")
        if req.order_type not in {"limit", "market"}:
            raise ValueError("order_type must be limit or market")
        if req.qty <= 0:
            raise ValueError("qty must be positive")
        if req.side == "buy" and req.qty % 100 != 0:
            raise ValueError("A-share buy order qty must be multiple of 100")
        if req.order_type == "limit" and (req.limit_price is None or req.limit_price <= 0):
            raise ValueError("limit_price required for limit orders")
        if req.order_type == "market" and not is_trading_time():
            raise ValueError("market orders are only accepted during trading hours")
        if req.order_type == "limit" and req.limit_price is not None:
            req.limit_price = validate_price_tick(req.limit_price)

        quote = self.market_data.get_quote(req.symbol)
        if req.order_type == "market" and not self._is_quote_tradable(quote):
            raise ValueError(f"symbol {req.symbol} quote is stale or unavailable for market order")
        self._validate_price_limits(req, quote)
        with self._lock, self._connect() as conn:
            account = conn.execute("SELECT * FROM accounts WHERE account_id = ?", (req.account_id,)).fetchone()
            if not account:
                raise ValueError(f"account {req.account_id} not found")

            reserved_cash = 0.0
            if req.side == "buy":
                if req.order_type == "limit":
                    reserved_cash = req.qty * float(req.limit_price) + calc_commission(req.qty * float(req.limit_price), req.symbol)
                    available_cash = float(account["cash"]) - float(account["frozen_cash"])
                    if available_cash + 1e-6 < reserved_cash:
                        raise ValueError("insufficient available cash")
                else:
                    estimated_amount = req.qty * quote.price
                    total_need = estimated_amount + calc_commission(estimated_amount, req.symbol)
                    available_cash = float(account["cash"]) - float(account["frozen_cash"])
                    if available_cash + 1e-6 < total_need:
                        raise ValueError("insufficient available cash")
            else:
                available_sellable = self._get_sellable_qty(conn, req.account_id, req.symbol) - self._get_pending_sell_qty(conn, req.account_id, req.symbol)
                if available_sellable < req.qty:
                    raise ValueError(f"insufficient sellable qty, available={available_sellable}")
                total_position = self._get_total_qty(conn, req.account_id, req.symbol)
                if req.qty % 100 != 0 and req.qty != total_position:
                    raise ValueError("A-share sell qty must be multiple of 100 unless selling all remaining shares")

            order_id = uuid.uuid4().hex[:16]
            ts = now_ts()
            conn.execute(
                """
                INSERT INTO orders(order_id, account_id, symbol, side, order_type, limit_price, qty, reserved_cash, filled_qty, avg_fill_price, status, note, last_checked_at, validity, created_at, updated_at)
                VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, 'open', ?, ?, 'day', ?, ?)
                """,
                (order_id, req.account_id, req.symbol, req.side, req.order_type, req.limit_price, req.qty, reserved_cash, req.note, ts, ts, ts),
            )
            if reserved_cash > 0:
                conn.execute("UPDATE accounts SET frozen_cash = frozen_cash + ?, updated_at = ? WHERE account_id = ?", (reserved_cash, ts, req.account_id))

        if req.order_type == "market":
            self.process_orders()
        return self.get_order(order_id)

    def cancel_order(self, order_id: str) -> Dict:
        with self._lock, self._connect() as conn:
            order = conn.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone()
            if not order:
                raise ValueError(f"order {order_id} not found")
            if order["status"] != "open":
                raise ValueError(f"order {order_id} is not open")
            ts = now_ts()
            if float(order["reserved_cash"]) > 0:
                conn.execute("UPDATE accounts SET frozen_cash = MAX(0, frozen_cash - ?), updated_at = ? WHERE account_id = ?", (float(order["reserved_cash"]), ts, order["account_id"]))
            conn.execute("UPDATE orders SET status = 'cancelled', updated_at = ? WHERE order_id = ?", (ts, order_id))
        return self.get_order(order_id)

    def process_orders(self) -> Dict:
        if not is_trading_time():
            expired = self.expire_day_orders()
            return {"processed": 0, "filled": 0, "expired": expired}
        with self._lock, self._connect() as conn:
            open_orders = conn.execute("SELECT * FROM orders WHERE status = 'open' ORDER BY created_at ASC").fetchall()
            if not open_orders:
                return {"processed": 0, "filled": 0}
            filled = 0
            for order in open_orders:
                quote = self.market_data.get_quote(order["symbol"])
                if not self._is_quote_tradable(quote):
                    ts = now_ts()
                    conn.execute("UPDATE orders SET last_checked_at = ?, updated_at = ? WHERE order_id = ?", (ts, ts, order["order_id"]))
                    continue
                market_date = quote.timestamp.split(" ")[0] if quote.timestamp else trade_date()
                fill_price = self._get_fill_price(order, quote)
                if fill_price is not None:
                    self._fill_order(conn, order, fill_price, market_date)
                    filled += 1
                else:
                    ts = now_ts()
                    conn.execute("UPDATE orders SET last_checked_at = ?, updated_at = ? WHERE order_id = ?", (ts, ts, order["order_id"]))
            return {"processed": len(open_orders), "filled": filled, "expired": 0}

    def expire_day_orders(self) -> int:
        if not is_after_close():
            return 0
        with self._lock, self._connect() as conn:
            open_orders = conn.execute("SELECT * FROM orders WHERE status = 'open' AND validity = 'day' ORDER BY created_at ASC").fetchall()
            expired = 0
            for order in open_orders:
                ts = now_ts()
                if float(order["reserved_cash"]) > 0:
                    conn.execute("UPDATE accounts SET frozen_cash = MAX(0, frozen_cash - ?), updated_at = ? WHERE account_id = ?", (float(order["reserved_cash"]), ts, order["account_id"]))
                conn.execute("UPDATE orders SET status = 'expired', note = ?, updated_at = ? WHERE order_id = ?", ("expired at market close", ts, order["order_id"]))
                expired += 1
            return expired

    def snapshot_accounts(self) -> Dict:
        accounts = self.list_accounts()
        with self._lock, self._connect() as conn:
            ts = now_ts()
            for account in accounts:
                conn.execute(
                    """
                    INSERT INTO account_snapshots(snapshot_id, account_id, snapshot_time, cash, frozen_cash, market_value, net_asset, position_count)
                    VALUES(?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                    (
                        uuid.uuid4().hex[:16],
                        account["account_id"],
                        ts,
                        account["cash"],
                        account["frozen_cash"],
                        account["market_value"],
                        account["net_asset"],
                        len(account["positions"]),
                    ),
                )
        return {"snapshots": len(accounts), "timestamp": ts}

    def get_order(self, order_id: str) -> Dict:
        with self._lock, self._connect() as conn:
            row = conn.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone()
            if not row:
                raise ValueError(f"order {order_id} not found")
            return dict(row)

    def list_orders(self, account_id: str, status: Optional[str] = None) -> List[Dict]:
        with self._lock, self._connect() as conn:
            if status:
                rows = conn.execute("SELECT * FROM orders WHERE account_id = ? AND status = ? ORDER BY created_at DESC", (account_id, status)).fetchall()
            else:
                rows = conn.execute("SELECT * FROM orders WHERE account_id = ? ORDER BY created_at DESC", (account_id,)).fetchall()
        return [dict(row) for row in rows]

    def list_trades(self, account_id: str) -> List[Dict]:
        with self._lock, self._connect() as conn:
            rows = conn.execute("SELECT * FROM trades WHERE account_id = ? ORDER BY created_at DESC", (account_id,)).fetchall()
        return [dict(row) for row in rows]

    def get_positions(self, account_id: str) -> List[Dict]:
        with self._lock, self._connect() as conn:
            rows = conn.execute(
                """
                SELECT symbol, SUM(remaining_qty) AS qty, SUM(remaining_qty * cost_price) AS cost_amount
                FROM position_lots
                WHERE account_id = ? AND remaining_qty > 0
                GROUP BY symbol
                ORDER BY symbol
                """,
                (account_id,),
            ).fetchall()
            symbols = [row["symbol"] for row in rows]
            quotes = self.market_data.get_quotes(symbols) if symbols else {}
            out = []
            for row in rows:
                qty = int(row["qty"] or 0)
                cost_amount = float(row["cost_amount"] or 0.0)
                avg_cost = cost_amount / qty if qty else 0.0
                quote = quotes.get(row["symbol"])
                last_price = quote.price if quote else avg_cost
                market_value = last_price * qty
                out.append(
                    {
                        "symbol": row["symbol"],
                        "qty": qty,
                        "sellable_qty": self._get_sellable_qty(conn, account_id, row["symbol"]),
                        "avg_cost": round(avg_cost, 4),
                        "last_price": round(last_price, 4),
                        "market_value": round(market_value, 2),
                        "unrealized_pnl": round(market_value - cost_amount, 2),
                    }
                )
        return out

    def get_account(self, account_id: str) -> Dict:
        with self._lock, self._connect() as conn:
            account = conn.execute("SELECT * FROM accounts WHERE account_id = ?", (account_id,)).fetchone()
            if not account:
                raise ValueError(f"account {account_id} not found")
            positions = self.get_positions(account_id)
            market_value = round(sum(item["market_value"] for item in positions), 2)
            net_asset = round(float(account["cash"]) + market_value, 2)
            return {
                "account_id": account_id,
                "initial_cash": round(float(account["initial_cash"]), 2),
                "cash": round(float(account["cash"]), 2),
                "frozen_cash": round(float(account["frozen_cash"]), 2),
                "available_cash": round(float(account["cash"]) - float(account["frozen_cash"]), 2),
                "market_value": market_value,
                "net_asset": net_asset,
                "positions": positions,
                "order_count": len(self.list_orders(account_id)),
                "trade_count": len(self.list_trades(account_id)),
                "cash_flow_pnl": round(net_asset - float(account["initial_cash"]), 2),
                "updated_at": account["updated_at"],
            }

    def run_backtest(self, symbol: str, strategy: str, start: str, end: str, initial_cash: float, params: Optional[Dict] = None) -> Dict:
        params = params or {}
        bars = self.market_data.get_history(symbol, start=start, end=end, count=params.get("count", 500))
        if len(bars) < 30:
            raise ValueError("not enough bars for backtest")
        cash = float(initial_cash)
        qty = 0
        avg_cost = 0.0
        trades: List[Dict] = []
        equity_curve: List[Dict] = []
        bars = bars.copy()
        bars["ma_fast"] = bars["close"].rolling(int(params.get("fast", 5))).mean()
        bars["ma_slow"] = bars["close"].rolling(int(params.get("slow", 20))).mean()
        delta = bars["close"].diff()
        gain = delta.where(delta > 0, 0.0).rolling(14).mean()
        loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean()
        rs = gain / loss.replace(0, pd.NA)
        bars["rsi"] = 100 - (100 / (1 + rs))
        for i, row in bars.iterrows():
            price = float(row["close"])
            date_str = row["time"].strftime("%Y-%m-%d")
            signal = "hold"
            if strategy == "buy_and_hold":
                if i == 0 and qty == 0:
                    signal = "buy"
            elif strategy == "sma_cross":
                if i > 0 and pd.notna(row["ma_fast"]) and pd.notna(row["ma_slow"]):
                    prev = bars.iloc[i - 1]
                    if prev["ma_fast"] <= prev["ma_slow"] and row["ma_fast"] > row["ma_slow"]:
                        signal = "buy"
                    elif prev["ma_fast"] >= prev["ma_slow"] and row["ma_fast"] < row["ma_slow"]:
                        signal = "sell"
            elif strategy == "rsi_revert":
                if pd.notna(row["rsi"]):
                    if row["rsi"] < float(params.get("buy_rsi", 30)) and qty == 0:
                        signal = "buy"
                    elif row["rsi"] > float(params.get("sell_rsi", 70)) and qty > 0:
                        signal = "sell"
            else:
                raise ValueError("unsupported strategy")
            if signal == "buy" and qty == 0:
                lot_qty = int((cash / price) // 100) * 100
                if lot_qty >= 100:
                    amount = lot_qty * price
                    commission = calc_commission(amount, symbol)
                    cash -= amount + commission
                    qty = lot_qty
                    avg_cost = price
                    trades.append({"date": date_str, "action": "buy", "price": round(price, 3), "qty": lot_qty, "amount": round(amount, 2), "profit": 0.0})
            elif signal == "sell" and qty > 0:
                amount = qty * price
                commission = calc_commission(amount, symbol)
                tax = calc_tax("sell", amount)
                profit = amount - commission - tax - qty * avg_cost
                cash += amount - commission - tax
                trades.append({"date": date_str, "action": "sell", "price": round(price, 3), "qty": qty, "amount": round(amount, 2), "profit": round(profit, 2)})
                qty = 0
                avg_cost = 0.0
            equity_curve.append({"date": date_str, "equity": round(cash + qty * price, 2)})
        final_price = float(bars.iloc[-1]["close"])
        final_equity = cash + qty * final_price
        peak = 0.0
        max_drawdown = 0.0
        for item in equity_curve:
            peak = max(peak, item["equity"])
            if peak > 0:
                max_drawdown = max(max_drawdown, (peak - item["equity"]) / peak)
        sell_profits = [trade["profit"] for trade in trades if trade["action"] == "sell"]
        return {
            "symbol": self.market_data.normalize_symbol(symbol),
            "strategy": strategy,
            "initial_capital": round(initial_cash, 2),
            "final_capital": round(final_equity, 2),
            "total_return_pct": round((final_equity - initial_cash) / initial_cash * 100.0, 2),
            "max_drawdown_pct": round(max_drawdown * 100.0, 2),
            "win_count": len([p for p in sell_profits if p > 0]),
            "loss_count": len([p for p in sell_profits if p <= 0]),
            "trades": trades,
            "equity_curve": equity_curve[-60:],
        }

    def _validate_price_limits(self, req: OrderRequest, quote) -> None:
        if req.order_type != "limit" or req.limit_price is None:
            return
        if quote.limit_up is None or quote.limit_down is None:
            return
        price = validate_price_tick(req.limit_price)
        limit_up = round(float(quote.limit_up), 2)
        limit_down = round(float(quote.limit_down), 2)
        if price > limit_up:
            raise ValueError(f"order price {price} above daily limit up {limit_up}")
        if price < limit_down:
            raise ValueError(f"order price {price} below daily limit down {limit_down}")

    def _is_quote_tradable(self, quote) -> bool:
        try:
            if float(quote.price) <= 0:
                return False
            if max(float(quote.open), float(quote.high), float(quote.low), float(quote.price)) <= 0:
                return False
        except Exception:
            return False
        if not getattr(quote, "timestamp", None):
            return False
        try:
            quote_date = str(quote.timestamp).split(" ")[0]
        except Exception:
            return False
        return quote_date == trade_date()

    def _should_fill(self, order: sqlite3.Row, last_price: float) -> bool:
        if order["order_type"] == "market":
            return True
        limit_price = float(order["limit_price"])
        return last_price <= limit_price + 1e-9 if order["side"] == "buy" else last_price >= limit_price - 1e-9

    def _is_locked_limit(self, side: str, quote) -> bool:
        if quote.limit_up is None or quote.limit_down is None:
            return False
        price = round(float(quote.price), 2)
        open_price = round(float(quote.open), 2)
        high_price = round(float(quote.high), 2)
        low_price = round(float(quote.low), 2)
        limit_up = round(float(quote.limit_up), 2)
        limit_down = round(float(quote.limit_down), 2)
        if side == "buy":
            return price >= limit_up and open_price >= limit_up and low_price >= limit_up
        return price <= limit_down and open_price <= limit_down and high_price <= limit_down

    def _can_fill_buy_on_bar(self, limit_price: float, quote, bar_low: float) -> bool:
        if bar_low > limit_price + 1e-9:
            return False
        if quote.limit_up is not None and round(float(quote.price), 2) >= round(float(quote.limit_up), 2):
            return bar_low < round(float(quote.limit_up), 2) - 1e-9
        return True

    def _can_fill_sell_on_bar(self, limit_price: float, quote, bar_high: float) -> bool:
        if bar_high < limit_price - 1e-9:
            return False
        if quote.limit_down is not None and round(float(quote.price), 2) <= round(float(quote.limit_down), 2):
            return bar_high > round(float(quote.limit_down), 2) + 1e-9
        return True

    def _can_fill_with_quote_only(self, side: str, quote) -> bool:
        if side == "buy" and quote.limit_up is not None and round(float(quote.price), 2) >= round(float(quote.limit_up), 2):
            return False
        if side == "sell" and quote.limit_down is not None and round(float(quote.price), 2) <= round(float(quote.limit_down), 2):
            return False
        return True

    def _get_fill_price(self, order: sqlite3.Row, quote) -> Optional[float]:
        if not self._is_quote_tradable(quote):
            return None
        if order["order_type"] == "market":
            return None if self._is_locked_limit(order["side"], quote) else quote.price
        limit_price = float(order["limit_price"])
        if self._is_locked_limit(order["side"], quote):
            return None
        last_checked = order["last_checked_at"] or order["created_at"]
        try:
            last_checked_dt = pd.to_datetime(last_checked)
        except Exception:
            last_checked_dt = pd.Timestamp.now() - pd.Timedelta(minutes=5)
        bars = self.market_data.get_intraday_bars(order["symbol"], freq="1m", count=240)
        if not bars.empty:
            recent = bars[bars["time"] >= last_checked_dt.floor("min")]
            if not recent.empty:
                for _, bar in recent.iterrows():
                    bar_low = float(bar["low"])
                    bar_high = float(bar["high"])
                    bar_open = float(bar["open"])
                    if order["side"] == "buy" and self._can_fill_buy_on_bar(limit_price, quote, bar_low):
                        return min(limit_price, bar_open) if bar_open <= limit_price else limit_price
                    if order["side"] == "sell" and self._can_fill_sell_on_bar(limit_price, quote, bar_high):
                        return max(limit_price, bar_open) if bar_open >= limit_price else limit_price
        if self._should_fill(order, quote.price) and self._can_fill_with_quote_only(order["side"], quote):
            return min(limit_price, float(quote.price)) if order["side"] == "buy" else max(limit_price, float(quote.price))
        return None

    def _fill_order(self, conn: sqlite3.Connection, order: sqlite3.Row, fill_price: float, market_date: str) -> None:
        qty = int(order["qty"])
        amount = round(fill_price * qty, 2)
        commission = calc_commission(amount, order["symbol"])
        tax = calc_tax(order["side"], amount)
        ts = now_ts()
        if order["side"] == "buy":
            account = conn.execute("SELECT * FROM accounts WHERE account_id = ?", (order["account_id"],)).fetchone()
            if float(account["cash"]) + 1e-6 < amount + commission:
                conn.execute("UPDATE orders SET status = 'rejected', note = ?, updated_at = ? WHERE order_id = ?", ("insufficient cash at fill time", ts, order["order_id"]))
                if float(order["reserved_cash"]) > 0:
                    conn.execute("UPDATE accounts SET frozen_cash = MAX(0, frozen_cash - ?), updated_at = ? WHERE account_id = ?", (float(order["reserved_cash"]), ts, order["account_id"]))
                return
            conn.execute("UPDATE accounts SET cash = cash - ?, frozen_cash = MAX(0, frozen_cash - ?), updated_at = ? WHERE account_id = ?", (amount + commission, float(order["reserved_cash"]), ts, order["account_id"]))
            conn.execute(
                "INSERT INTO position_lots(lot_id, account_id, symbol, acquired_date, qty, remaining_qty, cost_price, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
                (uuid.uuid4().hex[:16], order["account_id"], order["symbol"], market_date, qty, qty, fill_price, ts),
            )
        else:
            sellable_lots = conn.execute(
                "SELECT * FROM position_lots WHERE account_id = ? AND symbol = ? AND remaining_qty > 0 AND acquired_date < ? ORDER BY acquired_date ASC, created_at ASC",
                (order["account_id"], order["symbol"], trade_date()),
            ).fetchall()
            remaining = qty
            for lot in sellable_lots:
                if remaining <= 0:
                    break
                take_qty = min(remaining, int(lot["remaining_qty"]))
                conn.execute("UPDATE position_lots SET remaining_qty = remaining_qty - ? WHERE lot_id = ?", (take_qty, lot["lot_id"]))
                remaining -= take_qty
            if remaining > 0:
                conn.execute("UPDATE orders SET status = 'rejected', note = ?, updated_at = ? WHERE order_id = ?", ("insufficient sellable qty at fill time", ts, order["order_id"]))
                return
            conn.execute("UPDATE accounts SET cash = cash + ?, updated_at = ? WHERE account_id = ?", (amount - commission - tax, ts, order["account_id"]))
        conn.execute(
            "INSERT INTO trades(trade_id, order_id, account_id, symbol, side, price, qty, amount, commission, tax, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (uuid.uuid4().hex[:16], order["order_id"], order["account_id"], order["symbol"], order["side"], fill_price, qty, amount, commission, tax, ts),
        )
        conn.execute("UPDATE orders SET filled_qty = ?, avg_fill_price = ?, status = 'filled', updated_at = ? WHERE order_id = ?", (qty, fill_price, ts, order["order_id"]))

    def _get_total_qty(self, conn: sqlite3.Connection, account_id: str, symbol: str) -> int:
        row = conn.execute("SELECT COALESCE(SUM(remaining_qty), 0) AS qty FROM position_lots WHERE account_id = ? AND symbol = ? AND remaining_qty > 0", (account_id, symbol)).fetchone()
        return int(row["qty"] or 0)

    def _get_sellable_qty(self, conn: sqlite3.Connection, account_id: str, symbol: str) -> int:
        row = conn.execute(
            "SELECT COALESCE(SUM(remaining_qty), 0) AS qty FROM position_lots WHERE account_id = ? AND symbol = ? AND remaining_qty > 0 AND acquired_date < ?",
            (account_id, symbol, trade_date()),
        ).fetchone()
        return int(row["qty"] or 0)

    def _get_pending_sell_qty(self, conn: sqlite3.Connection, account_id: str, symbol: str) -> int:
        row = conn.execute(
            "SELECT COALESCE(SUM(qty - filled_qty), 0) AS qty FROM orders WHERE account_id = ? AND symbol = ? AND side = 'sell' AND status = 'open'",
            (account_id, symbol),
        ).fetchone()
        return int(row["qty"] or 0)


path: scripts/paper_trading/market_data.py
content: #!/usr/bin/env python3
"""Market data adapter with fully local dependencies."""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, List, Optional

import akshare as ak
import pandas as pd
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


HEADERS = {
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
    "Referer": "https://finance.eastmoney.com",
}
SINA_KLINE_URL = "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData"
TENCENT_DAY_URL = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
TENCENT_MIN_URL = "https://ifzq.gtimg.cn/appstock/app/kline/mkline"
TENCENT_QUOTE_URL = "https://qt.gtimg.cn/q="
SINA_FREQ_MAP = {"5m": 5, "15m": 15, "30m": 30, "60m": 60, "1d": 240, "1w": 1200, "1M": 7200}
TENCENT_DAY_FREQ_MAP = {"1d": "day", "1w": "week", "1M": "month"}


def _build_session() -> requests.Session:
    session = requests.Session()
    session.headers.update(HEADERS)
    retry = Retry(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.5,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=("GET",),
        raise_on_status=False,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session


def _get_json(session: requests.Session, url: str, params=None, timeout: int = 10):
    resp = session.get(url, params=params, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


def normalize_code(code: str) -> str:
    c = (code or "").strip()
    if ".XSHG" in c:
        return "sh" + c.replace(".XSHG", "")
    if ".XSHE" in c:
        return "sz" + c.replace(".XSHE", "")
    if "." in c:
        parts = c.split(".")
        if len(parts) == 2:
            left = parts[0].lower()
            right = parts[1]
            if left in ("sh", "sz") and right.isdigit():
                return f"{left}{right}"
    c = c.lower()
    if c.startswith(("sh", "sz")):
        return c
    if c.isdigit():
        if c.startswith("6"):
            return "sh" + c
        return "sz" + c
    return c


def _code_digits(code: str) -> str:
    norm = normalize_code(code)
    return norm[2:] if norm.startswith(("sh", "sz")) else norm


def _generate_mainboard_codes() -> list[str]:
    codes: list[str] = []
    for prefix in ("600", "601", "603", "605", "000", "001", "002"):
        start = int(prefix) * 1000
        end = start + 1000
        for value in range(start, end):
            code = f"{value:06d}"
            market = "sh" if code.startswith(("600", "601", "603", "605")) else "sz"
            codes.append(f"{market}{code}")
    return codes


def infer_limit_ratio(symbol: str, name: str = "") -> float:
    digits = _code_digits(symbol)
    upper_name = str(name or "").upper()
    if digits.startswith(("300", "301", "688", "689")):
        return 0.20
    if "ST" in upper_name:
        return 0.05
    if digits.startswith(("430", "440", "830", "831", "832", "833", "834", "835", "836", "837", "838", "839", "870", "871", "872", "873", "874", "875", "876", "877", "878", "879")):
        return 0.30
    return 0.10


def infer_limit_prices(symbol: str, prev_close: float, name: str = "") -> tuple[Optional[float], Optional[float]]:
    if prev_close <= 0:
        return None, None
    ratio = infer_limit_ratio(symbol, name)
    up = round(prev_close * (1 + ratio), 2)
    down = round(prev_close * (1 - ratio), 2)
    return up, down


def _get_price_sina(session: requests.Session, code: str, count: int, frequency: str) -> pd.DataFrame:
    if frequency not in SINA_FREQ_MAP:
        return pd.DataFrame()
    params = {"symbol": code, "scale": SINA_FREQ_MAP[frequency], "ma": 5, "datalen": count}
    try:
        data = _get_json(session, SINA_KLINE_URL, params=params, timeout=10)
        if not data or isinstance(data, dict):
            return pd.DataFrame()
        df = pd.DataFrame(data)
        if "day" in df.columns:
            df = df.rename(columns={"day": "time"})
        df = df[["time", "open", "high", "low", "close", "volume"]]
        for col in ["open", "high", "low", "close", "volume"]:
            df[col] = pd.to_numeric(df[col], errors="coerce")
        df["time"] = pd.to_datetime(df["time"], errors="coerce")
        df = df.dropna(subset=["time"]).set_index("time")
        df.index.name = ""
        return df
    except Exception:
        return pd.DataFrame()


def _get_price_day_tencent(session: requests.Session, code: str, count: int, frequency: str) -> pd.DataFrame:
    if frequency not in TENCENT_DAY_FREQ_MAP:
        return pd.DataFrame()
    unit = TENCENT_DAY_FREQ_MAP[frequency]
    url = f"{TENCENT_DAY_URL}?param={code},{unit},,,{count},qfq"
    try:
        payload = _get_json(session, url, timeout=10)
        if "data" not in payload or code not in payload["data"]:
            return pd.DataFrame()
        block = payload["data"][code]
        key = "qfq" + unit
        arr = block.get(key) or block.get(unit) or []
        if not arr:
            return pd.DataFrame()
        df = pd.DataFrame(arr, columns=["time", "open", "close", "high", "low", "volume"])
        df = df[["time", "open", "high", "low", "close", "volume"]]
        for col in ["open", "high", "low", "close", "volume"]:
            df[col] = pd.to_numeric(df[col], errors="coerce")
        df["time"] = pd.to_datetime(df["time"], errors="coerce")
        df = df.dropna(subset=["time"]).set_index("time")
        df.index.name = ""
        return df
    except Exception:
        return pd.DataFrame()


def _get_price_min_tencent(session: requests.Session, code: str, count: int, frequency: str) -> pd.DataFrame:
    ts = int(frequency[:-1]) if frequency[:-1].isdigit() else 1
    url = f"{TENCENT_MIN_URL}?param={code},m{ts},,{count}"
    try:
        payload = _get_json(session, url, timeout=10)
        if "data" not in payload or code not in payload["data"]:
            return pd.DataFrame()
        mkey = "m" + str(ts)
        arr = payload["data"][code].get(mkey)
        if not arr:
            return pd.DataFrame()
        df = pd.DataFrame(arr, columns=["time", "open", "close", "high", "low", "volume", "n1", "n2"])
        df = df[["time", "open", "high", "low", "close", "volume"]]
        for col in ["open", "high", "low", "close", "volume"]:
            df[col] = pd.to_numeric(df[col], errors="coerce")
        df["time"] = pd.to_datetime(df["time"], errors="coerce")
        df = df.dropna(subset=["time"]).set_index("time")
        df.index.name = ""
        return df
    except Exception:
        return pd.DataFrame()


def get_price(session: requests.Session, code: str, frequency: str = "1d", count: int = 60) -> pd.DataFrame:
    norm = normalize_code(code)
    if frequency in ("1d", "1w", "1M"):
        df = _get_price_sina(session, norm, count, frequency)
        if not df.empty:
            return df
        df = _get_price_day_tencent(session, norm, count, frequency)
        if not df.empty:
            return df
        if frequency == "1d":
            try:
                ak_df = ak.stock_zh_a_daily(symbol=norm, adjust="")
                if ak_df is not None and not ak_df.empty:
                    ak_df = ak_df.tail(count).copy()
                    ak_df = ak_df.rename(columns={"date": "time"})
                    ak_df["time"] = pd.to_datetime(ak_df["time"], errors="coerce")
                    ak_df = ak_df.set_index("time")
                    ak_df.index.name = ""
                    return ak_df[["open", "high", "low", "close", "volume"]]
            except Exception:
                pass
        return pd.DataFrame()
    if frequency == "1m":
        return _get_price_min_tencent(session, norm, count, frequency)
    df = _get_price_sina(session, norm, count, frequency)
    if not df.empty:
        return df
    return _get_price_min_tencent(session, norm, count, frequency)


def _parse_tencent_quote(line: str) -> Optional[dict]:
    if "~" not in line or len(line) < 50:
        return None
    parts = line.split("~")
    if len(parts) < 48:
        return None
    try:
        price = float(parts[3]) if parts[3] else 0
        if price <= 0:
            return None
        prev_close = float(parts[4]) if parts[4] else 0
        change_pct = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else 0
        market_prefix = "sh" if parts[0] == "1" else "sz"
        return {
            "code": f"{market_prefix}{parts[2]}",
            "name": parts[1],
            "price": price,
            "prev_close": prev_close,
            "open": float(parts[5]) if parts[5] else 0,
            "change_pct": change_pct,
            "volume": int(parts[6]) if parts[6] else 0,
            "amount": float(parts[37]) * 10000 if len(parts) > 37 and parts[37] else 0,
            "high": float(parts[33]) if parts[33] else 0,
            "low": float(parts[34]) if parts[34] else 0,
            "market_cap": float(parts[45]) if len(parts) > 45 and parts[45] else 0,
            "limit_up": float(parts[47]) if parts[47] else 0,
            "limit_down": float(parts[48]) if len(parts) > 48 and parts[48] else 0,
        }
    except Exception:
        return None


def _fetch_kline_with_fallback(
    session: requests.Session,
    norm: str,
    freq: str,
    fetch_count: int,
    start: str | None = None,
    end: str | None = None,
    retry: int = 2,
):
    last_df = pd.DataFrame()
    for _ in range(max(1, retry)):
        df = _get_price_day_tencent(session, norm, fetch_count, freq)
        if not df.empty:
            out = df.reset_index().rename(columns={"index": "time"})
            return out, "tencent"
        last_df = df
        df = _get_price_sina(session, norm, fetch_count, freq)
        if not df.empty:
            out = df.reset_index().rename(columns={"index": "time"})
            return out, "sina"
        last_df = df
    if freq == "1d":
        try:
            df = ak.stock_zh_a_hist(
                symbol=_code_digits(norm),
                period="daily",
                start_date=pd.to_datetime(start).strftime("%Y%m%d") if start else "",
                end_date=pd.to_datetime(end).strftime("%Y%m%d") if end else "",
                adjust="qfq",
            )
            if df is not None and not df.empty:
                out = df.rename(
                    columns={"日期": "time", "开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume"}
                )
                out = out[["time", "open", "high", "low", "close", "volume"]].copy()
                out["time"] = pd.to_datetime(out["time"], errors="coerce")
                out = out.dropna(subset=["time"])
                return out, "eastmoney"
        except Exception:
            pass
    return last_df, None


normalize_history_code = normalize_code
normalize_realtime_code = normalize_code


@dataclass
class Quote:
    symbol: str
    name: str
    price: float
    open: float
    high: float
    low: float
    prev_close: float
    volume: int
    change_pct: float
    timestamp: str
    source: str
    limit_up: Optional[float] = None
    limit_down: Optional[float] = None


class MarketDataProvider:
    def __init__(self) -> None:
        self._session = _build_session()
        self._realtime_session = _build_session()

    def normalize_symbol(self, symbol: str) -> str:
        norm = normalize_realtime_code(symbol)
        return norm[2:] if norm.startswith(("sh", "sz")) else norm

    def _latest_history_snapshot(self, symbol: str) -> Quote:
        normalized = normalize_realtime_code(symbol)
        day_df = get_price(self._session, normalized, frequency="1d", count=120)
        if day_df is None or day_df.empty or len(day_df) < 2:
            raise ValueError(f"failed to load daily bars for {symbol}")

        prev_close = float(day_df.iloc[-2]["close"]) if len(day_df) >= 2 else float(day_df.iloc[-1]["close"])
        minute_df = get_price(self._session, normalized, frequency="5m", count=320)
        if minute_df is not None and not minute_df.empty:
            latest_bar = minute_df.iloc[-1]
            ts = minute_df.index[-1].strftime("%Y-%m-%d %H:%M:%S")
            open_price = float(latest_bar["open"])
            high_price = float(minute_df["high"].max())
            low_price = float(minute_df["low"].min())
            latest_price = float(latest_bar["close"])
            volume = int(minute_df["volume"].sum())
            source = "tencent/sina-minute"
        else:
            latest_bar = day_df.iloc[-1]
            ts = day_df.index[-1].strftime("%Y-%m-%d 15:00:00")
            open_price = float(latest_bar["open"])
            high_price = float(latest_bar["high"])
            low_price = float(latest_bar["low"])
            latest_price = float(latest_bar["close"])
            volume = int(latest_bar["volume"])
            source = "tencent/sina-daily"

        change_pct = ((latest_price - prev_close) / prev_close * 100.0) if prev_close else 0.0
        return Quote(
            symbol=self.normalize_symbol(symbol),
            name=self.normalize_symbol(symbol),
            price=round(latest_price, 3),
            open=round(open_price, 3),
            high=round(high_price, 3),
            low=round(low_price, 3),
            prev_close=round(prev_close, 3),
            volume=volume,
            change_pct=round(change_pct, 3),
            timestamp=ts,
            source=source,
        )

    def get_quote(self, symbol: str) -> Quote:
        snapshot = self._latest_history_snapshot(symbol)
        normalized = normalize_realtime_code(symbol)
        try:
            response = self._realtime_session.get(f"{TENCENT_QUOTE_URL}{normalized}", timeout=10)
            parsed = _parse_tencent_quote(response.text.strip())
            if parsed:
                snapshot.name = parsed.get("name") or snapshot.name
                snapshot.limit_up = float(parsed["limit_up"]) if parsed.get("limit_up") else None
                snapshot.limit_down = float(parsed["limit_down"]) if parsed.get("limit_down") else None
                snapshot.open = float(parsed.get("open") or snapshot.open)
                snapshot.high = float(parsed.get("high") or snapshot.high)
                snapshot.low = float(parsed.get("low") or snapshot.low)
                snapshot.price = float(parsed.get("price") or snapshot.price)
        except Exception:
            pass
        if snapshot.limit_up is None or snapshot.limit_down is None:
            inferred_up, inferred_down = infer_limit_prices(snapshot.symbol, snapshot.prev_close, snapshot.name)
            snapshot.limit_up = snapshot.limit_up or inferred_up
            snapshot.limit_down = snapshot.limit_down or inferred_down
        return snapshot

    def get_quotes(self, symbols: List[str]) -> Dict[str, Quote]:
        return {self.normalize_symbol(symbol): self.get_quote(symbol) for symbol in symbols}

    def get_history(self, symbol: str, start: str | None = None, end: str | None = None, count: int = 240) -> pd.DataFrame:
        norm = normalize_history_code(symbol)
        fetch_count = max(60, int(count))
        result = _fetch_kline_with_fallback(
            self._session,
            norm,
            "1d",
            fetch_count=fetch_count,
            start=start,
            end=end,
            retry=2,
        )
        df = result[0] if isinstance(result, tuple) else result
        if df is None or df.empty:
            raise ValueError(f"failed to load history for {symbol}")
        out = df.copy()
        if "time" not in out.columns:
            out = out.rename(columns={out.columns[0]: "time"})
        out["time"] = pd.to_datetime(out["time"])
        out = out.sort_values("time").reset_index(drop=True)
        for column in ["open", "high", "low", "close", "volume"]:
            out[column] = pd.to_numeric(out[column], errors="coerce")
        out = out.dropna(subset=["time", "open", "high", "low", "close"])
        if start:
            out = out[out["time"] >= pd.to_datetime(start)]
        if end:
            out = out[out["time"] <= pd.to_datetime(end)]
        return out.tail(count).reset_index(drop=True)

    def get_intraday_bars(self, symbol: str, freq: str = "1m", count: int = 240) -> pd.DataFrame:
        normalized = normalize_realtime_code(symbol)
        df = get_price(self._session, normalized, frequency=freq, count=count)
        if df is None or df.empty:
            return pd.DataFrame(columns=["time", "open", "high", "low", "close", "volume"])
        out = df.copy().reset_index()
        out = out.rename(columns={out.columns[0]: "time"})
        out = out[["time", "open", "high", "low", "close", "volume"]]
        out["time"] = pd.to_datetime(out["time"])
        for column in ["open", "high", "low", "close", "volume"]:
            out[column] = pd.to_numeric(out[column], errors="coerce")
        out = out.dropna(subset=["time", "open", "high", "low", "close"]).sort_values("time").reset_index(drop=True)
        return out

    def get_mainboard_universe(self, as_of: str | None = None, top_n: int = 80) -> list[str]:
        try:
            df = ak.stock_zh_a_spot_em()
            if df is None or df.empty:
                return []
            out = df.copy()
            out["代码"] = out["代码"].astype(str).str.zfill(6)
            out["名称"] = out["名称"].astype(str)
            out = out[
                out["代码"].str.startswith(("600", "601", "603", "605", "000", "001", "002"))
                & (~out["名称"].str.contains("ST", case=False, na=False))
                & (~out["名称"].str.contains("退", na=False))
            ]
            out["成交额"] = pd.to_numeric(out.get("成交额"), errors="coerce")
            out = out.dropna(subset=["成交额"]).sort_values("成交额", ascending=False)
            if top_n > 0:
                out = out.head(int(top_n))
            return out["代码"].drop_duplicates().tolist()
        except Exception:
            pass
        try:
            session = _build_session()
            session.trust_env = False
            codes = _generate_mainboard_codes()
            batches = [codes[i : i + 600] for i in range(0, len(codes), 600)]
            rows: list[dict] = []

            def fetch_batch(batch: list[str]) -> list[dict]:
                resp = session.get(f"{TENCENT_QUOTE_URL}{','.join(batch)}", timeout=20)
                out: list[dict] = []
                for line in resp.text.strip().split("\n"):
                    parsed = _parse_tencent_quote(line)
                    if parsed:
                        out.append(parsed)
                return out

            with ThreadPoolExecutor(max_workers=8) as pool:
                futures = [pool.submit(fetch_batch, batch) for batch in batches]
                for future in as_completed(futures):
                    rows.extend(future.result())
            if not rows:
                return []
            df = pd.DataFrame(rows)
            df["code"] = df["code"].astype(str).str.replace("^(sh|sz)", "", regex=True).str.zfill(6)
            df["name"] = df["name"].astype(str)
            df = df[
                (~df["name"].str.contains("ST", case=False, na=False))
                & (~df["name"].str.contains("退", na=False))
                & (df["price"] > 0)
            ]
            if "amount" in df.columns:
                df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
                df = df.sort_values("amount", ascending=False)
            else:
                df["volume"] = pd.to_numeric(df["volume"], errors="coerce")
                df = df.sort_values("volume", ascending=False)
            if top_n > 0:
                df = df.head(int(top_n))
            return df["code"].drop_duplicates().tolist()
        except Exception:
            return []


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