@v587d/capital-generation 2.8.3 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/README.md +40 -19
- package/chart-ui/README.md +5 -4
- package/chart-ui/client.src.cjs +7 -16
- package/chart-ui/index.js +12 -33
- package/chart-ui/package.json +2 -2
- package/config/client.js +18 -39
- package/config/client.src.cjs +79 -217
- package/config/index.js +22 -72
- package/config/package.json +2 -2
- package/cordis.patch.yml +46 -51
- package/lib/agents/bash-guard.d.ts +9 -55
- package/lib/agents/bash-guard.js +27 -67
- package/lib/agents/preset-roster.d.ts +22 -0
- package/lib/agents/preset-roster.js +87 -0
- package/lib/agents/role-tool-policy.d.ts +46 -0
- package/lib/agents/role-tool-policy.js +205 -0
- package/lib/agents/root-tool-policy.d.ts +28 -124
- package/lib/agents/root-tool-policy.js +164 -237
- package/lib/agents/selected-skills-provider.d.ts +2 -6
- package/lib/agents/selected-skills-provider.js +27 -84
- package/lib/capital-settings.d.ts +27 -0
- package/lib/capital-settings.js +23 -0
- package/lib/chart/artifact-ref.d.ts +13 -8
- package/lib/chart/artifact-ref.js +27 -10
- package/lib/chart/downsample.d.ts +2 -5
- package/lib/chart/downsample.js +3 -8
- package/lib/chart/errors.d.ts +3 -7
- package/lib/chart/errors.js +1 -2
- package/lib/chart/event-context.d.ts +8 -0
- package/lib/chart/event-context.js +39 -0
- package/lib/chart/events.d.ts +21 -78
- package/lib/chart/events.js +43 -189
- package/lib/chart/html.d.ts +2 -5
- package/lib/chart/html.js +8 -15
- package/lib/chart/present-tool.d.ts +28 -0
- package/lib/chart/present-tool.js +128 -0
- package/lib/chart/runtime.d.ts +5 -18
- package/lib/chart/runtime.js +14 -34
- package/lib/chart/series.d.ts +10 -17
- package/lib/chart/series.js +22 -47
- package/lib/chart/source-token.d.ts +5 -13
- package/lib/chart/source-token.js +18 -17
- package/lib/chart/spec.d.ts +6 -12
- package/lib/chart/spec.js +7 -31
- package/lib/chart/tool.d.ts +7 -29
- package/lib/chart/tool.js +15 -25
- package/lib/chart-render-entry.d.ts +10 -0
- package/lib/chart-render-entry.js +14 -0
- package/lib/chart-source-entry.d.ts +13 -0
- package/lib/chart-source-entry.js +20 -0
- package/lib/collect-entry.d.ts +9 -0
- package/lib/collect-entry.js +10 -0
- package/lib/collector-runtime.d.ts +22 -0
- package/lib/collector-runtime.js +97 -0
- package/lib/data-collector/args.d.ts +6 -24
- package/lib/data-collector/args.js +6 -24
- package/lib/data-collector/dataset-tools.d.ts +7 -1
- package/lib/data-collector/dataset-tools.js +27 -56
- package/lib/data-collector/describe.d.ts +8 -33
- package/lib/data-collector/describe.js +17 -70
- package/lib/data-collector/hub.d.ts +27 -85
- package/lib/data-collector/hub.js +19 -85
- package/lib/data-collector/query-time.d.ts +7 -26
- package/lib/data-collector/query-time.js +8 -27
- package/lib/data-collector/store.d.ts +41 -117
- package/lib/data-collector/store.js +32 -111
- package/lib/data-collector/time-axis.d.ts +16 -76
- package/lib/data-collector/time-axis.js +25 -111
- package/lib/data-collector/tools.d.ts +6 -6
- package/lib/data-collector/tools.js +34 -43
- package/lib/dataset-read-entry.d.ts +9 -0
- package/lib/dataset-read-entry.js +7 -0
- package/lib/index.d.ts +7 -117
- package/lib/index.js +43 -422
- package/lib/net/eastmoney-client.d.ts +13 -65
- package/lib/net/eastmoney-client.js +12 -53
- package/lib/net/throttle.d.ts +2 -13
- package/lib/net/throttle.js +3 -14
- package/lib/ocr/client.d.ts +12 -21
- package/lib/ocr/client.js +23 -40
- package/lib/ocr/markdown.d.ts +20 -26
- package/lib/ocr/markdown.js +23 -36
- package/lib/ocr/store.d.ts +14 -47
- package/lib/ocr/store.js +15 -45
- package/lib/ocr-entry.d.ts +9 -0
- package/lib/ocr-entry.js +10 -0
- package/lib/policy-entry.d.ts +17 -0
- package/lib/policy-entry.js +22 -0
- package/lib/present-entry.d.ts +9 -0
- package/lib/present-entry.js +13 -0
- package/lib/preset-claim.d.ts +22 -0
- package/lib/preset-claim.js +24 -0
- package/lib/preset-ids.d.ts +38 -0
- package/lib/preset-ids.js +46 -0
- package/lib/retriever-config.d.ts +37 -0
- package/lib/retriever-config.js +36 -0
- package/lib/retriever-runtime.d.ts +31 -0
- package/lib/retriever-runtime.js +82 -0
- package/lib/root-extras-entry.d.ts +17 -0
- package/lib/root-extras-entry.js +25 -0
- package/lib/runtime-entry.d.ts +15 -0
- package/lib/runtime-entry.js +5 -0
- package/lib/runtime.d.ts +21 -0
- package/lib/runtime.js +33 -0
- package/lib/search-entry.d.ts +9 -0
- package/lib/search-entry.js +9 -0
- package/lib/sources/eastmoney-http.js +20 -68
- package/lib/sources/ftshare-rest.d.ts +6 -34
- package/lib/sources/ftshare-rest.js +84 -292
- package/lib/sources/fuyao-core.d.ts +8 -18
- package/lib/sources/fuyao-core.js +10 -21
- package/lib/sources/fuyao-rest.d.ts +2 -6
- package/lib/sources/fuyao-rest.js +23 -102
- package/lib/sources/security-code.d.ts +6 -12
- package/lib/sources/security-code.js +6 -12
- package/lib/sources/security-identity.d.ts +1 -4
- package/lib/sources/security-identity.js +3 -8
- package/lib/sources/tencent-http.js +14 -28
- package/lib/sources/tencent-public-core.d.ts +8 -60
- package/lib/sources/tencent-public-core.js +12 -77
- package/lib/sources/wind-mcp.d.ts +0 -5
- package/lib/sources/wind-mcp.js +23 -78
- package/lib/sources-entry.d.ts +9 -0
- package/lib/sources-entry.js +9 -0
- package/lib/time/tools.d.ts +8 -31
- package/lib/time/tools.js +30 -66
- package/lib/time-entry.d.ts +9 -0
- package/lib/time-entry.js +7 -0
- package/lib/tool-exec.d.ts +45 -11
- package/lib/tool-exec.js +59 -6
- package/lib/user-customization.d.ts +14 -0
- package/lib/user-customization.js +47 -0
- package/lib/watchlist/tools.d.ts +3 -13
- package/lib/watchlist/tools.js +10 -30
- package/lib/watchlist-entry.d.ts +9 -0
- package/lib/watchlist-entry.js +6 -0
- package/lib/web-retriever/engines.d.ts +6 -11
- package/lib/web-retriever/engines.js +9 -14
- package/lib/web-retriever/html-markdown.d.ts +14 -34
- package/lib/web-retriever/html-markdown.js +39 -75
- package/lib/web-retriever/local-fetch.d.ts +22 -58
- package/lib/web-retriever/local-fetch.js +33 -77
- package/lib/web-retriever/retriever.d.ts +6 -18
- package/lib/web-retriever/retriever.js +7 -7
- package/lib/web-retriever/sources.d.ts +37 -124
- package/lib/web-retriever/sources.js +64 -196
- package/lib/web-retriever/tools.d.ts +43 -27
- package/lib/web-retriever/tools.js +99 -137
- package/lib/web-retriever/wind-client.d.ts +14 -31
- package/lib/web-retriever/wind-client.js +16 -32
- package/locale/zh.json +1 -1
- package/package.json +72 -7
- package/preset/capital-generation/README.md +87 -53
- package/preset/capital-generation/agent.patch.yml +532 -337
- package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +6 -4
- package/preset/capital-generation/skills/capital-data-profile/SKILL.md +2 -2
- package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-equity-research/SKILL.md +4 -2
- package/preset/capital-generation/skills/capital-event-impact/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-orchestration/SKILL.md +30 -33
- package/preset/capital-generation/skills/capital-portfolio-review/SKILL.md +1 -1
- package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +14 -16
- package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +1 -1
- package/watchlist/client.js +15 -148
- package/watchlist/client.src.cjs +148 -662
- package/watchlist/holding-rules.js +11 -38
- package/watchlist/index.js +127 -397
- package/watchlist/package.json +2 -2
package/watchlist/index.js
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@v587d/watchlist
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* 刷新按钮),没有轮询、没有定时器、没有自动重试。
|
|
8
|
-
*
|
|
9
|
-
* 三条纪律在这里落地:
|
|
10
|
-
* - ⛔ 认证围栏与 `/capital-charts` 同一写法(`connection.requestRejection`)——
|
|
11
|
-
* 不接就是"任何本机进程凭路径即可读写用户的自选股"。
|
|
12
|
-
* - 出网复用 `lib/sources/fuyao-core.js` 与 `lib/sources/tencent-public-core.js`(**同一份实现**,
|
|
13
|
-
* AGENTS.md §9.7);key 引用名从 `capital-config` 卡片读,不硬编码 `FUYAO_API_KEY`。
|
|
14
|
-
* 两路上游按**行类**分工:A 股族走 Fuyao(要密钥),港美股走腾讯公开端点(不要密钥)——
|
|
15
|
-
* 所以一路上游失败只降它自己那一路,`credential_missing` 的射程从"整块面板"缩到"A 股那一路"。
|
|
16
|
-
* - 报价按 `asset_type` 分流;ETF 上游只接受单只(实测 `code=1002`),所以逐只扇出,规模由清单
|
|
17
|
-
* 上限(30)钉住、由 `REFRESH_CONCURRENCY` 收敛成约 8 波,总预算随条数走。港美股每个市场
|
|
18
|
-
* 一次批量(≤50),比 A 股 ETF 逐只便宜。
|
|
19
|
-
* - 清单的读-改-写在**进程内串行**(`serialize`):出网不进这一道,跨进程只到"同一键"为止,
|
|
20
|
-
* 两条边界都写在 `serialize` 的注释里,不假装我们锁住了磁盘。
|
|
2
|
+
* Host half of `@v587d/watchlist`. The list is a host-level user asset in the
|
|
3
|
+
* official `storage-domain` domain `capital_watchlist`, reached by the browser
|
|
4
|
+
* half through `/capital-watchlist/*`. Network is user-triggered only (no polling,
|
|
5
|
+
* timers or retries); auth uses `connection.requestRejection`; quote fetching
|
|
6
|
+
* reuses the shared source cores rather than a second parser.
|
|
21
7
|
*/
|
|
22
8
|
import z from 'zod'
|
|
23
9
|
import { DEFAULT_FUYAO_BASE_URL, FuyaoError, fuyaoItems, fuyaoQuery, fuyaoRequest, fuyaoTimestamp } from '../lib/sources/fuyao-core.js'
|
|
24
|
-
//
|
|
25
|
-
//
|
|
10
|
+
// Reuse the plugin-side offshore core (same throttle, field table and guards);
|
|
11
|
+
// a second parser would repeat the §9.7 incident class.
|
|
26
12
|
import {
|
|
27
13
|
fetchOffshoreIndexQuotes,
|
|
28
14
|
fetchOffshoreQuotes,
|
|
@@ -34,81 +20,41 @@ import { ASSET_TYPES, isHoldable, weightCeiling, otherHeldWeightPct, MAX_ROW_WEI
|
|
|
34
20
|
export const name = 'capital-watchlist'
|
|
35
21
|
|
|
36
22
|
export const ROUTE_PATH = '/capital-watchlist'
|
|
37
|
-
/**
|
|
38
|
-
* 域名走 storage 的 **unit name** 规则:`UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/`
|
|
39
|
-
* (`@deepseek-ai/dsh-storage`),**连字符会被拒**——实测报
|
|
40
|
-
* `malformed-medium:invalid unit name 'capital-watchlist'`。所以路由叫
|
|
41
|
-
* `/capital-watchlist`,域却叫 `capital_watchlist`,落盘
|
|
42
|
-
* `~/.dsh/storages/capital_watchlist.json`。这不是风格问题,是上游硬约束。
|
|
43
|
-
*/
|
|
23
|
+
/** Storage unit names allow only lowercase/underscore, so the hyphen belongs to the route. */
|
|
44
24
|
export const DOMAIN_NAME = 'capital_watchlist'
|
|
45
|
-
/**
|
|
46
|
-
* 清单条目上限(2026-10-01 从 10 抬到 30:用户点名 10 条不可接受)。它同时是**一次刷新的
|
|
47
|
-
* 请求个数上限**——ETF 上游只认单只,30 只全 ETF 就是 30 个请求,所以扇出必须并发(见
|
|
48
|
-
* `REFRESH_CONCURRENCY`)并且总预算随条数走(见 `refreshBudgetFor`);它也约束 `single`
|
|
49
|
-
* 布局的整写体积。
|
|
50
|
-
*/
|
|
25
|
+
/** List cap = per-refresh request cap; the ETF endpoint takes one code per call. */
|
|
51
26
|
export const MAX_ITEMS = 30
|
|
52
|
-
/** ETF 扇出的并发路数:30 个单只请求压成约 8 波,正常网络一次刷新几秒内落地。 */
|
|
53
27
|
export const REFRESH_CONCURRENCY = 4
|
|
54
|
-
/**
|
|
55
|
-
* 每一路自己能拿到几条——**这就是候选池的全部**(2026-10-08 起不再做显示切片:上游没有第二页,
|
|
56
|
-
* 用户点名"既然翻不了页,就直接放出来"):`50 + 10 + 10 = 70` 是三路上游硬顶之和,天然有界,
|
|
57
|
-
* 不会出现"搜个词回几千行"。
|
|
58
|
-
*
|
|
59
|
-
* `truncated` 那句"还有更多"**只按这一表判**:某一路回满自己那一档,就是上游那侧被砍了;
|
|
60
|
-
* 我们这边一行都没藏,所以那句 tip 里也不再有"候选不止这些"(只留下"请输入更准确的…"那半句)。
|
|
61
|
-
*
|
|
62
|
-
* 数字的来路:Fuyao `ticker_search` 的 `limit` 官方取值 1~50(默认 10),这里取到**官方上限**;
|
|
63
|
-
* smartbox 实测**硬顶 10 条**(`n=` / `count=` / `size=` / `limit=` / `max=` / `hits=` / `num=` /
|
|
64
|
-
* `rows=` 八种抬条数的写法全无效,分页的 `p=` / `page=` / `offset=` / `start=` / `from=` / `pn=` /
|
|
65
|
-
* `o=` 七种回包与基线**逐字节相同**;2026-10-07 与 10-08 两轮实测),多要也不会多给。
|
|
66
|
-
*/
|
|
28
|
+
/** Upstream hard caps per branch: A-share 50, HK 10, US 10; the pool is bounded at 70. */
|
|
67
29
|
export const SEARCH_BRANCH_LIMITS = { 'a-share': 50, hk: 10, us: 10 }
|
|
68
|
-
/** A
|
|
69
|
-
* 港美股那两路各有自己的白名单(`SMARTBOX_STOCK_TYPES` + `SMARTBOX_INDEX_TYPE`),互不通用。 */
|
|
30
|
+
/** A-share search whitelist; HK/US branches use their own smartbox type whitelists. */
|
|
70
31
|
export const SEARCH_ASSET_TYPES = 'a-share,a-share-index,fund-etf'
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
// `+` 什么时候置灰,与这里收不收这一格不可能各说一套。
|
|
74
|
-
/**
|
|
75
|
-
* `exchange` 这一格存的是**市场**而不是交易所:A 股沿用 `SH`/`SZ`,港美股落 `HK`/`US`。
|
|
76
|
-
* 具体交易所(美股的 `OQ` / `N` / `AM` / 粉单 `PS`)留在 canonical 代码里(`AAPL.OQ`)——
|
|
77
|
-
* 这两个维度在三个市场里不是同一件事(港股没有交易所分裂、美股的点是级别码还是交易所在
|
|
78
|
-
* 字面上分不开),塞进同一格就会有一格在说谎。
|
|
79
|
-
*/
|
|
32
|
+
// Holdability and weight ceiling live only in `./holding-rules.js`, shared with the browser half.
|
|
33
|
+
/** `exchange` stores the market; US venue suffixes stay inside the canonical code. */
|
|
80
34
|
const EXCHANGES = ['SH', 'SZ', 'HK', 'US']
|
|
81
35
|
const SEARCH_TIMEOUT_MS = 15_000
|
|
82
|
-
/**
|
|
83
|
-
* smartbox 可用的市场段:只能是单值。实测 `t=hk,us` 这种列表形态回 `N` 哨兵——与"查无此票"
|
|
84
|
-
* 同一个回文,所以段值必须是这里的常量,不能让一个拼错的值伪装成"没有匹配的标的"。
|
|
85
|
-
*/
|
|
36
|
+
/** smartbox market segment is single-valued; a list form returns the `N` sentinel. */
|
|
86
37
|
const SMARTBOX_MARKETS = ['hk', 'us']
|
|
87
|
-
/** smartbox
|
|
38
|
+
/** smartbox type whitelist: `GP` stock, `GP-FUND` fund/ETF, `ZS` index. */
|
|
88
39
|
const SMARTBOX_STOCK_TYPES = ['GP', 'GP-FUND']
|
|
89
40
|
const SMARTBOX_INDEX_TYPE = 'ZS'
|
|
90
|
-
/**
|
|
91
|
-
* (尾段同样排除我们自己占用的四个市场标记,见下面 `KEY_FORMS` 里那条注释)。 */
|
|
41
|
+
/** Same shape guards as the core; the US suffix excludes our four market markers. */
|
|
92
42
|
const SMARTBOX_INDEX_CODE = /^[A-Z][A-Z0-9.]{1,9}$/u
|
|
93
43
|
const SMARTBOX_US_IDENTITY = /^[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3}$/u
|
|
94
44
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* 一起拖长。预算在**每个工作线程领下一组之前**检查,所以真实上界是 `budget + 一次请求的超时`
|
|
98
|
-
* (一次超时 = `SEARCH_TIMEOUT_MS`);被预算切掉的行如实记 `refresh_timeout`——它们这次没被问过,
|
|
99
|
-
* 不是"这个标的没有报价"。最坏情形(每只都撞超时)由并发路数收敛:30 只 ETF ≈ 8 波。
|
|
45
|
+
* Refresh budget `20s + 2.5s × items`, 45–90s; checked before claiming each group,
|
|
46
|
+
* so the real bound is budget + one timeout. Cut rows report `refresh_timeout`.
|
|
100
47
|
*/
|
|
101
48
|
const REFRESH_BUDGET_BASE_MS = 20_000
|
|
102
49
|
const REFRESH_BUDGET_PER_ITEM_MS = 2_500
|
|
103
50
|
const REFRESH_BUDGET_FLOOR_MS = 45_000
|
|
104
51
|
const REFRESH_BUDGET_CEIL_MS = 90_000
|
|
105
|
-
/** 一次刷新的总预算:`options.refreshBudgetMs` 给了就用给的,没给按这次要问的条数算。 */
|
|
106
52
|
export function refreshBudgetFor(count) {
|
|
107
53
|
return Math.min(REFRESH_BUDGET_CEIL_MS, Math.max(REFRESH_BUDGET_FLOOR_MS, REFRESH_BUDGET_BASE_MS + REFRESH_BUDGET_PER_ITEM_MS * count))
|
|
108
54
|
}
|
|
109
55
|
const MAX_BODY_BYTES = 65_536
|
|
110
56
|
const MAX_QUERY_LENGTH = 64
|
|
111
|
-
/**
|
|
57
|
+
/** Same entry id as the `capital-config` card (settings namespace = entry id). */
|
|
112
58
|
const CAPITAL_CONFIG_ENTRY_ID = 'capital-config'
|
|
113
59
|
const DEFAULT_CREDENTIAL_REF = 'FUYAO_API_KEY'
|
|
114
60
|
|
|
@@ -120,8 +66,8 @@ const ENDPOINTS = {
|
|
|
120
66
|
}
|
|
121
67
|
|
|
122
68
|
/**
|
|
123
|
-
* A
|
|
124
|
-
*
|
|
69
|
+
* A-share quote templates: unchanged endpoint shapes and whitelist. `currency`
|
|
70
|
+
* is written only here (`CNY`), never for indices (points are unitless).
|
|
125
71
|
*/
|
|
126
72
|
const FUYAO_TEMPLATES = [
|
|
127
73
|
['a-share', { path: ENDPOINTS.stock, allowed: ['thscodes'], batch: true, scope: 'a-share', currency: 'CNY' }],
|
|
@@ -129,11 +75,7 @@ const FUYAO_TEMPLATES = [
|
|
|
129
75
|
['fund-etf', { path: ENDPOINTS.fund, allowed: ['thscode'], batch: false, scope: 'a-share', currency: 'CNY' }],
|
|
130
76
|
]
|
|
131
77
|
|
|
132
|
-
/**
|
|
133
|
-
* 港美股四档的规格:一个市场一族行类 = 一次批量(清单上限 30,实测的 ≤50 之内用不到分片)。
|
|
134
|
-
* `index` 决定走内核哪一个取数编排(两市的字段位序表都在内核里,面板不抄第二份);
|
|
135
|
-
* `withCurrency` 只在个股档为真——指数档即便上游在同一列塞了 `HKD` / `USD` 也不收(§3.5)。
|
|
136
|
-
*/
|
|
78
|
+
/** One market family = one batch; currency only for stocks, never indices. */
|
|
137
79
|
const OFFSHORE_SPECS = [
|
|
138
80
|
{ assetType: 'hk-stock', market: 'hk', index: false, withCurrency: true, scope: 'hk' },
|
|
139
81
|
{ assetType: 'hk-index', market: 'hk', index: true, withCurrency: false, scope: 'hk' },
|
|
@@ -142,14 +84,8 @@ const OFFSHORE_SPECS = [
|
|
|
142
84
|
]
|
|
143
85
|
|
|
144
86
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* canonical 键**不能**直接当请求写法:美股的键是上游回显的带后缀身份(`AAPL.OQ`),而快照只认
|
|
148
|
-
* 不带后缀的那个写法(实测 `usAAPL.OQ` 整行不回、`usAAPL` 回)。这里的剥法与内核参数层的
|
|
149
|
-
* `normalizeUsCode` **不是同一条规则**,也不能是同一条:参数层要在"级别码"(BRK.B)与
|
|
150
|
-
* "交易所码"(BABA.N)之间猜,所以单字母尾段必须留着;而面板存的就是回显身份,最后一个点号
|
|
151
|
-
* 之后那一段**已知**是交易所码,剥掉即可,不用猜、也不用多一轮请求。
|
|
152
|
-
* 剥完仍不认识的那几只由内核的三轮写法回退保兜(`BRK.B` → 没回行再退 `BRK`)。
|
|
87
|
+
* Canonical US keys are echoed identities (`AAPL.OQ`) but snapshots want `AAPL`;
|
|
88
|
+
* here the trailing segment is known to be the venue, so strip it without guessing.
|
|
153
89
|
*/
|
|
154
90
|
function offshoreGroup(spec, keys) {
|
|
155
91
|
const codeByKey = new Map()
|
|
@@ -158,8 +94,7 @@ function offshoreGroup(spec, keys) {
|
|
|
158
94
|
const stem = stemOf(thscode)
|
|
159
95
|
const code = spec.market === 'us' && !spec.index ? stem.replace(/\.[A-Z]{2,3}$/u, '') : stem
|
|
160
96
|
if (!/^(?:\d{5}|[A-Z][A-Z0-9.]{1,9})$/u.test(code)) {
|
|
161
|
-
//
|
|
162
|
-
// (键形状本该挡住这种行,走到这里说明磁盘上的键与形状表已经不对账了)。
|
|
97
|
+
// Not requestable: record `quote_unavailable`, never ask with a truncated code.
|
|
163
98
|
failures.push({ thscode, code: 'quote_unavailable' })
|
|
164
99
|
continue
|
|
165
100
|
}
|
|
@@ -169,7 +104,7 @@ function offshoreGroup(spec, keys) {
|
|
|
169
104
|
return { group: { ...spec, kind: 'tencent', keys: [...codeByKey.keys()], codes: [...new Set(codeByKey.values())] }, failures }
|
|
170
105
|
}
|
|
171
106
|
|
|
172
|
-
/**
|
|
107
|
+
/** Upstream identity → canonical key; the `.HK` / `.US` markers are ours. */
|
|
173
108
|
function offshoreKeyOf(row, group) {
|
|
174
109
|
const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''
|
|
175
110
|
if (code.length === 0) return ''
|
|
@@ -177,14 +112,7 @@ function offshoreKeyOf(row, group) {
|
|
|
177
112
|
return group.index ? `${code}.US` : code
|
|
178
113
|
}
|
|
179
114
|
|
|
180
|
-
/**
|
|
181
|
-
* 播种条目:三个市场各给一根"今天有没有开盘"的标尺。A 股那条走 Fuyao 的 `index` 端点,
|
|
182
|
-
* 恒指走腾讯港股指数窄表,纳指/标普走腾讯美股指数窄表——四条均已用 2026-10-07 真报文验证
|
|
183
|
-
* 有值(`usINX` 是标普在腾讯的写法,`SPX` / `usSSPX` 不回指数,见设计文档 P18 / P19)。
|
|
184
|
-
* 2026-10-07 由用户定为这四条,替换掉原先的深证成指 / 创业板指 / 沪深300。
|
|
185
|
-
* ⚠ 只对新域生效:老用户磁盘上仍是旧四条,**不补种、不替换**(清单是用户资产,
|
|
186
|
-
* 我们没有任何依据替他改掉三行)。
|
|
187
|
-
*/
|
|
115
|
+
/** Seed one index per market, fresh domains only: existing lists are never migrated. */
|
|
188
116
|
export const SEED_ITEMS = [
|
|
189
117
|
{ thscode: '000001.SH', ticker: '000001', name: '上证指数', exchange: 'SH', asset_type: 'a-share-index' },
|
|
190
118
|
{ thscode: 'HSI.HK', ticker: 'HSI', name: '恒生指数', exchange: 'HK', asset_type: 'hk-index' },
|
|
@@ -193,36 +121,24 @@ export const SEED_ITEMS = [
|
|
|
193
121
|
]
|
|
194
122
|
|
|
195
123
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* 所以这一张表就是 `keyFormOf()` 的遍历顺序,顺序即规则;改顺序必须先回答"哪两串会互相吃掉"。
|
|
200
|
-
* 第一档 `a-share` 是唯一一"形"对多"类"(个股 / 指数 / ETF 同形),故 `assetTypes` 是集合:
|
|
201
|
-
* 这一族的 `asset_type` 只能来自搜索侧给的值,形状只负责否证。
|
|
124
|
+
* Canonical key shapes. Order is semantics: a `.HK` or `.US` key must not fall
|
|
125
|
+
* into an earlier branch, or it can be added and never quoted. The A-share shape
|
|
126
|
+
* covers several asset types; types come from search.
|
|
202
127
|
*/
|
|
203
128
|
const KEY_FORMS = [
|
|
204
129
|
{ form: 'a-share', regex: /^\d{6}\.(SH|SZ)$/u, assetTypes: ['a-share', 'a-share-index', 'fund-etf'] },
|
|
205
130
|
{ form: 'hk-stock', regex: /^\d{5}\.HK$/u, assetTypes: ['hk-stock'] },
|
|
206
131
|
{ form: 'hk-index', regex: /^[A-Z][A-Z.]{0,9}\.HK$/u, assetTypes: ['hk-index'] },
|
|
207
132
|
{ form: 'us-index', regex: /^[A-Z]{2,8}\.US$/u, assetTypes: ['us-index'] },
|
|
208
|
-
//
|
|
209
|
-
// `ABCDEF.SH` 会同时命中"美股身份"那一档(`[A-Z]{1,3}` 装得下任何两个字母),
|
|
210
|
-
// 于是一个不存在的键能进清单、还能去敲腾讯的门。交易所码本身的取值域不归我们发明
|
|
211
|
-
// (实测有 OQ / N / AM / PS,内核 `parseUsQuoteRows` 因此只约束长度),这一条只收自己的账。
|
|
133
|
+
// Exclude our own market markers, or `ABCDEF.SH` could pass as a US identity.
|
|
212
134
|
{ form: 'us-stock', regex: /^[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3}$/u, assetTypes: ['us-stock'] },
|
|
213
135
|
]
|
|
214
|
-
/**
|
|
136
|
+
/** Returns the first match, never the closest: unrecognized means unrecognized. */
|
|
215
137
|
export function keyFormOf(thscode) {
|
|
216
138
|
if (typeof thscode !== 'string') return null
|
|
217
139
|
return KEY_FORMS.find((entry) => entry.regex.test(thscode)) ?? null
|
|
218
140
|
}
|
|
219
141
|
const KEY_REGEX = /^(?:\d{6}\.(?:SH|SZ)|\d{5}\.HK|[A-Z][A-Z.]{0,9}\.HK|[A-Z]{2,8}\.US|[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3})$/u
|
|
220
|
-
/**
|
|
221
|
-
* `not_found` 那句话里要说清"这一档支持什么"(市场名从键的形状读,不写死"沪深 A 股 / 指数 /
|
|
222
|
-
* 场内 ETF"——那句在港美股进来之后会指错路)。指数两档要把"写法不许猜"写进用户看得见的句子:
|
|
223
|
-
* 实测 `usSPX` / `usNDQ` / `usSSPX` 都不回指数(最后那个回了一只真 ETF 且不报错),
|
|
224
|
-
* 只有搜索回来的那一串才被腾讯认。
|
|
225
|
-
*/
|
|
226
142
|
const FORM_SCOPE_TEXT = {
|
|
227
143
|
'a-share': '沪深 A 股 / 指数 / 场内 ETF',
|
|
228
144
|
'hk-stock': '港股现货',
|
|
@@ -233,28 +149,13 @@ const FORM_SCOPE_TEXT = {
|
|
|
233
149
|
|
|
234
150
|
const quoteSchema = z.object({
|
|
235
151
|
price: z.number(),
|
|
236
|
-
/**
|
|
237
|
-
* 涨跌可以是**没有**:上游用 `null` 表示"这个数没有"(停牌、未开盘),此时最新价仍然有值。
|
|
238
|
-
* 早先这里写死 `z.number()`,于是 `quoteOf` 拿 `pct ?? 0` 兜底——停牌标的被画成「+0.00%」,
|
|
239
|
-
* 一次刷新把上一次真实的涨跌顶掉、页脚照报"刷新于此刻"、还不记任何失败。金融应用里
|
|
240
|
-
* "没有这个数"和"这个数是零"必须分开;客户端把 null 画成破折号并降到陈旧那一档的灰。
|
|
241
|
-
*/
|
|
152
|
+
/** Null means "no number" (halted, not open), while the price still exists; never coerce to 0. */
|
|
242
153
|
change_pct: z.number().nullable(),
|
|
243
154
|
captured_at: z.number().int(),
|
|
244
155
|
source_ts: z.number().int().nullable(),
|
|
245
|
-
/**
|
|
246
|
-
* 币种**只跟着钱走**:个股与 ETF 每份回包都带(港美股从上游那一列读、A 股由本文件唯一一处写
|
|
247
|
-
* `CNY`),指数族一律不落——点位是无量纲的数,腾讯确实在指数行的同一位塞了 `HKD` / `USD`,
|
|
248
|
-
* 画上去等于替上游说了一句它没说的话(设计文档 §3.5 / R9)。
|
|
249
|
-
* 取值域**不是闭集**:这个列归上游所有(今天已看见 `HKD` / `USD` / `CNY` 三种,`-R` 柜台就是
|
|
250
|
-
* CNY 报价的港股),我们不拥有它,所以只限形状不限内容,面板对不认识的币种照字面画。
|
|
251
|
-
*/
|
|
156
|
+
/** Currency follows money: stocks/ETFs only; indices are unitless. Upstream owns the value domain. */
|
|
252
157
|
currency: z.string().min(1).max(8).optional(),
|
|
253
|
-
/**
|
|
254
|
-
* 交易所**当地时间**的原串(`2026-10-05 16:08:09`,HKT / 美东),只有港美股那一路有;
|
|
255
|
-
* `source_ts`(epoch)只由 Fuyao 信封填。两者不互相伪造、也不互相换算——换算要用到我们没有的
|
|
256
|
-
* 偏移表与夏令时口径(§7-N4)。页脚那句"刷新于此刻"讲的仍是我们的取数时刻(`captured_at`)。
|
|
257
|
-
*/
|
|
158
|
+
/** Exchange-local wall time, offshore only; never fabricated from `source_ts` (no DST table). */
|
|
258
159
|
source_time: z.string().max(32).nullable().optional(),
|
|
259
160
|
})
|
|
260
161
|
|
|
@@ -267,26 +168,11 @@ const itemSchema = z.object({
|
|
|
267
168
|
added_at: z.number().int(),
|
|
268
169
|
source: z.enum(['seed', 'user']),
|
|
269
170
|
quote: quoteSchema.nullable(),
|
|
270
|
-
/**
|
|
271
|
-
* 置顶时刻(用户动作写入;缺省 = 未置顶,读法见 `rowsOf`)。
|
|
272
|
-
* **可选是刻意的**:磁盘上已有的 v1 记录没有这一格,写成必填会把整份清单判成
|
|
273
|
-
* `invalid-record`(域在加载边界逐条校验,读一次就 `store_unavailable`),而新写的记录
|
|
274
|
-
* 永远带它——两个方向的兼容因此都不需要迁移、也不需要抬 domain version。
|
|
275
|
-
*/
|
|
171
|
+
/** Optional on purpose: v1 records lack it; required would invalidate the whole list. */
|
|
276
172
|
pinned_at: z.number().int().optional(),
|
|
277
173
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* 三态两轴,不许糊成一态:缺 `holding` = 未持仓;`weight_pct === null` = 标了持仓但比例没填;
|
|
281
|
-
* `0` = 用户真填了 0——与上面 `change_pct` 同一条口径,"没有这个数"永远不等于"这个数是零"。
|
|
282
|
-
* 所以这一格**外部可选、内部 nullable**:外面写成 `.nullable()` 会让"未持仓"与"持仓没填比例"
|
|
283
|
-
* 共用一种写法,里面写 `.default(null)` 会把磁盘上手改出来的缺字段读成一份 0% 仓位。
|
|
284
|
-
* 可选同样是为了不迁移、不抬 domain version(见上面 `pinned_at` 那一段)。
|
|
285
|
-
*
|
|
286
|
-
* `marked_at` 记的是**标记时刻**,不是"持仓起始日":比例归用户所有、没有到期日,但"这句话是
|
|
287
|
-
* 哪天说的"必须能自证——`get_watchlist` 把它交给主 Agent 判断要不要向用户复核。合计是否正好
|
|
288
|
-
* 100、分母含不含现金,仍不由我们校验(那本账只有用户自己知道怎么记的);我们只挡一个**上限**:
|
|
289
|
-
* 只做多、不算 put,全组合加起来不许超过 120%(判据在 `./holding-rules.js`,面板与宿主共用)。
|
|
174
|
+
* User-reported holding: absent = not held, `null` = held without weight, `0` =
|
|
175
|
+
* a real zero. `marked_at` is when the claim was last made, not a start date.
|
|
290
176
|
*/
|
|
291
177
|
holding: z.object({
|
|
292
178
|
weight_pct: z.number().min(0).max(100).nullable(),
|
|
@@ -294,25 +180,20 @@ const itemSchema = z.object({
|
|
|
294
180
|
}).optional(),
|
|
295
181
|
})
|
|
296
182
|
|
|
297
|
-
/**
|
|
298
|
-
* 域声明。`defineDomain` 只是同一个 spec 的恒等校验器,host 半边因此不 import dsh 的
|
|
299
|
-
* 运行时包、只 import `zod`——与 `capital-config` 只 import schemastery 是同一条纪律
|
|
300
|
-
* (少一个上游耦合面;域名与版本号由下面的常量与 schema 自己保证)。
|
|
301
|
-
*/
|
|
183
|
+
/** `defineDomain` only validates this spec; keeping imports to zod avoids dsh runtime coupling. */
|
|
302
184
|
export const domainSpec = {
|
|
303
185
|
name: DOMAIN_NAME,
|
|
304
186
|
version: 1,
|
|
305
187
|
layout: 'single',
|
|
306
188
|
global: {
|
|
307
189
|
schema: z.object({ seeded_at: z.number().int(), schema: z.literal(1) }),
|
|
308
|
-
// `seeded_at: 0` =
|
|
190
|
+
// `seeded_at: 0` = never seeded; a non-zero stamp prevents reseeding after deletion.
|
|
309
191
|
initial: { seeded_at: 0, schema: 1 },
|
|
310
192
|
},
|
|
311
193
|
tables: { items: { valueSchema: itemSchema } },
|
|
312
194
|
}
|
|
313
195
|
|
|
314
|
-
/**
|
|
315
|
-
* 没法和回显身份核对,`000001` 到底是指数还是平安银行由上游裁决、不由我们猜。 */
|
|
196
|
+
/** Trim + uppercase; only the five shapes above. Bare codes are rejected: the market is not ours to guess. */
|
|
316
197
|
export function normalizeThscode(value) {
|
|
317
198
|
if (typeof value !== 'string') return undefined
|
|
318
199
|
const cleaned = value.trim().toUpperCase()
|
|
@@ -320,27 +201,23 @@ export function normalizeThscode(value) {
|
|
|
320
201
|
}
|
|
321
202
|
|
|
322
203
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* 这一串同时是三处共用的"检索/请求写法":smartbox 只认它(把 canonical 键原样丢过去一律回
|
|
326
|
-
* `N` 哨兵,2026-10-07 实测 `TCEHY.PS` / `00700.HK` / `HSI.HK` / `IXIC.US` 全部不回行),
|
|
327
|
-
* 美指数用 `IXIC.US` → `IXIC` 也正好是腾讯认的写法(回显是 `.IXIC`,差一个前导点)。
|
|
204
|
+
* Text before the last dot; never `split('.')[0]` (`BRK.B.N` is a real identity).
|
|
205
|
+
* smartbox accepts this stem, not the canonical key.
|
|
328
206
|
*/
|
|
329
207
|
export function stemOf(thscode) {
|
|
330
208
|
const dot = thscode.lastIndexOf('.')
|
|
331
209
|
return dot > 0 ? thscode.slice(0, dot) : thscode
|
|
332
210
|
}
|
|
333
211
|
|
|
334
|
-
/**
|
|
212
|
+
/** Market is the suffix only, never a middle dot segment. */
|
|
335
213
|
function marketOfKey(thscode) {
|
|
336
214
|
const dot = thscode.lastIndexOf('.')
|
|
337
215
|
return dot > 0 ? thscode.slice(dot + 1) : ''
|
|
338
216
|
}
|
|
339
217
|
|
|
340
218
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* `asset_type` 不能让一行港股悄悄流进 Fuyao 端点(那边根本不认它)。
|
|
219
|
+
* Route by key shape, not `asset_type`: the shape gate is the same one used when
|
|
220
|
+
* adding, so a hand-edited type cannot send HK rows to the Fuyao endpoints.
|
|
344
221
|
*/
|
|
345
222
|
function quoteRoute(item) {
|
|
346
223
|
const entry = keyFormOf(item.thscode)
|
|
@@ -349,14 +226,8 @@ function quoteRoute(item) {
|
|
|
349
226
|
}
|
|
350
227
|
|
|
351
228
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* `source` 决定**哪一家**不通:两路的处置动作完全不同——Fuyao 那一路要去看密钥卡片,
|
|
356
|
-
* 腾讯那一路无密钥可查(公开端点,只可能是上游或网络)。混成一条就是叫用户去配一个
|
|
357
|
-
* 本来不需要的 key。腾讯侧的判定读内核挂在 Error 上的 `code`(`tencent_*`):这一家
|
|
358
|
-
* 没有"凭据"与"限流"之外的可处置区分,所以报文畸形与传输失败在**批级**都说成
|
|
359
|
-
* `tencent_unavailable`,不假装我们能指出是哪一只。
|
|
229
|
+
* Upstream error → local code. `perItem` separates a missing symbol from an
|
|
230
|
+
* unreachable branch; `source` separates Fuyao (key card) from Tencent (public).
|
|
360
231
|
*/
|
|
361
232
|
export function watchlistErrorCode(error, { perItem = false, source = 'fuyao' } = {}) {
|
|
362
233
|
if (source === 'tencent') return perItem ? 'quote_unavailable' : (error?.code === 'tencent_rate_limit' ? 'rate_limited' : 'tencent_unavailable')
|
|
@@ -376,12 +247,7 @@ export function watchlistErrorCode(error, { perItem = false, source = 'fuyao' }
|
|
|
376
247
|
}
|
|
377
248
|
}
|
|
378
249
|
|
|
379
|
-
/**
|
|
380
|
-
* 清单读法(KvTable 只有 `entries()`,没有 `values()`)+ 置顶排序,**只有这一份实现**:
|
|
381
|
-
* 带 `pinned_at` 的排前面(后置顶的在前),其余保持表里的插入顺序(用原始下标兜底,
|
|
382
|
-
* 比较稳定)。持久化文件里的键序**不动**——顺序是"读"出来的,不是"写"进去的:
|
|
383
|
-
* 官方单文件后端是 Map,`put` 已有键保留原位置,靠删了重插来改顺序会把整份 JSON 写 N 遍。
|
|
384
|
-
*/
|
|
250
|
+
/** Pinned rows first (newest pin first), others in insertion order; persistence order untouched. */
|
|
385
251
|
function rowsOf(table) {
|
|
386
252
|
return [...table.entries()]
|
|
387
253
|
.map(([, value], index) => ({ value, index }))
|
|
@@ -396,33 +262,28 @@ function rowsOf(table) {
|
|
|
396
262
|
.map((entry) => entry.value)
|
|
397
263
|
}
|
|
398
264
|
|
|
399
|
-
/**
|
|
265
|
+
/** Upstream candidate row → panel shape. */
|
|
400
266
|
function candidateOf(row) {
|
|
401
267
|
const thscode = typeof row.thscode === 'string' ? row.thscode : ''
|
|
402
268
|
return {
|
|
403
269
|
thscode,
|
|
404
270
|
ticker: typeof row.ticker === 'string' && row.ticker.length > 0 ? row.ticker : stemOf(thscode),
|
|
405
271
|
name: typeof row.name === 'string' && row.name.length > 0 ? row.name : thscode,
|
|
406
|
-
//
|
|
407
|
-
// 且只能取**最后一个**点号之后:`BRK.B.N` 用 `split('.')[1]` 会取出 `'B'`。
|
|
272
|
+
// Prefer the upstream market; fall back to the last suffix, never a middle segment.
|
|
408
273
|
exchange: typeof row.exchange === 'string' && row.exchange.length > 0 ? row.exchange.toUpperCase() : marketOfKey(thscode),
|
|
409
274
|
asset_type: typeof row.asset_type === 'string' ? row.asset_type : '',
|
|
410
275
|
}
|
|
411
276
|
}
|
|
412
277
|
|
|
413
278
|
/**
|
|
414
|
-
* smartbox
|
|
415
|
-
*
|
|
416
|
-
* 两条闸门叠在一起用(设计文档 §3.6):类型白名单收掉衍生品,键形状收掉"类型说的和形状
|
|
417
|
-
* 不是一回事"的行。市场段由调用方给(`t=hk` / `t=us` 各一次请求),所以这里不再从 `row.market`
|
|
418
|
-
* 猜——实测 `t=hk&q=纳指` 十条里七条是窝轮,白名单必须事后收而不是指望上游收。
|
|
419
|
-
* 名称直接用上游给的(smartbox 的 `~` 第 3 位已是解过 `\uXXXX` 的正文)。
|
|
279
|
+
* smartbox row → candidate, or `undefined`: the type whitelist drops derivatives
|
|
280
|
+
* and the key shape must agree with the type.
|
|
420
281
|
*/
|
|
421
282
|
function offshoreCandidateOf(row, market) {
|
|
422
283
|
if (typeof row?.code !== 'string' || row.code.length === 0) return undefined
|
|
423
284
|
if (!SMARTBOX_STOCK_TYPES.includes(row.type) && row.type !== SMARTBOX_INDEX_TYPE) return undefined
|
|
424
285
|
const isIndex = row.type === SMARTBOX_INDEX_TYPE
|
|
425
|
-
//
|
|
286
|
+
// Accept both index spellings; normalize to the bare code.
|
|
426
287
|
const code = row.code.trim().toUpperCase().replace(/^\./u, '')
|
|
427
288
|
const stem = isIndex
|
|
428
289
|
? (SMARTBOX_INDEX_CODE.test(code) ? code : undefined)
|
|
@@ -430,19 +291,16 @@ function offshoreCandidateOf(row, market) {
|
|
|
430
291
|
? fiveDigitHk(code)
|
|
431
292
|
: (SMARTBOX_US_IDENTITY.test(code) ? code : undefined)
|
|
432
293
|
if (stem === undefined) return undefined
|
|
433
|
-
//
|
|
434
|
-
// 不再补市场标记;其余四档都补(港股上游只回 `00700` / `HSI`,`.HK` 本来就是我们加的)。
|
|
294
|
+
// US stock keys are the echoed identity; the other forms get our market marker.
|
|
435
295
|
const thscode = market === 'us' && !isIndex ? stem : `${stem}.${market === 'hk' ? 'HK' : 'US'}`
|
|
436
296
|
const assetType = market === 'hk' ? (isIndex ? 'hk-index' : 'hk-stock') : isIndex ? 'us-index' : 'us-stock'
|
|
437
297
|
const entry = keyFormOf(thscode)
|
|
438
|
-
//
|
|
439
|
-
// 收进清单就是一行"加得进去、永远取不到数"的条目。宁可少一条候选。
|
|
298
|
+
// Shape and type must agree, or the row could be added but never quoted.
|
|
440
299
|
if (entry === null || !entry.assetTypes.includes(assetType)) return undefined
|
|
441
300
|
const name = typeof row.name === 'string' && row.name.length > 0 ? row.name : thscode
|
|
442
301
|
return { thscode, ticker: stemOf(thscode), name, exchange: market === 'hk' ? 'HK' : 'US', asset_type: assetType }
|
|
443
302
|
}
|
|
444
303
|
|
|
445
|
-
/** 港股代码的"5 位左补零"只有一份实现(内核 `normalizeHkCode`);判不成立回 `undefined`,不抛。 */
|
|
446
304
|
function fiveDigitHk(code) {
|
|
447
305
|
try {
|
|
448
306
|
return normalizeHkCode(code, 'smartbox.code')
|
|
@@ -452,10 +310,8 @@ function fiveDigitHk(code) {
|
|
|
452
310
|
}
|
|
453
311
|
|
|
454
312
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* 停牌 / 未开盘的标的会被写成一份"0.00 元、涨跌 0%"的新快照——顶掉上一次成功的值、页脚照报
|
|
458
|
-
* "刷新于此刻"、还不记任何失败。数字字符串照收(与 `matchesField` 的 number 分支同口径)。
|
|
313
|
+
* Never `Number(null)` (0): halted symbols would be written as a real flat quote.
|
|
314
|
+
* Numeric strings are accepted like `matchesField`.
|
|
459
315
|
*/
|
|
460
316
|
function numberValue(value) {
|
|
461
317
|
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
@@ -470,28 +326,19 @@ function quoteOf(row, sourceTs, capturedAt, currency) {
|
|
|
470
326
|
const pct = numberValue(row.price_change_ratio_pct)
|
|
471
327
|
const quote = {
|
|
472
328
|
price,
|
|
473
|
-
//
|
|
474
|
-
// 停牌 / 未开盘的标的,等于伪造一份"横盘"的新快照顶掉上一次的真涨跌,还不记任何失败。
|
|
475
|
-
// 客户端读到 null 画破折号(与"取不到报价"同一档,而不是红/绿/平三档里的平)。
|
|
329
|
+
// Missing change is missing, never 0: 0 is a real flat day for A-shares.
|
|
476
330
|
change_pct: pct ?? null,
|
|
477
331
|
captured_at: capturedAt,
|
|
478
332
|
source_ts: typeof sourceTs === 'number' ? sourceTs : null,
|
|
479
333
|
}
|
|
480
|
-
//
|
|
481
|
-
// 都说清币种(R9)。指数族不写——点位无量纲。港美股那一路的币种从回包取,见 `offshoreQuoteOf`。
|
|
334
|
+
// Fuyao snapshots carry no currency column; write `CNY` for stocks/ETFs only.
|
|
482
335
|
if (currency !== undefined) quote.currency = currency
|
|
483
336
|
return quote
|
|
484
337
|
}
|
|
485
338
|
|
|
486
339
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* `is_stale`(成交额 0 且现价等于昨收)落成 `null`,不自建"接近 0 就当没有"的猜测。
|
|
490
|
-
* 指数行没有 `is_stale` 这一格(内核不给成交额),所以平盘的指数照实回 `0.00`——那是真值。
|
|
491
|
-
* - **币种**:只有个股/ETF 读 `[75]` / `[35]`(实测 `-R` 柜台那批是 `CNY`,按市场猜必错);
|
|
492
|
-
* 指数族一律不写,上游在同一位置塞了 `HKD` / `USD` 也不接(点位不是钱)。
|
|
493
|
-
* - **时刻**:`source_time` 存交易所当地原串,`source_ts`(epoch)留给 Fuyao 那一路,两边不互相
|
|
494
|
-
* 伪造、不换算时区。
|
|
340
|
+
* Offshore quote: `is_stale` becomes null; currency for stocks/ETFs only;
|
|
341
|
+
* exchange-local time stays a raw string.
|
|
495
342
|
*/
|
|
496
343
|
function offshoreQuoteOf(row, capturedAt, { withCurrency }) {
|
|
497
344
|
const price = numberValue(row.price)
|
|
@@ -511,15 +358,8 @@ function offshoreQuoteOf(row, capturedAt, { withCurrency }) {
|
|
|
511
358
|
}
|
|
512
359
|
|
|
513
360
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
* @param options.openDomain - `() => Promise<Domain>`(host 里是 `storageDomain.open`)。
|
|
518
|
-
* @param options.resolveApiKey - `(ref: string) => Promise<string | undefined>`。
|
|
519
|
-
* @param options.credentialRef - `() => string`,取自 `capital-config` 卡片。
|
|
520
|
-
* @param options.baseUrl - Fuyao base URL;省略时用内核里的官方默认值。
|
|
521
|
-
* @param options.now - 时间源(测试可注入)。
|
|
522
|
-
* @param options.refreshBudgetMs - 一次刷新的总预算;省略时按这次要问的条数算(`refreshBudgetFor`)。
|
|
361
|
+
* Domain, key resolution, base URL and clock are injected, so store / route /
|
|
362
|
+
* network assertions avoid the real filesystem and network.
|
|
523
363
|
*/
|
|
524
364
|
export function createWatchlistService(options = {}) {
|
|
525
365
|
const {
|
|
@@ -533,14 +373,12 @@ export function createWatchlistService(options = {}) {
|
|
|
533
373
|
} = options
|
|
534
374
|
|
|
535
375
|
let domainPromise
|
|
536
|
-
/**
|
|
376
|
+
/** Single gate for panel open and manual refresh: later callers share the in-flight run. */
|
|
537
377
|
let refreshing = null
|
|
538
378
|
|
|
539
379
|
/**
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
* 把 rejection memoize 住就等于"装配期抢跑一次,功能锁到进程结束"(AGENTS.md §9.7 ⑤ 的形状,
|
|
543
|
-
* 与 webServer 那条同一个族)。facility 的 `open()` 失败时已经 `reserved.delete`,所以重试安全。
|
|
380
|
+
* Never memoize a failed open: `storageDomain` may not be provided yet during
|
|
381
|
+
* warm-up, and caching the rejection would disable the feature for the process.
|
|
544
382
|
*/
|
|
545
383
|
const domain = () => {
|
|
546
384
|
if (domainPromise === undefined) {
|
|
@@ -555,23 +393,13 @@ export function createWatchlistService(options = {}) {
|
|
|
555
393
|
const table = async () => (await domain()).table('items')
|
|
556
394
|
|
|
557
395
|
/**
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
* 两个并发 `/add` 都先通过 `size >= MAX_ITEMS`、再各写一条 → 清单能写成 31 条(2.5.2 是 11 条)。
|
|
561
|
-
* 刷新写回拿批次快照整条覆写、撤销并发删除与置顶,也是同一族(下面 `doRefresh` 里逐键重读那一处)。
|
|
562
|
-
*
|
|
563
|
-
* 出网**不进**这一道:只有"读当前记录 → 改 → 落盘"这几步排队。否则一次预算 90 秒的刷新会把
|
|
564
|
-
* 用户随后的删除与置顶全堵在门外,而那正是这次审查里用户体验最差的一条。
|
|
565
|
-
*
|
|
566
|
-
* ⛔ 射程只有本进程。跨进程(桌面端 App 与本机的另一个宿主 profile)共用同一个
|
|
567
|
-
* `~/.dsh/storages/capital_watchlist.json`,官方原子写只保证文件不撕裂、不保证不丢更新;
|
|
568
|
-
* 清单必须走官方 storage-domain,本仓不许自建文件锁或文件路径,所以这里的边界是**如实收窄**
|
|
569
|
-
* 而不是解决:写回改成逐键重读之后,跨进程丢失面从"整张清单"缩到"同一只标的的报价那一格"。
|
|
396
|
+
* Read-modify-write queue, in-process only; network stays outside it. Cross-process
|
|
397
|
+
* writes are not locked, but per-key re-read on write-back limits the loss to one row.
|
|
570
398
|
*/
|
|
571
399
|
let queue = Promise.resolve()
|
|
572
400
|
function serialize(task) {
|
|
573
401
|
const run = queue.then(task, task)
|
|
574
|
-
//
|
|
402
|
+
// Swallow this link's rejection so one failure cannot block the queue.
|
|
575
403
|
queue = run.then(() => {}, () => {})
|
|
576
404
|
return run
|
|
577
405
|
}
|
|
@@ -596,43 +424,31 @@ export function createWatchlistService(options = {}) {
|
|
|
596
424
|
}
|
|
597
425
|
|
|
598
426
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
* 因为上游对 `q` 是区分大小写的子串匹配——那是"三路"之外唯一会多出一笔的地方。
|
|
603
|
-
* 为什么 smartbox 不共用一次 `t=all`:实测 `t=all&q=富通`
|
|
604
|
-
* 回 9 条 A 股 + 1 条 hk + **0 条 us**,`q=00700` 只回 1 条 hk——共用一次会让"美股这一档根本没进
|
|
605
|
-
* 候选"从偶发变成常态,那是"入口统一"最直接的反面(设计文档 P17 / §3.2)。
|
|
606
|
-
* 每一路各自失败只进 `partial`,**只有三路全挂**才整块失败:没配 Fuyao 密钥时港美股照常可加
|
|
607
|
-
* 可刷(R12),`credential_missing` 的射程因此从整块面板缩到 A 股那一路。
|
|
427
|
+
* Three branches in parallel: one Fuyao `ticker_search` plus smartbox `t=hk`
|
|
428
|
+
* and `t=us` (they must not share `t=all`). Each branch fails independently;
|
|
429
|
+
* only all three failing fails the panel, so HK/US work without a Fuyao key.
|
|
608
430
|
*/
|
|
609
431
|
async function search(query) {
|
|
610
432
|
const q = typeof query === 'string' ? query.trim() : ''
|
|
611
433
|
if (q.length === 0 || q.length > MAX_QUERY_LENGTH) {
|
|
612
434
|
return { ok: false, code: 'invalid_query', message: '输入为空或过长' }
|
|
613
435
|
}
|
|
614
|
-
//
|
|
615
|
-
// 存储失败因此从这一路也走响亮那条(路由层的逐次 catch → store_unavailable),不靠预热闩住。
|
|
436
|
+
// Touch storage before the network so an unavailable domain does not burn quota.
|
|
616
437
|
const items = await table()
|
|
617
|
-
// `
|
|
618
|
-
// 哨兵——与"查无此票"同一个回文,拼错一次就是静默少一路(§3.6)。
|
|
438
|
+
// `t` has one source: an unknown value returns the `N` sentinel, i.e. a silent empty branch.
|
|
619
439
|
const branches = await Promise.all([searchAShare(q), ...SMARTBOX_MARKETS.map((market) => searchOffshore(q, market))])
|
|
620
440
|
const failures = branches.filter((branch) => branch.ok !== true)
|
|
621
441
|
if (failures.length === branches.length) {
|
|
622
|
-
//
|
|
442
|
+
// Only all-branch failure takes center stage; prefer the code the user can act on.
|
|
623
443
|
const worst = worstProblem(failures)
|
|
624
444
|
return { ok: false, code: worst.code, message: messageFor(worst.code, 'all'), scope: 'all' }
|
|
625
445
|
}
|
|
626
446
|
const merged = mergeCandidates(branches.filter((branch) => branch.ok === true).flatMap((branch) => branch.rows), q)
|
|
627
447
|
const result = {
|
|
628
448
|
ok: true,
|
|
629
|
-
//
|
|
630
|
-
// 由三路上游各自的硬顶定死(A 股 50 + 港 10 + 美 10 = 70),不需要再切一刀给"一屏"。
|
|
449
|
+
// No display slicing: the pool is bounded by upstream caps (50 + 10 + 10).
|
|
631
450
|
items: merged.map((row) => ({ ...row, in_list: items.get(row.thscode) !== undefined })),
|
|
632
|
-
// "
|
|
633
|
-
// 合并后的总条数不再参与判断——我们一行没藏,条数多不等于还有没显示的;
|
|
634
|
-
// "回满自己那档"由每一路在**它自己的那一笔**上算好带回来(A 股可能问了两笔,两笔各 50
|
|
635
|
-
// 合起来 100 时我们只留 50,那也算被砍,`saturated` 里已经记了)。
|
|
451
|
+
// "More" is truthful only when a branch filled its own cap.
|
|
636
452
|
truncated: branches.some((branch) => branch.ok === true && branch.saturated === true),
|
|
637
453
|
}
|
|
638
454
|
if (failures.length > 0) result.partial = failures.map((failure) => ({ market: failure.market, code: failure.code }))
|
|
@@ -640,23 +456,8 @@ export function createWatchlistService(options = {}) {
|
|
|
640
456
|
}
|
|
641
457
|
|
|
642
458
|
/**
|
|
643
|
-
* A
|
|
644
|
-
*
|
|
645
|
-
* **大小写要问两遍**(2026-10-08 真上游实测):Fuyao 的 `q` 是区分大小写的子串匹配——
|
|
646
|
-
* `q=tcl` 0 条而 `q=TCL` 3 条、`q=etf` 0 条而 `q=ETF` 50 条、`q=a50` 0 条而 `q=A50` 50 条、
|
|
647
|
-
* `q=600519.sh` 0 条而 `q=600519.SH` 1 条。而全量 8708 条 A 股 / 指数 / ETF 名称里含**小写**
|
|
648
|
-
* 拉丁字母的只有 8 条、且全是 `.TI` 概念指数(下一行就过滤掉),含大写的 2092 条——
|
|
649
|
-
* 所以大写那一支是现实的主力,原样那一支是"不丢小写专有命中"的保险:`q` 与 `q.toUpperCase()`
|
|
650
|
-
* 不同时**两笔并发**,按 canonical 键去重、原样那份排前面(保持"上游顺序"的既有口径);
|
|
651
|
-
* 两者相同(数字 / 中文 / 已经全大写)就只发一笔,不白烧一次配额。
|
|
652
|
-
*
|
|
653
|
-
* `saturated` 讲的是"**这一路被砍在自己那一档**",按笔 + 按并集一起判:某一笔回满 50 是上游
|
|
654
|
-
* 砍的;两笔并起来超过 50 时我们自己也会砍一刀(见下),那同样是"还有没显示的"。反过来说,
|
|
655
|
-
* 两笔各 15 条、并起来 30 条并不说明上游还有货——按合并后的条数判就会凭空给一句假 tip。
|
|
656
|
-
*
|
|
657
|
-
* 半边失败:**拿到候选就算这一路答上了**——`partial` 那行的文案是"A股搜索不可用",
|
|
658
|
-
* 与同屏的 A 股候选并排是自相矛盾;一支候选都没拿到时才走整路失败,绝不把"这一支没答上来"
|
|
659
|
-
* 说成"查无此票"(大写那支挂掉时,我们并不知道该词在 A 股到底有没有票)。
|
|
459
|
+
* A-share branch. Fuyao `q` is case-sensitive, so query raw + uppercase when they
|
|
460
|
+
* differ, raw first; rows count as answered once any candidate is found.
|
|
660
461
|
*/
|
|
661
462
|
async function searchAShare(q) {
|
|
662
463
|
const variants = [q, q.toUpperCase()].filter((value, index, all) => all.indexOf(value) === index)
|
|
@@ -664,8 +465,7 @@ export function createWatchlistService(options = {}) {
|
|
|
664
465
|
const outcomes = await Promise.all(variants.map(async (variant) => {
|
|
665
466
|
try {
|
|
666
467
|
const envelope = await callFuyao(ENDPOINTS.search, { q: variant, asset_type: SEARCH_ASSET_TYPES, limit }, ['q', 'asset_type', 'limit'])
|
|
667
|
-
//
|
|
668
|
-
// 它既不在本版报价范围、也过不了域 schema——不挡掉就是"添加"那一下 500。
|
|
468
|
+
// Drop out-of-scope suffixes (e.g. `.TI` concept indices): the domain schema rejects them.
|
|
669
469
|
const rows = fuyaoItems(envelope).map(candidateOf).filter((row) => ASSET_TYPES.includes(row.asset_type) && normalizeThscode(row.thscode) !== undefined)
|
|
670
470
|
return { rows, saturated: rows.length >= limit, code: undefined }
|
|
671
471
|
} catch (error) {
|
|
@@ -683,14 +483,12 @@ export function createWatchlistService(options = {}) {
|
|
|
683
483
|
}
|
|
684
484
|
const codes = outcomes.filter((outcome) => outcome.code !== undefined).map((outcome) => outcome.code)
|
|
685
485
|
if (union.length === 0 && codes.length > 0) return { ok: false, market: 'a-share', code: worstErrorCode(codes) }
|
|
686
|
-
//
|
|
687
|
-
// 50 + 10 + 10 = 70(`SEARCH_BRANCH_LIMITS` 那条注释里的算法)。自己砍掉的那几行同样算"被砍",
|
|
688
|
-
// 否则 truncated 会在还有没显示的行时说"就这些"。
|
|
486
|
+
// Union also caps at this branch's limit; our own slice still counts as saturated.
|
|
689
487
|
const saturated = outcomes.some((outcome) => outcome.saturated) || union.length > limit
|
|
690
488
|
return { ok: true, market: 'a-share', rows: union.slice(0, limit), saturated }
|
|
691
489
|
}
|
|
692
490
|
|
|
693
|
-
/** smartbox
|
|
491
|
+
/** smartbox branch: type whitelist plus key shape; derivatives never reach candidates. */
|
|
694
492
|
async function searchOffshore(q, market) {
|
|
695
493
|
try {
|
|
696
494
|
const found = await fetchSmartbox(q, market, AbortSignal.timeout(timeoutMs))
|
|
@@ -701,11 +499,7 @@ export function createWatchlistService(options = {}) {
|
|
|
701
499
|
}
|
|
702
500
|
}
|
|
703
501
|
|
|
704
|
-
/**
|
|
705
|
-
* 合并三路:按 canonical 键去重(同一只票从任一路加过,另一路那条也标"已添加"),
|
|
706
|
-
* **输入完全等于代码的行排最前**(比对到 stem:`00700` 要认 `00700.HK`、`TCEHY` 要认 `TCEHY.PS`),
|
|
707
|
-
* 其余保持上游顺序(A 股 → 港 → 美)。`Array.prototype.sort` 是稳定排序,同分不换序。
|
|
708
|
-
*/
|
|
502
|
+
/** Dedupe by key; rows equal to the query (by stem) first, then stable upstream order. */
|
|
709
503
|
function mergeCandidates(rows, q) {
|
|
710
504
|
const seen = new Map()
|
|
711
505
|
for (const row of rows) {
|
|
@@ -720,7 +514,7 @@ export function createWatchlistService(options = {}) {
|
|
|
720
514
|
return stemOf(row.thscode) === needle ? 1 : 0
|
|
721
515
|
}
|
|
722
516
|
|
|
723
|
-
/**
|
|
517
|
+
/** Pick the most actionable code when every branch failed. */
|
|
724
518
|
function worstErrorCode(codes) {
|
|
725
519
|
for (const candidate of ['credential_missing', 'rate_limited', 'invalid_query', 'fuyao_unavailable', 'tencent_unavailable']) {
|
|
726
520
|
if (codes.includes(candidate)) return candidate
|
|
@@ -731,19 +525,16 @@ export function createWatchlistService(options = {}) {
|
|
|
731
525
|
async function list() {
|
|
732
526
|
await ensureSeeded()
|
|
733
527
|
const opened = await domain()
|
|
734
|
-
//
|
|
735
|
-
// 面板那句话和 `get_watchlist` 那句都不许自己抄一份。
|
|
528
|
+
// Limit travels in the response so client copy never duplicates the number.
|
|
736
529
|
return { ok: true, items: rowsOf(opened.table('items')), seeded_at: opened.global.get().seeded_at, limit: MAX_ITEMS }
|
|
737
530
|
}
|
|
738
531
|
|
|
739
532
|
async function add(input) {
|
|
740
533
|
await ensureSeeded()
|
|
741
534
|
const items = await table()
|
|
742
|
-
// `limit` 跟着回包走:客户端那句「最多 N 条,先删一条再加」不许自己抄一份数字(2.5.2 就是
|
|
743
|
-
// 服务端常量 10 + 中文字典写死"10"两处各写一遍,抬上限只会改红一边)。
|
|
744
535
|
const full = () => ({ ok: false, code: 'list_full', limit: MAX_ITEMS, message: `自选股最多 ${MAX_ITEMS} 条,先删一条再加` })
|
|
745
536
|
|
|
746
|
-
//
|
|
537
|
+
// Early exit avoids a search, but concurrency means the lock still re-checks.
|
|
747
538
|
if (items.size >= MAX_ITEMS) return full()
|
|
748
539
|
|
|
749
540
|
const direct = input?.thscode
|
|
@@ -753,15 +544,14 @@ export function createWatchlistService(options = {}) {
|
|
|
753
544
|
return { ok: false, code: 'invalid_query', message: '代码格式不符:要带市场或交易所后缀的完整代码,例如 300750.SZ / 00700.HK / HSI.HK / AAPL.OQ / IXIC.US' }
|
|
754
545
|
}
|
|
755
546
|
const entry = keyFormOf(thscode)
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
// `TCEHY.PS`、`HSI.HK`、`IXIC.US` 全都不回行),拿它当"查无此票"会把一只真票判成不存在。
|
|
547
|
+
// Existence check uses the upstream-accepted spelling: full code for A-shares,
|
|
548
|
+
// stem for HK/US (canonical keys return the `N` sentinel).
|
|
759
549
|
const found = await search(entry.form === 'a-share' ? thscode : stemOf(thscode))
|
|
760
550
|
if (!found.ok) return found
|
|
761
551
|
const hit = found.items.find((row) => row.thscode === thscode)
|
|
762
552
|
if (hit === undefined) return { ok: false, code: 'not_found', message: `该标的不在本版支持范围(${FORM_SCOPE_TEXT[entry.form] ?? '可检索的证券代码'})` }
|
|
763
553
|
return serialize(() => {
|
|
764
|
-
//
|
|
554
|
+
// Up to 15s of network passed: re-check the cap inside the lock.
|
|
765
555
|
if (items.size >= MAX_ITEMS) return full()
|
|
766
556
|
return write(hit, items)
|
|
767
557
|
})
|
|
@@ -803,17 +593,14 @@ export function createWatchlistService(options = {}) {
|
|
|
803
593
|
}
|
|
804
594
|
|
|
805
595
|
/**
|
|
806
|
-
*
|
|
807
|
-
* 不把"键挪到最前"当成实现:顺序是读法的一部分,写进去就要求整表重写。
|
|
808
|
-
* 重复置顶是幂等的用户动作(只是把时间戳推新,行不会跳走),`not_found` 则响亮失败。
|
|
596
|
+
* Pin = timestamp; ordering belongs to `rowsOf` (no key moves, no table rewrite).
|
|
809
597
|
*/
|
|
810
598
|
async function pin(input) {
|
|
811
599
|
await ensureSeeded()
|
|
812
600
|
const items = await table()
|
|
813
601
|
const thscode = normalizeThscode(input?.thscode)
|
|
814
602
|
if (thscode === undefined) return { ok: false, code: 'invalid_query', message: '代码格式不符' }
|
|
815
|
-
//
|
|
816
|
-
// 置顶会把刚删掉的标的连同它的旧报价一起写回来(和刷新写回同一族,见 `doRefresh`)。
|
|
603
|
+
// Read-modify-write inside the lock, or a concurrent delete gets resurrected.
|
|
817
604
|
return serialize(async () => {
|
|
818
605
|
const existing = items.get(thscode)
|
|
819
606
|
if (existing === undefined) return { ok: false, code: 'not_found', message: '该标的不在自选清单里' }
|
|
@@ -823,22 +610,9 @@ export function createWatchlistService(options = {}) {
|
|
|
823
610
|
}
|
|
824
611
|
|
|
825
612
|
/**
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
* 与 `pin()` 同一条读—改—写口径:**锁内逐键重读**——刷新在途刚被用户删掉的那一行不许被旧记录
|
|
830
|
-
* 连同持仓一起写回来(那还会重新占掉一格)。比例只收 0–100,越界与"根本不是数"都回
|
|
831
|
-
* `invalid_query`:这一格是用户自报的权重,我们不替用户核对券商账,但**也不接一个明显不成立的数**。
|
|
832
|
-
* ⛔ `Number(null)` 是 0——空值一律先在这外面判成"未填",绝不放进 `numberValue` 硬转。
|
|
833
|
-
*
|
|
834
|
-
* 还有一道**类型闸门**(`isHoldable`,与面板共用 `./holding-rules.js` 那一份):指数不是一个可以
|
|
835
|
-
* 持有并配比率的标的。`marked_at` 每次写入都重打——它是"这句话最后一次说出来的时刻",
|
|
836
|
-
* 不是"仓位建立日";用户把 20% 调成 30%,那句自报的话确实就是刚刚说的。
|
|
837
|
-
*
|
|
838
|
-
* 第三道是**合计闸门**(`weightCeiling`,同一份模块):只做多、没有 put,所以全组合的自报占比
|
|
839
|
-
* 合计封顶 120%(2026-10-09 用户点名"我居然可以对多只标的都填超过 50%")。额度按**其余持仓**
|
|
840
|
-
* 算,不含这一格自己,所以 lowering 永远走得通。⛔ 勾上但没填(`weight === null`)不加任何敞口,
|
|
841
|
-
* 因此**不因合计被拒**——否则一条手改出来的超额数据会把"先勾上再慢慢填"这条路一起堵死。
|
|
613
|
+
* Mark holding: empty weight stays `null`, never 0; `clearHolding` removes the
|
|
614
|
+
* field. Gates: 0–100 weight, holdable type, total ceiling (unset weight adds no
|
|
615
|
+
* exposure), all evaluated inside the lock after `not_found`.
|
|
842
616
|
*/
|
|
843
617
|
async function markHolding(input) {
|
|
844
618
|
await ensureSeeded()
|
|
@@ -857,16 +631,11 @@ export function createWatchlistService(options = {}) {
|
|
|
857
631
|
return serialize(async () => {
|
|
858
632
|
const existing = items.get(thscode)
|
|
859
633
|
if (existing === undefined) return { ok: false, code: 'not_found', message: '该标的不在自选清单里' }
|
|
860
|
-
//
|
|
861
|
-
// 不是"指数不能标持仓"——后者会把人引去查类型,而真正的问题是那一行不存在。
|
|
862
|
-
// `existing.holding === undefined` 这一半是给"重标"留的门:已经标过的行(包括在旧版本或
|
|
863
|
-
// 手改数据里标上的指数)改比例仍走得通,⛔ 否则那一格会退不掉——面板不画取消出口、宿主又不收。
|
|
864
|
-
// 只拦"标"、不拦"取消"(见 `clearHolding`):抹一格不声明任何领域事实。
|
|
634
|
+
// Type gate after `not_found`; legacy holdings stay editable so they can be removed.
|
|
865
635
|
if (existing.holding === undefined && !isHoldable(existing)) {
|
|
866
636
|
return { ok: false, code: 'invalid_query', message: '这一条不是一个可以持有并配比率的标的(指数是市场读数,不是券),因此不能标记为持仓' }
|
|
867
637
|
}
|
|
868
|
-
//
|
|
869
|
-
// 被并发写入各放一只过去(两行都看到"还有额度",落盘的合计却超了)。
|
|
638
|
+
// Ceiling also inside the lock: concurrent writes would otherwise each see room.
|
|
870
639
|
if (weight !== null) {
|
|
871
640
|
const rows = rowsOf(items)
|
|
872
641
|
const ceiling = weightCeiling(rows, thscode)
|
|
@@ -881,15 +650,8 @@ export function createWatchlistService(options = {}) {
|
|
|
881
650
|
}
|
|
882
651
|
|
|
883
652
|
/**
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* 不删键再插:官方单文件后端是 `Map` + 整份 JSON 原子写,`put` 已有键保留原位置,
|
|
887
|
-
* 而删了重插会把这一行甩到插入顺序的末尾——用户取消一个持仓标记,清单却跟着重排,
|
|
888
|
-
* 那是"动作与位移对不上"的静默副作用(与 `pin()` 不改键序同一条理由,设计文档 §3.2)。
|
|
889
|
-
*
|
|
890
|
-
* ⛔ 这里**故意不查类型**:`markHolding` 拒的是"把指数说成仓位"这句新话,`clearHolding` 只是
|
|
891
|
-
* 不再说那句话。两边都拦,一条遗留或手改出来的"指数持仓"就永远退不掉——面板不画取消出口
|
|
892
|
-
* (它按类型决定画不画),宿主又拒绝写,那一格被锁死在域里。
|
|
653
|
+
* Clear by writing the record without `holding`; no delete/reinsert, so order is
|
|
654
|
+
* preserved. Deliberately no type gate: legacy index holdings must be removable.
|
|
893
655
|
*/
|
|
894
656
|
async function clearHolding(input) {
|
|
895
657
|
await ensureSeeded()
|
|
@@ -906,7 +668,7 @@ export function createWatchlistService(options = {}) {
|
|
|
906
668
|
})
|
|
907
669
|
}
|
|
908
670
|
|
|
909
|
-
/**
|
|
671
|
+
/** One group = one snapshot request (only ETF is per-symbol); group failure stays local. */
|
|
910
672
|
function fetchGroup(group) {
|
|
911
673
|
return group.kind === 'tencent' ? fetchOffshoreGroup(group) : fetchFuyaoGroup(group)
|
|
912
674
|
}
|
|
@@ -940,12 +702,8 @@ export function createWatchlistService(options = {}) {
|
|
|
940
702
|
}
|
|
941
703
|
|
|
942
704
|
/**
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
* 回包第 2 位推回来的 canonical 键必须正是我们问出去的那一只,否则这一行**不落价**,
|
|
946
|
-
* 让它照实落在 `quote_unavailable`(设计文档 §3.4 / §3.9 第 4 点:实测 `usSSPX` 会回一只
|
|
947
|
-
* 真 ETF 且不报错,只比 stem 抓不住,形状由内核的守卫给、身份由这一句"必须是问出去的那只"给)。
|
|
948
|
-
* 这一句同时就是日志:面板那一格写着"取不到报价",用户看得见,不存在静默丢弃。
|
|
705
|
+
* One batch per offshore family. The core supplies quotes and guards; this layer
|
|
706
|
+
* only ensures the returned key is exactly the one requested.
|
|
949
707
|
*/
|
|
950
708
|
async function fetchOffshoreGroup(group) {
|
|
951
709
|
const capturedAt = now()
|
|
@@ -977,9 +735,8 @@ export function createWatchlistService(options = {}) {
|
|
|
977
735
|
}
|
|
978
736
|
|
|
979
737
|
/**
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
* 一次会拿到上一次的结果——用户看到的症状是"点了刷新没反应"。
|
|
738
|
+
* Single gate for panel open, manual refresh and post-add refresh; reset on the
|
|
739
|
+
* same settle chain, or the next click returns the previous run.
|
|
983
740
|
*/
|
|
984
741
|
function refresh() {
|
|
985
742
|
if (refreshing !== null) return refreshing
|
|
@@ -994,14 +751,11 @@ export function createWatchlistService(options = {}) {
|
|
|
994
751
|
await ensureSeeded()
|
|
995
752
|
const opened = await domain()
|
|
996
753
|
const items = opened.table('items')
|
|
997
|
-
//
|
|
754
|
+
// Snapshot only decides which codes to ask for; write-back re-reads per key.
|
|
998
755
|
const batch = rowsOf(items)
|
|
999
756
|
if (batch.length === 0) return { ok: true, items: [], refreshed_at: null, failures: [] }
|
|
1000
757
|
|
|
1001
|
-
//
|
|
1002
|
-
// 美个股与美指数各一次批量。分派判据是**键的形状与 `asset_type` 互相认账**(同一张 `KEY_FORMS`
|
|
1003
|
-
// 表,与加入清单时同一条),不认账的行不进任何一组、照实记 `quote_unavailable`——
|
|
1004
|
-
// 磁盘上被人改过的那一格不能让一行港股流进 Fuyao 端点(那边根本不认它,反之亦然)。
|
|
758
|
+
// Seven groups dispatch on key shape + asset_type; disagreement stays `quote_unavailable`.
|
|
1005
759
|
const groups = []
|
|
1006
760
|
const failures = []
|
|
1007
761
|
const keysByType = new Map()
|
|
@@ -1034,23 +788,21 @@ export function createWatchlistService(options = {}) {
|
|
|
1034
788
|
const budgetMs = refreshBudgetMs ?? refreshBudgetFor(batch.length)
|
|
1035
789
|
const startedAt = now()
|
|
1036
790
|
const results = new Array(groups.length)
|
|
1037
|
-
// `cursor`
|
|
791
|
+
// `cursor` increments atomically on one thread: each worker gets an exclusive group.
|
|
1038
792
|
let cursor = 0
|
|
1039
793
|
const worker = async () => {
|
|
1040
794
|
for (;;) {
|
|
1041
795
|
const index = cursor
|
|
1042
796
|
cursor += 1
|
|
1043
797
|
if (index >= groups.length) return
|
|
1044
|
-
// 0
|
|
1045
|
-
// 剩下的行如实记 refresh_timeout——它们这次没被问过,不是"没有报价"。
|
|
798
|
+
// Group 0 always runs; later groups check the budget before being asked.
|
|
1046
799
|
if (index > 0 && now() - startedAt >= budgetMs) return
|
|
1047
800
|
results[index] = await fetchGroup(groups[index])
|
|
1048
801
|
}
|
|
1049
802
|
}
|
|
1050
803
|
await Promise.all(Array.from({ length: Math.min(REFRESH_CONCURRENCY, groups.length) }, () => worker()))
|
|
1051
804
|
|
|
1052
|
-
//
|
|
1053
|
-
// 并发不能把它变成"谁先回来谁在前"。
|
|
805
|
+
// Merge by group order, not completion order: failures order is part of the contract.
|
|
1054
806
|
const quotes = new Map()
|
|
1055
807
|
const problems = []
|
|
1056
808
|
let failedGroups = 0
|
|
@@ -1068,25 +820,20 @@ export function createWatchlistService(options = {}) {
|
|
|
1068
820
|
}
|
|
1069
821
|
}
|
|
1070
822
|
|
|
1071
|
-
//
|
|
1072
|
-
// 拿批次快照整条覆写会撤销刷新在途期间用户的两个动作——刚删的标的被原样写回来
|
|
1073
|
-
// (还重新占掉一格),刚置顶的被旧记录里的空 pinned_at 盖掉、静默掉回原位。
|
|
1074
|
-
// 面板一打开就自动刷新,所以这扇窗几乎每次都开着;ETF 逐只扇出时它最宽。
|
|
823
|
+
// Write-back re-reads each key; a batch overwrite would undo in-flight deletes and pins.
|
|
1075
824
|
const written = await serialize(async () => {
|
|
1076
825
|
let count = 0
|
|
1077
826
|
for (const [thscode, quote] of quotes) {
|
|
1078
827
|
const fresh = items.get(thscode)
|
|
1079
|
-
if (fresh === undefined) continue //
|
|
828
|
+
if (fresh === undefined) continue // Deleted in flight: do not resurrect or take a slot.
|
|
1080
829
|
await items.put(thscode, { ...fresh, quote })
|
|
1081
830
|
count += 1
|
|
1082
831
|
}
|
|
1083
832
|
return { count, items: rowsOf(items) }
|
|
1084
833
|
})
|
|
1085
|
-
//
|
|
1086
|
-
// 跟着一次全失败的点击往前走,就成了每一行都陈旧、只有页脚是新的(客户端据此保留上一个值)。
|
|
834
|
+
// Nothing landed = no "refreshed at" timestamp: the footer tracks the last real success.
|
|
1087
835
|
const result = { ok: true, items: written.items, refreshed_at: written.count > 0 ? now() : null, failures }
|
|
1088
|
-
//
|
|
1089
|
-
// 面板据此决定要不要占用居中位。把"半边失败"说成"服务不可用"就是面板对用户说谎。
|
|
836
|
+
// `scope: 'all'` only when every group failed; half-failure must not claim "service down".
|
|
1090
837
|
if (problems.length > 0) {
|
|
1091
838
|
const worst = worstProblem(problems)
|
|
1092
839
|
const scope = failedGroups === groups.length && groups.length > 0 ? 'all' : worst.scope
|
|
@@ -1112,16 +859,13 @@ export function createWatchlistService(options = {}) {
|
|
|
1112
859
|
}
|
|
1113
860
|
}
|
|
1114
861
|
|
|
1115
|
-
/**
|
|
862
|
+
/** Only whole-batch failures become batch errors (rate limit, missing key, service down). */
|
|
1116
863
|
function batchLevelCode(error, source = 'fuyao') {
|
|
1117
864
|
const code = watchlistErrorCode(error, { source })
|
|
1118
865
|
return ['credential_missing', 'rate_limited', 'fuyao_unavailable', 'tencent_unavailable'].includes(code) ? code : undefined
|
|
1119
866
|
}
|
|
1120
867
|
|
|
1121
|
-
/**
|
|
1122
|
-
* 多路失败时挑一句最能指导动作的:密钥缺失是唯一"用户现在就能自己修"的,其次是限流,
|
|
1123
|
-
* 最后才是服务不通。`scope` 跟着这一条走(谁最该被修,就报谁的射程)。
|
|
1124
|
-
*/
|
|
868
|
+
/** Most actionable problem wins: missing key, then rate limit, then service down. */
|
|
1125
869
|
const ERROR_CODE_PRIORITY = ['credential_missing', 'rate_limited', 'invalid_query', 'fuyao_unavailable', 'tencent_unavailable']
|
|
1126
870
|
function worstProblem(problems) {
|
|
1127
871
|
for (const code of ERROR_CODE_PRIORITY) {
|
|
@@ -1131,16 +875,16 @@ function worstProblem(problems) {
|
|
|
1131
875
|
return problems[0]
|
|
1132
876
|
}
|
|
1133
877
|
|
|
1134
|
-
/**
|
|
878
|
+
/** Error code → user copy; `scope` only decides whether to name the failing branch. */
|
|
1135
879
|
function messageFor(code, scope) {
|
|
1136
|
-
if (code === 'credential_missing') return '未配置 Fuyao 密钥:在「插件」页的
|
|
880
|
+
if (code === 'credential_missing') return '未配置 Fuyao 密钥:在「插件」页的 自动模式卡片里填 API Key(只影响 A 股那一路,港美股照常用)'
|
|
1137
881
|
if (code === 'rate_limited') return '行情服务限流,请稍后再试'
|
|
1138
882
|
if (code === 'invalid_query') return '输入不被接受'
|
|
1139
883
|
if (code === 'tencent_unavailable') return scope === 'a-share' ? '行情服务暂时不可用,请稍后再试' : '港美股行情暂时取不到,请稍后再试'
|
|
1140
884
|
return '行情服务暂时不可用,请稍后再试'
|
|
1141
885
|
}
|
|
1142
886
|
|
|
1143
|
-
/**
|
|
887
|
+
/** Read the configured Fuyao ref from `capital-config`; falls back to the default name. */
|
|
1144
888
|
export function readCredentialRef(ctx) {
|
|
1145
889
|
try {
|
|
1146
890
|
const settings = ctx.get('settings')
|
|
@@ -1184,10 +928,8 @@ const FAILURE_STATUS = {
|
|
|
1184
928
|
}
|
|
1185
929
|
|
|
1186
930
|
/**
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
* @param service - `createWatchlistService` 的返回。
|
|
1190
|
-
* @param options.authorize - `(req) => 401 | 403 | undefined`,接 `connection.requestRejection`。
|
|
931
|
+
* GET uses query params, POST a JSON body. `authorize` is
|
|
932
|
+
* `connection.requestRejection` and runs before any service call.
|
|
1191
933
|
*/
|
|
1192
934
|
export function createRouteHandler(service, options = {}) {
|
|
1193
935
|
const { authorize } = options
|
|
@@ -1241,14 +983,12 @@ export function createRouteHandler(service, options = {}) {
|
|
|
1241
983
|
if (method === 'POST' && pathname === '/remove') return sendBusiness(res, await service.remove(input))
|
|
1242
984
|
if (method === 'POST' && pathname === '/pin') return sendBusiness(res, await service.pin(input))
|
|
1243
985
|
if (method === 'POST' && pathname === '/holding') {
|
|
1244
|
-
//
|
|
1245
|
-
// 我的持仓"),拆两条要多一套字典与一份围栏回归,而两者差别只是写不写那一格。
|
|
986
|
+
// One route for mark and clear: the panel has one concept, split only by `clear`.
|
|
1246
987
|
const next = input?.clear === true ? service.clearHolding(input) : service.markHolding(input)
|
|
1247
988
|
return sendBusiness(res, await next)
|
|
1248
989
|
}
|
|
1249
990
|
if (method === 'POST' && pathname === '/refresh') {
|
|
1250
|
-
//
|
|
1251
|
-
// 面板按 error.code 居中显示,绝不清空列表。
|
|
991
|
+
// Refresh is "partial success + per-row failures": always 200 with items and error.
|
|
1252
992
|
return send(res, 200, await service.refresh())
|
|
1253
993
|
}
|
|
1254
994
|
if (method === 'GET' && pathname === '/list') return send(res, 200, await service.list())
|
|
@@ -1260,11 +1000,7 @@ export function createRouteHandler(service, options = {}) {
|
|
|
1260
1000
|
}
|
|
1261
1001
|
}
|
|
1262
1002
|
|
|
1263
|
-
/**
|
|
1264
|
-
* 存储侧失败的对外一句话:code 与 message 都要留。只留 code 运维看的是天书(实测
|
|
1265
|
-
* `malformed-medium` 单独出现时完全指不出是哪份介质、哪一步),只留 message 又丢掉
|
|
1266
|
-
* `backend-not-found` / `invalid-record` 这类可定位的判据。
|
|
1267
|
-
*/
|
|
1003
|
+
/** Storage failure copy keeps both code (locatable) and message (human-readable). */
|
|
1268
1004
|
function storeCause(error) {
|
|
1269
1005
|
return [error?.code, error?.message ?? error].filter(Boolean).join(':') || 'unknown'
|
|
1270
1006
|
}
|
|
@@ -1299,17 +1035,13 @@ export function apply(ctx) {
|
|
|
1299
1035
|
|
|
1300
1036
|
ctx.provide('capitalWatchlist', service)
|
|
1301
1037
|
|
|
1302
|
-
//
|
|
1303
|
-
// catch 里(`store_unavailable`),而预热跑在装配期,此刻 `storageDomain` 完全可能还没
|
|
1304
|
-
// provide——把这一次失败当结论钉住,就是"抢跑一次锁到进程结束"(见 `domain()`)。
|
|
1038
|
+
// Warm-up logs domain failures only; no latch — each route retries and reports loudly.
|
|
1305
1039
|
service.list().catch((error) => {
|
|
1306
1040
|
ctx.logger?.error?.(`capital-watchlist: 域预热失败,下一次动作会重试(${storeCause(error)})`)
|
|
1307
1041
|
})
|
|
1308
1042
|
|
|
1309
1043
|
/**
|
|
1310
|
-
*
|
|
1311
|
-
* 提供),把它的 `requestRejection` 接到本路由上——trusted-Host(403)+ 签名 cookie(401)。
|
|
1312
|
-
* 惰性解析:`connection` 与本行的挂载先后不由我们决定。
|
|
1044
|
+
* Resolve `connection` lazily; absent = no auth plane, throwing = fail closed 401.
|
|
1313
1045
|
*/
|
|
1314
1046
|
const authorize = (req) => {
|
|
1315
1047
|
let connection
|
|
@@ -1338,12 +1070,10 @@ export function apply(ctx) {
|
|
|
1338
1070
|
'capital-watchlist: route',
|
|
1339
1071
|
)
|
|
1340
1072
|
}
|
|
1341
|
-
//
|
|
1342
|
-
// "本层声明过 inject")。先探测再直接调用 = 把正确性押在装配顺序上,桌面端就是这么炸的
|
|
1343
|
-
// (2026-09-30 实机,与 capital-charts 同一处形状)。
|
|
1073
|
+
// Only via inject: a resolved `ctx.get('webServer')` does not make `ctx.webServer` legal.
|
|
1344
1074
|
ctx.inject(['webServer'], registerRoute)
|
|
1345
1075
|
|
|
1346
|
-
//
|
|
1076
|
+
// The domain is ours to close; a duplicate open of the same name reports already-open.
|
|
1347
1077
|
ctx.effect(() => () => {
|
|
1348
1078
|
service.close().catch(() => {})
|
|
1349
1079
|
}, 'capital-watchlist: domain')
|