a-share-after-hours-brief-skill 0.1.0
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/LICENSE +21 -0
- package/README.md +169 -0
- package/SKILL.md +85 -0
- package/agents/openai.yaml +8 -0
- package/assets/brief-template.html +106 -0
- package/assets/plain-email-summary-template.md +29 -0
- package/bin/install.js +130 -0
- package/package.json +43 -0
- package/references/event-triggers.md +43 -0
- package/references/history-and-review.md +111 -0
- package/references/html-email.md +38 -0
- package/references/industry-news.md +19 -0
- package/references/wind-data.md +68 -0
- package/scripts/correlation.py +136 -0
- package/scripts/review_journal.py +339 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 a-share-after-hours-brief contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# A Share After-Hours Brief Skill
|
|
2
|
+
|
|
3
|
+
一个面向 Codex / Agent Skills 的 A 股个股盘后复盘 Skill。它聚焦一只或多只指定 A 股,生成移动端友好的 HTML 盘后复盘,并用本地 JSON 记录上一期判断,便于下次自动校验。
|
|
4
|
+
|
|
5
|
+
This is a Codex / Agent Skill for after-hours reviews of specified China A-share stocks. It generates a mobile-friendly HTML brief and keeps portable JSON history for previous-judgment validation.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 指定 A 股个股或股票池盘后复盘
|
|
10
|
+
- 上一期判断校验:`已验证 / 部分验证 / 未验证 / 已失效 / 无法判断`
|
|
11
|
+
- 本地 JSON 历史记录,默认保存到 HTML 输出目录的 `history/`
|
|
12
|
+
- 精简市场和行业背景,用于区分市场、行业和个股因素
|
|
13
|
+
- 重大事项、公告、行业新闻和相关性分析
|
|
14
|
+
- 移动端友好的 HTML 报告模板
|
|
15
|
+
- 可选 Gmail 草稿正文模板
|
|
16
|
+
- 可选持仓纪律检查,仅在用户提供持仓或交易记录时启用
|
|
17
|
+
|
|
18
|
+
## What This Skill Does Not Do
|
|
19
|
+
|
|
20
|
+
- 不提供默认买卖建议
|
|
21
|
+
- 不执行交易
|
|
22
|
+
- 不预测 1-3 日价格走势
|
|
23
|
+
- 不做完整全市场复盘
|
|
24
|
+
- 不上传或集中存储用户的历史记录
|
|
25
|
+
- 不内置 Wind、Gmail 或其他服务凭证
|
|
26
|
+
|
|
27
|
+
## Directory Structure
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
a-share-after-hours-brief/
|
|
31
|
+
├── SKILL.md
|
|
32
|
+
├── agents/
|
|
33
|
+
│ └── openai.yaml
|
|
34
|
+
├── assets/
|
|
35
|
+
│ ├── brief-template.html
|
|
36
|
+
│ └── plain-email-summary-template.md
|
|
37
|
+
├── references/
|
|
38
|
+
│ ├── event-triggers.md
|
|
39
|
+
│ ├── history-and-review.md
|
|
40
|
+
│ ├── html-email.md
|
|
41
|
+
│ ├── industry-news.md
|
|
42
|
+
│ └── wind-data.md
|
|
43
|
+
└── scripts/
|
|
44
|
+
├── correlation.py
|
|
45
|
+
└── review_journal.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Python 3.10+
|
|
51
|
+
- A Codex / Agent Skills-compatible client
|
|
52
|
+
- Wind data access if you want live A-share market, announcement, news, and K-line data
|
|
53
|
+
- Gmail connector only if you want draft email delivery
|
|
54
|
+
|
|
55
|
+
The bundled scripts use Python standard library only.
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
Copy this folder into your Codex skills directory:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cp -R a-share-after-hours-brief ~/.codex/skills/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or keep it in another skills directory that your Agent client can load.
|
|
66
|
+
|
|
67
|
+
### Install with npm / npx
|
|
68
|
+
|
|
69
|
+
After this package is published to npm:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx a-share-after-hours-brief-skill install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This installs the Skill to:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
~/.codex/skills/a-share-after-hours-brief/
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To overwrite an existing installation:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx a-share-after-hours-brief-skill install --force
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
To install into a custom skills root:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx a-share-after-hours-brief-skill install --target /path/to/skills
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage Examples
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
复盘今天的宁德时代
|
|
97
|
+
今天A股股票A和股票B盘后总结,输出HTML
|
|
98
|
+
校验上次判断,列出下一交易日验证条件
|
|
99
|
+
复盘这几只A股,并创建Gmail草稿
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## JSON History
|
|
103
|
+
|
|
104
|
+
By default, history is stored next to the generated HTML report:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
reports/
|
|
108
|
+
├── 2026-06-16_A股盘后复盘.html
|
|
109
|
+
└── history/
|
|
110
|
+
└── 2026-06-16__300750-SZ_600519-SH.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
History files are portable:
|
|
114
|
+
|
|
115
|
+
- No absolute local paths are stored.
|
|
116
|
+
- The whole report folder can be moved or backed up.
|
|
117
|
+
- Markdown history is not generated by default.
|
|
118
|
+
- JSON is the only structured history source.
|
|
119
|
+
|
|
120
|
+
## Scripts
|
|
121
|
+
|
|
122
|
+
Calculate correlation from two K-line files:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 scripts/correlation.py stock_a.json stock_b.json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Look up previous history:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python3 scripts/review_journal.py lookup \
|
|
132
|
+
--history-dir ./reports/history \
|
|
133
|
+
--before-date 2026-06-16 \
|
|
134
|
+
--stocks 300750.SZ,600519.SH
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Build and save a current history record:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python3 scripts/review_journal.py build \
|
|
141
|
+
--input current-draft.json \
|
|
142
|
+
--output-html ./reports/2026-06-16_A股盘后复盘.html
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Validation
|
|
146
|
+
|
|
147
|
+
If you have the Skill Creator validation script available:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
python3 /path/to/skill-creator/scripts/quick_validate.py ./a-share-after-hours-brief
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Also check script syntax:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python3 -m py_compile scripts/review_journal.py scripts/correlation.py
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Data and Privacy
|
|
160
|
+
|
|
161
|
+
Do not commit generated reports or `history/*.json` if they contain private research, holdings, or trading information. This repository is intended to publish the reusable Skill logic and templates only.
|
|
162
|
+
|
|
163
|
+
## Disclaimer
|
|
164
|
+
|
|
165
|
+
This Skill is for research notes and workflow automation only. It does not provide investment advice, trading instructions, or guaranteed outcomes. All investment decisions remain the user's responsibility.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT License. See [LICENSE](LICENSE).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: a-share-after-hours-brief
|
|
3
|
+
description: Generate one-stock or multi-stock A-share after-hours review briefs as polished mobile-friendly HTML attachments with optional Gmail summary drafts and portable JSON history. Use for requests like “复盘今天的宁德时代”, “今天A股股票A和股票B盘后总结”, “股票池盘后HTML简报”, “校验上次判断”, “Gmail草稿”, “近60日相关性”, or event-triggered A-share updates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A 股个股盘后复盘
|
|
7
|
+
|
|
8
|
+
Create a practical Chinese after-hours review for one or more specified A-share stocks: what changed today, whether the thesis changed, how the previous judgment held up, and what observable conditions matter next session. This is not a full-market debrief. Default output is a polished mobile-friendly `.html` attachment; Gmail delivery uses a short plain summary body plus the HTML attachment.
|
|
9
|
+
|
|
10
|
+
## Defaults
|
|
11
|
+
|
|
12
|
+
- Report date: use the user-specified date; otherwise use today's date. Convert relative dates such as `昨天` or `上周五` into concrete dates and show the date in the report.
|
|
13
|
+
- Length: standard multi-stock brief is 2-4 HTML pages; use 1-2 pages only when the user asks for a short version.
|
|
14
|
+
- Correlation: compare the user-specified pair; if omitted, compare the first two stocks and label the assumption.
|
|
15
|
+
- History: `history=on`, `compare_previous=true`. Save JSON under `<HTML output directory>/history/` unless `history_dir` is supplied.
|
|
16
|
+
- Position review: enable only when the user provides holdings/trades or explicitly asks for discipline review.
|
|
17
|
+
- Gmail: create drafts by default; never send until the user explicitly authorizes sending and gives recipients. Do not put rich HTML in the Gmail body.
|
|
18
|
+
- Notion: do not sync.
|
|
19
|
+
|
|
20
|
+
## Dependencies
|
|
21
|
+
|
|
22
|
+
- Use `wind-find-finance-skill` if required financial capabilities or routing are uncertain.
|
|
23
|
+
- Use `wind-mcp-skill` for A-share prices, K-line data, announcements, news, company events, and financial facts. Do not replace Wind facts with web search or model memory.
|
|
24
|
+
- Use Gmail tools only for summary drafts/sending; attach the polished HTML file.
|
|
25
|
+
|
|
26
|
+
## Workflow
|
|
27
|
+
|
|
28
|
+
1. **Scope**
|
|
29
|
+
- Identify stocks, report date, output HTML path, optional Gmail recipients, optional correlation pair, history options, and whether position review is triggered.
|
|
30
|
+
|
|
31
|
+
2. **Wind data**
|
|
32
|
+
- Use request tiers to protect quota. Start with Tier 1 and escalate only when triggered.
|
|
33
|
+
- Read `references/wind-data.md` before selecting Wind fields or calling Wind CLI.
|
|
34
|
+
- Fetch a compact broad-market benchmark and a relevant sector/style benchmark. Market data is context for the specified stocks, not a full-market review.
|
|
35
|
+
- Use K-line data for the correlation pair and calculate return correlation locally.
|
|
36
|
+
|
|
37
|
+
3. **Previous review**
|
|
38
|
+
- Read `references/history-and-review.md`.
|
|
39
|
+
- Use `scripts/review_journal.py lookup` before writing the report when history and comparison are enabled.
|
|
40
|
+
- For each overlapping stock, evaluate the previous record's confirmation and invalidation conditions from current Wind facts. Mark each condition `met`, `not_met`, or `unknown`; never infer missing facts.
|
|
41
|
+
- Use `scripts/review_journal.py build` to calculate the final review status and atomically save the current JSON record.
|
|
42
|
+
|
|
43
|
+
4. **Industry news**
|
|
44
|
+
- Use web/current news only as supplemental context when industry news may affect the brief.
|
|
45
|
+
- Cite links for material external news.
|
|
46
|
+
- Read `references/industry-news.md` when using external industry news.
|
|
47
|
+
|
|
48
|
+
5. **Major events**
|
|
49
|
+
- Check announcements, news, earnings, meeting notes, abnormal moves, and policy/industry shocks.
|
|
50
|
+
- Call event skills only when trigger conditions are met; do not expand routine disclosures.
|
|
51
|
+
- Read `references/event-triggers.md` before invoking event skills.
|
|
52
|
+
|
|
53
|
+
6. **Correlation**
|
|
54
|
+
- Wind provides K-line series; calculate correlation locally from aligned daily returns.
|
|
55
|
+
- Use `scripts/correlation.py` when K-line data is saved as JSON/CSV.
|
|
56
|
+
- If common return observations are fewer than 30, report sample insufficiency and avoid a directional conclusion.
|
|
57
|
+
|
|
58
|
+
7. **Next-session watch**
|
|
59
|
+
- For each stock, write observable watch items, confirmation conditions, and invalidation conditions.
|
|
60
|
+
- Do not predict direction or exact prices. Conditions may reference price/volume behavior, relative strength, disclosures, or sector confirmation when supported by available facts.
|
|
61
|
+
|
|
62
|
+
8. **Optional position review**
|
|
63
|
+
- When triggered, compare the user's original thesis and exit conditions with current facts.
|
|
64
|
+
- State whether an exit condition was triggered and whether the user reports executing it.
|
|
65
|
+
- Do not invent position size, cost, exit conditions, or transactions.
|
|
66
|
+
|
|
67
|
+
9. **Write and deliver**
|
|
68
|
+
- Use `references/html-email.md` for sections, length, HTML attachment, mobile layout, Gmail summary body, and attachment rules.
|
|
69
|
+
- Use `assets/brief-template.html` for the polished HTML attachment.
|
|
70
|
+
- Use `assets/plain-email-summary-template.md` for Gmail body.
|
|
71
|
+
|
|
72
|
+
## Verification
|
|
73
|
+
|
|
74
|
+
Before delivery, check:
|
|
75
|
+
|
|
76
|
+
- Stock codes, report date, price data, announcement/news dates, and source labeling.
|
|
77
|
+
- Wind request tier discipline, especially that Tier 3 was only used when triggered.
|
|
78
|
+
- If Tier 3 returned data, it appears in the stock analysis, next-session conditions, or risk caveat.
|
|
79
|
+
- Major-event skill use, if any, matches `references/event-triggers.md`.
|
|
80
|
+
- Correlation uses returns, not raw prices, and reports sample size.
|
|
81
|
+
- Previous-review conditions use current facts and valid IDs from the prior record.
|
|
82
|
+
- History JSON contains no absolute paths or directional outlook fields.
|
|
83
|
+
- Position review appears only when holdings/trades or an explicit request triggered it.
|
|
84
|
+
- HTML opens correctly, is readable on mobile, includes disclaimer, and uses the polished attachment template.
|
|
85
|
+
- Gmail draft, if created, has a readable plain summary body and the HTML attachment; do not imply it was sent.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{TITLE}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--ink: #111827;
|
|
10
|
+
--muted: #64748b;
|
|
11
|
+
--line: #e2e8f0;
|
|
12
|
+
--paper: #ffffff;
|
|
13
|
+
--page: #eef2f7;
|
|
14
|
+
--blue: #1f4f8f;
|
|
15
|
+
--blue-soft: #eff6ff;
|
|
16
|
+
--green-soft: #f0fdf4;
|
|
17
|
+
--orange-soft: #fff7ed;
|
|
18
|
+
--yellow-soft: #fefce8;
|
|
19
|
+
--slate-soft: #f8fafc;
|
|
20
|
+
}
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body { margin: 0; background: var(--page); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif; line-height: 1.65; }
|
|
23
|
+
.page { max-width: 980px; margin: 0 auto; padding: 18px; }
|
|
24
|
+
.hero { overflow: hidden; border-radius: 18px; background: var(--paper); border: 1px solid #dbe3ef; box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08); }
|
|
25
|
+
.hero-top { padding: 26px; color: #ffffff; background: linear-gradient(135deg, #1f4f8f 0%, #2563eb 58%, #0f766e 100%); }
|
|
26
|
+
.eyebrow { color: #bfdbfe; font-size: 13px; font-weight: 800; }
|
|
27
|
+
h1 { margin: 6px 0 8px; font-size: 28px; line-height: 1.25; letter-spacing: 0; }
|
|
28
|
+
.hero-meta { color: #dbeafe; font-size: 14px; }
|
|
29
|
+
.hero-summary { padding: 18px 22px; background: #ffffff; display: flex; gap: 12px; align-items: flex-start; border-bottom: 1px solid var(--line); }
|
|
30
|
+
.summary-text { margin: 0; font-size: 16px; }
|
|
31
|
+
h2 { margin: 0 0 12px; font-size: 19px; line-height: 1.35; }
|
|
32
|
+
h3 { margin: 0 0 8px; font-size: 16px; line-height: 1.4; }
|
|
33
|
+
.muted { color: var(--muted); font-size: 13px; }
|
|
34
|
+
.tag { display: inline-block; padding: 5px 11px; border-radius: 999px; font-size: 12px; font-weight: 800; white-space: nowrap; }
|
|
35
|
+
.tag-positive { background: #dcfce7; color: #166534; }
|
|
36
|
+
.tag-neutral { background: #e0f2fe; color: #075985; }
|
|
37
|
+
.tag-cautious { background: #ffedd5; color: #9a3412; }
|
|
38
|
+
.tag-verify { background: #fef9c3; color: #854d0e; }
|
|
39
|
+
.section { background: var(--paper); border: 1px solid var(--line); border-radius: 14px; padding: 20px; margin-top: 16px; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04); }
|
|
40
|
+
.section-blue { background: var(--blue-soft); border-color: #bfdbfe; }
|
|
41
|
+
.section-green { background: var(--green-soft); border-color: #bbf7d0; }
|
|
42
|
+
.section-orange { background: var(--orange-soft); border-color: #fed7aa; }
|
|
43
|
+
.section-yellow { background: var(--yellow-soft); border-color: #fde68a; }
|
|
44
|
+
.section-slate { background: var(--slate-soft); border-color: #cbd5e1; }
|
|
45
|
+
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 12px 0; }
|
|
46
|
+
.metric-card { border: 1px solid var(--line); background: #ffffff; border-radius: 12px; padding: 12px; }
|
|
47
|
+
.metric-label { color: var(--muted); font-size: 12px; }
|
|
48
|
+
.metric { display: block; margin-top: 4px; font-size: 24px; font-weight: 850; line-height: 1.15; letter-spacing: 0; }
|
|
49
|
+
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
50
|
+
.card { border: 1px solid var(--line); border-radius: 14px; padding: 15px; background: #ffffff; }
|
|
51
|
+
.card-accent { border-left: 5px solid #2563eb; }
|
|
52
|
+
table { width: 100%; border-collapse: separate; border-spacing: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: #ffffff; font-size: 14px; }
|
|
53
|
+
th, td { border-bottom: 1px solid var(--line); padding: 11px 10px; text-align: left; vertical-align: top; }
|
|
54
|
+
tr:last-child td { border-bottom: 0; }
|
|
55
|
+
th { background: #f8fafc; color: #334155; font-weight: 800; }
|
|
56
|
+
ul { margin: 8px 0 0 19px; padding: 0; }
|
|
57
|
+
li { margin: 5px 0; }
|
|
58
|
+
.footer { margin-top: 16px; color: var(--muted); font-size: 12px; background: #ffffff; border: 1px solid var(--line); border-radius: 12px; padding: 13px 15px; }
|
|
59
|
+
@media (max-width: 760px) {
|
|
60
|
+
body { background: #ffffff; }
|
|
61
|
+
.page { padding: 10px; }
|
|
62
|
+
.hero { border-radius: 14px; box-shadow: none; }
|
|
63
|
+
.hero-top { padding: 20px 18px; }
|
|
64
|
+
h1 { font-size: 23px; }
|
|
65
|
+
h2 { font-size: 18px; }
|
|
66
|
+
.hero-summary { display: block; padding: 16px; }
|
|
67
|
+
.hero-summary .tag { margin-bottom: 10px; }
|
|
68
|
+
.section { padding: 16px; border-radius: 13px; margin-top: 12px; }
|
|
69
|
+
.cards, .metric-grid { grid-template-columns: 1fr; }
|
|
70
|
+
table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; font-size: 13px; border-radius: 10px; }
|
|
71
|
+
th, td { padding: 9px 8px; min-width: 92px; }
|
|
72
|
+
.metric { font-size: 22px; }
|
|
73
|
+
}
|
|
74
|
+
@media print { body { background: #ffffff; } .page { padding: 0; } .section, .hero, .card { break-inside: avoid; box-shadow: none; } }
|
|
75
|
+
</style>
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<main class="page">
|
|
79
|
+
<section class="hero">
|
|
80
|
+
<div class="hero-top">
|
|
81
|
+
<div class="eyebrow">A股盘后研究记录</div>
|
|
82
|
+
<h1>{{TITLE}}</h1>
|
|
83
|
+
<div class="hero-meta">{{DATE}} · {{STOCK_LIST}}</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="hero-summary">
|
|
86
|
+
<span class="tag {{JUDGMENT_CLASS}}">{{JUDGMENT}}</span>
|
|
87
|
+
<p class="summary-text">{{ONE_SENTENCE}}</p>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
{{MARKET_CONTEXT_SECTION}}
|
|
92
|
+
{{PREVIOUS_REVIEW_SECTION}}
|
|
93
|
+
{{PORTFOLIO_TABLE}}
|
|
94
|
+
{{STOCK_CARDS}}
|
|
95
|
+
{{EVENTS_SECTION}}
|
|
96
|
+
{{INDUSTRY_NEWS_SECTION}}
|
|
97
|
+
{{CORRELATION_SECTION}}
|
|
98
|
+
{{NEXT_SESSION_SECTION}}
|
|
99
|
+
{{POSITION_REVIEW_SECTION}}
|
|
100
|
+
|
|
101
|
+
<div class="footer">
|
|
102
|
+
数据来源于万得 Wind 金融数据服务。仅供研究记录,不构成投资建议。
|
|
103
|
+
</div>
|
|
104
|
+
</main>
|
|
105
|
+
</body>
|
|
106
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{{TITLE}}
|
|
2
|
+
{{DATE}} · {{STOCK_LIST}}
|
|
3
|
+
|
|
4
|
+
结论:{{JUDGMENT}}|{{ONE_SENTENCE}}
|
|
5
|
+
|
|
6
|
+
市场背景
|
|
7
|
+
{{PLAIN_MARKET_CONTEXT}}
|
|
8
|
+
|
|
9
|
+
上一期判断验证
|
|
10
|
+
{{PLAIN_PREVIOUS_REVIEW}}
|
|
11
|
+
|
|
12
|
+
组合概览
|
|
13
|
+
{{PLAIN_PORTFOLIO_SUMMARY}}
|
|
14
|
+
|
|
15
|
+
重大事项
|
|
16
|
+
{{PLAIN_EVENTS_SUMMARY}}
|
|
17
|
+
|
|
18
|
+
行业新闻
|
|
19
|
+
{{PLAIN_INDUSTRY_NEWS_SUMMARY}}
|
|
20
|
+
|
|
21
|
+
相关性
|
|
22
|
+
{{PLAIN_CORRELATION_SUMMARY}}
|
|
23
|
+
|
|
24
|
+
下一交易日重点观察
|
|
25
|
+
{{PLAIN_NEXT_SESSION}}
|
|
26
|
+
|
|
27
|
+
{{PLAIN_POSITION_REVIEW}}
|
|
28
|
+
|
|
29
|
+
完整排版版 HTML 已作为附件附上。数据来源于万得 Wind 金融数据服务。仅供研究记录,不构成投资建议。
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const SKILL_NAME = "a-share-after-hours-brief";
|
|
9
|
+
const REQUIRED_ENTRIES = [
|
|
10
|
+
"SKILL.md",
|
|
11
|
+
"agents",
|
|
12
|
+
"assets",
|
|
13
|
+
"references",
|
|
14
|
+
"scripts",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function usage() {
|
|
18
|
+
console.log(`Usage:
|
|
19
|
+
a-share-after-hours-brief-skill install [--target <dir>] [--force]
|
|
20
|
+
a-share-after-hours-brief-skill --dry-run
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--target <dir> Skills root directory. Default: $CODEX_HOME/skills or ~/.codex/skills
|
|
24
|
+
--force Overwrite existing ${SKILL_NAME} installation
|
|
25
|
+
--dry-run Validate package contents without copying
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const args = {
|
|
31
|
+
command: "install",
|
|
32
|
+
target: null,
|
|
33
|
+
force: false,
|
|
34
|
+
dryRun: false,
|
|
35
|
+
};
|
|
36
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
37
|
+
const arg = argv[i];
|
|
38
|
+
if (arg === "install") {
|
|
39
|
+
args.command = "install";
|
|
40
|
+
} else if (arg === "--target") {
|
|
41
|
+
const value = argv[i + 1];
|
|
42
|
+
if (!value) throw new Error("--target requires a directory");
|
|
43
|
+
args.target = value;
|
|
44
|
+
i += 1;
|
|
45
|
+
} else if (arg === "--force") {
|
|
46
|
+
args.force = true;
|
|
47
|
+
} else if (arg === "--dry-run") {
|
|
48
|
+
args.dryRun = true;
|
|
49
|
+
} else if (arg === "-h" || arg === "--help") {
|
|
50
|
+
args.command = "help";
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function packageRoot() {
|
|
59
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function defaultSkillsRoot() {
|
|
63
|
+
const codexHome = process.env.CODEX_HOME;
|
|
64
|
+
if (codexHome) return path.join(codexHome, "skills");
|
|
65
|
+
return path.join(os.homedir(), ".codex", "skills");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validatePackage(root) {
|
|
69
|
+
const missing = REQUIRED_ENTRIES.filter((entry) => !fs.existsSync(path.join(root, entry)));
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
throw new Error(`Package is missing required entries: ${missing.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function copyRecursive(src, dest) {
|
|
76
|
+
const stat = fs.statSync(src);
|
|
77
|
+
if (stat.isDirectory()) {
|
|
78
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
79
|
+
for (const entry of fs.readdirSync(src)) {
|
|
80
|
+
if (entry === "__pycache__") continue;
|
|
81
|
+
copyRecursive(path.join(src, entry), path.join(dest, entry));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
fs.copyFileSync(src, dest);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeIfExists(target) {
|
|
89
|
+
if (fs.existsSync(target)) {
|
|
90
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function install({ target, force, dryRun }) {
|
|
95
|
+
const root = packageRoot();
|
|
96
|
+
validatePackage(root);
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
console.log(`Dry run OK: package contains ${SKILL_NAME}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const skillsRoot = path.resolve(target || defaultSkillsRoot());
|
|
103
|
+
const installDir = path.join(skillsRoot, SKILL_NAME);
|
|
104
|
+
if (fs.existsSync(installDir) && !force) {
|
|
105
|
+
throw new Error(`${installDir} already exists. Re-run with --force to overwrite.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
fs.mkdirSync(skillsRoot, { recursive: true });
|
|
109
|
+
removeIfExists(installDir);
|
|
110
|
+
fs.mkdirSync(installDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
for (const entry of REQUIRED_ENTRIES) {
|
|
113
|
+
copyRecursive(path.join(root, entry), path.join(installDir, entry));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(`Installed ${SKILL_NAME} to ${installDir}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const args = parseArgs(process.argv.slice(2));
|
|
121
|
+
if (args.command === "help") {
|
|
122
|
+
usage();
|
|
123
|
+
} else {
|
|
124
|
+
install(args);
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`Error: ${error.message}`);
|
|
128
|
+
usage();
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "a-share-after-hours-brief-skill",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codex skill for A-share after-hours stock review with portable JSON history.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/SkyBridgeM/a-share-after-hours-brief-skill.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/SkyBridgeM/a-share-after-hours-brief-skill/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/SkyBridgeM/a-share-after-hours-brief-skill#readme",
|
|
15
|
+
"bin": {
|
|
16
|
+
"a-share-after-hours-brief-skill": "bin/install.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"SKILL.md",
|
|
20
|
+
"agents",
|
|
21
|
+
"assets",
|
|
22
|
+
"references",
|
|
23
|
+
"scripts",
|
|
24
|
+
"bin",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "node bin/install.js --dry-run && PYTHONPYCACHEPREFIX=/tmp/a-share-after-hours-brief-skill-pycache python3 -m py_compile scripts/review_journal.py scripts/correlation.py"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"codex",
|
|
33
|
+
"skill",
|
|
34
|
+
"agent-skill",
|
|
35
|
+
"a-share",
|
|
36
|
+
"stock",
|
|
37
|
+
"finance",
|
|
38
|
+
"china"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Event Trigger Rules
|
|
2
|
+
|
|
3
|
+
Use event skills only when there is a real information update. Do not expand routine disclosures into long analysis.
|
|
4
|
+
|
|
5
|
+
## major-announcement-impact-skill
|
|
6
|
+
|
|
7
|
+
Trigger for:
|
|
8
|
+
|
|
9
|
+
- M&A, restructuring, asset sale, spin-off.
|
|
10
|
+
- Private placement, rights issue, convertible bond, large financing.
|
|
11
|
+
- Major shareholder reduction/increase, control change.
|
|
12
|
+
- Major contract/order/cooperation.
|
|
13
|
+
- Litigation, regulatory penalty, investigation.
|
|
14
|
+
- Production capacity, asset impairment, large dividend or buyback if material.
|
|
15
|
+
|
|
16
|
+
Output should be compressed into the brief: event nature, direct impact, second-order impact, uncertainty, and thesis impact.
|
|
17
|
+
|
|
18
|
+
## conference-call-takeaway-skill
|
|
19
|
+
|
|
20
|
+
Trigger for:
|
|
21
|
+
|
|
22
|
+
- Earnings call, investor communication, roadshow notes, shareholder meeting notes.
|
|
23
|
+
- Management comments with new information on demand, orders, margin, capacity, pricing, policy, or capital allocation.
|
|
24
|
+
|
|
25
|
+
Compress into: new information, management tone, Q&A focus, warning signal, follow-up variable.
|
|
26
|
+
|
|
27
|
+
## earnings-analysis
|
|
28
|
+
|
|
29
|
+
Trigger for:
|
|
30
|
+
|
|
31
|
+
- Quarterly report, annual report, earnings preannouncement, performance forecast, or results update.
|
|
32
|
+
|
|
33
|
+
Do not generate an 8-12 page earnings report. Compress into: beat/miss, key metric changes, guidance/forecast implication, thesis impact.
|
|
34
|
+
|
|
35
|
+
## valuation-pricing-framework
|
|
36
|
+
|
|
37
|
+
Trigger for:
|
|
38
|
+
|
|
39
|
+
- Large single-day price move without obvious fundamental news.
|
|
40
|
+
- Valuation multiple reaches notable high/low range.
|
|
41
|
+
- Market appears to reprice growth, cycle, policy, or risk premium.
|
|
42
|
+
|
|
43
|
+
Compress into: what expectation price now implies, whether valuation moved ahead of fundamentals, and what would trigger re-rating or de-rating.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# History and Previous-Review Rules
|
|
2
|
+
|
|
3
|
+
Use `scripts/review_journal.py` for portable JSON history.
|
|
4
|
+
|
|
5
|
+
## Storage
|
|
6
|
+
|
|
7
|
+
- Default directory: `<HTML output directory>/history/`.
|
|
8
|
+
- Override with `history_dir`.
|
|
9
|
+
- JSON is the only history source. Do not generate Markdown logs.
|
|
10
|
+
- Records must not contain absolute paths.
|
|
11
|
+
- File name: `<report-date>__<sorted-stock-codes>.json`.
|
|
12
|
+
- Same date and same stock pool are atomically replaced; other pools are untouched.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
Look up the latest prior record for each current stock:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python3 scripts/review_journal.py lookup \
|
|
20
|
+
--history-dir /path/to/report/history \
|
|
21
|
+
--before-date 2026-06-16 \
|
|
22
|
+
--stocks 300750.SZ,600519.SH
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Build and save a current record:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 scripts/review_journal.py build \
|
|
29
|
+
--input /path/to/current-draft.json \
|
|
30
|
+
--output-html /path/to/report/2026-06-16-review.html
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `--history-dir` to override the default. Use `--history off` to validate and emit the record without saving. Use `--compare-previous false` to skip lookup.
|
|
34
|
+
|
|
35
|
+
## Condition contract
|
|
36
|
+
|
|
37
|
+
Each current stock must define next-session conditions with stable IDs:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"next_session_watch": {
|
|
42
|
+
"watch_items": ["成交量能否恢复"],
|
|
43
|
+
"confirmation_conditions": [
|
|
44
|
+
{"id": "volume-recovery", "condition": "成交量较本日明显恢复"}
|
|
45
|
+
],
|
|
46
|
+
"invalidation_conditions": [
|
|
47
|
+
{"id": "thesis-break", "condition": "公司披露否定核心业务假设"}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
When a prior record exists, add `condition_results` to the current stock:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
[
|
|
57
|
+
{
|
|
58
|
+
"id": "volume-recovery",
|
|
59
|
+
"outcome": "met",
|
|
60
|
+
"evidence": "成交量较上一交易日增加 28%"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "thesis-break",
|
|
64
|
+
"outcome": "not_met",
|
|
65
|
+
"evidence": "未发现否定核心假设的公告"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Allowed outcomes: `met`, `not_met`, `unknown`.
|
|
71
|
+
|
|
72
|
+
## Status mapping
|
|
73
|
+
|
|
74
|
+
- Any invalidation condition `met` -> `已失效`.
|
|
75
|
+
- All confirmation conditions `met`, with no invalidation met -> `已验证`.
|
|
76
|
+
- Some confirmation conditions `met` -> `部分验证`.
|
|
77
|
+
- All required conditions are known but no confirmation is met -> `未验证`.
|
|
78
|
+
- Missing facts, missing condition results, or only unknown outcomes -> `无法判断`.
|
|
79
|
+
- No prior record -> `无历史基线`.
|
|
80
|
+
|
|
81
|
+
The script calculates the status. The agent supplies evidence-backed condition outcomes and a concise adjustment note. Never mark unavailable data as `not_met`; use `unknown`.
|
|
82
|
+
|
|
83
|
+
## Draft record minimum
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"schema_version": 1,
|
|
88
|
+
"report_date": "2026-06-16",
|
|
89
|
+
"generated_at": "2026-06-16T16:30:00+08:00",
|
|
90
|
+
"market_context": {},
|
|
91
|
+
"stocks": [
|
|
92
|
+
{
|
|
93
|
+
"code": "300750.SZ",
|
|
94
|
+
"name": "宁德时代",
|
|
95
|
+
"facts": {},
|
|
96
|
+
"attribution": "mixed",
|
|
97
|
+
"thesis_change": "未改变",
|
|
98
|
+
"condition_results": [],
|
|
99
|
+
"review_adjustment": "继续观察量能与行业相对强弱",
|
|
100
|
+
"next_session_watch": {
|
|
101
|
+
"watch_items": [],
|
|
102
|
+
"confirmation_conditions": [],
|
|
103
|
+
"invalidation_conditions": []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
"position_review": null
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Allowed attribution values: `market_beta`, `sector`, `stock_specific`, `mixed`, `unknown`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# HTML and Gmail Delivery
|
|
2
|
+
|
|
3
|
+
Use concise Chinese. Standard multi-stock reports can be 2-4 HTML pages; use 1-2 pages only when the user asks for a short version.
|
|
4
|
+
|
|
5
|
+
## Sections
|
|
6
|
+
|
|
7
|
+
Include these sections in order:
|
|
8
|
+
|
|
9
|
+
1. Header: title, date, stock list, overall judgment, one-sentence conclusion.
|
|
10
|
+
2. Compact market context: broad benchmark, relevant style/sector benchmark, breadth/turnover when available, and attribution use.
|
|
11
|
+
3. Previous judgment review: one row/card per overlapping stock; show prior date, prior judgment, status, evidence, and adjustment. Show `无历史基线` for new stocks.
|
|
12
|
+
4. Portfolio overview: stock, code, close/change, turnover/turnover rate, key update, thesis impact.
|
|
13
|
+
5. Stock cards: today's performance, information update, attribution, thesis impact.
|
|
14
|
+
6. Major events: whether triggered, event type, event skill used, thesis impact.
|
|
15
|
+
7. Industry news: material sector news, relevance, source links.
|
|
16
|
+
8. Correlation: pair, window, observation count, Pearson correlation, label, caveat.
|
|
17
|
+
9. Next-session watch: observable variables, confirmation conditions, and invalidation conditions. Do not predict direction or price.
|
|
18
|
+
10. Optional position review: original thesis, exit conditions, trigger status, reported execution, and discipline gap.
|
|
19
|
+
11. Source and disclaimer.
|
|
20
|
+
|
|
21
|
+
## Deliverables
|
|
22
|
+
|
|
23
|
+
- Polished report: static mobile-friendly `.html` using `assets/brief-template.html`.
|
|
24
|
+
- Gmail draft: plain summary using `assets/plain-email-summary-template.md`, with the polished `.html` attached.
|
|
25
|
+
- Do not send polished HTML as Gmail body through the connector.
|
|
26
|
+
|
|
27
|
+
## HTML Attachment Rules
|
|
28
|
+
|
|
29
|
+
- Mobile-first: single-column on phones; avoid wide tables when cards/key-value blocks work better.
|
|
30
|
+
- Use embedded CSS, no remote images/fonts, and print-friendly contrast.
|
|
31
|
+
- Use visual hierarchy: blue hero header, colored panels, metric cards, status pills, accent borders on stock cards.
|
|
32
|
+
- Let important event and condition-check sections breathe; do not compress away useful judgment.
|
|
33
|
+
|
|
34
|
+
## Gmail Summary Rules
|
|
35
|
+
|
|
36
|
+
- Keep body short: conclusion, stock list, previous-review summary, key changes, next-session watch, and note to open attachment.
|
|
37
|
+
- Do not include CSS, HTML tables, or rich layout in Gmail body.
|
|
38
|
+
- Always attach the polished `.html` when creating a Gmail draft.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Industry News
|
|
2
|
+
|
|
3
|
+
Use this reference when industry news may explain the specified stocks' after-hours review.
|
|
4
|
+
|
|
5
|
+
## Source priority
|
|
6
|
+
|
|
7
|
+
1. Company announcements and exchange disclosures.
|
|
8
|
+
2. Official regulators, ministries, exchanges, and industry associations.
|
|
9
|
+
3. Wind financial news.
|
|
10
|
+
4. Major financial media and credible industry publications.
|
|
11
|
+
|
|
12
|
+
## Rules
|
|
13
|
+
|
|
14
|
+
- Prefer recent news from the report date or immediately preceding trading sessions.
|
|
15
|
+
- Include source links for material external references.
|
|
16
|
+
- Mark rumor-like information as unverified and do not build a strong conclusion on it.
|
|
17
|
+
- Do not use web news as a substitute for Wind price, K-line, announcement, or financial data.
|
|
18
|
+
- Explain relevance to the specified stock; do not turn the section into a general market-news digest.
|
|
19
|
+
- Do not use industry news to predict price direction.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Wind Data Notes
|
|
2
|
+
|
|
3
|
+
Use `wind-mcp-skill` for A-share行情、K线、公告、新闻、财务、事件、技术和风险数据. Do not replace these facts with web search or model memory.
|
|
4
|
+
|
|
5
|
+
## Default Fields
|
|
6
|
+
|
|
7
|
+
For each A-share, prefer one `stock_data.get_stock_price_indicators` call with only needed fields:
|
|
8
|
+
|
|
9
|
+
`中文简称`, `最新成交价`, `涨跌幅`, `成交量`, `成交额`, `换手率`, `量比`, `振幅`, `5日涨跌幅`, `10日涨跌幅`, `20日涨跌幅`.
|
|
10
|
+
|
|
11
|
+
Add `60日涨跌幅` only when medium-term context is needed. Verify exact field names against `wind-mcp-skill/references/indicators.md`.
|
|
12
|
+
|
|
13
|
+
Use:
|
|
14
|
+
|
|
15
|
+
- `financial_docs.get_company_announcements` for announcements/reports/disclosures.
|
|
16
|
+
- `financial_docs.get_financial_news` for news and market reports.
|
|
17
|
+
- `stock_data.get_stock_kline` for A-share daily K-line.
|
|
18
|
+
- `index_data.get_index_price_indicators` or `index_data.get_index_kline` for broad/sector index context.
|
|
19
|
+
|
|
20
|
+
## Request Tiers
|
|
21
|
+
|
|
22
|
+
Protect Wind quota. Start with Tier 1 and escalate only when triggered.
|
|
23
|
+
|
|
24
|
+
### Tier 1: Default daily brief
|
|
25
|
+
|
|
26
|
+
Use for ordinary daily reports and multi-stock pools.
|
|
27
|
+
|
|
28
|
+
- Per stock: one compact price snapshot call.
|
|
29
|
+
- Per stock: concise date-scoped announcement/news check.
|
|
30
|
+
- For the report: K-line only for the specified correlation pair.
|
|
31
|
+
- Broad market index snapshot only when needed for overall context.
|
|
32
|
+
|
|
33
|
+
### Tier 2: Context and condition checks
|
|
34
|
+
|
|
35
|
+
Use when previous-review conditions or next-session watch items require time-series confirmation.
|
|
36
|
+
|
|
37
|
+
- Add `stock_data.get_stock_kline` for stocks needing K-line confirmation, usually near 60 trading days ending at report date.
|
|
38
|
+
- Add one broad benchmark snapshot and one relevant style/sector benchmark when available.
|
|
39
|
+
- Prefer local calculations from K-line: 5/20/60-day trend, recent high/low, support/resistance, consecutive rise/fall, and return correlation.
|
|
40
|
+
|
|
41
|
+
### Tier 3: Triggered enrichment
|
|
42
|
+
|
|
43
|
+
Use only for abnormal movement, major event, unresolved condition checks, or explicit user request.
|
|
44
|
+
|
|
45
|
+
- `stock_data.get_stock_technicals` for MACD/KDJ/RSI/BOLL.
|
|
46
|
+
- `stock_data.get_risk_metrics` for beta/volatility/risk.
|
|
47
|
+
- Sector/theme index K-line or extra announcements/news beyond date scope.
|
|
48
|
+
|
|
49
|
+
Triggers include: daily move around 5%+, unusually high turnover/volume ratio, sharp divergence from market/sector, major announcement/earnings/conference call, policy/news shock, or a previous condition that cannot be checked with Tier 1 data.
|
|
50
|
+
|
|
51
|
+
If Tier 3 is triggered and data is successfully fetched, include those indicators as explicit evidence in the stock analysis, condition check, or risk caveat. Do not leave fetched Tier 3 data unused.
|
|
52
|
+
|
|
53
|
+
## K-Line and Correlation
|
|
54
|
+
|
|
55
|
+
K-line params: `windcode` one stock only, `begin_date`/`end_date` as `yyyyMMdd`, `period: "10"` for daily K, `aftime: "0"` for forward-adjusted data when possible.
|
|
56
|
+
|
|
57
|
+
For two-stock correlation, call K-line separately for each stock, align common trading dates, convert close prices to daily returns, and calculate Pearson correlation locally. If common return observations are fewer than 30, report sample insufficiency.
|
|
58
|
+
|
|
59
|
+
## Market Context
|
|
60
|
+
|
|
61
|
+
Fetch compact context only:
|
|
62
|
+
|
|
63
|
+
- One broad benchmark relevant to the stock pool.
|
|
64
|
+
- One sector/style benchmark relevant to each stock where practical.
|
|
65
|
+
- Prefer `最新成交价`, `涨跌幅`, `成交额`, `上涨家数`, and `下跌家数` when supported for the selected index.
|
|
66
|
+
- Use context to classify the move as mainly market beta, sector influence, stock-specific, or mixed.
|
|
67
|
+
|
|
68
|
+
If a field is unavailable, continue with available data and state the limitation briefly only when it affects the conclusion.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Calculate Pearson correlation from two K-line CSV/JSON files.
|
|
3
|
+
|
|
4
|
+
Inputs must contain date and close fields. The parser accepts common English and
|
|
5
|
+
Chinese field names so it can handle exported Wind results after light cleanup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DATE_KEYS = ("date", "trade_date", "datetime", "日期", "交易日期", "时间")
|
|
19
|
+
CLOSE_KEYS = ("close", "close_price", "收盘价", "收盘", "前复权收盘价", "复权收盘价")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_rows(path: Path) -> list[dict[str, Any]]:
|
|
23
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
24
|
+
if path.suffix.lower() == ".csv":
|
|
25
|
+
return list(csv.DictReader(text.splitlines()))
|
|
26
|
+
|
|
27
|
+
data = json.loads(text)
|
|
28
|
+
if isinstance(data, list):
|
|
29
|
+
return data
|
|
30
|
+
if isinstance(data, dict):
|
|
31
|
+
for key in ("data", "rows", "items", "result"):
|
|
32
|
+
value = data.get(key)
|
|
33
|
+
if isinstance(value, list):
|
|
34
|
+
return value
|
|
35
|
+
raise ValueError(f"Cannot find row list in {path}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pick(row: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
|
39
|
+
for key in keys:
|
|
40
|
+
if key in row and row[key] not in (None, ""):
|
|
41
|
+
return row[key]
|
|
42
|
+
lowered = {str(k).lower(): v for k, v in row.items()}
|
|
43
|
+
for key in keys:
|
|
44
|
+
value = lowered.get(key.lower())
|
|
45
|
+
if value not in (None, ""):
|
|
46
|
+
return value
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_close(value: Any) -> float | None:
|
|
51
|
+
if value is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return float(str(value).replace(",", "").replace("%", "").strip())
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def close_series(path: Path) -> dict[str, float]:
|
|
60
|
+
series: dict[str, float] = {}
|
|
61
|
+
for row in load_rows(path):
|
|
62
|
+
if not isinstance(row, dict):
|
|
63
|
+
continue
|
|
64
|
+
date = pick(row, DATE_KEYS)
|
|
65
|
+
close = parse_close(pick(row, CLOSE_KEYS))
|
|
66
|
+
if date is None or close is None or close <= 0:
|
|
67
|
+
continue
|
|
68
|
+
series[str(date)[:10]] = close
|
|
69
|
+
return dict(sorted(series.items()))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def returns(series: dict[str, float]) -> dict[str, float]:
|
|
73
|
+
out: dict[str, float] = {}
|
|
74
|
+
prev: float | None = None
|
|
75
|
+
for date, close in sorted(series.items()):
|
|
76
|
+
if prev and prev > 0:
|
|
77
|
+
out[date] = close / prev - 1.0
|
|
78
|
+
prev = close
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pearson(xs: list[float], ys: list[float]) -> float | None:
|
|
83
|
+
n = len(xs)
|
|
84
|
+
if n != len(ys) or n < 2:
|
|
85
|
+
return None
|
|
86
|
+
mean_x = sum(xs) / n
|
|
87
|
+
mean_y = sum(ys) / n
|
|
88
|
+
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
|
|
89
|
+
var_x = sum((x - mean_x) ** 2 for x in xs)
|
|
90
|
+
var_y = sum((y - mean_y) ** 2 for y in ys)
|
|
91
|
+
denom = math.sqrt(var_x * var_y)
|
|
92
|
+
if denom == 0:
|
|
93
|
+
return None
|
|
94
|
+
return cov / denom
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def label(value: float | None, n: int, min_observations: int) -> str:
|
|
98
|
+
if value is None or n < min_observations:
|
|
99
|
+
return "样本不足"
|
|
100
|
+
if value > 0.75:
|
|
101
|
+
return "高度正相关"
|
|
102
|
+
if value >= 0.40:
|
|
103
|
+
return "中等正相关"
|
|
104
|
+
if value > -0.40:
|
|
105
|
+
return "相关性较弱"
|
|
106
|
+
return "负相关"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
parser = argparse.ArgumentParser()
|
|
111
|
+
parser.add_argument("stock_a", type=Path)
|
|
112
|
+
parser.add_argument("stock_b", type=Path)
|
|
113
|
+
parser.add_argument("--min-observations", type=int, default=30)
|
|
114
|
+
args = parser.parse_args()
|
|
115
|
+
|
|
116
|
+
ret_a = returns(close_series(args.stock_a))
|
|
117
|
+
ret_b = returns(close_series(args.stock_b))
|
|
118
|
+
common_dates = sorted(set(ret_a) & set(ret_b))
|
|
119
|
+
xs = [ret_a[d] for d in common_dates]
|
|
120
|
+
ys = [ret_b[d] for d in common_dates]
|
|
121
|
+
value = pearson(xs, ys)
|
|
122
|
+
enough = len(common_dates) >= args.min_observations and value is not None
|
|
123
|
+
|
|
124
|
+
result = {
|
|
125
|
+
"observations": len(common_dates),
|
|
126
|
+
"correlation": round(value, 4) if enough and value is not None else None,
|
|
127
|
+
"label": label(value, len(common_dates), args.min_observations),
|
|
128
|
+
"method": "Pearson correlation of aligned daily returns",
|
|
129
|
+
"common_start": common_dates[0] if common_dates else None,
|
|
130
|
+
"common_end": common_dates[-1] if common_dates else None,
|
|
131
|
+
}
|
|
132
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
main()
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Maintain portable JSON history for A-share after-hours reviews."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
VALID_OUTCOMES = {"met", "not_met", "unknown"}
|
|
18
|
+
VALID_ATTRIBUTIONS = {
|
|
19
|
+
"market_beta",
|
|
20
|
+
"sector",
|
|
21
|
+
"stock_specific",
|
|
22
|
+
"mixed",
|
|
23
|
+
"unknown",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_bool(value: str) -> bool:
|
|
28
|
+
lowered = value.strip().lower()
|
|
29
|
+
if lowered in {"1", "true", "yes", "on"}:
|
|
30
|
+
return True
|
|
31
|
+
if lowered in {"0", "false", "no", "off"}:
|
|
32
|
+
return False
|
|
33
|
+
raise argparse.ArgumentTypeError(f"Expected true/false, got {value!r}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
37
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_code(code: str) -> str:
|
|
44
|
+
value = str(code).strip().upper()
|
|
45
|
+
if not re.fullmatch(r"\d{6}\.(SH|SZ|BJ)", value):
|
|
46
|
+
raise ValueError(f"Invalid A-share code: {code!r}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_iso_date(value: str) -> date:
|
|
51
|
+
return date.fromisoformat(value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def history_filename(report_date: str, codes: list[str]) -> str:
|
|
55
|
+
safe_codes = [code.replace(".", "-") for code in sorted(set(codes))]
|
|
56
|
+
return f"{report_date}__{'_'.join(safe_codes)}.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def iter_records(history_dir: Path) -> list[tuple[Path, dict[str, Any]]]:
|
|
60
|
+
records: list[tuple[Path, dict[str, Any]]] = []
|
|
61
|
+
if not history_dir.exists():
|
|
62
|
+
return records
|
|
63
|
+
for path in sorted(history_dir.glob("*.json")):
|
|
64
|
+
try:
|
|
65
|
+
record = load_json(path)
|
|
66
|
+
parse_iso_date(str(record["report_date"]))
|
|
67
|
+
if isinstance(record.get("stocks"), list):
|
|
68
|
+
records.append((path, record))
|
|
69
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
70
|
+
continue
|
|
71
|
+
return records
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stock_map(record: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
75
|
+
result: dict[str, dict[str, Any]] = {}
|
|
76
|
+
for stock in record.get("stocks", []):
|
|
77
|
+
if not isinstance(stock, dict) or "code" not in stock:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
result[normalize_code(str(stock["code"]))] = stock
|
|
81
|
+
except ValueError:
|
|
82
|
+
continue
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def lookup_previous(
|
|
87
|
+
history_dir: Path, before_date: str, codes: list[str]
|
|
88
|
+
) -> dict[str, dict[str, Any] | None]:
|
|
89
|
+
cutoff = parse_iso_date(before_date)
|
|
90
|
+
normalized = [normalize_code(code) for code in codes]
|
|
91
|
+
candidates: dict[str, list[tuple[date, str, dict[str, Any]]]] = {
|
|
92
|
+
code: [] for code in normalized
|
|
93
|
+
}
|
|
94
|
+
for _, record in iter_records(history_dir):
|
|
95
|
+
record_date = parse_iso_date(str(record["report_date"]))
|
|
96
|
+
if record_date >= cutoff:
|
|
97
|
+
continue
|
|
98
|
+
generated_at = str(record.get("generated_at", ""))
|
|
99
|
+
by_code = stock_map(record)
|
|
100
|
+
for code in normalized:
|
|
101
|
+
stock = by_code.get(code)
|
|
102
|
+
if stock is not None:
|
|
103
|
+
candidates[code].append((record_date, generated_at, {
|
|
104
|
+
"report_date": record["report_date"],
|
|
105
|
+
"generated_at": generated_at,
|
|
106
|
+
"stock": stock,
|
|
107
|
+
}))
|
|
108
|
+
return {
|
|
109
|
+
code: max(items, key=lambda item: (item[0], item[1]))[2] if items else None
|
|
110
|
+
for code, items in candidates.items()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def condition_catalog(previous_stock: dict[str, Any]) -> dict[str, str]:
|
|
115
|
+
watch = previous_stock.get("next_session_watch") or {}
|
|
116
|
+
catalog: dict[str, str] = {}
|
|
117
|
+
for kind in ("confirmation_conditions", "invalidation_conditions"):
|
|
118
|
+
for item in watch.get(kind, []):
|
|
119
|
+
if not isinstance(item, dict):
|
|
120
|
+
continue
|
|
121
|
+
condition_id = str(item.get("id", "")).strip()
|
|
122
|
+
if not condition_id:
|
|
123
|
+
raise ValueError(f"Previous {kind} contains a condition without id")
|
|
124
|
+
if condition_id in catalog:
|
|
125
|
+
raise ValueError(f"Duplicate previous condition id: {condition_id}")
|
|
126
|
+
catalog[condition_id] = kind
|
|
127
|
+
return catalog
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def evaluate_previous(
|
|
131
|
+
previous: dict[str, Any] | None,
|
|
132
|
+
results: list[dict[str, Any]],
|
|
133
|
+
adjustment: str,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
if previous is None:
|
|
136
|
+
return {
|
|
137
|
+
"previous_date": None,
|
|
138
|
+
"status": "无历史基线",
|
|
139
|
+
"evidence": [],
|
|
140
|
+
"adjustment": adjustment,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
previous_stock = previous["stock"]
|
|
144
|
+
catalog = condition_catalog(previous_stock)
|
|
145
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
146
|
+
for item in results:
|
|
147
|
+
if not isinstance(item, dict):
|
|
148
|
+
raise ValueError("condition_results entries must be objects")
|
|
149
|
+
condition_id = str(item.get("id", "")).strip()
|
|
150
|
+
outcome = str(item.get("outcome", "")).strip()
|
|
151
|
+
if condition_id not in catalog:
|
|
152
|
+
raise ValueError(f"Unknown previous condition id: {condition_id}")
|
|
153
|
+
if outcome not in VALID_OUTCOMES:
|
|
154
|
+
raise ValueError(f"Invalid outcome for {condition_id}: {outcome}")
|
|
155
|
+
if condition_id in by_id:
|
|
156
|
+
raise ValueError(f"Duplicate condition result: {condition_id}")
|
|
157
|
+
by_id[condition_id] = {
|
|
158
|
+
"id": condition_id,
|
|
159
|
+
"kind": catalog[condition_id],
|
|
160
|
+
"outcome": outcome,
|
|
161
|
+
"evidence": str(item.get("evidence", "")).strip(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if not catalog or set(by_id) != set(catalog):
|
|
165
|
+
status = "无法判断"
|
|
166
|
+
else:
|
|
167
|
+
invalidation_met = any(
|
|
168
|
+
item["kind"] == "invalidation_conditions" and item["outcome"] == "met"
|
|
169
|
+
for item in by_id.values()
|
|
170
|
+
)
|
|
171
|
+
confirmations = [
|
|
172
|
+
item for item in by_id.values()
|
|
173
|
+
if item["kind"] == "confirmation_conditions"
|
|
174
|
+
]
|
|
175
|
+
any_unknown = any(item["outcome"] == "unknown" for item in by_id.values())
|
|
176
|
+
confirmations_met = sum(item["outcome"] == "met" for item in confirmations)
|
|
177
|
+
|
|
178
|
+
if invalidation_met:
|
|
179
|
+
status = "已失效"
|
|
180
|
+
elif any_unknown:
|
|
181
|
+
status = "无法判断"
|
|
182
|
+
elif confirmations and confirmations_met == len(confirmations):
|
|
183
|
+
status = "已验证"
|
|
184
|
+
elif confirmations_met > 0:
|
|
185
|
+
status = "部分验证"
|
|
186
|
+
else:
|
|
187
|
+
status = "未验证"
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"previous_date": previous["report_date"],
|
|
191
|
+
"status": status,
|
|
192
|
+
"evidence": list(by_id.values()),
|
|
193
|
+
"adjustment": adjustment,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def validate_next_watch(stock: dict[str, Any]) -> None:
|
|
198
|
+
watch = stock.get("next_session_watch")
|
|
199
|
+
if not isinstance(watch, dict):
|
|
200
|
+
raise ValueError(f"{stock['code']} requires next_session_watch")
|
|
201
|
+
seen: set[str] = set()
|
|
202
|
+
for kind in ("confirmation_conditions", "invalidation_conditions"):
|
|
203
|
+
items = watch.get(kind)
|
|
204
|
+
if not isinstance(items, list):
|
|
205
|
+
raise ValueError(f"{stock['code']} {kind} must be a list")
|
|
206
|
+
for item in items:
|
|
207
|
+
if not isinstance(item, dict):
|
|
208
|
+
raise ValueError(f"{stock['code']} {kind} entries must be objects")
|
|
209
|
+
condition_id = str(item.get("id", "")).strip()
|
|
210
|
+
condition = str(item.get("condition", "")).strip()
|
|
211
|
+
if not condition_id or not condition:
|
|
212
|
+
raise ValueError(f"{stock['code']} conditions require id and condition")
|
|
213
|
+
if condition_id in seen:
|
|
214
|
+
raise ValueError(f"{stock['code']} duplicate condition id: {condition_id}")
|
|
215
|
+
seen.add(condition_id)
|
|
216
|
+
if not isinstance(watch.get("watch_items"), list):
|
|
217
|
+
raise ValueError(f"{stock['code']} watch_items must be a list")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_record(
|
|
221
|
+
draft: dict[str, Any],
|
|
222
|
+
history_dir: Path,
|
|
223
|
+
compare_previous: bool,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
if int(draft.get("schema_version", SCHEMA_VERSION)) != SCHEMA_VERSION:
|
|
226
|
+
raise ValueError(f"Unsupported schema_version: {draft.get('schema_version')}")
|
|
227
|
+
report_date = str(draft.get("report_date", ""))
|
|
228
|
+
parse_iso_date(report_date)
|
|
229
|
+
stocks = draft.get("stocks")
|
|
230
|
+
if not isinstance(stocks, list) or not stocks:
|
|
231
|
+
raise ValueError("stocks must be a non-empty list")
|
|
232
|
+
|
|
233
|
+
codes: list[str] = []
|
|
234
|
+
for stock in stocks:
|
|
235
|
+
if not isinstance(stock, dict):
|
|
236
|
+
raise ValueError("stocks entries must be objects")
|
|
237
|
+
code = normalize_code(str(stock.get("code", "")))
|
|
238
|
+
stock["code"] = code
|
|
239
|
+
if code in codes:
|
|
240
|
+
raise ValueError(f"Duplicate stock code: {code}")
|
|
241
|
+
codes.append(code)
|
|
242
|
+
if stock.get("attribution", "unknown") not in VALID_ATTRIBUTIONS:
|
|
243
|
+
raise ValueError(f"{code} has invalid attribution")
|
|
244
|
+
validate_next_watch(stock)
|
|
245
|
+
|
|
246
|
+
previous = (
|
|
247
|
+
lookup_previous(history_dir, report_date, codes)
|
|
248
|
+
if compare_previous
|
|
249
|
+
else {code: None for code in codes}
|
|
250
|
+
)
|
|
251
|
+
for stock in stocks:
|
|
252
|
+
code = stock["code"]
|
|
253
|
+
results = stock.pop("condition_results", [])
|
|
254
|
+
if not isinstance(results, list):
|
|
255
|
+
raise ValueError(f"{code} condition_results must be a list")
|
|
256
|
+
adjustment = str(stock.pop("review_adjustment", "")).strip()
|
|
257
|
+
stock["previous_review"] = evaluate_previous(
|
|
258
|
+
previous.get(code), results, adjustment
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
draft["schema_version"] = SCHEMA_VERSION
|
|
262
|
+
draft["stock_codes"] = sorted(codes)
|
|
263
|
+
draft.setdefault("market_context", {})
|
|
264
|
+
draft.setdefault("position_review", None)
|
|
265
|
+
return draft
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
|
269
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
fd, temp_name = tempfile.mkstemp(
|
|
271
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
275
|
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
|
276
|
+
handle.write("\n")
|
|
277
|
+
handle.flush()
|
|
278
|
+
os.fsync(handle.fileno())
|
|
279
|
+
os.replace(temp_name, path)
|
|
280
|
+
except Exception:
|
|
281
|
+
try:
|
|
282
|
+
os.unlink(temp_name)
|
|
283
|
+
except FileNotFoundError:
|
|
284
|
+
pass
|
|
285
|
+
raise
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def default_history_dir(output_html: Path) -> Path:
|
|
289
|
+
return output_html.expanduser().resolve().parent / "history"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def command_lookup(args: argparse.Namespace) -> None:
|
|
293
|
+
codes = [item for item in args.stocks.split(",") if item.strip()]
|
|
294
|
+
result = lookup_previous(args.history_dir, args.before_date, codes)
|
|
295
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def command_build(args: argparse.Namespace) -> None:
|
|
299
|
+
draft = load_json(args.input)
|
|
300
|
+
history_dir = args.history_dir or default_history_dir(args.output_html)
|
|
301
|
+
record = build_record(draft, history_dir, args.compare_previous)
|
|
302
|
+
codes = [stock["code"] for stock in record["stocks"]]
|
|
303
|
+
target = history_dir / history_filename(record["report_date"], codes)
|
|
304
|
+
if args.history:
|
|
305
|
+
atomic_write_json(target, record)
|
|
306
|
+
print(json.dumps({
|
|
307
|
+
"saved": args.history,
|
|
308
|
+
"history_file": str(target) if args.history else None,
|
|
309
|
+
"record": record,
|
|
310
|
+
}, ensure_ascii=False, indent=2))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def make_parser() -> argparse.ArgumentParser:
|
|
314
|
+
parser = argparse.ArgumentParser()
|
|
315
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
316
|
+
|
|
317
|
+
lookup = subparsers.add_parser("lookup")
|
|
318
|
+
lookup.add_argument("--history-dir", type=Path, required=True)
|
|
319
|
+
lookup.add_argument("--before-date", required=True)
|
|
320
|
+
lookup.add_argument("--stocks", required=True)
|
|
321
|
+
lookup.set_defaults(func=command_lookup)
|
|
322
|
+
|
|
323
|
+
build = subparsers.add_parser("build")
|
|
324
|
+
build.add_argument("--input", type=Path, required=True)
|
|
325
|
+
build.add_argument("--output-html", type=Path, required=True)
|
|
326
|
+
build.add_argument("--history-dir", type=Path)
|
|
327
|
+
build.add_argument("--history", type=parse_bool, default=True)
|
|
328
|
+
build.add_argument("--compare-previous", type=parse_bool, default=True)
|
|
329
|
+
build.set_defaults(func=command_build)
|
|
330
|
+
return parser
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main() -> None:
|
|
334
|
+
args = make_parser().parse_args()
|
|
335
|
+
args.func(args)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
if __name__ == "__main__":
|
|
339
|
+
main()
|