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.
Files changed (95) hide show
  1. stockproof/__init__.py +15 -0
  2. stockproof/__main__.py +5 -0
  3. stockproof/announcements.py +263 -0
  4. stockproof/backtest.py +282 -0
  5. stockproof/cli.py +1121 -0
  6. stockproof/clock.py +14 -0
  7. stockproof/cn.py +513 -0
  8. stockproof/companies.py +51 -0
  9. stockproof/compare.py +146 -0
  10. stockproof/core.py +124 -0
  11. stockproof/dashboard.py +788 -0
  12. stockproof/dashboard_page.py +1152 -0
  13. stockproof/demo.py +56 -0
  14. stockproof/demo_data/calendar.json +1 -0
  15. stockproof/demo_data/cn/eps_v2_000898.SZ.json +1 -0
  16. stockproof/demo_data/cn/eps_v2_00700.HK.json +1 -0
  17. stockproof/demo_data/cn/eps_v2_09988.HK.json +1 -0
  18. stockproof/demo_data/cn/eps_v2_300750.SZ.json +1 -0
  19. stockproof/demo_data/cn/eps_v2_600519.SH.json +1 -0
  20. stockproof/demo_data/cn/names.json +1 -0
  21. stockproof/demo_data/dashboard.json +1 -0
  22. stockproof/demo_data/demo.json +1 -0
  23. stockproof/demo_data/digests/2026-10-30.html +53 -0
  24. stockproof/demo_data/digests/2026-10-30.json +1 -0
  25. stockproof/demo_data/lab/reports/20261030-064920-f50929.html +66 -0
  26. stockproof/demo_data/lab/reports/20261030-065010-434ad9.html +66 -0
  27. stockproof/demo_data/lab/trials.jsonl +2 -0
  28. stockproof/demo_data/policy/industry_map.json +1 -0
  29. stockproof/demo_data/policy/results.json +1 -0
  30. stockproof/demo_data/quotes/000898.SZ.csv +487 -0
  31. stockproof/demo_data/quotes/00700.HK.csv +494 -0
  32. stockproof/demo_data/quotes/09988.HK.csv +494 -0
  33. stockproof/demo_data/quotes/300750.SZ.csv +488 -0
  34. stockproof/demo_data/quotes/600519.SH.csv +487 -0
  35. stockproof/demo_data/quotes/AAPL.csv +503 -0
  36. stockproof/demo_data/quotes/BABA.csv +503 -0
  37. stockproof/demo_data/quotes/JPM.csv +503 -0
  38. stockproof/demo_data/quotes/MRK.csv +503 -0
  39. stockproof/demo_data/quotes/MSFT.csv +503 -0
  40. stockproof/demo_data/quotes/NVDA.csv +503 -0
  41. stockproof/demo_data/quotes/_CSI300.csv +488 -0
  42. stockproof/demo_data/quotes/_HSI.csv +494 -0
  43. stockproof/demo_data/quotes/_SPX.csv +503 -0
  44. stockproof/demo_data/research/cn_backtest.json +1 -0
  45. stockproof/demo_data/research/cn_earnings.csv +11064 -0
  46. stockproof/demo_data/research/cn_meta.json +1 -0
  47. stockproof/demo_data/research/cn_news.csv +65 -0
  48. stockproof/demo_data/research/us_backtest.json +1 -0
  49. stockproof/demo_data/research/us_earnings.csv +2469 -0
  50. stockproof/demo_data/research/us_meta.json +1 -0
  51. stockproof/demo_data/research/us_news.csv +595 -0
  52. stockproof/demo_data/screens/cn_2026-10-29.json +1 -0
  53. stockproof/demo_data/screens/cn_backtest.json +1 -0
  54. stockproof/demo_data/screens/scorecards_2026-10-30.json +1 -0
  55. stockproof/demo_data/sec/company_tickers.json +1 -0
  56. stockproof/demo_data/sec/facts_1045810.json +1 -0
  57. stockproof/demo_data/sec/facts_1577552.json +1 -0
  58. stockproof/demo_data/sec/facts_19617.json +1 -0
  59. stockproof/demo_data/sec/facts_310158.json +1 -0
  60. stockproof/demo_data/sec/facts_320193.json +1 -0
  61. stockproof/demo_data/sec/facts_789019.json +1 -0
  62. stockproof/demo_data/stockproof.db +0 -0
  63. stockproof/digest.py +214 -0
  64. stockproof/earnings.py +184 -0
  65. stockproof/earnings_calendar.py +204 -0
  66. stockproof/earnings_report.py +116 -0
  67. stockproof/eps.py +100 -0
  68. stockproof/explain.py +152 -0
  69. stockproof/i18n.py +479 -0
  70. stockproof/lab.py +904 -0
  71. stockproof/lab_report.py +217 -0
  72. stockproof/llm.py +98 -0
  73. stockproof/mcp_server.py +944 -0
  74. stockproof/news.py +91 -0
  75. stockproof/policy.py +514 -0
  76. stockproof/portfolio.py +328 -0
  77. stockproof/portfolio_report.py +121 -0
  78. stockproof/quotes.py +202 -0
  79. stockproof/report.py +146 -0
  80. stockproof/research.py +556 -0
  81. stockproof/research_cn.py +415 -0
  82. stockproof/research_report.py +449 -0
  83. stockproof/schedule.py +323 -0
  84. stockproof/scorecard.py +476 -0
  85. stockproof/scoring.py +83 -0
  86. stockproof/screener.py +551 -0
  87. stockproof/sec.py +103 -0
  88. stockproof/store.py +169 -0
  89. stockproof/summary.py +71 -0
  90. stockproof-0.3.0.dist-info/METADATA +276 -0
  91. stockproof-0.3.0.dist-info/RECORD +95 -0
  92. stockproof-0.3.0.dist-info/WHEEL +5 -0
  93. stockproof-0.3.0.dist-info/entry_points.txt +2 -0
  94. stockproof-0.3.0.dist-info/licenses/LICENSE +21 -0
  95. 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,5 @@
1
+ """Allows `python3 -m stockproof ...` without installing."""
2
+ from stockproof.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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