Skill: xuyun9160-lgtm/noah-stock-trade (version 0.1.4)

--- name: noah-stock-trade description: 用于股票交易相关的订单、成交、可买可卖数量、订单详情、订单费用、下单前费用预估，以及账户、持仓、证券资产、资金流水等场景。当用户询问今日订单、历史订单、今日成交、历史成交、订单状态、订单详情、费用明细、可买可卖数量、融资最大可买数量、账户信息、当前持仓、证券资产、资金流水、组合概览、交易前评估时使用。优先通过统一 CLI / 脚本入口执行查询与评估，不要让 agent 直接手拼交易接口。当前默认只开放只读查询与交易前评估；真实下单、改单、撤单属于高风险写操作，当前不对外承诺。 --- # Noah Stock Trade 股票交易、账户与持仓查询 skill。默认把交易能力视为**高风险域**：先走统一脚本入口，先做只读，先做结构化错误，再考虑写操作。 ## Quick Start 先读取： - `references/auth-and-preflight.md` - `references/current-availability.md` 根据任务再按需读取： - 订单状态/成交状态解释 → `references/order-status-mapping.md` - 输出风格与用户态表达 → `references/output-policy.md` - 常见错误与排障 → `references/error-catalog.md` - 示例与调用习惯 → `references/usage-guide.md` ## Runtime Rule 优先使用统一 CLI / 脚本入口，而不是直接在对话里拼接交易 OpenAPI。 推荐入口： - `python3 scripts/noah_trade_cli.py account-info` - `python3 scripts/noah_trade_cli.py positions --market HK` - `python3 scripts/noah_trade_cli.py sec-asset` - `python3 scripts/noah_trade_cli.py sec-capital-flow --start-date 20250401 --end-date 20260415` - `python3 scripts/noah_trade_cli.py today-orders --market HK --page 1 --page-size 20` - `python3 scripts/noah_trade_cli.py today-deals` - `python3 scripts/noah_trade_cli.py history-orders --start-date 20250401 --end-date 20260415` - `python3 scripts/noah_trade_cli.py finished-orders --start-date 20250401 --end-date 20260415 --page 1 --page-size 20` - `python3 scripts/noah_trade_cli.py order-detail --order-id <id> --is-history` - `python3 scripts/noah_trade_cli.py order-fee-detail --order-id <id> --is-history` - `python3 scripts/noah_trade_cli.py fee-estimate --symbol HK.00700 --side BUY --order-type LIMIT --price 320 --qty 100` - `python3 scripts/noah_trade_cli.py stock-amount --symbol HK.00700 --order-type LO` 如果脚本返回 `ok=false`： 1. 先向用户说明失败原因 2. 优先使用结构化错误中的 `message` / `hint` 3. 不要编造订单、成交、费用、账户、持仓或资产信息 ## Supported Scope 当前优先支持： - 账户信息 - 按市场查询当前持仓 - 证券资产 - 证券资金流水 - 今日订单 / 当日未完成订单 - 历史订单 / 已完成订单 - 今日成交 / 历史成交 - 订单详情 - 订单费用详情 - 下单费用预估 - 可买可卖数量 - 融资最大可买数量 - 推送数据查询 暂不开放： - 真实下单 / 改单 / 撤单 ## Routing Boundary 适用于： - 账户信息 / 持仓 / 证券资产 / 资金流水 - 订单列表 / 订单状态 / 订单详情 - 成交列表 / 成交状态 - 费用预估 / 费用详情 - 可买可卖数量 / 最大可买数量 - 交易前评估 不适用于： - 行情数据查询（交给 `noah-stock-market`） - 条件选股（交给 `noah-stock-screener`） - 主观投资建议 ## Symbol Rule 交易模块内部优先使用 `MARKET.CODE` 格式： - `HK.00700` - `US.AAPL` 如果用户输入的是： - `HK-00700` - `US-AAPL` - `00700` - 股票名称 先做代码标准化，再进入交易脚本。 若标的存在歧义，不要猜，先要求用户澄清市场或代码。 ## Output Rule 默认输出顺序： 1. 一句话结论 2. 关键字段 3. 状态解释 4. 必要时补充错误原因或限制说明 对用户展示时： - 优先说人话，不直接倾倒整个原始 JSON - 把订单状态、成交状态、方向、费用项翻译成人能看懂的话 - 对空结果要明确说明“未查到”还是“接口失败” 在开发/调试场景下，可以额外说明： - 使用了哪个 CLI 命令 - 调用了哪个 endpoint - 返回了什么结构化错误 如果用户询问“`noah-stock-trade` 有什么功能”“交易 skill 支持什么”“这个交易 skill 能做什么”等能力范围问题，优先使用下面这套标准口径回答： `noah-stock-trade` 当前支持以下交易相关功能： - 查询账户信息 - 按市场查询当前持仓 - 查询证券资产 - 查询证券资金流水 - 查询今日订单 - 查询当日未完成订单 - 查询历史订单 - 查询已完成订单 - 查询今日成交 - 查询历史成交 - 查询订单详情 - 查询订单费用详情 - 查询证券可买可卖数量 - 查询融资最大可买数量 - 查询下单前费用预估 - 查询推送数据 当前暂不开放： - 下单 - 改单 - 撤单 ## 多币种持仓 / 资产展示规则 当用户查询持仓、证券资产、总资产、账户资产时，必须注意不同市场和账户可能涉及多种计价货币（如 HKD、USD、CNY）。 规则如下： - 不对不同计价货币的持仓市值、资产金额做直接加总 - 如果用户问“总资产是多少”，默认按币种分别列出，并明确说明“以下为各货币资产，未做汇率换算” - 如果接口本身已经返回换算后的汇总值，可以原样展示，但必须注明“该汇总值由接口按当日汇率换算” - 如果用户明确要求折算，必须说明需要汇率依据，并引导用户提供汇率口径，或改用行情数据作为换算依据 禁止： - 直接把 HKD、USD、CNY 等金额相加后当作“总资产”返回给用户 - 在没有汇率依据时自行做跨币种折算 ## Risk Rule - 默认按只读模式处理 - 当前不直接承诺真实下单、改单、撤单 - 若未来开放写操作，必须： 1. 明确市场、标的、方向、价格、数量 2. 明确实盘/测试盘环境 3. 做二次确认 4. 防止重复提交 ## Preflight Checklist 执行前至少确认： 1. 已配置 `NOAH_TRADE_API_BASE_URL` 2. 已由用户自行配置统一 Bearer token（`NOAH_MARKET_APIKEY`） 3. Bearer token 对当前 trade 接口有访问权限 4. 当前环境是测试还是正式已明确 5. 当前任务是否属于只读查询或交易前评估 若以上任一项不满足，先返回缺失项，不要继续假设可交易。 ## Implementation Direction 本 skill 的实现原则： - Skill 负责路由和风险边界 - `scripts/noah_trade_cli.py` 负责统一执行入口 - `scripts/trade_client.py` / `scripts/portfolio_client.py` 负责 HTTP / header / 错误封装 - 参数校验、symbol 转换、状态码映射尽量下沉到脚本层 不要让 agent 在每次任务里重新发明交易接口调用逻辑。 ## Notes - 当前阶段以查询和评估为主，不以写操作为主承诺 - 若 `references/current-availability.md` 与脚本实际能力不一致，以脚本真实可执行结果为准 - 若用户提到“之前已经能调”，优先检查现有脚本和调用链路，而不是只看文档状态

path: references/README.md
content: # References Index

## Purpose
本目录用于沉淀 `noah-stock-trade` 模块的接入前提、能力状态、订单状态解释、输出策略和使用方式。

## Documents
- `auth-and-preflight.md`：交易侧接入前提与前置检查说明
- `current-availability.md`：当前 trade 能力状态矩阵
- `order-status-mapping.md`：订单状态码与用户态解释映射
- `output-policy.md`：订单 / 成交 / 费用 / 可买可卖结果的输出策略
- `usage-guide.md`：当前只读交易能力的使用说明与示例场景

## Suggested Reading Order
1. `auth-and-preflight.md`
2. `current-availability.md`
3. `usage-guide.md`
4. `order-status-mapping.md`
5. `output-policy.md`


path: references/api-issues.md
content: # API Issues / Follow-ups

本文档记录当前 `noah-stock-trade` 在测试环境联调中发现的接口异常、文档不一致点和待确认问题，方便后续继续推进或反馈给后端。

## 1. `/trade/get_order_list`
### OpenAPI 文档预期
- `GET /trade/get_order_list`
- 支持空参查询当日订单列表
- 可选参数：`code`、`page`、`page_size`

### 当前测试环境实际返回
空参或常规列表参数下，返回：
- `请求参数错误,不传订单场景场景则查询订单详情，此时订单号必传`

### 当前判断
- 服务端实现与 OpenAPI 文档不一致
- 或当前测试环境挂载的是不同版本实现

## 2. `/trade/get_finished_order_list`
### OpenAPI 文档预期
- `GET /trade/get_finished_order_list`
- 支持日期范围、分页、订单过滤等参数

### 当前测试环境实际返回
即使补入：
- `start_date`
- `end_date`
- `code`
- `order_status`
- `order_id`

仍返回：
- `请求参数错误,不传订单场景场景则查询订单详情，此时订单号必传`

### 当前判断
- 与 `get_order_list` 类似，疑似服务实现或路由逻辑异常

## 3. `/trade/get_order_detail`
### 测试参数
- `order_id=1`
- `is_history=true`

### 当前测试环境实际返回
- HTTP 500
- `Cannot invoke "String.toUpperCase(java.util.Locale)" because the return value of "...firstNonBlank(String[])" is null`

### 当前判断
- 当前测试环境对无效参数或缺失字段的空值处理不稳
- 更像服务端空指针类问题，而不是单纯前端脚本错误

## 4. `/trade/get_order_fee_detail`
### 测试参数
- `order_id=1`
- `is_history=true`

### 当前测试环境实际返回
- HTTP 500
- 与 `get_order_detail` 类似的空指针类错误

### 当前判断
- 服务端空值处理或内部参数依赖存在问题

## 5. `/trade/max_enable_buy_amt`
### 初始认知
原先以为只需：
- `code`
- `order_type`

### 当前测试环境实际要求
至少还需要：
- `entrust_price`

### 已验证可成功参数示例
- `code=HK.00700`
- `order_type=LO`
- `entrust_price=320`

### 返回示例
- 普通可买：`33900`
- 融资最大可买：`84100`

## 6. 当前已验证成功的接口
### 账户 / 持仓 / 资产
- `/trade/get_account_info`
- `/trade/get_positions`
- `/trade/get_sec_asset`
- `/trade/get_sec_capital_flow`

### 交易 / 评估
- `/trade/order_fee_query`
- `/trade/get_stock_amount`
- `/trade/max_enable_buy_amt`（需 `entrust_price`）
- `/trade/get_today_deal_list`
- `/trade/get_history_deal_list`（需日期）
- `/trade/get_today_order_list`

## 7. 建议后续动作
1. 与后端确认 `get_order_list` / `get_finished_order_list` 的真实参数与路由逻辑
2. 与后端确认 `get_order_detail` / `get_order_fee_detail` 的空值处理和必要参数约束
3. 更新 OpenAPI 文档，确保与当前测试环境保持一致
4. 在 skill references 中继续沉淀“已成功能力”和“已知异常”清单，避免上下文丢失


path: references/auth-and-preflight.md
content: # Auth and Preflight

## Current Access Assumption
当前交易侧 / trade 模块按以下新接入前提理解：
- Base URL：与当前环境一致的 securities-open-api 域名
- Request Header：`Authorization: Bearer <token>`

当前阶段：
- trade 与 market 共用同一个 Bearer token
- token 不内置；安装后用户需自行配置
- 不再使用 `groupNo` 作为主要鉴权方式

## Recommended Local Config Names
建议本地统一使用：
- `NOAH_TRADE_API_BASE_URL`
- `NOAH_MARKET_APIKEY`

示例：

```bash
NOAH_TRADE_API_BASE_URL=https://stock-open-api.noahgroup.com
NOAH_MARKET_APIKEY=your_bearer_token
```

## Preflight Checklist
1. 确认交易侧 Base URL 已配置。
2. 确认统一 Bearer token 已配置，并放入 `Authorization` 请求头。
3. 确认当前调用的是订单 / 成交 / 费用 / 可买可卖相关接口。
4. 确认证券代码格式使用交易侧约定：`MARKET.CODE`，例如 `HK.00700`、`US.AAPL`。
5. 对写操作保持禁用认知：当前一期不承诺下单、改单、撤单。

## Notes
- 当前 trade 与 market 复用同一套 token 配置。
- 当前 OpenAPI 中写操作仍为 TEMP DISABLED。
- 若后续 trade 再拆回独立鉴权，再单独补充新的鉴权规则。


path: references/current-availability.md
content: # Current Availability

## Trade Module Capability Status

| Capability | Status | Notes |
|---|---|---|
| Account Info | Scripted / Verified | 已联调成功 |
| Positions | Scripted / Verified | 已按最新文档口径验证；当前使用 `market` 必填 |
| Securities Asset | Scripted / Verified | 已联调成功 |
| Securities Capital Flow | Scripted / Verified | 已联调成功；日期范围查询可用 |
| Stock Amount (Buy/Sell Availability) | Scripted / Verified | 已联调成功；需 `code` + `order_type` |
| Margin Max Buy Amount | Scripted / Verified | 已联调成功；需 `code` + `order_type` + `entrust_price` |
| Today Deal List | Scripted / Verified | 已联调成功 |
| History Deal List | Scripted / Verified | 已联调成功；需 `start_date` + `end_date` |
| Today Unfinished Order List | Scripted / Verified | 对应 `/trade/get_today_order_list`，已联调成功 |
| Today Order List | Scripted / Verified | 已按最新文档口径验证；当前使用 `market` 必填 |
| History Order List | Scripted / Verified | 已联调成功；当前按最新文档需传 `start_date` + `end_date` |
| Finished Order List | Scripted / Verified | 已联调成功 |
| Order Detail | Scripted / Verified | 已用真实 `order_id` 联调成功 |
| Order Fee Detail | Scripted / Verified | 已用真实 `order_id` 联调成功 |
| Order Fee Query | Scripted / Verified | 已联调成功；需 POST + JSON body |
| Push Data Query | Scripted / Verified | 当前按最新文档需传 `init_date` + `begin_serial_no` + `end_serial_no` |
| Place Order | Deferred / Not enabled | 当前阶段不对外承诺写操作 |
| Modify Order | Deferred / Not enabled | 当前阶段不对外承诺写操作 |
| Cancel Order | Deferred / Not enabled | 当前阶段不对外承诺写操作 |

## Access Prerequisites

当前 trade 模块使用：
- Base URL：`https://stock-open-api.noahgroup.com`
- Header：`Authorization: Bearer <token>`

推荐本地变量名：
- `NOAH_TRADE_API_BASE_URL`
- `NOAH_MARKET_APIKEY`

## Notes

- 当前 `noah-stock-trade` 已吸收原 `noah-stock-portfolio` 的账户、持仓、资产、资金流水能力。
- 当前状态中的 `Scripted / Verified` 表示：
  - 脚本入口已存在
  - 已在当前测试环境完成至少一轮实际联调
- `Routed but inconsistent` 表示：
  - OpenAPI 文档已有定义
  - CLI / client 已接入
  - 但当前测试环境返回行为与文档不一致
- `Routed but server-side error` 表示：
  - 路由和参数链路已打到目标接口
  - 但当前测试环境返回服务端异常，仍需后端或接口实现侧确认
- 交易模块使用的证券代码格式为 `MARKET.CODE`，例如 `HK.00700`、`US.AAPL`。
- 后续可继续补充与 market 模块代码格式（`HK-00700` / `US-AAPL`）之间的统一展示转换。


path: references/error-catalog.md
content: # Error Catalog

## 1. ConfigError
### Typical User-facing Message
- 交易模块必要配置缺失
- 未配置 `NOAH_TRADE_API_BASE_URL`
- 未配置 `NOAH_MARKET_APIKEY`

### Typical Cause
- 本地环境变量未配置
- `.env` / secrets 文件未加载
- 变量名拼写错误

### Suggested Recovery Action
- 检查并配置：
  - `NOAH_TRADE_API_BASE_URL`
  - `NOAH_TRADE_GROUP_NO`
- 确认变量名与配置文件位置正确

---

## 2. GroupNoError
### Typical User-facing Message
- 当前交易账户分组不可用，请检查 `groupNo` 配置

### Typical Cause
- `groupNo` 缺失
- `groupNo` 无效
- `groupNo` 与当前测试环境不匹配

### Suggested Recovery Action
- 检查 `NOAH_TRADE_GROUP_NO`
- 确认请求头已正确携带 `groupNo`

---

## 3. CodeFormatError
### Typical User-facing Message
- 证券代码格式不正确，请使用交易模块支持的代码格式

### Typical Cause
- 交易接口要求 `MARKET.CODE`，但输入了 `HK-00700` / `US-AAPL`
- 标的代码未做市场格式转换

### Suggested Recovery Action
- 使用 `HK.00700`、`US.AAPL` 这类交易侧格式
- 或在脚本层先做 market / trade 代码格式转换

---

## 4. AuthOrAccessError
### Typical User-facing Message
- 当前交易接口访问失败，请检查接入前提与账户分组配置

### Typical Cause
- Base URL 错误
- `groupNo` 无法访问对应账户分组
- 当前环境访问条件不满足

### Suggested Recovery Action
- 检查 Base URL
- 检查 `groupNo`
- 确认当前测试环境是否开放该接口

---

## 5. NotFound404
### Typical User-facing Message
- 当前环境下，该订单、成交或查询参数暂不可用（接口返回 404）

### Typical Cause
- 当前环境中不存在对应订单 / 成交 / 历史记录
- 当前接口在测试环境未开放
- 参数虽然语义正确，但当前环境无数据返回

### Suggested Recovery Action
- 确认订单号 / 条件是否正确
- 缩小或调整查询范围
- 先查询当日 / 未完成订单等更稳能力

---

## 6. WriteOperationDisabled
### Typical User-facing Message
- 当前一期暂不支持真实下单、改单或撤单

### Typical Cause
- 写操作在 OpenAPI 中仍为 TEMP DISABLED
- 当前产品一期只承诺只读交易查询与交易前评估能力

### Suggested Recovery Action
- 不对外承诺写操作
- 如后续接口正式开放，再补确认链路和写操作能力

---

## 7. OrderNotFound
### Typical User-facing Message
- 未找到匹配的订单或订单详情

### Typical Cause
- `order_id` 不存在
- `order_no` 不存在
- 历史 / 当日查询范围不匹配

### Suggested Recovery Action
- 先确认订单号是否正确
- 若不确定，先查询订单列表，再点查详情

---

## 8. UnsupportedOrderState
### Typical User-facing Message
- 当前订单状态下，不支持该操作或无法给出预期结果

### Typical Cause
- 订单已完成 / 已撤 / 废单
- 当前状态不允许进一步处理
- 状态码属于待复核 / 待触发等特殊状态

### Suggested Recovery Action
- 先查看订单统一状态和原始状态解释
- 结合 `order-status-mapping.md` 判断当前所处阶段

---

## 9. FeeQueryParamError
### Typical User-facing Message
- 预估费用参数不完整，请补充标的、方向、类型、数量或价格

### Typical Cause
- 下单费用预估缺少必要参数
- 限价单未传价格
- 买卖方向 / 订单类型缺失

### Suggested Recovery Action
- 补齐：
  - 标的
  - 买卖方向
  - 订单类型
  - 数量
  - 必要时补价格

---

## 10. NoUsableData
### Typical User-facing Message
- 查询成功，但当前没有可展示的数据

### Typical Cause
- 接口请求成功，但结果为空
- 当前日期范围 / 标的 / 状态过滤下没有记录

### Suggested Recovery Action
- 调整日期范围
- 放宽筛选条件
- 改查订单列表、成交列表或详情接口

---

## 11. DetailTruncated
### Typical User-facing Message
- 仅展示最近若干条记录

### Typical Cause
- 订单 / 成交 / 资金流水等明细记录过多
- 系统为了可读性自动截断

### Suggested Recovery Action
- 当前属于预期产品行为
- 如需更长输出，后续可扩展为显式范围控制

---

## 12. Suggested Companion Documents
建议与以下文档配合查看：
- `references/current-availability.md`
- `references/output-policy.md`
- `references/order-status-mapping.md`
- `references/usage-guide.md`


path: references/openapi.yaml
content: openapi: 3.0.3
info:
  title: 股票交易开放接口
  version: 1.0.0
  description: |
    股票交易开放接口。
    所有 controller 接口统一采用 `success / msg / code / data` 结构返回，
    controller 层负责封装成功响应和失败响应，业务数据始终位于 `data` 字段中。

    鉴权方式：所有 `/trade/**` 接口都需要在请求 header 中传入 `Authorization: Bearer <token>`。
    `token` 由 `securities-open-api` 签发。

tags:
  - name: Trade
    description: 交易、账户、资产持仓、订单、成交相关接口。

security:
  - bearerAuth: []

paths:
  /trade/get_account_info:
    get:
      tags: [Trade]
      operationId: getAccountInfo
      summary: 获取账户信息
  /trade/get_positions:
    get:
      tags: [Trade]
      operationId: getPositions
      summary: 获取持仓列表
      description: |
        查询当前持仓数据。
        请求 header 需传入 `Authorization: Bearer <token>`。
        `market` 为必填参数，用于向下游传递查询市场并返回对应市场持仓。
  /trade/get_stock_amount:
    get:
      tags: [Trade]
      operationId: getStockAmount
      summary: 获取证券可买可卖数量
  /trade/max_enable_buy_amt:
    get:
      tags: [Trade]
      operationId: getMaxEnableBuyAmt
      summary: 查询融资最大可买数量
  /trade/get_sec_asset:
    get:
      tags: [Trade]
      operationId: getSecAsset
      summary: 获取证券资产
  /trade/get_sec_capital_flow:
    get:
      tags: [Trade]
      operationId: getSecCapitalFlow
      summary: 获取证券资金流水
  /trade/get_order_list:
    get:
      tags: [Trade]
      operationId: getOrderList
      summary: 获取当日订单列表
  /trade/get_today_order_list:
    get:
      tags: [Trade]
      operationId: getTodayOrderList
      summary: 获取当日订单列表(未完成)
  /trade/get_history_order_list:
    get:
      tags: [Trade]
      operationId: getHistoryOrderList
      summary: 获取历史订单列表
  /trade/get_today_deal_list:
    get:
      tags: [Trade]
      operationId: getTodayDealList
      summary: 获取当日成交列表
  /trade/get_history_deal_list:
    get:
      tags: [Trade]
      operationId: getHistoryDealList
      summary: 获取历史成交列表
  /trade/get_finished_order_list:
    get:
      tags: [Trade]
      operationId: getFinishedOrderList
      summary: 获取已完成订单列表
  /trade/get_order_detail:
    get:
      tags: [Trade]
      operationId: getOrderDetail
      summary: 获取订单详情
  /trade/get_order_fee_detail:
    get:
      tags: [Trade]
      operationId: getOrderFeeDetail
      summary: 获取订单费用详情
  /trade/order_fee_query:
    post:
      tags: [Trade]
      operationId: orderFeeQuery
      summary: 查询下单预估费用
  /trade/query_push_data:
    post:
      tags: [Trade]
      operationId: queryPushData
      summary: 查询推送数据

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT


path: references/order-status-mapping.md
content: # Order Status Mapping

## 1. Purpose
交易 OpenAPI 中的订单状态包含两层：
- OpenAPI 统一后的状态，例如：`SUBMITTED`、`PARTIAL_FILLED`、`FILLED`、`CANCELED`、`FAILED`
- 底层 sec-trade 原始状态码，例如：`0`、`1`、`2`、`3`、`4`、`7`、`8`、`9`、`A`、`B`、`H`、`W` 等

本文件用于把底层原始状态码整理成更适合用户理解的解释。

## 2. OpenAPI Unified Status
当前文档中出现的统一状态包括：
- `SUBMITTED`：已提交 / 处理中
- `PARTIAL_FILLED`：部分成交
- `FILLED`：全部成交
- `CANCELED`：已撤销
- `FAILED`：失败 / 废单

## 3. Raw Status Mapping

| Raw Status | Meaning | Suggested User-facing Interpretation |
|---|---|---|
| `0` | 未成 | 订单已报出，当前尚未成交 |
| `1` | 待报 | 订单已提交，等待报送市场 |
| `2` | 已报 | 订单已报送市场，等待成交 |
| `3` | 已报待撤 | 已提交撤单请求，等待撤单完成 |
| `4` | 部成待撤 | 订单部分成交，剩余部分正在撤销 |
| `5` | 部撤 | 订单部分成交，其余部分已撤 |
| `6` | 已撤 | 订单已完全撤销 |
| `7` | 部成 | 订单已部分成交 |
| `8` | 已成 | 订单已全部成交 |
| `9` | 废单 | 订单失败或被交易系统拒绝 |
| `A` | 已报待改 | 已提交改单请求，等待修改完成 |
| `B` | 待复核【人手买方待确认】 | 订单待人工复核 / 确认 |
| `C` | 重新检查中 | 订单正在重新检查处理中 |
| `D` | 待触发 | 条件未满足，订单尚未触发 |
| `E` | 部成待改 | 订单部分成交，剩余部分正在修改 |
| `F` | 撤单(过期) | 撤单请求因时效原因未完成 |
| `G` | 部撤(过期) | 部分撤销，但剩余流程已过期 |
| `H` | 待复核 | 订单待复核 |
| `J` | 复核未通过 | 订单复核未通过 |
| `W` | 待确认 | 订单待用户或系统确认 |
| `N` | 修改待复核 | 改单请求待复核 |
| `O` | 修改待复核【部成】 | 部分成交订单的改单请求待复核 |
| `P` | 修改确认 | 改单请求已确认 |

## 4. Product-level Grouping
为了减少最终用户面对大量原始状态码时的认知负担，后续用户态展示可按以下分组理解：

### 待处理 / 处理中
- `1` 待报
- `2` 已报
- `3` 已报待撤
- `A` 已报待改
- `B` 待复核
- `C` 重新检查中
- `H` 待复核
- `W` 待确认
- `N` 修改待复核
- `O` 修改待复核【部成】
- `P` 修改确认

### 部分成交
- `4` 部成待撤
- `5` 部撤
- `7` 部成
- `E` 部成待改
- `G` 部撤(过期)

### 已完成
- `6` 已撤
- `8` 已成

### 失败 / 异常
- `9` 废单
- `F` 撤单(过期)
- `J` 复核未通过

### 条件单 / 特殊状态
- `D` 待触发

## 5. Output Suggestion
后续在用户态输出时，建议：
- 优先展示 OpenAPI 统一状态
- 若需要补充细节，再追加原始状态码解释
- 避免直接把原始状态码裸暴露给最终用户而没有说明

## 6. Notes
- 原始状态码来自交易 OpenAPI 文档，后续若底层 sec-trade 状态定义变动，应同步更新本文件。
- 推送数据中的交易日志状态码与订单状态码不是完全同一套，后续如需要可再单独补 `push-status-mapping.md`。


path: references/output-policy.md
content: # Output Policy

## 1. Default Response Mode
trade 模块默认采用面向最终用户的交易助手模式：
- 优先输出订单、成交、费用、可买可卖数量等业务结果
- 格式清晰、简洁明了
- 不直接倾倒原始 JSON
- 不向最终用户暴露底层交易接口细节

## 2. Summary First
在没有额外要求时，默认优先给摘要结果。

适用场景：
- 可买可卖数量
- 融资最大可买数量
- 当日订单概览
- 成交概览
- 订单费用概览
- 订单详情摘要

摘要输出原则：
- 先给结论
- 再给关键字段
- 避免一次输出过多订单或成交记录

## 3. Output Priorities by Capability
### 可买可卖数量
优先展示：
- 标的
- 可买数量
- 可卖数量
- 委托属性
- 交易时段

### 融资最大可买数量
优先展示：
- 标的
- 委托价格
- 现金可买数量
- 融资可买数量

### 订单列表
优先展示：
- 标的
- 买卖方向
- 委托数量 / 价格
- 已成交数量
- 状态
- 是否允许撤单 / 改单

### 成交列表
优先展示：
- 标的
- 买卖方向
- 成交数量
- 成交价格
- 成交时间

### 订单详情
优先展示：
- 标的
- 状态
- 委托数量 / 价格
- 已成交数量 / 均价
- 总费用
- 时间线摘要

### 费用详情 / 预估费用
优先展示：
- 总费用
- 费用项明细
- 对应标的、数量、价格

## 4. Order Status Presentation Rule
订单状态展示时：
- 优先显示 OpenAPI 统一状态
- 需要时再补充原始状态解释
- 不建议直接把裸状态码暴露给最终用户

## 5. Detail Mode
当用户明确要求更详细信息时，可进入 detail mode，例如：
- 看全部订单
- 看订单详情
- 看费用明细
- 逐条列出来

当前规则：
- detail mode 仍应控制输出长度
- 若记录很多，优先展示最近若干条，并明确提示截断

## 6. Internal Detail Exposure Rule
在最终用户模式下，不应主动暴露：
- 底层接口 URL
- header 细节
- 脚本名
- 原始调试 JSON
- 底层 sec-trade 原始字段名（除非开发调试场景需要）

## 7. Error Message Policy
若查询失败，优先给用户：
- 当前环境限制说明
- 参数或配置问题提示
- Bearer token 或交易接入前提错误提示
- 写操作未开放说明

避免直接把底层错误原样抛给最终用户。

## 8. Product Voice Rule
trade 模块的输出应满足：
- 更像交易助手，而不是底层委托系统
- 先给最重要的交易结论
- 再给必要细节
- 对写操作保持谨慎，不把未开放能力表述成已支持


path: references/usage-guide.md
content: # Usage Guide

## 1. What This Module Is For
`noah-stock-trade` 当前用于：
- 账户信息查询
- 持仓查询
- 证券资产查询
- 证券资金流水查询
- 当日未完成订单查询
- 当日成交查询
- 历史成交查询
- 订单详情查询
- 订单费用查询
- 下单前费用预估
- 可买可卖数量查询
- 融资最大可买数量查询

当前阶段**不用于**：
- 真实下单
- 改单
- 撤单

## 2. Access Prerequisites
当前口径下，需要：
- `NOAH_TRADE_API_BASE_URL`
- `NOAH_MARKET_APIKEY`

其中：
- `NOAH_MARKET_APIKEY` 为 market / trade 共用通用 token
- token 不内置，需由用户安装后自行配置

当前交易侧接入前提：
- Base URL：`https://stock-open-api.noahgroup.com`
- Header：`Authorization: Bearer <token>`

## 3. Suitable Query Scenarios
适合的场景包括：
- 看账户信息
- 查当前持仓
- 看证券资产
- 查资金流水
- 看今天的未完成订单
- 看今天的成交
- 查历史成交
- 看某笔订单详情
- 看某笔订单费用
- 查询某只股票可买可卖数量
- 查询某只股票融资最大可买数量
- 查询某笔拟下单的预估费用

## 4. Recommended CLI Commands
### 账户 / 持仓 / 资产
```bash
python3 scripts/noah_trade_cli.py account-info
python3 scripts/noah_trade_cli.py positions --market HK
python3 scripts/noah_trade_cli.py positions --market US
python3 scripts/noah_trade_cli.py sec-asset
python3 scripts/noah_trade_cli.py sec-capital-flow --start-date 20260401 --end-date 20260413
```

### 订单 / 成交 / 费用 / 可交易能力
```bash
python3 scripts/noah_trade_cli.py today-orders --market HK --page 1 --page-size 20
python3 scripts/noah_trade_cli.py unfinished-orders
python3 scripts/noah_trade_cli.py today-deals
python3 scripts/noah_trade_cli.py history-orders --start-date 20250401 --end-date 20260415
python3 scripts/noah_trade_cli.py history-deals --start-date 20250401 --end-date 20260415
python3 scripts/noah_trade_cli.py finished-orders --start-date 20250401 --end-date 20260415 --page 1 --page-size 20
python3 scripts/noah_trade_cli.py query-push-data --init-date 20260415 --begin-serial-no 1 --end-serial-no 100
python3 scripts/noah_trade_cli.py order-detail --order-id 2025101600HK0000000001 --is-history
python3 scripts/noah_trade_cli.py order-fee-detail --order-id 2025101600HK0000000001 --is-history
python3 scripts/noah_trade_cli.py fee-estimate --symbol HK.00700 --side BUY --order-type LIMIT --price 320 --qty 100
python3 scripts/noah_trade_cli.py stock-amount --symbol HK.00700 --order-type LO
python3 scripts/noah_trade_cli.py max-enable-buy-amt --symbol HK.00700 --order-type LO --entrust-price 320
```

> 注意：当前 `max-enable-buy-amt` 实际联调时还需要额外价格参数，后续脚本将继续对齐到服务端要求。

## 5. Symbol Format Note
交易接口当前使用的证券代码格式是：
- `HK.00700`
- `US.AAPL`

这与 market 模块常用的：
- `HK-00700`
- `US-AAPL`

不同，脚本层已开始做统一转换，但用户态展示仍可继续优化。

## 6. Order Status Note
订单查询结果中可能同时包含：
- OpenAPI 统一状态
- sec-trade 原始状态码

建议优先向最终用户展示统一状态；如需补充解释，再结合：
- `references/order-status-mapping.md`

## 7. Current Limitations
当前已知限制包括：
- 写操作接口（下单 / 改单 / 撤单）当前不对外承诺
- `get_positions` 与 `get_order_list` 已按新版文档切换到 `market` 必填口径，旧参数理解不再适用
- `max_enable_buy_amt` 需要传 `entrust_price`
- 多币种资产展示时不得直接跨币种加总；如无汇率依据，应按币种分别展示

## 8. Relationship with Other Modules
- 行情 / K线 / 分时 / 摆盘 → `noah-stock-market`
- 条件选股 → 后续可并入 `noah-stock-market` 或单独保留
- 原 `noah-stock-portfolio` 能力已逐步合并到本模块


path: scripts/config.py
content: from dataclasses import dataclass
import os
from pathlib import Path

from errors import ConfigError


WORKSPACE = Path(__file__).resolve().parents[3]
SECRETS_FILES = [
    WORKSPACE / '.secrets' / 'noah-trade.env',
    WORKSPACE / '.secrets' / 'noah-market.env',
    Path.home() / '.openclaw' / '.secrets' / 'noah-market.env',
    Path.home() / '.openclaw' / '.secrets' / 'noah-trade.env',
]


def load_env_file(path: Path) -> None:
    if not path.exists():
        return
    for line in path.read_text(encoding='utf-8').splitlines():
        line = line.strip()
        if not line or line.startswith('#') or '=' not in line:
            continue
        key, value = line.split('=', 1)
        os.environ[key.strip()] = value.strip()


for _env_file in SECRETS_FILES:
    load_env_file(_env_file)


@dataclass
class TradeConfig:
    base_url: str
    token: str
    env: str = "test"
    read_only: bool = True
    timeout_seconds: int = 15


def load_trade_config() -> TradeConfig:
    base_url = os.getenv("NOAH_TRADE_API_BASE_URL", "https://stock-open-api.noahgroup.com").strip()
    [REDACTED_SECRET]"NOAH_MARKET_APIKEY", "").strip() or os.getenv("NOAH_MARKET_TOKEN", "").strip()
    env = os.getenv("NOAH_TRADE_ENV", "test").strip() or "test"
    read_only = os.getenv("NOAH_TRADE_READ_ONLY", "true").lower() != "false"
    timeout_seconds = int(os.getenv("NOAH_TRADE_TIMEOUT", "15"))

    if not base_url:
        raise ConfigError("NOAH_TRADE_API_BASE_URL is missing", hint="请先配置交易 API Base URL")
    if not token:
        raise ConfigError("NOAH_MARKET_APIKEY is missing", hint="请先配置统一 Bearer token（trade 与 market 共用）")

    return TradeConfig(
        base_url=base_url.rstrip("/"),
        token=token,
        env=env,
        read_only=read_only,
        timeout_seconds=timeout_seconds,
    )


path: scripts/errors.py
content: class TradeCliError(Exception):
    def __init__(
        self,
        message,
        hint=None,
        endpoint=None,
        http_status=None,
        biz_code=None,
        trace_id=None,
        retryable=False,
    ):
        super().__init__(message)
        self.message = message
        self.hint = hint
        self.endpoint = endpoint
        self.http_status = http_status
        self.biz_code = biz_code
        self.trace_id = trace_id
        self.retryable = retryable


class ConfigError(TradeCliError):
    pass


class ValidationError(TradeCliError):
    pass


class TradeApiError(TradeCliError):
    pass


class TradeBizError(TradeCliError):
    pass


class ReadOnlyViolation(TradeCliError):
    pass


path: scripts/format_portfolio_text.py
content: #!/usr/bin/env python3
from typing import Any, Dict, List


def _normalize_code(code):
    if code is None:
        return '-'
    s = str(code)
    if s.startswith('HK.HK-'):
        return 'HK.' + s.split('HK.HK-', 1)[1]
    if s.startswith('US.US-'):
        return 'US.' + s.split('US.US-', 1)[1]
    return s


def _safe(v):
    return '-' if v is None else v


def _num(v):
    if v is None:
        return '-'
    try:
        fv = float(v)
        if abs(fv) >= 1000:
            return f'{fv:,.2f}'.rstrip('0').rstrip('.')
        return f'{fv:.2f}'.rstrip('0').rstrip('.')
    except Exception:
        return v


def format_account_info(summary: Dict[str, Any]) -> str:
    return '\n'.join([
        '账户信息：',
        f'账户类型：{_safe(summary.get("account_type"))}',
        f'资金账户：{_safe(summary.get("fund_account"))}',
        f'分组号：{_safe(summary.get("group_no"))}',
        f'交易模式：{_safe(summary.get("trade_mode"))}',
        f'股票账户：{"已开通" if summary.get("stock_account_open") else "未开通"}',
        f'资金账户：{"已开通" if summary.get("fund_account_open") else "未开通"}',
    ])


def format_positions(summary: Dict[str, Any]) -> str:
    lines = [f'持仓概览：共 {summary.get("count", 0)} 条']
    for i, x in enumerate(summary.get('items', []), 1):
        lines.append(
            f'{i}. {x.get("name") or "-"}（{_normalize_code(x.get("code"))}）｜持仓{_num(x.get("quantity"))}｜可卖{_num(x.get("available_quantity"))}｜现价{_num(x.get("current_price"))}｜市值{_num(x.get("market_value"))}｜浮盈亏{_num(x.get("pnl"))}'
        )
    if summary.get('truncated'):
        lines.append('说明：仅展示前 10 条持仓记录。')
    return '\n'.join(lines)


def format_sec_asset(summary: Dict[str, Any]) -> str:
    lines = [f'证券资产：共 {summary.get("count", 0)} 条']
    for i, x in enumerate(summary.get('items', []), 1):
        lines.append(
            f'{i}. 币种{_safe(x.get("money_type_desc") or x.get("money_type"))}｜总资产{_num(x.get("asset_value"))}｜证券市值{_num(x.get("market_value"))}｜现金{_num(x.get("cash_value"))}｜冻结{_num(x.get("frozen_value"))}'
        )
    return '\n'.join(lines)


def format_capital_flow(summary: Dict[str, Any]) -> str:
    lines = [f'资金流水：最近 {summary.get("count", 0)} 条']
    for i, x in enumerate(summary.get('items', []), 1):
        lines.append(
            f'{i}. {x.get("curr_datetime") or "-"}｜{x.get("stock_name") or "-"}（{_normalize_code(x.get("stock_code"))}）｜{x.get("fund_direction_desc") or "-"}｜金额{_num(x.get("occur_balance"))}｜余额{_num(x.get("post_balance"))}｜{x.get("biz_type_desc") or "-"}｜{x.get("biz_flag_desc") or "-"}'
        )
    if summary.get('truncated'):
        lines.append('说明：仅展示前 10 条资金流水记录。')
    return '\n'.join(lines)


path: scripts/formatters.py
content: from errors import TradeCliError


def success_response(command, config, result, raw):
    return {
        "ok": True,
        "command": command,
        "environment": config.env,
        "mode": "read-only" if config.read_only else "read-write",
        "human_summary": result.get("human_summary"),
        "data": result.get("data"),
        "debug": {
            "endpoint": raw.get("endpoint"),
            "trace_id": raw.get("trace_id"),
        },
    }


def error_response(command, error):
    if isinstance(error, TradeCliError):
        return {
            "ok": False,
            "command": command,
            "error_type": error.__class__.__name__,
            "message": error.message,
            "hint": error.hint,
            "endpoint": error.endpoint,
            "http_status": error.http_status,
            "biz_code": error.biz_code,
            "trace_id": error.trace_id,
            "retryable": error.retryable,
        }

    return {
        "ok": False,
        "command": command,
        "error_type": error.__class__.__name__,
        "message": str(error),
        "hint": "发生未预期错误，请查看日志并补充结构化错误处理",
        "retryable": False,
    }


def summarize_today_orders(raw):
    data = raw.get("response", {})
    orders = data.get("data", []) if isinstance(data, dict) else []
    return {
        "human_summary": f"查询到今日订单 {len(orders)} 笔。",
        "data": {
            "count": len(orders),
            "items": orders[:20],
        },
    }


def summarize_today_deals(raw):
    data = raw.get("response", {})
    deals = data.get("data", []) if isinstance(data, dict) else []
    return {
        "human_summary": f"查询到今日成交 {len(deals)} 笔。",
        "data": {
            "count": len(deals),
            "items": deals[:20],
        },
    }


def summarize_history_deals(raw):
    data = raw.get("response", {})
    deals = data.get("data", []) if isinstance(data, dict) else []
    return {
        "human_summary": f"查询到历史成交 {len(deals)} 笔。",
        "data": {
            "count": len(deals),
            "items": deals[:20],
        },
    }


def summarize_today_unfinished_orders(raw):
    data = raw.get("response", {})
    items = data.get("data", []) if isinstance(data, dict) else []
    return {
        "human_summary": f"查询到当日未完成订单 {len(items)} 笔。",
        "data": {
            "count": len(items),
            "items": items[:20],
        },
    }


def summarize_finished_orders(raw):
    data = raw.get("response", {})
    items = data.get("data", []) if isinstance(data, dict) else []
    return {
        "human_summary": f"查询到已完成订单 {len(items)} 笔。",
        "data": {
            "count": len(items),
            "items": items[:20],
        },
    }


def summarize_order_detail(raw):
    data = raw.get("response", {})
    return {
        "human_summary": "已获取订单详情。",
        "data": data.get("data", data),
    }


def summarize_fee_estimate(raw, symbol, side, price, qty):
    data = raw.get("response", {})
    return {
        "human_summary": f"已获取 {symbol} {side} {qty} 股/份、价格 {price} 的费用预估。",
        "data": {
            "symbol": symbol,
            "side": side,
            "price": price,
            "qty": qty,
            "result": data.get("data", data),
        },
    }


def summarize_stock_amount(raw, symbol):
    data = raw.get("response", {})
    return {
        "human_summary": f"已获取 {symbol} 的可买可卖数量。",
        "data": {
            "symbol": symbol,
            "result": data.get("data", data),
        },
    }


def summarize_order_fee_detail(raw):
    data = raw.get("response", {})
    return {
        "human_summary": "已获取订单费用详情。",
        "data": data.get("data", data),
    }


def multi_currency_asset_note() -> str:
    return "涉及多种计价货币时，不直接跨币种加总；默认按币种分别展示，未做汇率换算。"


def summarize_max_enable_buy_amt(raw, symbol, order_type):
    data = raw.get("response", {})
    return {
        "human_summary": f"已获取 {symbol} 在 {order_type} 委托属性下的最大可买数量。",
        "data": {
            "symbol": symbol,
            "order_type": order_type,
            "result": data.get("data", data),
        },
    }


path: scripts/noah_trade_cli.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import json
import sys

from config import load_trade_config
from errors import TradeCliError
from trade_client import NoahTradeClient
from portfolio_client import (
    NoahPortfolioClient,
    summarize_account_info,
    summarize_positions,
    summarize_sec_asset,
    summarize_capital_flow,
    portfolio_text,
)
from formatters import (
    success_response,
    error_response,
    summarize_today_orders,
    summarize_today_deals,
    summarize_history_deals,
    summarize_today_unfinished_orders,
    summarize_finished_orders,
    summarize_order_detail,
    summarize_fee_estimate,
    summarize_stock_amount,
    summarize_order_fee_detail,
    summarize_max_enable_buy_amt,
)
from symbol_utils import normalize_trade_symbol


def build_parser():
    parser = argparse.ArgumentParser(
        description="Noah Trade CLI - query and pre-trade evaluation for internal stock trade APIs"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser("account-info", help="Get account info")

    p_positions = subparsers.add_parser("positions", help="Get positions")
    p_positions.add_argument("--market", required=True, choices=["HK", "US", "SH", "SZ"], help="Market code")

    subparsers.add_parser("sec-asset", help="Get securities asset")

    p_flow = subparsers.add_parser("sec-capital-flow", help="Get securities capital flow")
    p_flow.add_argument("--start-date", help="Start date, yyyyMMdd")
    p_flow.add_argument("--end-date", help="End date, yyyyMMdd")
    p_flow.add_argument("--business-type")
    p_flow.add_argument("--fund-direction")
    p_flow.add_argument("--page", type=int)
    p_flow.add_argument("--page-size", type=int)
    p_flow.add_argument("--last-id")
    p_flow.add_argument("--record-last-id")
    p_flow.add_argument("--trade-code")
    p_flow.add_argument("--unique-code")

    p_orders = subparsers.add_parser("today-orders", help="Get today's order list")
    p_orders.add_argument("--market", required=True, choices=["HK", "US", "SH", "SZ"], help="Market code")
    p_orders.add_argument("--symbol", help="Optional trade symbol, e.g. HK.00700 / HK-00700 / 00700")
    p_orders.add_argument("--page", type=int, default=1, help="Page number")
    p_orders.add_argument("--page-size", type=int, default=50, help="Page size")

    p_deals = subparsers.add_parser("today-deals", help="Get today's deal list")
    p_deals.add_argument("--symbol", help="Optional trade symbol, e.g. HK.00700 / HK-00700 / 00700")

    p_hist_orders = subparsers.add_parser("history-orders", help="Get history order list")
    p_hist_orders.add_argument("--symbol", help="Optional trade symbol")
    p_hist_orders.add_argument("--start-date", required=True, help="Start date, yyyyMMdd")
    p_hist_orders.add_argument("--end-date", required=True, help="End date, yyyyMMdd")
    p_hist_orders.add_argument("--order-id")
    p_hist_orders.add_argument("--order-no")
    p_hist_orders.add_argument("--order-status")

    p_hist_deals = subparsers.add_parser("history-deals", help="Get history deal list")
    p_hist_deals.add_argument("--symbol", help="Optional trade symbol")
    p_hist_deals.add_argument("--start-date", required=True, help="Start date, yyyyMMdd")
    p_hist_deals.add_argument("--end-date", required=True, help="End date, yyyyMMdd")

    p_unfinished = subparsers.add_parser("unfinished-orders", help="Get unfinished today orders")
    p_unfinished.add_argument("--symbol", help="Optional trade symbol")
    p_unfinished.add_argument("--order-id")
    p_unfinished.add_argument("--order-no")
    p_unfinished.add_argument("--order-status")

    p_finished = subparsers.add_parser("finished-orders", help="Get finished order list")
    p_finished.add_argument("--symbol", help="Optional trade symbol")
    p_finished.add_argument("--start-date", help="Start date, yyyyMMdd")
    p_finished.add_argument("--end-date", help="End date, yyyyMMdd")
    p_finished.add_argument("--page", type=int)
    p_finished.add_argument("--page-size", type=int)
    p_finished.add_argument("--order-id")
    p_finished.add_argument("--order-no")
    p_finished.add_argument("--order-status")
    p_finished.add_argument("--order-side", choices=["BUY", "SELL"])
    p_finished.add_argument("--order-type", choices=["LIMIT", "MARKET"])

    p_detail = subparsers.add_parser("order-detail", help="Get order detail by order id")
    p_detail.add_argument("--order-id", required=True, help="Order ID")
    p_detail.add_argument("--order-no", help="Broker order no")
    p_detail.add_argument("--is-history", action="store_true", help="Query from history set")

    p_fee = subparsers.add_parser("fee-estimate", help="Estimate order fee")
    p_fee.add_argument("--symbol", required=True, help="Trade symbol, e.g. HK.00700 / HK-00700 / 00700")
    p_fee.add_argument("--side", required=True, choices=["BUY", "SELL"], help="Order side")
    p_fee.add_argument("--order-type", required=True, choices=["LIMIT", "MARKET"], help="Order type")
    p_fee.add_argument("--price", type=float, help="Order price; usually required for LIMIT")
    p_fee.add_argument("--qty", required=True, type=float, help="Order quantity")

    p_push = subparsers.add_parser("query-push-data", help="Query push data")
    p_push.add_argument("--init-date", required=True, help="Init date, yyyyMMdd")
    p_push.add_argument("--begin-serial-no", required=True)
    p_push.add_argument("--end-serial-no", required=True)

    p_fee_detail = subparsers.add_parser("order-fee-detail", help="Get order fee detail")
    p_fee_detail.add_argument("--order-id", required=True)
    p_fee_detail.add_argument("--order-no")
    p_fee_detail.add_argument("--is-history", action="store_true")

    p_amt = subparsers.add_parser("stock-amount", help="Get available buy/sell stock amount")
    p_amt.add_argument("--symbol", required=True, help="Trade symbol, e.g. HK.00700 / HK-00700 / 00700")
    p_amt.add_argument("--order-type", required=True, choices=["LO", "ELO", "SLO", "AO", "ALO", "TLO", "MO", "ODD"], help="Entrust property")
    p_amt.add_argument("--session-type", type=int, choices=[0, 1, 2, 3], help="Session type")

    p_max = subparsers.add_parser("max-enable-buy-amt", help="Get max enable buy amount")
    p_max.add_argument("--symbol", required=True, help="Trade symbol")
    p_max.add_argument("--order-type", required=True, choices=["LO", "ELO", "SLO", "AO", "ALO", "TLO", "MO", "ODD"], help="Entrust property")
    p_max.add_argument("--entrust-price", required=True, type=float, help="Entrust price")
    p_max.add_argument("--session-type", type=int, choices=[0, 1, 2, 3], help="Session type")

    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()
    command = getattr(args, "command", "unknown")

    try:
        config = load_trade_config()
        client = NoahTradeClient(config)
        portfolio = NoahPortfolioClient(config)

        if command == "account-info":
            raw = portfolio.get_account_info()
            data = raw.get('response', {}).get('data') or {}
            summary = summarize_account_info(data)
            result = {'human_summary': portfolio_text(command, summary), 'data': summary}
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "positions":
            raw = portfolio.get_positions(market=args.market)
            data = raw.get('response', {}).get('data') or []
            summary = summarize_positions(data)
            result = {'human_summary': portfolio_text(command, summary), 'data': summary}
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "sec-asset":
            raw = portfolio.get_sec_asset()
            data = raw.get('response', {}).get('data') or []
            summary = summarize_sec_asset(data)
            result = {'human_summary': portfolio_text(command, summary), 'data': summary}
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "sec-capital-flow":
            raw = portfolio.get_sec_capital_flow(
                start_date=args.start_date,
                end_date=args.end_date,
                business_type=args.business_type,
                fund_direction=args.fund_direction,
                page=args.page,
                page_size=args.page_size,
                last_id=args.last_id,
                record_last_id=args.record_last_id,
                trade_code=args.trade_code,
                unique_code=args.unique_code,
            )
            data = raw.get('response', {}).get('data') or {}
            summary = summarize_capital_flow(data)
            result = {'human_summary': portfolio_text(command, summary), 'data': summary}
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "today-orders":
            symbol = normalize_trade_symbol(args.symbol) if args.symbol else None
            raw = client.get_today_orders(market=args.market, code=symbol, page=args.page, page_size=args.page_size)
            result = summarize_today_orders(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "today-deals":
            symbol = normalize_trade_symbol(args.symbol) if args.symbol else None
            raw = client.get_today_deals(code=symbol)
            result = summarize_today_deals(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "history-orders":
            symbol = normalize_trade_symbol(args.symbol) if getattr(args, "symbol", None) else None
            raw = client.get_history_orders(code=symbol, start_date=args.start_date, end_date=args.end_date, order_id=args.order_id, order_no=args.order_no, order_status=args.order_status)
            result = summarize_today_orders(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "history-deals":
            symbol = normalize_trade_symbol(args.symbol) if args.symbol else None
            raw = client.get_history_deals(code=symbol, start_date=args.start_date, end_date=args.end_date)
            result = summarize_history_deals(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "unfinished-orders":
            symbol = normalize_trade_symbol(args.symbol) if args.symbol else None
            raw = client.get_today_unfinished_orders(code=symbol, order_id=args.order_id, order_no=args.order_no, order_status=args.order_status)
            result = summarize_today_unfinished_orders(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "finished-orders":
            symbol = normalize_trade_symbol(args.symbol) if args.symbol else None
            raw = client.get_finished_orders(
                code=symbol,
                start_date=args.start_date,
                end_date=args.end_date,
                page=args.page,
                page_size=args.page_size,
                order_id=args.order_id,
                order_no=args.order_no,
                order_status=args.order_status,
                order_side=args.order_side,
                order_type=args.order_type,
            )
            result = summarize_finished_orders(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "order-detail":
            raw = client.get_order_detail(order_id=args.order_id, order_no=args.order_no, is_history=args.is_history)
            result = summarize_order_detail(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "fee-estimate":
            symbol = normalize_trade_symbol(args.symbol)
            raw = client.order_fee_query(
                symbol=symbol,
                side=args.side,
                order_type=args.order_type,
                price=args.price,
                qty=args.qty,
            )
            result = summarize_fee_estimate(raw, symbol=symbol, side=args.side, price=args.price, qty=args.qty)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "query-push-data":
            raw = client.query_push_data(init_date=args.init_date, begin_serial_no=args.begin_serial_no, end_serial_no=args.end_serial_no)
            print(json.dumps(success_response(command, config, {"raw": raw.get("response", {}).get("data")}, raw), ensure_ascii=False))
            return 0

        if command == "order-fee-detail":
            raw = client.get_order_fee_detail(order_id=args.order_id, order_no=args.order_no, is_history=args.is_history)
            result = summarize_order_fee_detail(raw)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "stock-amount":
            symbol = normalize_trade_symbol(args.symbol)
            raw = client.get_stock_amount(symbol=symbol, order_type=args.order_type, session_type=args.session_type)
            result = summarize_stock_amount(raw, symbol=symbol)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        if command == "max-enable-buy-amt":
            symbol = normalize_trade_symbol(args.symbol)
            raw = client.max_enable_buy_amt(
                symbol=symbol,
                order_type=args.order_type,
                entrust_price=args.entrust_price,
                session_type=args.session_type,
            )
            result = summarize_max_enable_buy_amt(raw, symbol=symbol, order_type=args.order_type)
            print(json.dumps(success_response(command, config, result, raw), ensure_ascii=False))
            return 0

        raise TradeCliError(f"Unsupported command: {command}")

    except Exception as e:
        print(json.dumps(error_response(command, e), ensure_ascii=False))
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: scripts/portfolio_client.py
content: from trade_client import NoahTradeClient
from format_portfolio_text import format_account_info, format_positions, format_sec_asset, format_capital_flow

DETAIL_LIMIT = 10


class NoahPortfolioClient(NoahTradeClient):
    def get_account_info(self):
        return self._get('/trade/get_account_info', {})

    def get_positions(self, market):
        params = {'market': market}
        return self._get('/trade/get_positions', params)

    def get_sec_asset(self):
        return self._get('/trade/get_sec_asset', {})

    def get_sec_capital_flow(self, **kwargs):
        params = {}
        for k in ['start_date', 'end_date', 'business_type', 'fund_direction', 'page', 'page_size', 'last_id', 'record_last_id', 'trade_code', 'unique_code']:
            if kwargs.get(k) not in (None, ''):
                params[k] = kwargs[k]
        return self._get('/trade/get_sec_capital_flow', params)


def summarize_account_info(item):
    return item or {}


def summarize_positions(items):
    shown = items[:DETAIL_LIMIT]
    return {'count': len(items), 'items': shown, 'truncated': len(items) > DETAIL_LIMIT}


def summarize_sec_asset(items):
    return {'count': len(items), 'items': items[:DETAIL_LIMIT], 'truncated': len(items) > DETAIL_LIMIT}


def summarize_capital_flow(data):
    records = (data or {}).get('records') or []
    return {'count': len(records), 'items': records[:DETAIL_LIMIT], 'truncated': len(records) > DETAIL_LIMIT, 'last_update_time': (data or {}).get('last_update_time')}


def portfolio_text(intent, summary):
    if intent == 'account-info':
        return format_account_info(summary)
    if intent == 'positions':
        return format_positions(summary)
    if intent == 'sec-asset':
        return format_sec_asset(summary)
    if intent == 'sec-capital-flow':
        return format_capital_flow(summary)
    return ''


path: scripts/symbol_utils.py
content: from errors import ValidationError


def normalize_trade_symbol(symbol: str) -> str:
    s = (symbol or "").strip().upper()
    if not s:
        raise ValidationError("symbol is required", hint="请传入证券代码，例如 HK.00700 或 US.AAPL")

    if s.startswith("HK-"):
        return s.replace("-", ".", 1)
    if s.startswith("US-"):
        return s.replace("-", ".", 1)

    if s.startswith("HK.") or s.startswith("US."):
        return s

    if s.isdigit() and len(s) <= 5:
        return f"HK.{s.zfill(5)}"

    raise ValidationError(
        f"unsupported symbol format: {symbol}",
        hint="当前请使用 HK.00700、HK-00700、US.AAPL 这类格式",
    )


path: scripts/trade_client.py
content: from urllib.parse import urljoin

import requests

from errors import TradeApiError, TradeBizError


class NoahTradeClient:
    def __init__(self, config):
        self.config = config
        self.base_url = config.base_url.rstrip('/') + '/'
        self.headers = {
            'Authorization': f'Bearer {config.token}',
            'Accept': 'application/json',
        }

    def _normalize_error(self, resp, endpoint, data, trace_id):
        detail = data.get('detail') if isinstance(data, dict) else None
        message = data.get('msg') if isinstance(data, dict) else None
        text = detail or message or str(data)
        raise TradeApiError(
            f"HTTP {resp.status_code}: {text}",
            endpoint=endpoint,
            http_status=resp.status_code,
            trace_id=trace_id,
            hint='交易接口返回 HTTP 错误，请检查请求参数与访问权限',
        )

    def _handle_response(self, resp, endpoint):
        trace_id = resp.headers.get('x-trace-id') or resp.headers.get('traceId')
        try:
            data = resp.json()
        except Exception:
            raise TradeApiError(
                f"HTTP {resp.status_code}: {resp.text[:500]}",
                endpoint=endpoint,
                http_status=resp.status_code,
                trace_id=trace_id,
                hint='接口返回内容不是合法 JSON',
            )

        if resp.status_code >= 400:
            self._normalize_error(resp, endpoint, data, trace_id)

        if isinstance(data, dict):
            success = data.get('success', True)
            biz_code = data.get('code')
            message = data.get('msg') or data.get('message')
            if success is False or biz_code == 0:
                raise TradeBizError(
                    message or 'Trade business error',
                    endpoint=endpoint,
                    biz_code=biz_code,
                    trace_id=trace_id,
                    hint='请检查业务参数、账户状态和 Bearer token 是否匹配',
                )

        return {
            'endpoint': endpoint,
            'trace_id': trace_id,
            'response': data,
        }

    def _get(self, endpoint, params=None):
        url = urljoin(self.base_url, endpoint.lstrip('/'))
        params = params or {}
        try:
            resp = requests.get(
                url,
                headers=self.headers,
                params=params,
                timeout=self.config.timeout_seconds,
            )
        except requests.RequestException as e:
            raise TradeApiError(
                f"HTTP request failed: {e}",
                endpoint=endpoint,
                hint='请检查网络、Base URL 和交易服务可达性',
                retryable=True,
            )
        return self._handle_response(resp, endpoint)

    def _post(self, endpoint, payload=None):
        url = urljoin(self.base_url, endpoint.lstrip('/'))
        payload = payload or {}
        headers = dict(self.headers)
        headers['Content-Type'] = 'application/json'
        try:
            resp = requests.post(
                url,
                headers=headers,
                json=payload,
                timeout=self.config.timeout_seconds,
            )
        except requests.RequestException as e:
            raise TradeApiError(
                f"HTTP request failed: {e}",
                endpoint=endpoint,
                hint='请检查网络、Base URL 和交易服务可达性',
                retryable=True,
            )
        return self._handle_response(resp, endpoint)

    def get_today_orders(self, market, code=None, page=1, page_size=50):
        params = {'market': market}
        if code:
            params['code'] = code
        if page is not None:
            params['page'] = page
        if page_size is not None:
            params['page_size'] = page_size
        return self._get('/trade/get_order_list', params)

    def get_today_deals(self, code=None):
        params = {}
        if code:
            params['code'] = code
        return self._get('/trade/get_today_deal_list', params)


    def get_history_orders(self, code=None, start_date=None, end_date=None, order_id=None, order_no=None, order_status=None):
        params = {}
        if code:
            params['code'] = code
        if start_date:
            params['start_date'] = start_date
        if end_date:
            params['end_date'] = end_date
        if order_id:
            params['order_id'] = order_id
        if order_no:
            params['order_no'] = order_no
        if order_status:
            params['order_status'] = order_status
        return self._get('/trade/get_history_order_list', params)

    def query_push_data(self, init_date, begin_serial_no, end_serial_no):
        payload = {
            'init_date': init_date,
            'begin_serial_no': str(begin_serial_no),
            'end_serial_no': str(end_serial_no),
        }
        return self._post('/trade/query_push_data', payload)
    def get_history_deals(self, code=None, start_date=None, end_date=None):
        params = {}
        if code:
            params['code'] = code
        if start_date:
            params['start_date'] = start_date
        if end_date:
            params['end_date'] = end_date
        return self._get('/trade/get_history_deal_list', params)

    def get_today_unfinished_orders(self, code=None, order_id=None, order_no=None, order_status=None):
        params = {}
        if code:
            params['code'] = code
        if order_id:
            params['order_id'] = order_id
        if order_no:
            params['order_no'] = order_no
        if order_status:
            params['order_status'] = order_status
        return self._get('/trade/get_today_order_list', params)

    def get_finished_orders(self, code=None, start_date=None, end_date=None, page=None, page_size=None, order_id=None, order_no=None, order_status=None, order_side=None, order_type=None):
        params = {}
        for k, v in {
            'code': code,
            'start_date': start_date,
            'end_date': end_date,
            'page': page,
            'page_size': page_size,
            'order_id': order_id,
            'order_no': order_no,
            'order_status': order_status,
            'order_side': order_side,
            'order_type': order_type,
        }.items():
            if v not in (None, ''):
                params[k] = v
        return self._get('/trade/get_finished_order_list', params)

    def get_order_detail(self, order_id, order_no=None, is_history=None):
        params = {'order_id': order_id}
        if order_no:
            params['order_no'] = order_no
        if is_history is not None:
            params['is_history'] = str(bool(is_history)).lower()
        return self._get('/trade/get_order_detail', params)

    def get_order_fee_detail(self, order_id, order_no=None, is_history=None):
        params = {'order_id': order_id}
        if order_no:
            params['order_no'] = order_no
        if is_history is not None:
            params['is_history'] = str(bool(is_history)).lower()
        return self._get('/trade/get_order_fee_detail', params)

    def max_enable_buy_amt(self, symbol, order_type, entrust_price, session_type=None):
        params = {
            'code': symbol,
            'order_type': order_type,
            'entrust_price': str(entrust_price),
        }
        if session_type is not None:
            params['session_type'] = session_type
        return self._get('/trade/max_enable_buy_amt', params)

    def order_fee_query(self, symbol, side, order_type, qty, price=None):
        payload = {
            'code': symbol,
            'order_side': side,
            'order_type': order_type,
            'quantity': str(qty),
        }
        if price is not None:
            payload['price'] = str(price)
        return self._post('/trade/order_fee_query', payload)

    def get_stock_amount(self, symbol, order_type, session_type=None):
        params = {
            'code': symbol,
            'order_type': order_type,
        }
        if session_type is not None:
            params['session_type'] = session_type
        return self._get('/trade/get_stock_amount', params)


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