stockproof 0.3.0__py3-none-any.whl
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.
- stockproof/__init__.py +15 -0
- stockproof/__main__.py +5 -0
- stockproof/announcements.py +263 -0
- stockproof/backtest.py +282 -0
- stockproof/cli.py +1121 -0
- stockproof/clock.py +14 -0
- stockproof/cn.py +513 -0
- stockproof/companies.py +51 -0
- stockproof/compare.py +146 -0
- stockproof/core.py +124 -0
- stockproof/dashboard.py +788 -0
- stockproof/dashboard_page.py +1152 -0
- stockproof/demo.py +56 -0
- stockproof/demo_data/calendar.json +1 -0
- stockproof/demo_data/cn/eps_v2_000898.SZ.json +1 -0
- stockproof/demo_data/cn/eps_v2_00700.HK.json +1 -0
- stockproof/demo_data/cn/eps_v2_09988.HK.json +1 -0
- stockproof/demo_data/cn/eps_v2_300750.SZ.json +1 -0
- stockproof/demo_data/cn/eps_v2_600519.SH.json +1 -0
- stockproof/demo_data/cn/names.json +1 -0
- stockproof/demo_data/dashboard.json +1 -0
- stockproof/demo_data/demo.json +1 -0
- stockproof/demo_data/digests/2026-10-30.html +53 -0
- stockproof/demo_data/digests/2026-10-30.json +1 -0
- stockproof/demo_data/lab/reports/20261030-064920-f50929.html +66 -0
- stockproof/demo_data/lab/reports/20261030-065010-434ad9.html +66 -0
- stockproof/demo_data/lab/trials.jsonl +2 -0
- stockproof/demo_data/policy/industry_map.json +1 -0
- stockproof/demo_data/policy/results.json +1 -0
- stockproof/demo_data/quotes/000898.SZ.csv +487 -0
- stockproof/demo_data/quotes/00700.HK.csv +494 -0
- stockproof/demo_data/quotes/09988.HK.csv +494 -0
- stockproof/demo_data/quotes/300750.SZ.csv +488 -0
- stockproof/demo_data/quotes/600519.SH.csv +487 -0
- stockproof/demo_data/quotes/AAPL.csv +503 -0
- stockproof/demo_data/quotes/BABA.csv +503 -0
- stockproof/demo_data/quotes/JPM.csv +503 -0
- stockproof/demo_data/quotes/MRK.csv +503 -0
- stockproof/demo_data/quotes/MSFT.csv +503 -0
- stockproof/demo_data/quotes/NVDA.csv +503 -0
- stockproof/demo_data/quotes/_CSI300.csv +488 -0
- stockproof/demo_data/quotes/_HSI.csv +494 -0
- stockproof/demo_data/quotes/_SPX.csv +503 -0
- stockproof/demo_data/research/cn_backtest.json +1 -0
- stockproof/demo_data/research/cn_earnings.csv +11064 -0
- stockproof/demo_data/research/cn_meta.json +1 -0
- stockproof/demo_data/research/cn_news.csv +65 -0
- stockproof/demo_data/research/us_backtest.json +1 -0
- stockproof/demo_data/research/us_earnings.csv +2469 -0
- stockproof/demo_data/research/us_meta.json +1 -0
- stockproof/demo_data/research/us_news.csv +595 -0
- stockproof/demo_data/screens/cn_2026-10-29.json +1 -0
- stockproof/demo_data/screens/cn_backtest.json +1 -0
- stockproof/demo_data/screens/scorecards_2026-10-30.json +1 -0
- stockproof/demo_data/sec/company_tickers.json +1 -0
- stockproof/demo_data/sec/facts_1045810.json +1 -0
- stockproof/demo_data/sec/facts_1577552.json +1 -0
- stockproof/demo_data/sec/facts_19617.json +1 -0
- stockproof/demo_data/sec/facts_310158.json +1 -0
- stockproof/demo_data/sec/facts_320193.json +1 -0
- stockproof/demo_data/sec/facts_789019.json +1 -0
- stockproof/demo_data/stockproof.db +0 -0
- stockproof/digest.py +214 -0
- stockproof/earnings.py +184 -0
- stockproof/earnings_calendar.py +204 -0
- stockproof/earnings_report.py +116 -0
- stockproof/eps.py +100 -0
- stockproof/explain.py +152 -0
- stockproof/i18n.py +479 -0
- stockproof/lab.py +904 -0
- stockproof/lab_report.py +217 -0
- stockproof/llm.py +98 -0
- stockproof/mcp_server.py +944 -0
- stockproof/news.py +91 -0
- stockproof/policy.py +514 -0
- stockproof/portfolio.py +328 -0
- stockproof/portfolio_report.py +121 -0
- stockproof/quotes.py +202 -0
- stockproof/report.py +146 -0
- stockproof/research.py +556 -0
- stockproof/research_cn.py +415 -0
- stockproof/research_report.py +449 -0
- stockproof/schedule.py +323 -0
- stockproof/scorecard.py +476 -0
- stockproof/scoring.py +83 -0
- stockproof/screener.py +551 -0
- stockproof/sec.py +103 -0
- stockproof/store.py +169 -0
- stockproof/summary.py +71 -0
- stockproof-0.3.0.dist-info/METADATA +276 -0
- stockproof-0.3.0.dist-info/RECORD +95 -0
- stockproof-0.3.0.dist-info/WHEEL +5 -0
- stockproof-0.3.0.dist-info/entry_points.txt +2 -0
- stockproof-0.3.0.dist-info/licenses/LICENSE +21 -0
- stockproof-0.3.0.dist-info/top_level.txt +1 -0
stockproof/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""stockproof (有据): an LLM reads stock news, earnings and filings with cited sources, and history checks what matters."""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
|
|
5
|
+
__version__ = "0.3.0"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compat_env() -> None:
|
|
9
|
+
"""The project was called marketmood until 0.2: keep reading the old MARKETMOOD_* variables."""
|
|
10
|
+
for k, v in list(_os.environ.items()):
|
|
11
|
+
if k.startswith("MARKETMOOD_"):
|
|
12
|
+
_os.environ.setdefault("STOCKPROOF_" + k[len("MARKETMOOD_"):], v)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
compat_env()
|
stockproof/__main__.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Announcement monitor (A-shares and Hong Kong, via Eastmoney).
|
|
3
|
+
|
|
4
|
+
Company announcements are the most important information source in A-shares, and their titles
|
|
5
|
+
are highly standardized ("关于股东减持股份计划的公告", "关于收到上海证券交易所问询函的公告"…).
|
|
6
|
+
So the type and importance come from title rules: free, instant and predictable. The LLM is only
|
|
7
|
+
used for the important ones: a two-sentence summary and an impact score, read from the full text.
|
|
8
|
+
|
|
9
|
+
Categories carry a typical direction (+1 usually good for the price, -1 usually bad, 0 unclear);
|
|
10
|
+
the LLM's score for the specific announcement can differ (e.g. a small buyback plan).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from stockproof import clock
|
|
20
|
+
from stockproof import cn
|
|
21
|
+
|
|
22
|
+
# (key, importance 3/2/1, typical direction, title patterns). First match wins, so order matters:
|
|
23
|
+
# specific risks before generic words ("终止回购" is not a buyback).
|
|
24
|
+
RULES = [
|
|
25
|
+
("delisting_risk", 3, -1, [r"退市", r"\*ST", r"终止上市", r"暂停上市", r"实施其他风险警示", r"delisting"]),
|
|
26
|
+
("investigation", 3, -1, [r"立案", r"行政处罚", r"刑事", r"留置", r"被调查", r"investigation"]),
|
|
27
|
+
("regulatory_letter", 3, -1, [r"问询函", r"关注函", r"监管函", r"警示函", r"监管措施", r"纪律处分", r"通报批评"]),
|
|
28
|
+
("trading_halt", 3, 0, [r"停牌", r"trading halt", r"suspension of trading"]),
|
|
29
|
+
("restructuring", 3, 0, [r"重大资产重组", r"重大资产", r"吸收合并", r"收购报告书", r"要约收购", r"控制权变更",
|
|
30
|
+
r"实际控制人.{0,6}变更", r"major transaction", r"very substantial"]),
|
|
31
|
+
("earnings_warning", 3, 0, [r"业绩预告", r"业绩预亏", r"业绩预增", r"业绩快报", r"profit warning",
|
|
32
|
+
r"positive profit alert", r"盈利警告", r"盈利预告"]),
|
|
33
|
+
("insider_selling", 3, -1, [r"减持", r"大宗交易"]),
|
|
34
|
+
("insider_buying", 2, 1, [r"增持"]),
|
|
35
|
+
("buyback", 2, 1, [r"(?<!终止)回购.{0,10}(股份|方案|计划|报告书|进展|实施)", r"buy-?back", r"repurchase"]),
|
|
36
|
+
("lawsuit", 2, -1, [r"诉讼", r"仲裁", r"litigation"]),
|
|
37
|
+
("pledge_freeze", 2, -1, [r"质押", r"冻结", r"司法拍卖"]),
|
|
38
|
+
("lockup_expiry", 2, -1, [r"限售股.{0,8}上市流通", r"解除限售", r"解禁"]),
|
|
39
|
+
("share_issue", 2, -1, [r"非公开发行", r"向特定对象发行", r"定向增发", r"配股", r"可转换公司债券", r"可转债",
|
|
40
|
+
r"placing", r"rights issue", r"subscription of new shares"]),
|
|
41
|
+
("management_change", 2, 0, [r"辞职", r"辞任", r"离任", r"resignation", r"change of (director|chief)"]),
|
|
42
|
+
("big_contract", 2, 1, [r"中标", r"重大合同", r"战略合作", r"framework agreement"]),
|
|
43
|
+
("incentive", 1, 1, [r"股权激励", r"员工持股", r"限制性股票", r"股票期权", r"share award", r"share option"]),
|
|
44
|
+
("dividend", 1, 1, [r"利润分配", r"分红", r"派息", r"权益分派", r"dividend"]),
|
|
45
|
+
("periodic_report", 1, 0, [r"季度报告", r"半年度报告", r"年度报告", r"interim report", r"annual report",
|
|
46
|
+
r"quarterly report", r"results announcement", r"业绩公告"]),
|
|
47
|
+
]
|
|
48
|
+
# Paperwork that only accompanies an event (lawyers' opinions, brokers' checks, replies, meeting
|
|
49
|
+
# resolutions…) or is routine. Checked FIRST: "律师事务所关于…增持股份的法律意见书" is not a purchase.
|
|
50
|
+
NOISE = [r"律师事务所", r"法律意见", r"核查意见", r"专项核查", r"持续督导", r"自查报告", r"的回复", r"之回复",
|
|
51
|
+
r"论证分析", r"会议规则", r"募集说明书", r"上市保荐", r"保荐书", r"独立董事", r"会议决议", r"董事会决议",
|
|
52
|
+
r"监事会决议", r"股东(大)?会(决议|通知|的通知|会议资料)", r"召开.{0,30}(股东|说明会|会议)", r"说明会",
|
|
53
|
+
r"投资者关系", r"章程", r"议事规则", r"管理制度", r"工作细则", r"履职情况", r"内部控制", r"社会责任",
|
|
54
|
+
r"ESG", r"環境、社會", r"可持续发展", r"更正", r"英文", r"H股公告", r"月报表", r"月報表", r"通函",
|
|
55
|
+
r"关联交易预计", r"日常关联交易", r"提示性公告$", r"财务顾问", r"董事会关于本次交易", r"验资",
|
|
56
|
+
r"评级报告", r"可行性分析", r"审核意见", r"前十(大|名)(股东|流通)",
|
|
57
|
+
r"monthly return", r"next day disclosure", r"date of board meeting", r"董事会会议召开日期"]
|
|
58
|
+
# Words that turn a matching title into something routine.
|
|
59
|
+
NOT_EVENT = {
|
|
60
|
+
"regulatory_letter": [r"不存在", r"未被", r"审核问询", r"注册.{0,6}问询", r"回复", r"回函"],
|
|
61
|
+
"trading_halt": [r"债券", r"转债", r"转股", r"票据", r"可交换"],
|
|
62
|
+
"buyback": [r"限制性股票", r"激励", r"债券", r"注销.{0,6}限制性"],
|
|
63
|
+
"lockup_expiry": [r"限制性股票", r"激励", r"员工持股"],
|
|
64
|
+
"investigation": [r"不存在", r"说明会"],
|
|
65
|
+
"pledge_freeze": [r"解除质押(?!.{0,4}及)"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
CATEGORY_NAMES = {
|
|
69
|
+
"en": {"delisting_risk": "delisting / ST risk", "investigation": "investigation / penalty",
|
|
70
|
+
"regulatory_letter": "regulator's letter", "trading_halt": "trading halt", "restructuring": "M&A / restructuring",
|
|
71
|
+
"earnings_warning": "earnings preview", "insider_selling": "shareholder selling",
|
|
72
|
+
"insider_buying": "shareholder buying", "buyback": "buyback", "lawsuit": "lawsuit", "pledge_freeze": "pledge / freeze",
|
|
73
|
+
"lockup_expiry": "lock-up expiry", "share_issue": "new shares / convertible", "management_change": "management change",
|
|
74
|
+
"big_contract": "major contract", "incentive": "stock incentive", "dividend": "dividend",
|
|
75
|
+
"periodic_report": "periodic report", "other": "other"},
|
|
76
|
+
"zh": {"delisting_risk": "退市/ST 风险", "investigation": "立案调查/处罚", "regulatory_letter": "监管问询",
|
|
77
|
+
"trading_halt": "停牌", "restructuring": "重组/收购", "earnings_warning": "业绩预告/快报",
|
|
78
|
+
"insider_selling": "股东减持", "insider_buying": "股东增持", "buyback": "回购", "lawsuit": "诉讼仲裁",
|
|
79
|
+
"pledge_freeze": "质押/冻结", "lockup_expiry": "限售股解禁", "share_issue": "增发/可转债",
|
|
80
|
+
"management_change": "高管变动", "big_contract": "重大合同/中标", "incentive": "股权激励", "dividend": "分红",
|
|
81
|
+
"periodic_report": "定期报告", "other": "其他"},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
SCHEMA = """
|
|
85
|
+
CREATE TABLE IF NOT EXISTS announcements (
|
|
86
|
+
art_code TEXT PRIMARY KEY,
|
|
87
|
+
ticker TEXT NOT NULL,
|
|
88
|
+
date TEXT NOT NULL,
|
|
89
|
+
title TEXT NOT NULL,
|
|
90
|
+
category TEXT NOT NULL,
|
|
91
|
+
importance INTEGER NOT NULL,
|
|
92
|
+
direction INTEGER NOT NULL,
|
|
93
|
+
url TEXT,
|
|
94
|
+
first_seen TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_ann_ticker ON announcements (ticker, date);
|
|
97
|
+
CREATE TABLE IF NOT EXISTS announcement_reads (
|
|
98
|
+
art_code TEXT NOT NULL,
|
|
99
|
+
model TEXT NOT NULL,
|
|
100
|
+
lang TEXT NOT NULL,
|
|
101
|
+
result TEXT NOT NULL,
|
|
102
|
+
read_at TEXT NOT NULL,
|
|
103
|
+
PRIMARY KEY (art_code, model, lang)
|
|
104
|
+
);
|
|
105
|
+
"""
|
|
106
|
+
READ_VERSION = 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def classify(title: str) -> tuple[str, int, int]:
|
|
110
|
+
"""(category, importance 0-3, typical direction). Importance 0 = routine paperwork."""
|
|
111
|
+
t = title.split(":", 1)[-1] if ":" in title[:20] else title # drop the "公司名:" prefix
|
|
112
|
+
low = t.lower()
|
|
113
|
+
hit = lambda pats: any(re.search(p, t) or re.search(p, low, re.I) for p in pats)
|
|
114
|
+
if hit(NOISE):
|
|
115
|
+
return "other", 0, 0
|
|
116
|
+
for key, imp, direction, pats in RULES:
|
|
117
|
+
if hit(pats) and not hit(NOT_EVENT.get(key, [])):
|
|
118
|
+
return key, imp, direction
|
|
119
|
+
if re.search(r"限制性股票|激励", t):
|
|
120
|
+
return "incentive", 1, 1 # vesting / cancellation of incentive shares
|
|
121
|
+
if re.search(r"解除质押", t):
|
|
122
|
+
return "pledge_freeze", 1, 1 # pledge released: mildly reassuring
|
|
123
|
+
return "other", 1, 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def page_url(info: dict, art_code: str) -> str:
|
|
127
|
+
if info["market"] == "HK":
|
|
128
|
+
return f"https://data.eastmoney.com/notices/hk/detail/{info['code']}/{art_code}.html"
|
|
129
|
+
return f"https://data.eastmoney.com/notices/detail/{info['code']}/{art_code}.html"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ensure_schema(store) -> None:
|
|
133
|
+
store.conn.executescript(SCHEMA)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def fetch(store, ticker: str, days: int = 30) -> int:
|
|
137
|
+
"""Save the latest announcements of one stock (newest page only). Returns how many were new."""
|
|
138
|
+
ensure_schema(store)
|
|
139
|
+
info = cn.parse(ticker)
|
|
140
|
+
if not info:
|
|
141
|
+
return 0
|
|
142
|
+
since = (clock.today() - dt.timedelta(days=days)).isoformat()
|
|
143
|
+
now = dt.datetime.now().isoformat(timespec="seconds")
|
|
144
|
+
new = 0
|
|
145
|
+
for a in cn.announcements(info, page_size=100):
|
|
146
|
+
if not a.get("art_code") or not a.get("date") or a["date"] < since:
|
|
147
|
+
continue
|
|
148
|
+
cat, imp, direction = classify(a["title"])
|
|
149
|
+
cur = store.conn.execute(
|
|
150
|
+
"INSERT OR IGNORE INTO announcements VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
151
|
+
(a["art_code"], info["symbol"], a["date"], a["title"], cat, imp, direction,
|
|
152
|
+
page_url(info, a["art_code"]), now))
|
|
153
|
+
new += cur.rowcount
|
|
154
|
+
store.conn.commit()
|
|
155
|
+
return new
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_prompt(name: str, ticker: str, title: str, text: str, lang: str) -> str:
|
|
159
|
+
language = "Simplified Chinese" if lang == "zh" else "English"
|
|
160
|
+
return (
|
|
161
|
+
"You are an equity analyst reading a listed company's official announcement.\n"
|
|
162
|
+
f"Company: {name} ({ticker}). Title: {title}\n\n"
|
|
163
|
+
f"--- ANNOUNCEMENT (may be cut off) ---\n{text}\n--- END ---\n\n"
|
|
164
|
+
"Report only what the announcement says.\n"
|
|
165
|
+
"Reply ONLY with JSON:\n"
|
|
166
|
+
f'{{"summary": "at most 2 sentences in {language}, with the key numbers (amounts, share counts, '
|
|
167
|
+
'percentages, dates)",\n'
|
|
168
|
+
' "impact": -1 to 1 (likely effect on the share price; 0 if routine or unclear),\n'
|
|
169
|
+
f' "why": "one short sentence in {language}"}}'
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def read(store, client, model_id: str, art_code: str, name: str, ticker: str, title: str, lang: str) -> dict:
|
|
174
|
+
"""LLM summary of one announcement, cached per model and language."""
|
|
175
|
+
ensure_schema(store)
|
|
176
|
+
key = f"{model_id}#a{READ_VERSION}"
|
|
177
|
+
row = store.conn.execute("SELECT result FROM announcement_reads WHERE art_code=? AND model=? AND lang=?",
|
|
178
|
+
(art_code, key, lang)).fetchone()
|
|
179
|
+
if row:
|
|
180
|
+
return json.loads(row[0])
|
|
181
|
+
text, pdf = cn.announcement_text(art_code)
|
|
182
|
+
data = client.chat_json(build_prompt(name, ticker, title, text[:8000] or title, lang))
|
|
183
|
+
try:
|
|
184
|
+
impact = max(-1.0, min(1.0, float(data.get("impact", 0))))
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
impact = 0.0
|
|
187
|
+
result = {"summary": str(data.get("summary", ""))[:400], "impact": impact,
|
|
188
|
+
"why": str(data.get("why", ""))[:200], "pdf": pdf}
|
|
189
|
+
store.conn.execute("INSERT OR REPLACE INTO announcement_reads VALUES (?, ?, ?, ?, ?)",
|
|
190
|
+
(art_code, key, lang, json.dumps(result, ensure_ascii=False),
|
|
191
|
+
dt.datetime.now().isoformat(timespec="seconds")))
|
|
192
|
+
store.conn.commit()
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def refresh(store, client, model_id: str, tickers: list[str], names: dict, lang: str,
|
|
197
|
+
days: int = 30, min_importance: int = 2, max_reads: int = 40, log=print) -> dict:
|
|
198
|
+
"""Fetch announcements for the Chinese tickers; summarize the important new ones with the LLM."""
|
|
199
|
+
ensure_schema(store)
|
|
200
|
+
stats = {"stocks": 0, "new": 0, "read": 0, "errors": 0}
|
|
201
|
+
for tk in tickers:
|
|
202
|
+
if not cn.is_cn(tk):
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
stats["new"] += fetch(store, tk, days)
|
|
206
|
+
stats["stocks"] += 1
|
|
207
|
+
except cn.Blocked as e:
|
|
208
|
+
stats["errors"] += 1
|
|
209
|
+
log(f" announcements skipped: {e}")
|
|
210
|
+
return stats # the rest would be refused too
|
|
211
|
+
except Exception as e:
|
|
212
|
+
stats["errors"] += 1
|
|
213
|
+
log(f" {tk}: announcements unavailable ({type(e).__name__})")
|
|
214
|
+
if client is None:
|
|
215
|
+
return stats
|
|
216
|
+
since = (clock.today() - dt.timedelta(days=days)).isoformat()
|
|
217
|
+
key = f"{model_id}#a{READ_VERSION}"
|
|
218
|
+
todo = store.conn.execute(
|
|
219
|
+
"SELECT a.art_code, a.ticker, a.title FROM announcements a WHERE a.importance >= ? AND a.date >= ? "
|
|
220
|
+
"AND NOT EXISTS (SELECT 1 FROM announcement_reads r WHERE r.art_code = a.art_code AND r.model = ? "
|
|
221
|
+
"AND r.lang = ?) ORDER BY a.importance DESC, a.date DESC LIMIT ?",
|
|
222
|
+
(min_importance, since, key, lang, max_reads)).fetchall()
|
|
223
|
+
for code, tk, title in todo:
|
|
224
|
+
try:
|
|
225
|
+
read(store, client, model_id, code, names.get(tk, tk), tk, title, lang)
|
|
226
|
+
stats["read"] += 1
|
|
227
|
+
except Exception as e:
|
|
228
|
+
from stockproof.core import is_fatal
|
|
229
|
+
stats["errors"] += 1
|
|
230
|
+
if is_fatal(e):
|
|
231
|
+
raise
|
|
232
|
+
if isinstance(e, cn.Blocked):
|
|
233
|
+
log(f" announcement summaries skipped: {e}")
|
|
234
|
+
break
|
|
235
|
+
return stats
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def recent(store, tickers: list[str], days: int = 30, min_importance: int = 1,
|
|
239
|
+
lang: str = "en", model_id: str | None = None) -> list[dict]:
|
|
240
|
+
"""Saved announcements (newest first) with the LLM summary when there is one. No network."""
|
|
241
|
+
ensure_schema(store)
|
|
242
|
+
if not tickers:
|
|
243
|
+
return []
|
|
244
|
+
since = (clock.today() - dt.timedelta(days=days)).isoformat()
|
|
245
|
+
marks = ",".join("?" * len(tickers))
|
|
246
|
+
rows = store.conn.execute(
|
|
247
|
+
f"SELECT art_code, ticker, date, title, category, importance, direction, url FROM announcements "
|
|
248
|
+
f"WHERE ticker IN ({marks}) AND date >= ? AND importance >= ? ORDER BY date DESC, importance DESC",
|
|
249
|
+
(*tickers, since, min_importance)).fetchall()
|
|
250
|
+
out = []
|
|
251
|
+
for code, tk, date, title, cat, imp, direction, url in rows:
|
|
252
|
+
q = ("SELECT result FROM announcement_reads WHERE art_code = ? AND lang = ? "
|
|
253
|
+
+ ("AND model = ? " if model_id else "") + "ORDER BY read_at DESC LIMIT 1")
|
|
254
|
+
params = (code, lang, f"{model_id}#a{READ_VERSION}") if model_id else (code, lang)
|
|
255
|
+
r = store.conn.execute(q, params).fetchone()
|
|
256
|
+
if r is None: # fall back to a reading in the other language
|
|
257
|
+
r = store.conn.execute("SELECT result FROM announcement_reads WHERE art_code = ? "
|
|
258
|
+
"ORDER BY read_at DESC LIMIT 1", (code,)).fetchone()
|
|
259
|
+
out.append({"art_code": code, "ticker": tk, "date": date, "title": title, "category": cat,
|
|
260
|
+
"category_name": CATEGORY_NAMES.get(lang, CATEGORY_NAMES["en"]).get(cat, cat),
|
|
261
|
+
"importance": imp, "direction": direction, "url": url,
|
|
262
|
+
"read": json.loads(r[0]) if r else None})
|
|
263
|
+
return out
|
stockproof/backtest.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Can the post-earnings drift be traded? An event-driven backtest with real-world frictions.
|
|
3
|
+
|
|
4
|
+
Rules (all decided with information available at the time):
|
|
5
|
+
- Signal: SUE at the announcement. Long the stocks with SUE >= long_sue, (hypothetically)
|
|
6
|
+
short those with SUE <= short_sue.
|
|
7
|
+
- Entry: at the close of day 1 (the day after the announcement date), i.e. after the
|
|
8
|
+
reaction window, so the backtest earns only the drift. If the stock can't be traded that day
|
|
9
|
+
(suspended, or closed at the limit: limit-up blocks buying, limit-down blocks selling) the
|
|
10
|
+
order waits up to `max_wait` days, then is dropped.
|
|
11
|
+
- Exit: at the close `hold` trading days later; if the stock can't be sold then (suspended or
|
|
12
|
+
limit-down for a long), it waits for the first day it can.
|
|
13
|
+
- Portfolio: every open position has the same weight; days with no positions are in cash.
|
|
14
|
+
- Costs, charged on the first and last day of each trade: commission on both sides, stamp duty
|
|
15
|
+
on sales (China: 0.1%, 0.05% from 2023-08-28), and slippage on both sides.
|
|
16
|
+
- Benchmark: the equal-weighted average of all stocks in the price file (same universe, so the
|
|
17
|
+
survivorship of "today's index members" affects both sides alike).
|
|
18
|
+
|
|
19
|
+
Long-only excess return is the practical number for A-shares (shorting single stocks is limited and
|
|
20
|
+
expensive); the long-short result shows the size of the effect.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Costs:
|
|
34
|
+
commission: float = 0.00025 # each side
|
|
35
|
+
slippage: float = 0.001 # each side: spread + market impact for large caps
|
|
36
|
+
stamp_duty: float = 0.001 # on sales; China halved it on 2023-08-28
|
|
37
|
+
stamp_duty_cut: str | None = "2023-08-28"
|
|
38
|
+
stamp_duty_after: float = 0.0005
|
|
39
|
+
|
|
40
|
+
def buy(self, day) -> float:
|
|
41
|
+
return self.commission + self.slippage
|
|
42
|
+
|
|
43
|
+
def sell(self, day) -> float:
|
|
44
|
+
duty = self.stamp_duty
|
|
45
|
+
if self.stamp_duty_cut and pd.Timestamp(day) >= pd.Timestamp(self.stamp_duty_cut):
|
|
46
|
+
duty = self.stamp_duty_after
|
|
47
|
+
return self.commission + self.slippage + duty
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
COSTS = {"cn": Costs(),
|
|
51
|
+
"us": Costs(commission=0.0, slippage=0.0005, stamp_duty=0.0, stamp_duty_cut=None,
|
|
52
|
+
stamp_duty_after=0.0)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def price_limit(ticker: str, day) -> float | None:
|
|
56
|
+
"""Daily price limit for an A-share (None = no limit, e.g. US / HK)."""
|
|
57
|
+
if not ticker.endswith((".SH", ".SZ", ".BJ")):
|
|
58
|
+
return None
|
|
59
|
+
code = ticker[:6]
|
|
60
|
+
if ticker.endswith(".BJ"):
|
|
61
|
+
return 0.30
|
|
62
|
+
if code.startswith("688"):
|
|
63
|
+
return 0.20 # STAR market, since its start in 2019
|
|
64
|
+
if code.startswith(("300", "301")) and pd.Timestamp(day) >= pd.Timestamp("2020-08-24"):
|
|
65
|
+
return 0.20 # ChiNext reform
|
|
66
|
+
return 0.10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tradable_masks(prices: pd.DataFrame) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
70
|
+
"""(can_buy, can_sell, returns) as T x N arrays. A day with no price is a suspension.
|
|
71
|
+
A close within 0.3% of the limit is treated as locked at the limit (no fills on that side)."""
|
|
72
|
+
ret = prices.pct_change(fill_method=None).to_numpy(dtype=float)
|
|
73
|
+
has = prices.notna().to_numpy()
|
|
74
|
+
up = np.zeros_like(has)
|
|
75
|
+
down = np.zeros_like(has)
|
|
76
|
+
days = prices.index
|
|
77
|
+
for j, tk in enumerate(prices.columns):
|
|
78
|
+
lim_old, lim_new = price_limit(tk, days[0]), price_limit(tk, days[-1])
|
|
79
|
+
if lim_old is None:
|
|
80
|
+
continue
|
|
81
|
+
lim = np.array([price_limit(tk, d) for d in days]) if lim_old != lim_new else lim_old
|
|
82
|
+
r = ret[:, j]
|
|
83
|
+
up[:, j] = r >= lim - 0.003
|
|
84
|
+
down[:, j] = r <= -(lim - 0.003)
|
|
85
|
+
up &= ~np.isnan(ret)
|
|
86
|
+
down &= ~np.isnan(ret)
|
|
87
|
+
return has & ~up, has & ~down, ret
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_trades(events: pd.DataFrame, prices: pd.DataFrame, long_sue: float, short_sue: float,
|
|
91
|
+
hold: int, entry_lag: int = 1, max_wait: int = 5, max_exit_wait: int = 20) -> pd.DataFrame:
|
|
92
|
+
"""One row per trade: ticker, side (+1/-1), entry and exit positions in the price index."""
|
|
93
|
+
days = prices.index
|
|
94
|
+
col = {tk: j for j, tk in enumerate(prices.columns)}
|
|
95
|
+
can_buy, can_sell, _ = tradable_masks(prices)
|
|
96
|
+
rows = []
|
|
97
|
+
ev = events.dropna(subset=["sue", "day0"])
|
|
98
|
+
ev = ev[(ev["sue"] >= long_sue) | (ev["sue"] <= short_sue)]
|
|
99
|
+
for _, e in ev.iterrows():
|
|
100
|
+
j = col.get(e["ticker"])
|
|
101
|
+
if j is None:
|
|
102
|
+
continue
|
|
103
|
+
side = 1 if e["sue"] >= long_sue else -1
|
|
104
|
+
i0 = int(days.searchsorted(pd.Timestamp(e["day0"])))
|
|
105
|
+
open_ok = can_buy if side == 1 else can_sell # opening a short = selling
|
|
106
|
+
close_ok = can_sell if side == 1 else can_buy
|
|
107
|
+
entry = None
|
|
108
|
+
for i in range(i0 + entry_lag, min(i0 + entry_lag + max_wait + 1, len(days))):
|
|
109
|
+
if open_ok[i, j]:
|
|
110
|
+
entry = i
|
|
111
|
+
break
|
|
112
|
+
if entry is None:
|
|
113
|
+
continue
|
|
114
|
+
exit_ = None
|
|
115
|
+
for i in range(entry + hold, min(entry + hold + max_exit_wait + 1, len(days))):
|
|
116
|
+
if close_ok[i, j]:
|
|
117
|
+
exit_ = i
|
|
118
|
+
break
|
|
119
|
+
if exit_ is None:
|
|
120
|
+
if entry + hold < len(days):
|
|
121
|
+
continue # stuck for weeks: leave it out
|
|
122
|
+
exit_ = len(days) - 1 # still open today: mark to market
|
|
123
|
+
rows.append({"ticker": e["ticker"], "side": side, "sue": e["sue"], "day0": e["day0"],
|
|
124
|
+
"entry": entry, "exit": exit_, "entry_date": days[entry].date().isoformat(),
|
|
125
|
+
"exit_date": days[exit_].date().isoformat(), "open": entry + hold >= len(days)})
|
|
126
|
+
return pd.DataFrame(rows)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(events: pd.DataFrame, prices: pd.DataFrame, market: str = "cn", long_sue: float = 2.0,
|
|
130
|
+
short_sue: float = -2.0, hold: int = 60, costs: Costs | None = None, slots: int = 20,
|
|
131
|
+
entry_lag: int = 1) -> dict:
|
|
132
|
+
"""Daily returns of the long book, short book, long-short and the benchmark, plus trades."""
|
|
133
|
+
from stockproof.research import BENCHMARKS
|
|
134
|
+
costs = costs or COSTS.get(market, Costs())
|
|
135
|
+
stocks = prices.drop(columns=[c for c in BENCHMARKS if c in prices.columns])
|
|
136
|
+
days = stocks.index
|
|
137
|
+
_, _, ret = tradable_masks(stocks)
|
|
138
|
+
ret0 = np.nan_to_num(ret)
|
|
139
|
+
bench = pd.DataFrame(ret, index=days).mean(axis=1, skipna=True).fillna(0.0)
|
|
140
|
+
trades = make_trades(events, stocks, long_sue, short_sue, hold, entry_lag=entry_lag)
|
|
141
|
+
T = len(days)
|
|
142
|
+
out = {"days": days, "trades": trades, "bench": bench}
|
|
143
|
+
for side, name in ((1, "long"), (-1, "short")):
|
|
144
|
+
s = np.zeros(T)
|
|
145
|
+
n = np.zeros(T)
|
|
146
|
+
net = []
|
|
147
|
+
if len(trades):
|
|
148
|
+
col = {tk: j for j, tk in enumerate(stocks.columns)}
|
|
149
|
+
for _, t in trades[trades["side"] == side].iterrows():
|
|
150
|
+
j, a, b = col[t["ticker"]], int(t["entry"]), int(t["exit"])
|
|
151
|
+
if b <= a:
|
|
152
|
+
continue
|
|
153
|
+
r = ret0[a + 1:b + 1, j].copy()
|
|
154
|
+
r[0] -= costs.buy(days[a]) if side == 1 else costs.sell(days[a])
|
|
155
|
+
if not t["open"]:
|
|
156
|
+
r[-1] -= costs.sell(days[b]) if side == 1 else costs.buy(days[b])
|
|
157
|
+
s[a + 1:b + 1] += r
|
|
158
|
+
n[a + 1:b + 1] += 1
|
|
159
|
+
gross = float(np.prod(1 + ret0[a + 1:b + 1, j]) - 1)
|
|
160
|
+
bench_ret = float(np.prod(1 + bench.to_numpy()[a + 1:b + 1]) - 1)
|
|
161
|
+
cost = (costs.buy(days[a]) + costs.sell(days[b])) if not t["open"] else costs.buy(days[a])
|
|
162
|
+
net.append(side * (gross - bench_ret) - cost)
|
|
163
|
+
# Capital is split into `slots` equal parts; parts without a position hold the market
|
|
164
|
+
# (an index fund). With more positions than slots, all positions share the capital equally.
|
|
165
|
+
# Without this, a day with one open trade would put everything into one stock.
|
|
166
|
+
k = np.maximum(n, slots)
|
|
167
|
+
b = bench.to_numpy()
|
|
168
|
+
book = pd.Series(np.where(n > 0, (s + (k - n) * b) / k, b), index=days)
|
|
169
|
+
out[name] = book
|
|
170
|
+
out[name + "_n"] = pd.Series(n, index=days)
|
|
171
|
+
out[name + "_trade_excess"] = net
|
|
172
|
+
# a short position earns the negative of the stock: long-short = long book minus short book
|
|
173
|
+
out["long_excess"] = out["long"] - bench
|
|
174
|
+
out["long_short"] = out["long"] - out["short"] # the market parts cancel out
|
|
175
|
+
out["slots"] = slots
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _drawdown(r: pd.Series) -> float:
|
|
180
|
+
curve = (1 + r).cumprod()
|
|
181
|
+
return float((curve / curve.cummax() - 1).min()) if len(curve) else float("nan")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def stats(r: pd.Series, active: pd.Series | None = None) -> dict:
|
|
185
|
+
"""Annualized figures over the days the strategy was invested (or all days)."""
|
|
186
|
+
x = r[active > 0] if active is not None else r
|
|
187
|
+
if len(x) < 20:
|
|
188
|
+
return {"days": len(x)}
|
|
189
|
+
ann = (1 + x).prod() ** (252 / len(x)) - 1
|
|
190
|
+
vol = x.std(ddof=1) * math.sqrt(252)
|
|
191
|
+
t = x.mean() / (x.std(ddof=1) / math.sqrt(len(x))) if x.std(ddof=1) > 0 else float("nan")
|
|
192
|
+
return {"days": len(x), "ann_return": float(ann), "ann_vol": float(vol),
|
|
193
|
+
"sharpe": float(x.mean() / x.std(ddof=1) * math.sqrt(252)) if x.std(ddof=1) > 0 else float("nan"),
|
|
194
|
+
"t": float(t), "max_dd": _drawdown(x)}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def by_year(r: pd.Series, active: pd.Series | None = None) -> pd.DataFrame:
|
|
198
|
+
x = r if active is None else r.where(active > 0, 0.0)
|
|
199
|
+
g = (1 + x).groupby(x.index.year).prod() - 1
|
|
200
|
+
return g.rename("return").to_frame()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def summarize(bt: dict) -> dict:
|
|
204
|
+
"""Numbers for the report."""
|
|
205
|
+
tr = bt["trades"]
|
|
206
|
+
out = {"n_trades": len(tr), "n_long": int((tr["side"] == 1).sum()) if len(tr) else 0,
|
|
207
|
+
"n_short": int((tr["side"] == -1).sum()) if len(tr) else 0}
|
|
208
|
+
if not len(tr):
|
|
209
|
+
return out
|
|
210
|
+
out["start"], out["end"] = tr["entry_date"].min(), tr["exit_date"].max()
|
|
211
|
+
live = pd.Series(0, index=bt["long_n"].index)
|
|
212
|
+
first = bt["long_n"].to_numpy().nonzero()[0]
|
|
213
|
+
if len(first):
|
|
214
|
+
live.iloc[first[0]:] = 1 # from the first trade on
|
|
215
|
+
out["long_excess"] = stats(bt["long_excess"], live)
|
|
216
|
+
out["long"] = stats(bt["long"], live)
|
|
217
|
+
out["bench"] = stats(bt["bench"], live)
|
|
218
|
+
out["long_short"] = stats(bt["long_short"], live)
|
|
219
|
+
out["avg_positions"] = float(bt["long_n"][live > 0].mean())
|
|
220
|
+
out["slots"] = bt.get("slots")
|
|
221
|
+
for side in ("long", "short"):
|
|
222
|
+
net = pd.Series(bt[side + "_trade_excess"], dtype=float)
|
|
223
|
+
if len(net):
|
|
224
|
+
out[side + "_trade"] = {"n": len(net), "mean": float(net.mean()), "win": float((net > 0).mean()),
|
|
225
|
+
"t": float(net.mean() / (net.std(ddof=1) / math.sqrt(len(net))))
|
|
226
|
+
if len(net) > 1 and net.std(ddof=1) > 0 else float("nan")}
|
|
227
|
+
yrs = pd.concat([by_year(bt["long_excess"], live).rename(columns={"return": "long_excess"}),
|
|
228
|
+
by_year(bt["long_short"], live).rename(columns={"return": "long_short"})], axis=1)
|
|
229
|
+
yrs = yrs[yrs.index >= bt["long_n"].index[first[0]].year] if len(first) else yrs
|
|
230
|
+
out["years"] = yrs
|
|
231
|
+
curve = pd.DataFrame({"long_excess": (1 + bt["long_excess"]).cumprod(),
|
|
232
|
+
"long_short": (1 + bt["long_short"]).cumprod()})
|
|
233
|
+
first = bt["long_n"].to_numpy().nonzero()[0]
|
|
234
|
+
out["curve"] = curve.iloc[first[0]:] if len(first) else curve
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def sweep(events: pd.DataFrame, prices: pd.DataFrame, market: str,
|
|
239
|
+
thresholds=(0.5, 1.0, 2.0), holds=(20, 60)) -> pd.DataFrame:
|
|
240
|
+
"""Robustness: the same backtest with other SUE cut-offs and holding periods."""
|
|
241
|
+
rows = []
|
|
242
|
+
for th in thresholds:
|
|
243
|
+
for h in holds:
|
|
244
|
+
s = summarize(run(events, prices, market, th, -th, h))
|
|
245
|
+
le, lt = s.get("long_excess", {}), s.get("long_trade", {})
|
|
246
|
+
rows.append({"sue": th, "hold": h, "trades": s.get("n_long", 0),
|
|
247
|
+
"ann_excess": le.get("ann_return"), "t": le.get("t"),
|
|
248
|
+
"per_trade": lt.get("mean"), "win": lt.get("win"),
|
|
249
|
+
"ls_ann": s.get("long_short", {}).get("ann_return")})
|
|
250
|
+
return pd.DataFrame(rows)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def to_json(s: dict, params: dict) -> dict:
|
|
254
|
+
"""A JSON-safe copy of summarize() for saving next to the research results."""
|
|
255
|
+
out = {k: v for k, v in s.items() if k not in ("years", "curve")}
|
|
256
|
+
out["params"] = params
|
|
257
|
+
if "years" in s:
|
|
258
|
+
y = s["years"]
|
|
259
|
+
out["years"] = [{"year": int(i), **{c: (None if pd.isna(v) else float(v)) for c, v in r.items()}}
|
|
260
|
+
for i, r in y.iterrows()]
|
|
261
|
+
if "curve" in s:
|
|
262
|
+
c = s["curve"].groupby(pd.Grouper(freq="W")).tail(1) # last real day of each week
|
|
263
|
+
out["curve"] = {"dates": [d.date().isoformat() for d in c.index],
|
|
264
|
+
"long_excess": [round(float(v), 5) for v in c["long_excess"]],
|
|
265
|
+
"long_short": [round(float(v), 5) for v in c["long_short"]]}
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def full(events: pd.DataFrame, prices: pd.DataFrame, market: str, long_sue: float = 2.0,
|
|
270
|
+
hold: int = 60) -> dict:
|
|
271
|
+
"""Main backtest + robustness sweep, ready for the report (JSON-safe)."""
|
|
272
|
+
s = summarize(run(events, prices, market, long_sue, -long_sue, hold))
|
|
273
|
+
free = Costs(commission=0.0, slippage=0.0, stamp_duty=0.0, stamp_duty_cut=None, stamp_duty_after=0.0)
|
|
274
|
+
g = summarize(run(events, prices, market, long_sue, -long_sue, hold, costs=free))
|
|
275
|
+
s["long_excess_gross"] = g.get("long_excess", {})
|
|
276
|
+
s["long_short_gross"] = g.get("long_short", {})
|
|
277
|
+
out = to_json(s, {"long_sue": long_sue, "short_sue": -long_sue, "hold": hold, "market": market,
|
|
278
|
+
"costs": COSTS.get(market, Costs()).__dict__})
|
|
279
|
+
sw = sweep(events, prices, market)
|
|
280
|
+
out["sweep"] = [{k: (None if (isinstance(v, float) and math.isnan(v)) else v) for k, v in r.items()}
|
|
281
|
+
for r in sw.to_dict("records")]
|
|
282
|
+
return out
|