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.
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/CHANGELOG.md +53 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/PKG-INFO +1 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/__init__.py +1 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli.py +22 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/config.py +32 -2
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/llm_client.py +31 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/plugin_manager.py +9 -4
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/secrets.py +66 -10
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/tushare_client.py +49 -1
- deeptrade_quant-0.6.0/deeptrade/plugins_api/__init__.py +85 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/base.py +26 -3
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/metadata.py +47 -13
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/pyproject.toml +1 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_config.py +50 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_llm_client.py +123 -3
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_dependencies.py +1 -1
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_install.py +14 -4
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_security.py +66 -12
- deeptrade_quant-0.6.0/tests/core/test_secrets.py +178 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_client.py +79 -0
- deeptrade_quant-0.6.0/tests/plugins_api/test_api_version_2.py +222 -0
- deeptrade_quant-0.5.0/deeptrade/plugins_api/__init__.py +0 -36
- deeptrade_quant-0.5.0/tests/core/test_secrets.py +0 -89
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/.gitignore +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/LICENSE +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/README.md +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_config.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_data.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/cli_plugin.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/config_migrations.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/db.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/dep_installer.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/github_fetch.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/llm_manager.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/logging_config.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/migrations/core/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/paths.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/plugin_source.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/registry.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/core/run_status.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/errors.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/events.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/plugins_api/llm.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/deeptrade/theme.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_config_cmd.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_plugin_cmd.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_routing.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/conftest.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_config_migrations.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_db.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_github_fetch.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_llm_manager.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_paths.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_source.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_plugin_upgrade.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_registry.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_classifier.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/core/test_tushare_retry_r1.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/test_errors.py +0 -0
- {deeptrade_quant-0.5.0 → deeptrade_quant-0.6.0}/tests/plugins_api/test_protocol.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
177
|
-
|
|
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
|
|
276
|
+
if meta.api_version not in SUPPORTED_API_VERSIONS:
|
|
274
277
|
raise PluginInstallError(
|
|
275
|
-
f"plugin api_version {meta.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
|
|
546
|
+
if meta.api_version not in SUPPORTED_API_VERSIONS:
|
|
543
547
|
raise PluginInstallError(
|
|
544
|
-
f"plugin api_version {meta.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
|
-
"""
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 =
|
|
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,
|
|
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
|
"""
|