Skip to content

Commit 1dd2632

Browse files
committed
feat(tests): add comprehensive tests for down reset state defaults, historical terms, and strategy backtest
1 parent 1364b8d commit 1dd2632

58 files changed

Lines changed: 26029 additions & 2843 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,24 @@ cb-screen-pool --min-rating AA- # 准入筛选报告
123123
cb-sync-admission-status --limit 50 # 刷新状态
124124
python -m convertible_bond.cli.sync_events # 同步事件
125125
python -m convertible_bond.cli.sync_tradable # 同步可交易列表
126+
cb-calibrate-down-reset # 从 cb_events 校准下修博弈常量
126127
python CB.py 128009.SZ # 单只定价
127128
```
129+
130+
### 下修博弈建模 (三 regime, 按价格影响符号分)
131+
132+
`resolve_down_reset_intensity` 把观测合成成 pricer 入参, 三态互斥:
133+
134+
- **背景** (无确定性公告): "纯触发后"模型 — 触发线下方 (S < K·trigger_ratio) 一律按 `p_down` 年化概率下修 (每步 `1-exp(-p·dt)`, 网格无关), 触发线之上为 0。`p_down` = "触发后公司跟进下修"的年化概率; 不用"越跌越可能"的 S 渐变。
135+
- **已公告** (确定性正贡献): 输出 `scheduled_reset_date/prob/kind/target_k` 一次性下修节点, pricer 在预期生效日近确定施加, 不再放大背景强度。两个子态:
136+
- `kind="proposed"` 待股东会: 生效日 = 提议日+`PROPOSED_EFFECTIVE_LAG_DAYS`, 概率 `PROPOSED_PASS_PROB`
137+
- `kind="approved"` 已通过待生效: 生效日 = 公告生效日 (缺失按 `APPROVED_EFFECTIVE_LAG_DAYS` 兜底), 概率 `APPROVED_PASS_PROB`≈1; **仅当生效日 > 估值日才建节点 (防与条款刷新双计)**
138+
- `target_k` = 公告解析到的下修后新 K (`parse_down_reset_new_price``CBEvent.event_price`); 缺失时 pricer 回落 premium/floor 估算。`target_k==现 K` 时节点自动成 no-op, 天然防双计。
139+
- **冻结** (强制为 0): `down_reset_block_until` 屏蔽下修价值至冷静期满。
140+
- 常量经 `cb-calibrate-down-reset` 从历史事件校准; 改这些值或下修结构前先重跑校准。
141+
142+
> **关于 `event_price` 历史回填**: 不需要。`cb_data.json``conversion_price` 是 Wind
143+
> `clause_conversion2_swapshareprice`**当前 K**, 已内含所有"已生效"的历史下修; 这些历史
144+
> 事件也不会触发 regime-② 节点 (terminal/过期, 或 approved 生效日已过被守卫跳过)。`event_price`
145+
> 仅服务**在途公告** (已提议未通过 / 已通过未生效) — 此时 cb_data K 仍是旧值, 节点需公告新 K。
146+
> 存量事件 `event_price` 多为空 (解析代码后加), 但无害; 新公告同步时自动填充。

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
</p>
1313

1414
**基于 Crank-Nicolson PDE 引擎的 A 股可转债定价与机会筛选工作台**<br/>
15-
支持多数据源接入、公告事件解析、主池准入筛选以及完整的 GUI / CLI 研究工作流。<br/>
15+
支持多数据源接入、公告事件解析、公开交易主池筛选以及完整的 GUI / CLI 研究工作流。<br/>
1616
把转债条款、公告事件、正股行情、信用利差和数值定价模型串成一条可重复的研究管线。
1717

1818
</div>
@@ -45,7 +45,7 @@ CBLens 面向 **A 股可转债研究与复盘**。它不是交易下单系统,
4545

4646
### 📦 批量筛选与打分
4747
从全市场条款库出发,自动完成:
48-
- 主池准入筛选(停牌、强赎、ST、低流动性等
48+
- 主池公开交易筛选(剔除不可公开交易标的,风险进入复核标签
4949
- 批量 PDE 定价与多线程加速
5050
- 机会分 / 置信度 / 风险标签
5151
- 转股溢价率 / 低估率排序
@@ -90,7 +90,7 @@ CustomTkinter GUI 覆盖完整研究流:
9090
### ✅ 可测试模型
9191
核心模块均有 pytest 覆盖:
9292
- PDE 引擎精度与收敛性
93-
- 数据缓存 / 事件解析 / 准入筛选
93+
- 数据缓存 / 事件解析 / 公开交易筛选
9494
- 批量定价 / API 调用链
9595
- Wind mock 测试(无需真实连接)
9696

@@ -175,14 +175,14 @@ cb-sync-tradable --info
175175
# ② 月初或新债/退市/下修集中变化后,全量同步基础条款
176176
cb-sync-tradable
177177

178-
# ③ 每日刷新停牌、强赎、摘牌、正股 ST、成交额、余额、评级等准入字段
178+
# ③ 每日刷新停牌、强赎、摘牌、正股 ST、成交额、余额、评级等状态字段
179179
cb-sync-admission-status
180180

181181
# ④ 同步公告事件,并把事件状态应用回 cb_data
182182
cb-sync-events --apply
183183

184-
#批量定价前查看主池准入报告
185-
cb-screen-pool --min-rating A+ --min-balance 0.5
184+
#批量定价前查看公开交易主池报告
185+
cb-screen-pool
186186

187187
# ⑥ 打开 GUI 做批量复核、单债钻取和敏感性分析
188188
cb-gui
@@ -195,7 +195,7 @@ cb-gui
195195
```mermaid
196196
flowchart LR
197197
A["🌐 Wind / cninfo / akshare / CSV"] --> B["💾 data/*.json<br/>条款与事件缓存"]
198-
B --> C["🔍 准入筛选<br/>admission_status + batch_pricing"]
198+
B --> C["🔍 公开交易筛选<br/>admission_status + batch_pricing"]
199199
C --> D["⚙️ PDE 定价<br/>UniversalCBPricer"]
200200
D --> E["📊 机会分 / 风险标签<br/>复核视图"]
201201
E --> F["🖥️ GUI / CLI / Python API"]
@@ -206,7 +206,7 @@ flowchart LR
206206
| 层级 | 职责 | 核心模块 |
207207
| :---: | --- | --- |
208208
| **① 基础信息** | 发行条款、转股价、票息、强赎/回售规则、评级、余额 | `data_providers`, `cache` |
209-
| **② 事件状态** | 公告事件、停牌、强赎、ST、成交额等准入字段 | `cb_events`, `admission_status` |
209+
| **② 事件状态** | 公告事件、停牌、强赎、ST、成交额等状态字段 | `cb_events`, `admission_status` |
210210
| **③ 动态行情** | 正股/转债价格、历史波动率、股息率、无风险利率 | `data_providers` |
211211
| **④ 模型定价** | 理论价、希腊值、纯债底、转股价值、期权溢价 | `pricer` |
212212
| **⑤ 筛选打分** | 低估率、转股溢价、机会分、风险标签、置信度 | `batch_pricing` |
@@ -267,7 +267,7 @@ CBLens/
267267
│ ├── pricing_api.py # provider 驱动的单只/批量定价 helper
268268
│ ├── data_providers.py # Wind / akshare / CSV 数据源
269269
│ ├── cache.py # TermsBundle / TermsCache / CachedBondDataProvider
270-
│ ├── batch_pricing.py # 准入筛选、机会分、风险标签、批量结果缓存
270+
│ ├── batch_pricing.py # 公开交易筛选、机会分、风险标签、批量结果缓存
271271
│ ├── admission_status.py # 停牌、强赎、摘牌、ST、成交额等状态刷新
272272
│ ├── cb_events.py # 公告事件模型与解析
273273
│ ├── cb_event_sync.py # 公告同步和事件应用
@@ -317,7 +317,9 @@ pytest tests/test_batch_pricing.py -x -q
317317
> [!WARNING]
318318
> CBLens 是研究工具,不是交易系统。以下模型局限需要在使用时注意:
319319
320-
- **强赎路径依赖**:强赎触发是单点判断,尚未完整建模"30 个交易日中 15 日"的路径依赖。
320+
- **强赎路径依赖**:强赎触发是单点判断,尚未完整建模"30 个交易日中 15 日"的路径依赖;下修触发同理用单点 S 依赖强度近似。
321+
- **下修博弈分两态**:无公告时用"纯触发后"模型(`p_down` = 触发线下方公司跟进下修的年化概率,触发线之上为 0)建模"会不会修";董事会**已提议**时改用一次性近确定下修节点(生效日≈提议日+表决滞后、概率=历史通过率,经 `cb-calibrate-down-reset``cb_events` 校准),不再把背景强度放大数倍摊到全周期。
322+
- **下修幅度**:已公告(提议/通过)时优先用公告解析到的真实新转股价(`parse_down_reset_new_price`);解析不到或纯背景博弈时才回落 `down_reset_premium` + 监管下限(20 日均价/前收)近似,每股净资产下限暂未纳入。
321323
- **利率结构**:当前为标量利率,未建完整期限结构。
322324
- **股息率口径**`q` 按连续股息率处理,数据源缺失时默认 0;不同数据源的股息率口径可能不同,建议对高股息正股做人工复核。
323325
- **历史回测**:默认使用当前条款,历史下修发生过的债可能出现转股价跳点偏差。

convertible_bond/__init__.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@
3333
write_batch_results_csv,
3434
)
3535
from .backtest import backtest_theoretical_price
36+
from .strategy_backtest import (
37+
ScoreStrategyConfig,
38+
backtest_score_strategy,
39+
build_rebalance_schedule,
40+
write_strategy_backtest_csv,
41+
)
42+
from .historical_terms import (
43+
HistoricalBondDataProvider,
44+
TermsHistoryStore,
45+
TermsPatch,
46+
TermsPatchStore,
47+
default_terms_patch_store,
48+
project_terms,
49+
project_terms_history_dir,
50+
project_terms_patches_path,
51+
reload_default_terms_patch_store,
52+
strip_current_status_fields,
53+
)
3654
from .data_providers import (
3755
DataProvider,
3856
BondTerms,
@@ -84,10 +102,19 @@
84102
classify_announcement_title,
85103
default_event_store,
86104
parse_event_from_announcement,
105+
parse_call_redemption_dates,
106+
parse_conversion_suspension_terms,
107+
parse_down_reset_new_price,
108+
parse_putback_terms,
87109
project_events_path,
88110
)
89111
from .cb_event_sync import (
90112
apply_events_to_bundle,
113+
parse_credit_rating_change,
114+
parse_credit_rating_terms,
115+
parse_conversion_price_adjustment,
116+
parse_outstanding_balance_change,
117+
parse_terms_patch_from_announcement,
91118
sync_cb_events,
92119
)
93120

@@ -118,6 +145,20 @@
118145
"summarize_exclusions",
119146
"write_batch_results_csv",
120147
"backtest_theoretical_price",
148+
"ScoreStrategyConfig",
149+
"backtest_score_strategy",
150+
"build_rebalance_schedule",
151+
"write_strategy_backtest_csv",
152+
"HistoricalBondDataProvider",
153+
"TermsHistoryStore",
154+
"TermsPatch",
155+
"TermsPatchStore",
156+
"default_terms_patch_store",
157+
"project_terms",
158+
"project_terms_history_dir",
159+
"project_terms_patches_path",
160+
"reload_default_terms_patch_store",
161+
"strip_current_status_fields",
121162
"DataProvider",
122163
"BondTerms",
123164
"CashflowSchedule",
@@ -148,8 +189,17 @@
148189
"classify_announcement_title",
149190
"default_event_store",
150191
"parse_event_from_announcement",
192+
"parse_call_redemption_dates",
193+
"parse_conversion_suspension_terms",
194+
"parse_down_reset_new_price",
195+
"parse_putback_terms",
151196
"project_events_path",
152197
"apply_events_to_bundle",
198+
"parse_credit_rating_change",
199+
"parse_credit_rating_terms",
200+
"parse_conversion_price_adjustment",
201+
"parse_outstanding_balance_change",
202+
"parse_terms_patch_from_announcement",
153203
"sync_cb_events",
154204
"project_bundle_path",
155205
"seed_data_files",

convertible_bond/admission_status.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
"""主池准入状态字段的增量刷新.
1+
"""主池交易状态与风险字段的增量刷新.
22
3-
这一层只更新批量定价筛选会用到的事件/状态字段, 不重建完整条款:
3+
这一层只更新批量定价会用到的事件/状态字段, 不重建完整条款:
44
停牌、强赎公告、摘牌/最后交易日、正股 ST 风险、成交额、评级和余额等。
55
"""
66
from __future__ import annotations
@@ -23,6 +23,7 @@
2323
"call_status",
2424
"call_announce_date",
2525
"call_redemption_date",
26+
"call_redemption_price",
2627
"last_trading_date",
2728
"delisting_date",
2829
"underlying_name",
@@ -31,6 +32,8 @@
3132
"underlying_pct_change",
3233
"bond_turnover_amount",
3334
"credit_rating",
35+
"credit_rating_outlook",
36+
"credit_watch_status",
3437
"outstanding_balance",
3538
)
3639

@@ -55,7 +58,7 @@ def merge_admission_status(base: BondTerms | None, patch: BondTerms | None) -> B
5558

5659

5760
def changed_admission_fields(before: BondTerms | None, after: BondTerms) -> list[str]:
58-
"""返回准入状态字段中发生变化的字段名."""
61+
"""返回交易状态/风险字段中发生变化的字段名."""
5962
before_terms = before or BondTerms()
6063
return [
6164
name for name in ADMISSION_STATUS_FIELDS
@@ -70,7 +73,7 @@ def refresh_admission_status(
7073
valuation_date: date | None = None,
7174
on_progress=None,
7275
) -> dict:
73-
"""批量刷新主池准入状态字段并写回 store.
76+
"""批量刷新主池交易状态/风险字段并写回 store.
7477
7578
返回 ``{success, failed, changed, excluded, excluded_by_reason, store_path}``。
7679
``store`` 建议传 ``TermsBundle``; 若 store 中没有某只债, 会先用 provider
@@ -134,7 +137,7 @@ def refresh_admission_status_from_store(
134137
limit: int = 0,
135138
on_progress=None,
136139
) -> dict:
137-
"""对 store 中已有转债刷新准入状态字段."""
140+
"""对 store 中已有转债刷新交易状态/风险字段."""
138141
if store is None or not hasattr(store, "list_bonds"):
139142
raise ValueError("store 必须支持 list_bonds()")
140143
codes: Sequence[str] = store.list_bonds()

convertible_bond/backtest.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
from .pricer import UniversalCBPricer, DEFAULT_REDEMPTION_PRICE, DEFAULT_FACE_VALUE
1212
from .data_providers import DataProvider, WindDataProvider, BondTerms
13-
from .down_reset_overrides import resolve_down_reset
13+
from .down_reset_overrides import resolve_down_reset, resolve_down_reset_intensity
14+
from .model_defaults import DEFAULT_DOWN_RESET_TRIGGER_RATIO
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -84,12 +85,23 @@ def backtest_theoretical_price(
8485
redemption_price=redemption_price,
8586
coupon_rates=coupon_rates,
8687
)
88+
common_kwargs["down_reset_trigger_ratio"] = (
89+
float(terms.down_reset_trigger_pct) / 100.0
90+
if terms.down_reset_trigger_pct is not None
91+
else DEFAULT_DOWN_RESET_TRIGGER_RATIO
92+
)
8793
if terms.call_trigger_pct is not None:
8894
common_kwargs["call_trigger_ratio"] = float(terms.call_trigger_pct) / 100.0
8995
if terms.call_no_redemption_until is not None:
9096
common_kwargs["call_no_redemption_until"] = terms.call_no_redemption_until
9197
if terms.put_trigger_pct is not None:
9298
common_kwargs["put_trigger_ratio"] = float(terms.put_trigger_pct) / 100.0
99+
if terms.putback_start_date is not None:
100+
common_kwargs["putback_start_date"] = terms.putback_start_date
101+
if terms.putback_end_date is not None:
102+
common_kwargs["putback_end_date"] = terms.putback_end_date
103+
if terms.putback_price is not None:
104+
common_kwargs["putback_price"] = float(terms.putback_price)
93105
if terms.put_obs_months is not None and issue_dt and maturity_dt:
94106
total_months = (maturity_dt - issue_dt).days / 30.4375
95107
active_years = max(0, (total_months - float(terms.put_obs_months)) / 12)
@@ -168,9 +180,21 @@ def backtest_theoretical_price(
168180
if resolved_down_reset.block_until is not None:
169181
point_kwargs["down_reset_block_until"] = resolved_down_reset.block_until
170182
point_kwargs.update(pricer_overrides)
171-
effective_p_down = float(p_down)
172-
if resolved_down_reset.p_scale is not None:
173-
effective_p_down *= max(0.0, float(resolved_down_reset.p_scale))
183+
down_intensity = resolve_down_reset_intensity(
184+
p_down, resolved_down_reset,
185+
)
186+
effective_p_down = down_intensity.effective_p_down
187+
if (
188+
down_intensity.scheduled_reset_date is not None
189+
and down_intensity.scheduled_reset_prob > 0
190+
):
191+
point_kwargs.setdefault(
192+
"scheduled_reset_date", down_intensity.scheduled_reset_date)
193+
point_kwargs.setdefault(
194+
"scheduled_reset_prob", down_intensity.scheduled_reset_prob)
195+
if down_intensity.scheduled_reset_target_k is not None:
196+
point_kwargs.setdefault(
197+
"scheduled_reset_target_k", down_intensity.scheduled_reset_target_k)
174198

175199
pricer = UniversalCBPricer(
176200
S0=S0, current_date=val_date, **point_kwargs) # type: ignore[arg-type]

0 commit comments

Comments
 (0)