deeptrade-quant 0.5.0__tar.gz → 0.6.0__tar.gz

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 (71) hide show
  1. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/CHANGELOG.md +53 -0
  2. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/PKG-INFO +1 -1
  3. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/__init__.py +1 -1
  4. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli.py +22 -1
  5. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/config.py +32 -2
  6. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/llm_client.py +31 -1
  7. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/plugin_manager.py +9 -4
  8. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/secrets.py +66 -10
  9. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/tushare_client.py +49 -1
  10. deeptrade_quant-0.6.0/deeptrade/plugins_api/__init__.py +85 -0
  11. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/base.py +26 -3
  12. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/metadata.py +47 -13
  13. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/pyproject.toml +1 -1
  14. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_config.py +50 -0
  15. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_llm_client.py +123 -3
  16. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_dependencies.py +1 -1
  17. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_install.py +14 -4
  18. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_security.py +66 -12
  19. deeptrade_quant-0.6.0/tests/core/test_secrets.py +178 -0
  20. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_client.py +79 -0
  21. deeptrade_quant-0.6.0/tests/plugins_api/test_api_version_2.py +222 -0
  22. deeptrade_quant-0.5.0/deeptrade/plugins_api/__init__.py +0 -36
  23. deeptrade_quant-0.5.0/tests/core/test_secrets.py +0 -89
  24. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/.gitignore +0 -0
  25. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/LICENSE +0 -0
  26. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/README.md +0 -0
  27. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_config.py +0 -0
  28. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_data.py +0 -0
  29. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_plugin.py +0 -0
  30. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/__init__.py +0 -0
  31. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/config_migrations.py +0 -0
  32. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/db.py +0 -0
  33. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/dep_installer.py +0 -0
  34. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/github_fetch.py +0 -0
  35. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/llm_manager.py +0 -0
  36. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/logging_config.py +0 -0
  37. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/__init__.py +0 -0
  38. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  39. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
  40. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
  41. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  42. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/paths.py +0 -0
  43. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/plugin_source.py +0 -0
  44. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/registry.py +0 -0
  45. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/run_status.py +0 -0
  46. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/errors.py +0 -0
  47. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/events.py +0 -0
  48. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/llm.py +0 -0
  49. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/theme.py +0 -0
  50. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/__init__.py +0 -0
  51. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/__init__.py +0 -0
  52. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_config_cmd.py +0 -0
  53. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_plugin_cmd.py +0 -0
  54. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_routing.py +0 -0
  55. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
  56. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/conftest.py +0 -0
  57. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/__init__.py +0 -0
  58. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_config_migrations.py +0 -0
  59. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_db.py +0 -0
  60. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_github_fetch.py +0 -0
  61. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_llm_manager.py +0 -0
  62. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_paths.py +0 -0
  63. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_source.py +0 -0
  64. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_upgrade.py +0 -0
  65. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_registry.py +0 -0
  66. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_classifier.py +0 -0
  67. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_retry_r1.py +0 -0
  68. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/__init__.py +0 -0
  69. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/test_errors.py +0 -0
  70. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/test_protocol.py +0 -0
  71. {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/test_smoke.py +0 -0
@@ -2,6 +2,59 @@
2
2
 
3
3
  All notable changes to DeepTrade. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and SemVer.
4
4
 
5
+ ## [v0.6.0] — 2026-05-15 — 插件运行时 API + LLM 方言 + 杂项收尾
6
+
7
+ 本版按 2026-05-15 评审研判文档 `DeepTrade-review-assessment-and-fix-plan-2026-05-15.md` §5 中 v0.6 路线落地 6 项主线:
8
+
9
+ 1. **H4 插件运行时 API**:``api_version="2"`` 的插件改用 ``dispatch(ctx, argv)`` 接收 ``PluginContext``,无需再 ``import deeptrade.core.*`` 私有路径取 db / config。``api_version="1"`` 永久向后兼容,运行时按 metadata 决定调用形态。``plugins_api/__init__.py`` 把 ``LLMManager`` / ``TushareClient`` 提升为公共 API。
10
+ 2. **H5 LLM 方言收口**:``OpenAICompatTransport`` 加 class 属性 ``supports_reasoning_effort`` 默认 False;新增 ``OpenAIOfficialTransport``(base_url 含 ``api.openai.com``)改 True。其余 OpenAI-compat provider 不再裸送 ``reasoning_effort``,消除国内非推理模型常见的 400 与忽略两种失败模式。
11
+ 3. **H1 hard error**:``table_prefix`` 派生不匹配从 v0.5 的 ``DeprecationWarning`` 转 ``ValueError``,与显式声明路径合流,让"插件表必须落在派生命名空间"成为可执行约束。
12
+ 4. **M6**:``env_var_for(key)`` 把 provider 名中的 ``-`` 归一化为 ``_``,避免 ``DEEPTRADE_LLM_QWEN-PLUS_API_KEY`` 这种 bash/sh 非法标识符;``set_llm_provider`` 在归一化后冲突时硬拒绝,避免 ``qwen-plus`` / ``qwen_plus`` 共用同一个 env var 时的静默 shadowing。
13
+ 5. **M8**:``_try_load_keyring`` 改用 ``keyring.get_keyring()`` 的非写入式 backend 探测,只在 ``backends.fail.*`` / ``backends.null.*`` 类时返回 None;结果缓存到模块级变量,配 ``_invalidate_keyring_cache`` 给测试用。彻底消除 ``SecretStore.__init__`` 每次都向 OS 凭据存储写一个 ``__probe__`` 的副作用。
14
+ 6. **M9**:``TushareClient`` 首次遇未知 API 改打 INFO 日志(进程级 + 实例级双 dedup,不再每次默认就刷屏);``PluginMetadata.permissions.tushare_apis.cache_overrides: dict[str, str]`` 让插件作者声明非默认缓存策略,``TushareClient(cache_overrides=...)`` 在分类决策中优先采纳。
15
+
16
+ ### Added
17
+
18
+ - ``deeptrade/plugins_api/base.py``:docstring 标注 ``api_version`` 与 ``dispatch`` 签名的对应关系;``Plugin.dispatch`` 改写为变参 Protocol 占位(``runtime_checkable`` 只校验属性存在,签名由框架按 metadata 决定)。
19
+ - ``deeptrade/core/plugin_manager.py::SUPPORTED_API_VERSIONS``(``frozenset({"1", "2"})``);install / upgrade 路径用集合包含判断替换原先的 ``!= CURRENT_API_VERSION``,对未来扩 ``"3"`` 留接口。
20
+ - ``deeptrade/cli.py::_dispatch``:按 ``rec.api_version`` 选择 ``plugin.dispatch(argv)`` 或 ``plugin.dispatch(ctx, argv)``;v2 路径由框架开 ``Database`` + 构 ``PluginContext`` + 收尾关库,插件不再操心生命周期。
21
+ - ``deeptrade/plugins_api/__init__.py``:重导出 ``LLMManager`` 与 ``TushareClient``,正式纳入公共 surface 的 ``__all__``。
22
+ - ``deeptrade/plugins_api/metadata.py::TushareApiPermissions.cache_overrides: dict[str, str]``,配 ``_cache_overrides_values_valid`` model_validator 强制值落在 ``{static, trade_day_immutable, trade_day_mutable, hot_or_anns}``;非法值在 yaml 解析阶段拒绝,避免运行时缓存行为静默退化。
23
+ - ``deeptrade/core/tushare_client.py``:``TushareClient.__init__`` 新增 ``cache_overrides`` kwarg;新增 ``_resolve_cache_class`` 把"插件 overrides → 框架表 → 默认 + 一次性 INFO 日志"三级优先级集中表达;模块级 ``_UNKNOWN_API_LOGGED`` 做进程级 dedup。
24
+ - ``deeptrade/core/llm_client.py``:``OpenAICompatTransport.supports_reasoning_effort: bool = False`` 类标志位;``OpenAIOfficialTransport`` 子类(``api.openai.com`` base_url 触发)将其覆盖为 True;``chat()`` 仅在 ``supports_reasoning_effort and reasoning_effort`` 时把字段写入 ``kwargs``。``_TRANSPORT_BY_BASE_URL`` 新增 ``("api.openai.com", OpenAIOfficialTransport)`` 路由。
25
+ - ``deeptrade/core/secrets.py``:模块级 ``_keyring_cache`` / ``_keyring_probed`` 缓存;``_invalidate_keyring_cache`` 测试钩子;``_try_load_keyring`` 重写为按 backend FQCN 子串识别 fail/null 类。
26
+ - ``deeptrade/core/config.py::ConfigService.set_llm_provider``:归一化后冲突检测;``env_var_for`` 文档说明 ``-``→``_`` 规则的动机。
27
+ - 测试新增:``tests/plugins_api/test_api_version_2.py``(6 用例,覆盖双 api_version 安装、unknown api_version 拒绝、v1/v2 dispatch arity、公开类导出)、``tests/core/test_secrets.py`` v0.6 M8 节 3 用例(fail backend 拒绝、real backend 不写探针、cache 可重置)、``tests/core/test_config.py`` 3 用例(env 归一化、归一化冲突拒绝、同名更新放行)、``tests/core/test_plugin_security.py`` 3 用例(cache_overrides 默认空 / 合法值 / 非法值)、``tests/core/test_tushare_client.py`` 3 用例(overrides 优先、未知 API override 路径、INFO 日志一次性)、``tests/core/test_llm_client.py`` 4 用例(supports_reasoning_effort 默认 False、OpenAI-official 路由、Generic transport 不发送、empty 字符串不发送)。
28
+
29
+ ### Changed
30
+
31
+ - ``deeptrade/plugins_api/metadata.py::_tables_match_prefix``:``table_prefix`` 省略且不匹配派生时从 ``warnings.warn(DeprecationWarning)`` 转 ``raise ValueError``;删除现已无用的 ``import warnings``。模块顶部 docstring 把 v0.5 advisory / v0.6 enforcement 的边界明确标注。
32
+ - ``deeptrade/plugins_api/metadata.py``:``TushareApiPermissions`` 加 ``cache_overrides`` 字段;定义 ``_CACHE_CLASS_VALUES`` 内部常量便于校验。
33
+ - ``deeptrade/core/tushare_client.py``:``cache_class = API_CACHE_CLASS.get(api_name, "trade_day_immutable")`` 改为 ``cache_class = self._resolve_cache_class(api_name)``,集中所有分类决策。
34
+ - ``deeptrade/cli.py``:v2 路径在 dispatch 周边内联 ``ConfigService`` / ``PluginContext`` 导入,避免顶层冷启动负担;其余跨 dispatch 路径行为不变。
35
+ - ``tests/core/test_plugin_security.py``:``test_plugin_table_prefix_warns_when_omitted_and_mismatched`` 改名 ``test_plugin_table_prefix_hard_fails_when_omitted_and_mismatched``,正向断言 ``ValidationError``;``test_plugin_table_prefix_no_warning_when_tables_match_derived`` 改名 ``test_plugin_table_prefix_passes_when_tables_match_derived``,保留 ``simplefilter("error", DeprecationWarning)`` 作为反向断言。
36
+ - ``tests/core/test_plugin_install.py::_make_minimal_plugin_dir``:``table_name`` 默认值从硬编码 ``test_table`` 改为按 ``plugin_id`` 派生(``<pkg>_t``),满足 v0.6 派生前缀约束;``table_ddl`` 默认也从默认值同步派生。
37
+ - ``tests/core/test_plugin_dependencies.py::_minimal_meta_dict``:表名 ``x_t`` 改为 ``minimal_x_t`` 以匹配 ``plugin_id="minimal-x"`` 的派生前缀。
38
+ - ``tests/core/test_llm_client.py::test_select_transport_class_defaults_to_generic``:``api.openai.com`` 从"回退到 Generic"断言改为"回退用 Generic / OpenAI-official 走专属路由"的双行声明;docstring 标注 v0.6 行为变化。
39
+
40
+ ### Migration notes
41
+
42
+ - **插件作者侧(推荐路径)**:迁移到 ``api_version="2"``——
43
+ 1. ``deeptrade_plugin.yaml::api_version`` 改为 ``"2"``。
44
+ 2. ``def dispatch(self, argv):`` 改写为 ``def dispatch(self, ctx, argv):``。
45
+ 3. 把 ``Database(paths.db_path())`` / ``ConfigService(...)`` 等手动构造替换为 ``ctx.db`` / ``ctx.config``;之前从 ``deeptrade.core.*`` import 的 ``LLMManager`` / ``TushareClient`` 改为 ``from deeptrade.plugins_api import LLMManager, TushareClient``。
46
+ - **插件作者侧(最低改动路径)**:不动 ``api_version="1"``,所有既有插件**零代码改动**继续工作;唯一硬约束是 ``metadata.tables`` 必须落在 ``<plugin_id 派生前缀>_*`` 命名空间内(或显式声明 ``table_prefix``)。
47
+ - **多 LLM provider 命名**:使用 ``qwen-plus`` / ``qwen-max-2024`` 这类带连字符的 provider 名时,``DEEPTRADE_LLM_QWEN_PLUS_API_KEY`` 形式的 env var 现在可以正常生效;不要再同时注册归一化后冲突的名字(``qwen-plus`` 与 ``qwen_plus``)。
48
+ - **官方 OpenAI 推理模型**:``reasoning_effort`` 现在只在 ``base_url`` 含 ``api.openai.com`` 时上行。其他 OpenAI-compat 网关如需该字段(少见),临时方案是 fork 当前 transport 类并覆写 ``supports_reasoning_effort = True``;后续版本会按需要在路由表里追加。
49
+ - **Tushare 缓存策略覆盖**:插件作者可在 ``deeptrade_plugin.yaml`` 写
50
+ ```yaml
51
+ permissions:
52
+ tushare_apis:
53
+ cache_overrides:
54
+ moneyflow_industry_ths: trade_day_mutable
55
+ ```
56
+ 把框架表里没列出的 API 显式钉到正确的缓存类,避免依赖默认 ``trade_day_immutable``。运行时由插件自己把这份 dict 作为 ``cache_overrides=...`` 传给 ``TushareClient``。
57
+
5
58
  ## [v0.5.0] — 2026-05-15 — 插件信任边界 + CLI 中文化
6
59
 
7
60
  本版主线落地 2026-05-15 评审研判文档 `DeepTrade-review-assessment-and-fix-plan-2026-05-15.md` 中的 v0.5 路线:把"插件可以声明并清掉框架核心表"这条被忽视的信任边界明确收口,让框架的 SQL 迁移有边界、插件加载有 sys.modules 守卫、升级路径不会被静默改动的历史 migration 蒙混过关;同时把命令行用户向文案统一为中文,建立首道防回退的回归测试。整体不引入新框架概念(拒绝了报告里的 RuntimeContext 大对象、独立 venv、SQL DSL 等重型方案),保持"框架轻、插件重"的原则。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI
5
5
  Project-URL: Homepage, https://github.com/ty19880929/deeptrade
6
6
  Project-URL: Repository, https://github.com/ty19880929/deeptrade
@@ -2,5 +2,5 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.6.0"
6
6
  __all__ = ["__version__"]
@@ -126,8 +126,29 @@ def _build_plugin_command(plugin_id: str) -> click.Command | None:
126
126
  if not hasattr(plugin, "dispatch"):
127
127
  typer.echo(f"✘ 插件 {plugin_id!r} 未实现 dispatch()")
128
128
  raise typer.Exit(2)
129
+
130
+ argv = list(ctx.args)
129
131
  try:
130
- rc = plugin.dispatch(list(ctx.args))
132
+ # v0.6 H4 — dispatch arity is selected by ``metadata.api_version``.
133
+ # ``"1"`` (legacy) keeps the historical ``dispatch(argv)`` signature
134
+ # so already-shipped plugins remain runnable without a rebuild.
135
+ # ``"2"`` receives the same ``PluginContext`` shape passed to
136
+ # ``validate_static`` at install time, removing the need for plugins
137
+ # to import ``deeptrade.core.*`` private modules.
138
+ if rec.api_version == "2":
139
+ from deeptrade.core.config import ConfigService
140
+ from deeptrade.plugins_api.base import PluginContext
141
+
142
+ # Reuse the framework's open Database / dispose lifecycle —
143
+ # plugins should NOT manage the framework's DB handle.
144
+ db = Database(paths.db_path())
145
+ try:
146
+ plugin_ctx = PluginContext(db=db, config=ConfigService(db), plugin_id=plugin_id)
147
+ rc = plugin.dispatch(plugin_ctx, argv)
148
+ finally:
149
+ db.close()
150
+ else:
151
+ rc = plugin.dispatch(argv)
131
152
  except (SystemExit, KeyboardInterrupt):
132
153
  # Exit codes / Ctrl-C must propagate unaltered.
133
154
  raise
@@ -173,8 +173,20 @@ def llm_api_key_name(key: str) -> str | None:
173
173
 
174
174
 
175
175
  def env_var_for(key: str) -> str:
176
- """Map dotted key → DEEPTRADE_<UPPER_SNAKE> env var name."""
177
- return "DEEPTRADE_" + key.upper().replace(".", "_")
176
+ """Map dotted key → ``DEEPTRADE_<UPPER_SNAKE>`` env var name.
177
+
178
+ Hyphens are normalized to underscores so that hyphen-containing provider
179
+ names (e.g. ``llm.qwen-plus.api_key``) yield a POSIX-valid identifier
180
+ (``DEEPTRADE_LLM_QWEN_PLUS_API_KEY``). Without this, the env var
181
+ ``DEEPTRADE_LLM_QWEN-PLUS_API_KEY`` cannot be set via ``export`` on
182
+ bash / sh — variable names there must match ``[A-Za-z_][A-Za-z0-9_]*``.
183
+
184
+ Collision risk: provider names ``qwen-plus`` and ``qwen_plus`` would map
185
+ to the same env var. :meth:`ConfigService.set_llm_provider` refuses such
186
+ a registration so users discover the conflict at write time rather than
187
+ at first env-var read.
188
+ """
189
+ return "DEEPTRADE_" + key.upper().replace(".", "_").replace("-", "_")
178
190
 
179
191
 
180
192
  def known_keys() -> list[str]:
@@ -364,6 +376,24 @@ class ConfigService:
364
376
  )
365
377
  current_raw = self.get("llm.providers")
366
378
  current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
379
+
380
+ # M6 — refuse a name that collides with an existing provider after
381
+ # ``env_var_for`` normalization (``-`` → ``_``). Without this, two
382
+ # providers ``qwen-plus`` and ``qwen_plus`` would share the env var
383
+ # ``DEEPTRADE_LLM_QWEN_PLUS_API_KEY`` and silently shadow each other
384
+ # on read. Surface the conflict at registration time.
385
+ normalized = name.replace("-", "_")
386
+ for existing_name in current:
387
+ if existing_name == name:
388
+ continue
389
+ if existing_name.replace("-", "_") == normalized:
390
+ raise ValueError(
391
+ f"provider name {name!r} collides with {existing_name!r} after "
392
+ f"env-var normalization (both map to "
393
+ f"{env_var_for(f'llm.{name}.api_key')!r}); rename one to avoid "
394
+ f"silent env-var shadowing"
395
+ )
396
+
367
397
  is_first = len(current) == 0
368
398
  existing = dict(current.get(name) or {})
369
399
  prior_default = bool(existing.get("is_default", False))
@@ -139,8 +139,19 @@ class OpenAICompatTransport(LLMTransport):
139
139
  implementation returns ``{}``: appropriate for OpenAI-compatible providers
140
140
  that don't recognize a thinking concept, where ``StageProfile.thinking``
141
141
  is silently dropped per the plugins_api/llm.py contract.
142
+
143
+ v0.6 — ``supports_reasoning_effort`` (class attribute) gates whether
144
+ ``reasoning_effort`` from :class:`StageProfile` is forwarded to the
145
+ provider. Default ``False``: most OpenAI-compatible Chinese providers
146
+ (DeepSeek / Kimi / Qwen non-reasoning models / Doubao) either ignore
147
+ the field or reject it as a 400, so we drop it by default. Subclasses
148
+ override to ``True`` only when the provider has documented support.
142
149
  """
143
150
 
151
+ # v0.6 H5 — see class docstring. The default is False; OpenAI-official
152
+ # is the only known transport that flips it to True.
153
+ supports_reasoning_effort: bool = False
154
+
144
155
  def __init__(self, api_key: str, base_url: str, timeout: int) -> None:
145
156
  from openai import OpenAI # noqa: PLC0415
146
157
 
@@ -171,11 +182,16 @@ class OpenAICompatTransport(LLMTransport):
171
182
  {"role": "user", "content": user},
172
183
  ],
173
184
  "response_format": {"type": "json_object"},
174
- "reasoning_effort": reasoning_effort,
175
185
  "temperature": temperature,
176
186
  "max_tokens": max_tokens,
177
187
  "stream": False,
178
188
  }
189
+ # v0.6 H5 — only send ``reasoning_effort`` when the transport
190
+ # declares support AND the caller actually supplied a non-empty
191
+ # value. Sending it unconditionally was the v0.5 default and is
192
+ # the dominant failure mode on Chinese OpenAI-compat providers.
193
+ if self.supports_reasoning_effort and reasoning_effort:
194
+ kwargs["reasoning_effort"] = reasoning_effort
179
195
  extra_body = self._provider_extra_body(thinking=thinking)
180
196
  if extra_body:
181
197
  kwargs["extra_body"] = extra_body
@@ -220,6 +236,19 @@ class DashScopeTransport(OpenAICompatTransport):
220
236
  return {"enable_thinking": thinking}
221
237
 
222
238
 
239
+ class OpenAIOfficialTransport(OpenAICompatTransport):
240
+ """OpenAI's own ``api.openai.com`` endpoint.
241
+
242
+ Only this transport documents support for the ``reasoning_effort``
243
+ parameter (on the o1 / o3 reasoning family); flipping the base-class
244
+ flag here makes ``OpenAICompatTransport.chat`` forward the value from
245
+ :class:`StageProfile`. Other OpenAI-compatible providers either ignore
246
+ the field or 400 on it, so they keep the base-class default (False).
247
+ """
248
+
249
+ supports_reasoning_effort = True
250
+
251
+
223
252
  # ---------------------------------------------------------------------------
224
253
  # Transport routing (framework-internal)
225
254
  # ---------------------------------------------------------------------------
@@ -230,6 +259,7 @@ class DashScopeTransport(OpenAICompatTransport):
230
259
  # nowhere else; user-facing config has no "dialect" knob on purpose.
231
260
  _TRANSPORT_BY_BASE_URL: tuple[tuple[str, type[OpenAICompatTransport]], ...] = (
232
261
  ("dashscope.aliyuncs.com", DashScopeTransport),
262
+ ("api.openai.com", OpenAIOfficialTransport),
233
263
  )
234
264
 
235
265
 
@@ -42,6 +42,9 @@ from deeptrade.plugins_api.metadata import (
42
42
  logger = logging.getLogger(__name__)
43
43
 
44
44
  CURRENT_API_VERSION = "1"
45
+ # v0.6 — both legacy v1 and ctx-aware v2 dispatch are first-class. New
46
+ # plugin authors should use "2"; "1" is supported without deprecation.
47
+ SUPPORTED_API_VERSIONS: frozenset[str] = frozenset({"1", "2"})
45
48
 
46
49
  # Reserved framework-level command names. A plugin_id colliding with any of
47
50
  # these would shadow framework dispatch and is rejected at install time.
@@ -270,9 +273,10 @@ class PluginManager:
270
273
  f"(reserved: {sorted(RESERVED_PLUGIN_IDS)})"
271
274
  )
272
275
 
273
- if meta.api_version != CURRENT_API_VERSION:
276
+ if meta.api_version not in SUPPORTED_API_VERSIONS:
274
277
  raise PluginInstallError(
275
- f"plugin api_version {meta.api_version} != framework {CURRENT_API_VERSION}"
278
+ f"plugin api_version {meta.api_version!r} not supported by framework "
279
+ f"(supported: {sorted(SUPPORTED_API_VERSIONS)})"
276
280
  )
277
281
 
278
282
  # M3 hard-constraint enforcement (Pydantic Literal[False] also catches it)
@@ -539,9 +543,10 @@ class PluginManager:
539
543
  f"如需降级,请先 `deeptrade plugin uninstall {meta.plugin_id} --purge`"
540
544
  )
541
545
 
542
- if meta.api_version != CURRENT_API_VERSION:
546
+ if meta.api_version not in SUPPORTED_API_VERSIONS:
543
547
  raise PluginInstallError(
544
- f"plugin api_version {meta.api_version} != framework {CURRENT_API_VERSION}"
548
+ f"plugin api_version {meta.api_version!r} not supported by framework "
549
+ f"(supported: {sorted(SUPPORTED_API_VERSIONS)})"
545
550
  )
546
551
  if meta.permissions.llm_tools is not False:
547
552
  raise PluginInstallError("permissions.llm_tools=true is forbidden")
@@ -28,24 +28,80 @@ class _KeyringBackend(Protocol):
28
28
  def delete_password(self, service_name: str, username: str) -> None: ...
29
29
 
30
30
 
31
+ # M8 — module-level cache so the keyring probe runs at most once per process.
32
+ # Pre-v0.6 each ``SecretStore.__init__`` round-tripped a `__probe__` credential
33
+ # through the OS credential store (macOS Keychain / Windows Credential Manager
34
+ # / Linux Secret Service), which (a) cost a syscall per construction and (b)
35
+ # polluted the credential store's audit log every time. v0.6 inspects the
36
+ # selected backend's class instead and only marks the keyring unavailable
37
+ # when it's an explicitly broken backend (``null`` / ``fail`` family).
38
+ _keyring_cache: _KeyringBackend | None = None
39
+ _keyring_probed: bool = False
40
+
41
+
42
+ def _invalidate_keyring_cache() -> None:
43
+ """Test hook — clear the module-level probe cache so the next
44
+ ``_try_load_keyring`` re-probes. Production code never calls this."""
45
+ global _keyring_cache, _keyring_probed
46
+ _keyring_cache = None
47
+ _keyring_probed = False
48
+
49
+
31
50
  def _try_load_keyring() -> _KeyringBackend | None:
32
- """Attempt to import & probe keyring. Return backend or None on failure."""
51
+ """Detect whether a usable keyring backend is present.
52
+
53
+ Strategy (v0.6 M8 — non-write probe):
54
+
55
+ 1. Import ``keyring``; missing module → return None.
56
+ 2. Inspect the active backend class via ``keyring.get_keyring()``.
57
+ The standard library distinguishes "no usable backend" as the
58
+ ``keyring.backends.fail.Keyring`` / ``keyring.backends.null.Keyring``
59
+ classes (matched by FQCN substring so we don't have to import
60
+ optional internals).
61
+ 3. Any other backend (Windows Credential Manager, macOS Keychain,
62
+ Secret Service, kwallet, chainer, ...) is trusted. We do NOT
63
+ round-trip a sentinel credential — that was the v0.5 behavior and
64
+ it polluted the credential store on every ``SecretStore.__init__``.
65
+
66
+ The result is cached at module level (:func:`_invalidate_keyring_cache`
67
+ is the test escape hatch). Process-global cache is safe because
68
+ keyring backends are themselves process-global state.
69
+ """
70
+ global _keyring_cache, _keyring_probed
71
+ if _keyring_probed:
72
+ return _keyring_cache
73
+
74
+ _keyring_probed = True
33
75
  try:
34
76
  import keyring # noqa: PLC0415 — deferred import on purpose
35
- from keyring.errors import KeyringError # noqa: PLC0415
36
77
  except ImportError:
78
+ _keyring_cache = None
37
79
  return None
38
80
  try:
39
- # Probe: round-trip a sentinel
40
- keyring.set_password(_KR_SERVICE, "__probe__", "ok")
41
- if keyring.get_password(_KR_SERVICE, "__probe__") != "ok":
42
- return None
43
- keyring.delete_password(_KR_SERVICE, "__probe__")
44
- return keyring # type: ignore[return-value]
45
- except (KeyringError, Exception) as e: # noqa: BLE001 — capture all backend errors
46
- logger.warning("keyring unavailable: %s; falling back to plaintext", e)
81
+ backend = keyring.get_keyring()
82
+ except Exception as e: # noqa: BLE001 — keyring init can fail on weird hosts
83
+ logger.warning("keyring backend introspection failed: %s; using plaintext", e)
84
+ _keyring_cache = None
47
85
  return None
48
86
 
87
+ # Reject only the explicitly-broken backend classes; everything else
88
+ # is presumed functional. Match by FQCN substring rather than isinstance
89
+ # so we avoid importing optional internals that may not exist on every
90
+ # keyring version.
91
+ backend_fqcn = f"{type(backend).__module__}.{type(backend).__name__}"
92
+ if any(
93
+ marker in backend_fqcn for marker in ("keyring.backends.fail.", "keyring.backends.null.")
94
+ ):
95
+ logger.info(
96
+ "keyring backend is %s (no usable credential store); using plaintext",
97
+ backend_fqcn,
98
+ )
99
+ _keyring_cache = None
100
+ return None
101
+
102
+ _keyring_cache = keyring # type: ignore[assignment]
103
+ return _keyring_cache
104
+
49
105
 
50
106
  @dataclass
51
107
  class SecretRecord:
@@ -110,6 +110,11 @@ DEFAULT_HOT_TTL_SECONDS = 6 * 3600
110
110
  # Default TTL for static class
111
111
  STATIC_TTL_SECONDS = 7 * 24 * 3600
112
112
 
113
+ # v0.6 M9 — process-level dedup of "unknown api defaulted to trade_day_immutable"
114
+ # log lines. Without this each call site for an unclassified API would emit a
115
+ # fresh INFO row.
116
+ _UNKNOWN_API_LOGGED: set[str] = set()
117
+
113
118
 
114
119
  # ---------------------------------------------------------------------------
115
120
  # Errors
@@ -454,6 +459,7 @@ class TushareClient:
454
459
  intraday: bool = False,
455
460
  max_retries: int = 7,
456
461
  event_cb: Callable[[str, str, dict[str, Any]], None] | None = None,
462
+ cache_overrides: dict[str, CacheClass] | None = None,
457
463
  ) -> None:
458
464
  self._db = db
459
465
  self._transport = transport
@@ -461,6 +467,15 @@ class TushareClient:
461
467
  self._bucket = _TokenBucket(rps)
462
468
  self._intraday = intraday
463
469
  self._event_cb = event_cb
470
+ # v0.6 M9 — per-instance cache classification overrides. Plugin authors
471
+ # supply this from their ``deeptrade_plugin.yaml::permissions.tushare_apis
472
+ # .cache_overrides`` so an API the framework doesn't know about can be
473
+ # classified correctly without monkey-patching the framework's table.
474
+ self._cache_overrides: dict[str, CacheClass] = dict(cache_overrides or {})
475
+ # One-shot INFO log dedup: each unknown api_name logs at most once
476
+ # per client instance (and via _UNKNOWN_API_LOGGED below, once per
477
+ # process across all clients).
478
+ self._unknown_api_logged: set[str] = set()
464
479
  # R1 (HARD CONSTRAINT): the Retrying object wraps `_do_fetch`, whose
465
480
  # FIRST line is `self._bucket.acquire()`. Every retry attempt re-enters
466
481
  # `_do_fetch`, so the token bucket is honored on every attempt — the
@@ -582,7 +597,7 @@ class TushareClient:
582
597
  # so that daily(start=A,end=B) and daily(start=C,end=D) live in
583
598
  # different cache rows even when neither passes a single trade_date.
584
599
  cache_key_date = self._compute_cache_key_date(trade_date, params)
585
- cache_class = API_CACHE_CLASS.get(api_name, "trade_day_immutable")
600
+ cache_class = self._resolve_cache_class(api_name)
586
601
 
587
602
  state = self._read_state(api_name, cache_key_date)
588
603
  if not force_sync and self._cache_hit(
@@ -599,6 +614,39 @@ class TushareClient:
599
614
  # Apply field projection at the read site (cache stays full).
600
615
  return self._project_fields(df, fields)
601
616
 
617
+ def _resolve_cache_class(self, api_name: str) -> CacheClass:
618
+ """Pick the cache classification for ``api_name``, in priority order:
619
+
620
+ 1. Plugin-supplied ``cache_overrides`` (highest priority — lets a
621
+ plugin author correct the framework's table without a release).
622
+ 2. Framework-curated ``API_CACHE_CLASS`` table.
623
+ 3. Default ``trade_day_immutable``. The default fits the quant
624
+ workload (historical data dominates) but is wrong for
625
+ intraday-mutable APIs; emit a one-shot INFO log when we fall
626
+ into this branch so plugin authors notice and can declare the
627
+ correct class in their metadata.
628
+ """
629
+ override = self._cache_overrides.get(api_name)
630
+ if override is not None:
631
+ return override
632
+ explicit = API_CACHE_CLASS.get(api_name)
633
+ if explicit is not None:
634
+ return explicit
635
+ # Default + one-shot INFO log dedup'd both per-instance and
636
+ # per-process so high-frequency call paths don't drown logs.
637
+ if api_name not in self._unknown_api_logged:
638
+ self._unknown_api_logged.add(api_name)
639
+ if api_name not in _UNKNOWN_API_LOGGED:
640
+ _UNKNOWN_API_LOGGED.add(api_name)
641
+ logger.info(
642
+ "Tushare API %r has no explicit cache class; defaulting to "
643
+ "trade_day_immutable. Plugin authors: declare it under "
644
+ "metadata.permissions.tushare_apis.cache_overrides if this "
645
+ "API returns mutable data.",
646
+ api_name,
647
+ )
648
+ return "trade_day_immutable"
649
+
602
650
  @staticmethod
603
651
  def _compute_cache_key_date(trade_date: str | None, params: dict[str, Any]) -> str:
604
652
  """Pick a cache_key_date that uniquely partitions queries by date scope.
@@ -0,0 +1,85 @@
1
+ """Public plugin API.
2
+
3
+ All plugins import from this package; the rest of ``deeptrade.*`` is internal.
4
+
5
+ Stable surface:
6
+
7
+ * Contract: Plugin (Protocol), PluginContext (api_version "1" and "2").
8
+ * Metadata schema: PluginMetadata, TableSpec, MigrationSpec, PluginPermissions,
9
+ TushareApiPermissions.
10
+ * LLM profile: StageProfile — plugin-owned preset → stage mapping.
11
+ * Services: LLMManager, TushareClient — construct directly from
12
+ :class:`PluginContext` primitives. v0.6+ promotes these
13
+ to the public surface (was: ``deeptrade.core.*`` internals).
14
+ Lazy-loaded via PEP 562 ``__getattr__`` so ``TushareClient``
15
+ never drags ``pandas`` onto framework startup (the
16
+ ``plugin-runtime`` extras separation depends on this).
17
+ * Errors: render_exception — DEEPTRADE_DEBUG-aware formatter for
18
+ dispatch tails; debug_enabled — env-flag probe.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from deeptrade.plugins_api.base import Plugin, PluginContext
26
+ from deeptrade.plugins_api.errors import debug_enabled, render_exception
27
+ from deeptrade.plugins_api.llm import StageProfile
28
+ from deeptrade.plugins_api.metadata import (
29
+ MigrationSpec,
30
+ PluginMetadata,
31
+ PluginPermissions,
32
+ TableSpec,
33
+ TushareApiPermissions,
34
+ )
35
+
36
+ if TYPE_CHECKING: # pragma: no cover — import-time hint only
37
+ from deeptrade.core.llm_manager import LLMManager
38
+ from deeptrade.core.tushare_client import TushareClient
39
+
40
+ __all__ = [
41
+ "LLMManager",
42
+ "MigrationSpec",
43
+ "Plugin",
44
+ "PluginContext",
45
+ "PluginMetadata",
46
+ "PluginPermissions",
47
+ "StageProfile",
48
+ "TableSpec",
49
+ "TushareApiPermissions",
50
+ "TushareClient",
51
+ "debug_enabled",
52
+ "render_exception",
53
+ ]
54
+
55
+
56
+ # PEP 562 lazy attribute resolution.
57
+ #
58
+ # Eagerly importing ``deeptrade.core.tushare_client`` here would pull
59
+ # ``pandas`` into every framework startup — but ``pandas`` is intentionally
60
+ # NOT a framework dependency (see ``pyproject.toml::optional-dependencies
61
+ # .plugin-runtime``). Wheels published to PyPI install with only the 11
62
+ # framework deps; plugins that need TushareClient install pandas via their
63
+ # own ``deeptrade_plugin.yaml::dependencies``.
64
+ #
65
+ # Resolving ``LLMManager`` / ``TushareClient`` on first attribute access
66
+ # keeps the public surface promise (you can ``from deeptrade.plugins_api
67
+ # import TushareClient``) while preserving the import-time invariant.
68
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
69
+ "LLMManager": ("deeptrade.core.llm_manager", "LLMManager"),
70
+ "TushareClient": ("deeptrade.core.tushare_client", "TushareClient"),
71
+ }
72
+
73
+
74
+ def __getattr__(name: str) -> Any:
75
+ target = _LAZY_IMPORTS.get(name)
76
+ if target is None:
77
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
78
+ import importlib # noqa: PLC0415
79
+
80
+ module = importlib.import_module(target[0])
81
+ return getattr(module, target[1])
82
+
83
+
84
+ def __dir__() -> list[str]:
85
+ return sorted(__all__)
@@ -1,4 +1,4 @@
1
- """Plugin contract — api_version "1".
1
+ """Plugin contract — api_version "1" (legacy) and "2" (v0.6+).
2
2
 
3
3
  A plugin is a Python class implementing this Protocol. The framework loads it
4
4
  via the dotted entrypoint declared in the YAML metadata. The framework knows
@@ -6,7 +6,19 @@ NOTHING about the plugin's domain semantics; the plugin owns its own command
6
6
  parsing, execution, persistence, and output.
7
7
 
8
8
  ``PluginContext`` is the minimal services bundle the framework hands to every
9
- plugin's ``validate_static`` (during install).
9
+ plugin's ``validate_static`` (during install). v0.6 ``api_version="2"``
10
+ plugins ALSO receive it at dispatch time, removing the need to reach into
11
+ ``deeptrade.core.*`` from plugin code.
12
+
13
+ Dispatch signatures by ``api_version``:
14
+
15
+ * ``"1"`` — ``def dispatch(self, argv: list[str]) -> int`` (legacy).
16
+ Plugin reaches back into ``deeptrade.core.*`` for DB / config / etc.
17
+ * ``"2"`` — ``def dispatch(self, ctx: PluginContext, argv: list[str]) -> int``.
18
+ Framework hands the same ``PluginContext`` shape ``validate_static``
19
+ gets, so plugins can stay on the public surface.
20
+
21
+ Both versions remain supported; v0.6 does NOT deprecate v1.
10
22
  """
11
23
 
12
24
  from __future__ import annotations
@@ -49,9 +61,14 @@ class Plugin(Protocol):
49
61
  install (the framework will roll back).
50
62
  """
51
63
 
52
- def dispatch(self, argv: list[str]) -> int:
64
+ def dispatch(self, *args: object) -> int:
53
65
  """CLI dispatch entry point.
54
66
 
67
+ Signature depends on ``metadata.api_version``:
68
+
69
+ * ``"1"`` — ``dispatch(self, argv: list[str]) -> int``.
70
+ * ``"2"`` — ``dispatch(self, ctx: PluginContext, argv: list[str]) -> int``.
71
+
55
72
  ``argv`` is the remaining command-line tail after the framework strips
56
73
  the leading ``<plugin_id>`` token. For example, when the user runs
57
74
  ``deeptrade limit-up-board run --force-sync``, the plugin receives
@@ -59,4 +76,10 @@ class Plugin(Protocol):
59
76
 
60
77
  The plugin owns parsing, ``--help`` rendering, execution, persistence,
61
78
  and output. Returns the process exit code (0 = success).
79
+
80
+ The Protocol is declared variadic so ``isinstance(obj, Plugin)`` works
81
+ for both v1 and v2 implementations — ``runtime_checkable`` only
82
+ verifies attribute presence, never argument arity. The framework's
83
+ ``cli._dispatch`` decides which form to call based on
84
+ ``metadata.api_version``.
62
85
  """