deeptrade-quant 0.7.0__tar.gz → 0.8.1__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.7.0 → deeptrade_quant-0.8.1}/CHANGELOG.md +56 -0
  2. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/PKG-INFO +1 -1
  3. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/__init__.py +1 -1
  4. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/cli_plugin.py +0 -2
  5. deeptrade_quant-0.8.1/deeptrade/core/github_fetch.py +122 -0
  6. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/llm_client.py +62 -1
  7. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/plugin_source.py +23 -12
  8. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/registry.py +15 -4
  9. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/pyproject.toml +1 -1
  10. deeptrade_quant-0.8.1/tests/core/test_github_fetch.py +126 -0
  11. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_llm_client.py +92 -1
  12. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_plugin_source.py +78 -39
  13. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_registry.py +25 -0
  14. deeptrade_quant-0.7.0/deeptrade/core/github_fetch.py +0 -202
  15. deeptrade_quant-0.7.0/tests/core/test_github_fetch.py +0 -206
  16. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/.gitignore +0 -0
  17. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/LICENSE +0 -0
  18. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/README.md +0 -0
  19. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/cli.py +0 -0
  20. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/cli_config.py +0 -0
  21. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/cli_data.py +0 -0
  22. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/__init__.py +0 -0
  23. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/config.py +0 -0
  24. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/config_migrations.py +0 -0
  25. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/db.py +0 -0
  26. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/dep_installer.py +0 -0
  27. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/llm_manager.py +0 -0
  28. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/logging_config.py +0 -0
  29. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/migrations/__init__.py +0 -0
  30. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  31. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +0 -0
  32. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/migrations/core/20260515_002_affected_tables.sql +0 -0
  33. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/migrations/core/__init__.py +0 -0
  34. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/paths.py +0 -0
  35. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/plugin_manager.py +0 -0
  36. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/run_status.py +0 -0
  37. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/secrets.py +0 -0
  38. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/core/tushare_client.py +0 -0
  39. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/__init__.py +0 -0
  40. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/base.py +0 -0
  41. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/errors.py +0 -0
  42. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/events.py +0 -0
  43. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/llm.py +0 -0
  44. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/plugins_api/metadata.py +0 -0
  45. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/deeptrade/theme.py +0 -0
  46. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/__init__.py +0 -0
  47. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/cli/__init__.py +0 -0
  48. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/cli/test_config_cmd.py +0 -0
  49. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/cli/test_plugin_cmd.py +0 -0
  50. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/cli/test_routing.py +0 -0
  51. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/cli/test_user_facing_strings_are_chinese.py +0 -0
  52. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/conftest.py +0 -0
  53. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/__init__.py +0 -0
  54. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_config.py +0 -0
  55. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_config_migrations.py +0 -0
  56. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_db.py +0 -0
  57. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_llm_manager.py +0 -0
  58. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_paths.py +0 -0
  59. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_plugin_dependencies.py +0 -0
  60. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_plugin_install.py +0 -0
  61. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_plugin_security.py +0 -0
  62. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_plugin_upgrade.py +0 -0
  63. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_secrets.py +0 -0
  64. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_tushare_classifier.py +0 -0
  65. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_tushare_client.py +0 -0
  66. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/core/test_tushare_retry_r1.py +0 -0
  67. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/plugins_api/__init__.py +0 -0
  68. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/plugins_api/test_api_version_2.py +0 -0
  69. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/plugins_api/test_errors.py +0 -0
  70. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/plugins_api/test_protocol.py +0 -0
  71. {deeptrade_quant-0.7.0 → deeptrade_quant-0.8.1}/tests/test_smoke.py +0 -0
@@ -2,6 +2,62 @@
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.8.1] — 2026-05-16 — Moonshot reasoning 模型 temperature 兼容性
6
+
7
+ `limit-up-board` 等插件接入 Kimi K2.6(``base_url = https://api.moonshot.cn/v1``)后,**所有** LLM 调用 100% 命中 ``HTTP 400 invalid temperature: only 1 is allowed for this model``。根因:Kimi K2 系列的 thinking / reasoning 变体(与 OpenAI o1/o3、Anthropic Sonnet thinking 同侧设计)在服务端硬约束 ``temperature``——仅接受模型专属的固定值,而插件 ``StageProfile`` 出于复现性给的是 ``0.0 ~ 0.2``。
8
+
9
+ 修复职责完全在框架:插件不应感知具体 provider/model 的服务端约束,框架的契约是「插件给一个温度意图,框架在真正发出请求前 sanitize 到目标 provider/model 能接受的取值」。
10
+
11
+ ### Changed
12
+
13
+ - ``deeptrade/core/llm_client.py::OpenAICompatTransport``:新增 ``_adjust_temperature(model, temperature) -> float`` 钩子,默认 identity;``chat()`` 在写 kwargs 前调用钩子,并在改写时打一行 ``logger.info`` 便于排查。非 Moonshot 路径完全无感。
14
+ - 新增 ``MoonshotTransport(OpenAICompatTransport)``:``_FORCED_TEMPERATURE`` prefix 表强制 ``kimi-k2-thinking`` / ``kimi-k2.5`` / ``kimi-k2.6`` 到 ``1.0``、``kimi-for-coding`` 到 ``0.6``;fallthrough 走 ``min(temperature, 1.0)`` 兼顾非 reasoning 模型(``moonshot-v1-*`` / ``kimi-k2-instruct-*``)的 ``[0, 1]`` 上限——Pydantic 字段允许到 2.0,超界一样 400。
15
+ - ``_TRANSPORT_BY_BASE_URL`` 新增 ``("api.moonshot.cn", MoonshotTransport)``。substring 匹配自动覆盖 ``api.moonshot.cn`` / ``api.moonshot.cn/v1`` 所有形式;国际站 ``api.moonshot.ai`` 暂未支持,若后续需要追加一行即可。
16
+
17
+ ### Why prefix match, not exact / regex
18
+
19
+ Moonshot 命名空间 ``<major>.<minor>[-<dated-revision>]`` 的天然分界就在 prefix。exact 会让 ``kimi-k2.6-1106`` / ``kimi-k2-thinking-128k`` 这类 dated revision 漏网,触发 0day 失败;regex 转义复杂度抬高 review 成本,收益不抵。
20
+
21
+ ### Migration notes
22
+
23
+ - 插件零改动。``limit_up_board`` / 其他第三方插件的 ``profiles.py`` 不需要感知该约束。
24
+ - 用户原本在 Kimi reasoning 模型上设的 ``temperature=0.0`` 在改写后会被强制为 ``1.0``——这本来就是服务端唯一允许的取值,不改写就是 100% 失败。
25
+ - ``app.profile`` / ``llm.providers`` 配置无变动。
26
+
27
+ ## [v0.8.0] — 2026-05-16 — 插件 install / upgrade 走 CDN,零 GitHub API 调用
28
+
29
+ `deeptrade plugin install` 与 `deeptrade plugin upgrade` 此前在解析"最新版本"与下载 tarball 时各打一次 ``api.github.com``,未认证用户共享 60/h 的 IP 级配额。一旦插件用户数上来,或者用户与浏览器 / `gh` CLI / `git clone` 公共仓库共用同一公网 IP,``HTTP 403: rate limit exceeded`` 就会把 install / upgrade 直接打死。共享 token 会违反 GitHub ToS,且配额仍会在那个 token 上聚合——不是解。
30
+
31
+ 本版改造将插件分发热路径全部搬到 CDN 端点:
32
+
33
+ - **Tarball 下载**改走 ``codeload.github.com/<owner>/<repo>/tar.gz/<ref>``。codeload 是 CDN-backed 静态端点,不计入 REST API 限流。
34
+ - **"最新版本"**改从注册表 ``index.json`` 的新字段 ``latest_version`` 读取(``raw.githubusercontent.com`` 同样不限流,且已有 ETag 缓存)。
35
+ - **URL 形式安装**(``deeptrade plugin install https://github.com/...``)无 ``--ref`` 时默认 ``main`` 分支;其他默认分支需显式 ``--ref``。
36
+
37
+ 净效果:未认证用户走 ``install`` / ``upgrade`` 全流程**零 API 调用**,60/h 限制对默认路径不再存在。``GITHUB_TOKEN`` 不再被框架读取,文档不再推荐设置。
38
+
39
+ ### Changed
40
+
41
+ - ``deeptrade/core/github_fetch.py``:删除 ``latest_release_tag`` / ``NoMatchingReleaseError``;``fetch_tarball`` 改写为 codeload 单端点(移除 ``GITHUB_TOKEN`` / ``X-GitHub-Api-Version`` header)。
42
+ - ``deeptrade/core/registry.py``:``RegistryEntry`` 新增可选字段 ``latest_version: str | None``;解析器引入 ``_OPTIONAL_FIELDS`` 集合,含字段时填入,缺省 ``None``。schema_version 不变(仍为 1,向后兼容旧注册表文件)。
43
+ - ``deeptrade/core/plugin_source.py``:``_resolve_short_name`` 用 ``entry.latest_version`` 替代 ``latest_release_tag``;缺字段且无 ``--ref`` 时抛 ``SourceResolveError`` 提示需补 ``--ref``。``_resolve_url`` 无 ``--ref`` 默认 ``main``;codeload 失败时错误信息提示用户切换 ``--ref``。
44
+ - ``deeptrade/cli_plugin.py``:移除 ``NoMatchingReleaseError`` 引用。
45
+
46
+ ### Removed
47
+
48
+ - ``latest_release_tag`` 公共函数与 ``NoMatchingReleaseError`` 异常类。
49
+ - 对 ``GITHUB_TOKEN`` 环境变量的读取(github_fetch.py 中)。
50
+
51
+ ### Migration notes
52
+
53
+ - **注册表维护者**:在 ``DeepTradePluginOfficial/registry/index.json`` 每个 plugin entry 中加 ``latest_version`` 字段,每次插件发版后同步该字段。可由 plugin 仓库 release workflow 自动 PR 到注册表仓库。
54
+ - **存量旧版框架用户**:旧版(v0.7 及以下)仍会调 ``api.github.com``。``latest_version`` 字段是可选的、旧框架读取时会被忽略,不破坏现有用户;旧用户升级到本版后限流问题自动消失。
55
+ - **URL 形式高级用户**:若你 host 的仓库默认分支不是 ``main``,``deeptrade plugin install https://github.com/<your>/<repo>`` 需显式 ``--ref <branch>``。
56
+
57
+ ### Why not "use a shared token"
58
+
59
+ GitHub ToS 明确禁止 token 跨用户共享(被发现 token 会被吊销);即便允许,5000/h 也会被规模化使用快速吃掉,且 token 泄露需要维护者承担权限滥用风险。CDN 改造是唯一既不需要每用户认证、又能稳定服务的方案。未来若需要支持私有仓库或企业内部分发,再走 OAuth Device Flow(``gh auth login`` 同款流程)让每个用户认证自己的 token。
60
+
5
61
  ## [v0.7.0] — 2026-05-15 — 依赖隔离稳健化 + timezone IANA 校验
6
62
 
7
63
  本版按 2026-05-15 评审研判文档 §5 v0.7 路线落地 2 项主线,至此 v0.5 § 5 全部"采纳项"清零:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.7.0
3
+ Version: 0.8.1
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.7.0"
5
+ __version__ = "0.8.1"
6
6
  __all__ = ["__version__"]
@@ -12,7 +12,6 @@ from deeptrade.core import paths
12
12
  from deeptrade.core.db import Database
13
13
  from deeptrade.core.github_fetch import (
14
14
  GitHubFetchError,
15
- NoMatchingReleaseError,
16
15
  TarballFetchError,
17
16
  )
18
17
  from deeptrade.core.plugin_manager import (
@@ -41,7 +40,6 @@ _RESOLVE_ERRORS: tuple[type[Exception], ...] = (
41
40
  RegistryNotFoundError,
42
41
  RegistryFetchError,
43
42
  RegistrySchemaError,
44
- NoMatchingReleaseError,
45
43
  TarballFetchError,
46
44
  GitHubFetchError,
47
45
  SourceResolveError,
@@ -0,0 +1,122 @@
1
+ """GitHub tarball download helper (CDN-only).
2
+
3
+ The plugin install / upgrade path downloads release tarballs from
4
+ ``codeload.github.com`` instead of ``api.github.com``. codeload is a
5
+ CDN-backed static endpoint and does **not** count against the GitHub REST
6
+ API rate limit (60/h for anonymous IPs), so plugin install works for
7
+ every user out of the box — no ``GITHUB_TOKEN`` required.
8
+
9
+ "Latest version" used to be resolved via ``GET /repos/<repo>/releases``,
10
+ but that costs an API request per install. It now comes from the
11
+ ``latest_version`` field in the registry index, which is served from
12
+ ``raw.githubusercontent.com`` (also CDN, also un-metered). See
13
+ ``deeptrade.core.plugin_source`` for the wiring.
14
+
15
+ See ``CHANGELOG.md`` for the distribution / install design context.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import shutil
22
+ import tarfile
23
+ import tempfile
24
+ from pathlib import Path
25
+ from urllib.error import HTTPError, URLError
26
+ from urllib.request import Request, urlopen
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ CODELOAD_BASE = "https://codeload.github.com"
31
+
32
+
33
+ class GitHubFetchError(Exception):
34
+ """Generic GitHub fetch error."""
35
+
36
+
37
+ class TarballFetchError(GitHubFetchError):
38
+ """Tarball download or extraction failure."""
39
+
40
+
41
+ def _user_agent() -> str:
42
+ from deeptrade import __version__
43
+
44
+ return f"deeptrade-cli/{__version__}"
45
+
46
+
47
+ def _build_request(url: str) -> Request:
48
+ return Request(url, headers={"User-Agent": _user_agent()})
49
+
50
+
51
+ def fetch_tarball(repo: str, ref: str, dest_dir: Path, *, timeout: float = 60.0) -> Path:
52
+ """Download ``repo`` at ``ref`` from codeload.github.com and extract.
53
+
54
+ ``ref`` may be a tag (``v1.0.0`` or ``limit-up-board/v0.4.0``), a branch
55
+ name (``main``), a SHA, or a full git ref (``refs/tags/v1.0.0`` /
56
+ ``refs/heads/main``). codeload resolves all of these transparently.
57
+
58
+ Returns the unique top-level directory created inside ``dest_dir``
59
+ (codeload tarballs are wrapped in ``<owner>-<repo>-<sha7>/``).
60
+
61
+ Raises :class:`TarballFetchError` on network, HTTP, or extraction failure.
62
+ """
63
+ dest_dir.mkdir(parents=True, exist_ok=True)
64
+ url = f"{CODELOAD_BASE}/{repo}/tar.gz/{ref}"
65
+
66
+ tmp_path: Path | None = None
67
+ try:
68
+ with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
69
+ tmp_path = Path(tmp.name)
70
+
71
+ req = _build_request(url)
72
+ try:
73
+ with urlopen(req, timeout=timeout) as resp, tmp_path.open("wb") as fout:
74
+ shutil.copyfileobj(resp, fout)
75
+ except HTTPError as e:
76
+ raise TarballFetchError(
77
+ f"HTTP {e.code} downloading tarball {repo}@{ref} from codeload: {e}"
78
+ ) from e
79
+ except URLError as e:
80
+ raise TarballFetchError(
81
+ f"network error downloading tarball {repo}@{ref} from codeload: {e}"
82
+ ) from e
83
+
84
+ try:
85
+ with tarfile.open(tmp_path, mode="r:gz") as tf:
86
+ _safe_extract(tf, dest_dir)
87
+ except tarfile.TarError as e:
88
+ raise TarballFetchError(f"failed to extract tarball: {e}") from e
89
+ finally:
90
+ if tmp_path is not None:
91
+ try:
92
+ tmp_path.unlink()
93
+ except OSError:
94
+ pass
95
+
96
+ entries = [p for p in dest_dir.iterdir() if p.is_dir()]
97
+ if len(entries) != 1:
98
+ raise TarballFetchError(
99
+ f"expected one top-level dir in tarball, found {len(entries)}: "
100
+ f"{[p.name for p in entries]}"
101
+ )
102
+ return entries[0]
103
+
104
+
105
+ def _safe_extract(tf: tarfile.TarFile, dest: Path) -> None:
106
+ """Extract ``tf`` into ``dest`` with path-traversal protection.
107
+
108
+ Uses tarfile's ``data`` filter on Python 3.12+, with an explicit
109
+ relative-path check on every member as a belt-and-braces guard for 3.11.
110
+ """
111
+ dest_resolved = dest.resolve()
112
+ for m in tf.getmembers():
113
+ member_path = (dest / m.name).resolve()
114
+ try:
115
+ member_path.relative_to(dest_resolved)
116
+ except ValueError as e:
117
+ raise tarfile.TarError(f"unsafe path in tarball (would escape dest): {m.name!r}") from e
118
+
119
+ try:
120
+ tf.extractall(dest, filter="data")
121
+ except TypeError:
122
+ tf.extractall(dest) # noqa: S202 — paths already validated above
@@ -162,6 +162,16 @@ class OpenAICompatTransport(LLMTransport):
162
162
  del thinking # base class has no provider knobs
163
163
  return {}
164
164
 
165
+ def _adjust_temperature(self, *, model: str, temperature: float) -> float:
166
+ """Provider/model-specific temperature sanitization hook.
167
+
168
+ Default: identity. Subclasses override to clamp / force temperature
169
+ for models with server-side hard constraints (e.g. Moonshot reasoning
170
+ variants that only accept ``temperature == 1``).
171
+ """
172
+ del model # base class has no per-model constraints
173
+ return temperature
174
+
165
175
  def chat(
166
176
  self,
167
177
  *,
@@ -175,6 +185,15 @@ class OpenAICompatTransport(LLMTransport):
175
185
  ) -> LLMResponse:
176
186
  from openai import APIError, APITimeoutError # noqa: PLC0415
177
187
 
188
+ adjusted_temperature = self._adjust_temperature(model=model, temperature=temperature)
189
+ if adjusted_temperature != temperature:
190
+ logger.info(
191
+ "transport adjusted temperature for model=%s: %.3f -> %.3f",
192
+ model,
193
+ temperature,
194
+ adjusted_temperature,
195
+ )
196
+
178
197
  kwargs: dict[str, Any] = {
179
198
  "model": model,
180
199
  "messages": [
@@ -182,7 +201,7 @@ class OpenAICompatTransport(LLMTransport):
182
201
  {"role": "user", "content": user},
183
202
  ],
184
203
  "response_format": {"type": "json_object"},
185
- "temperature": temperature,
204
+ "temperature": adjusted_temperature,
186
205
  "max_tokens": max_tokens,
187
206
  "stream": False,
188
207
  }
@@ -236,6 +255,47 @@ class DashScopeTransport(OpenAICompatTransport):
236
255
  return {"enable_thinking": thinking}
237
256
 
238
257
 
258
+ class MoonshotTransport(OpenAICompatTransport):
259
+ """Moonshot Kimi (``api.moonshot.cn``).
260
+
261
+ Reasoning-variant models (``kimi-k2-thinking`` / ``kimi-k2.5`` /
262
+ ``kimi-k2.6``) have a server-side hard constraint: ``temperature`` MUST
263
+ equal a model-specific fixed value (1.0 for thinking variants, 0.6 for
264
+ ``kimi-for-coding``). Any other value returns HTTP 400 ``invalid
265
+ temperature``.
266
+
267
+ Non-reasoning Moonshot models accept the full ``[0, 1]`` range; values
268
+ above 1 also 400. We handle both: forced equality on known reasoning
269
+ variants, then fall through to range clamp for everyone else.
270
+
271
+ ``_FORCED_TEMPERATURE`` uses **prefix** match so that dated revisions
272
+ (``kimi-k2.6-1106``, ``kimi-k2-thinking-128k``, …) inherit the same
273
+ constraint without a code change. Only include models with confirmed
274
+ server-side enforcement, not just "recommended" values.
275
+
276
+ Note: the international site (``api.moonshot.ai``) shares the same
277
+ constraints — add a routing-table entry there if/when the framework
278
+ supports it.
279
+ """
280
+
281
+ # model-name prefix → forced temperature value
282
+ _FORCED_TEMPERATURE: tuple[tuple[str, float], ...] = (
283
+ ("kimi-k2-thinking", 1.0),
284
+ ("kimi-k2.5", 1.0),
285
+ ("kimi-k2.6", 1.0),
286
+ ("kimi-for-coding", 0.6),
287
+ )
288
+
289
+ def _adjust_temperature(self, *, model: str, temperature: float) -> float:
290
+ for prefix, forced in self._FORCED_TEMPERATURE:
291
+ if model.startswith(prefix):
292
+ return forced
293
+ # Moonshot accepts only [0, 1] across the whole API; upper-clamp guards
294
+ # non-reasoning models (moonshot-v1-*, kimi-k2-instruct-*) against a
295
+ # StageProfile that goes above 1.0 (the Pydantic field allows up to 2).
296
+ return min(temperature, 1.0)
297
+
298
+
239
299
  class OpenAIOfficialTransport(OpenAICompatTransport):
240
300
  """OpenAI's own ``api.openai.com`` endpoint.
241
301
 
@@ -259,6 +319,7 @@ class OpenAIOfficialTransport(OpenAICompatTransport):
259
319
  # nowhere else; user-facing config has no "dialect" knob on purpose.
260
320
  _TRANSPORT_BY_BASE_URL: tuple[tuple[str, type[OpenAICompatTransport]], ...] = (
261
321
  ("dashscope.aliyuncs.com", DashScopeTransport),
322
+ ("api.moonshot.cn", MoonshotTransport),
262
323
  ("api.openai.com", OpenAIOfficialTransport),
263
324
  )
264
325
 
@@ -28,9 +28,7 @@ from packaging.version import InvalidVersion, Version
28
28
 
29
29
  from deeptrade.core.github_fetch import (
30
30
  GitHubFetchError,
31
- NoMatchingReleaseError,
32
31
  fetch_tarball,
33
- latest_release_tag,
34
32
  )
35
33
  from deeptrade.core.registry import (
36
34
  RegistryClient,
@@ -109,14 +107,20 @@ class SourceResolver:
109
107
  self._check_framework_version(entry)
110
108
 
111
109
  if ref is None:
112
- try:
113
- ref = latest_release_tag(entry.repo, entry.tag_prefix)
114
- except (NoMatchingReleaseError, GitHubFetchError) as e:
115
- raise SourceResolveError(str(e)) from e
110
+ if not entry.latest_version:
111
+ raise SourceResolveError(
112
+ f"registry entry for {plugin_id!r} is missing 'latest_version' and no "
113
+ f"--ref was supplied; pass --ref <tag> explicitly, or wait for the "
114
+ f"registry to publish a latest_version for this plugin"
115
+ )
116
+ ref = entry.latest_version
116
117
 
117
118
  tmp = tempfile.TemporaryDirectory(prefix="deeptrade-plugin-")
118
119
  try:
119
- top = fetch_tarball(entry.repo, ref, Path(tmp.name))
120
+ try:
121
+ top = fetch_tarball(entry.repo, ref, Path(tmp.name))
122
+ except GitHubFetchError as e:
123
+ raise SourceResolveError(str(e)) from e
120
124
  plugin_path = top / entry.subdir
121
125
  if not plugin_path.is_dir():
122
126
  raise SourceResolveError(f"subdir {entry.subdir!r} not found in {entry.repo}@{ref}")
@@ -145,15 +149,22 @@ class SourceResolver:
145
149
  owner, repo_name = _parse_github_url(url)
146
150
  repo = f"{owner}/{repo_name}"
147
151
 
152
+ # URL form has no registry entry to consult, so we cannot know the
153
+ # "latest release tag" without hitting api.github.com (which the v0.8
154
+ # CDN refactor explicitly avoids). Default to the ``main`` branch; if
155
+ # the repo's default branch is something else, the codeload 404 surfaces
156
+ # with a hint to pass ``--ref`` explicitly.
148
157
  if ref is None:
149
- try:
150
- ref = latest_release_tag(repo, "")
151
- except (NoMatchingReleaseError, GitHubFetchError) as e:
152
- raise SourceResolveError(str(e)) from e
158
+ ref = "main"
153
159
 
154
160
  tmp = tempfile.TemporaryDirectory(prefix="deeptrade-plugin-")
155
161
  try:
156
- top = fetch_tarball(repo, ref, Path(tmp.name))
162
+ try:
163
+ top = fetch_tarball(repo, ref, Path(tmp.name))
164
+ except GitHubFetchError as e:
165
+ raise SourceResolveError(
166
+ f"{e}; if your repo's default branch is not 'main', pass --ref <branch-or-tag>"
167
+ ) from e
157
168
  yaml_path = top / "deeptrade_plugin.yaml"
158
169
  if not yaml_path.is_file():
159
170
  raise SourceResolveError(
@@ -28,6 +28,12 @@ REGISTRY_URL = (
28
28
  _REQUIRED_FIELDS = frozenset(
29
29
  {"name", "type", "description", "repo", "subdir", "tag_prefix", "min_framework_version"}
30
30
  )
31
+ # Optional fields parsed when present; absence is not a schema error.
32
+ # ``latest_version``: registry-curated "current release tag", consumed by the
33
+ # install/upgrade resolver so it never has to hit ``api.github.com`` to find
34
+ # the latest release. Plugin release CI should keep this field up-to-date
35
+ # in ``DeepTradePluginOfficial/registry/index.json``.
36
+ _OPTIONAL_FIELDS = frozenset({"latest_version"})
31
37
 
32
38
 
33
39
  class RegistryError(Exception):
@@ -56,6 +62,10 @@ class RegistryEntry:
56
62
  subdir: str
57
63
  tag_prefix: str
58
64
  min_framework_version: str
65
+ # CDN-only install path: when present, the resolver uses this as the
66
+ # tag to download from codeload instead of calling the GitHub releases
67
+ # API. Optional for backward compat with registries written before v0.8.
68
+ latest_version: str | None = None
59
69
 
60
70
 
61
71
  @dataclass(frozen=True)
@@ -93,10 +103,11 @@ def _parse_registry(data: Any) -> Registry:
93
103
  missing = _REQUIRED_FIELDS - set(raw)
94
104
  if missing:
95
105
  raise RegistrySchemaError(f"plugins.{plugin_id} missing fields: {sorted(missing)}")
96
- entries[plugin_id] = RegistryEntry(
97
- plugin_id=plugin_id,
98
- **{k: raw[k] for k in _REQUIRED_FIELDS},
99
- )
106
+ fields: dict[str, Any] = {k: raw[k] for k in _REQUIRED_FIELDS}
107
+ for k in _OPTIONAL_FIELDS:
108
+ if k in raw:
109
+ fields[k] = raw[k]
110
+ entries[plugin_id] = RegistryEntry(plugin_id=plugin_id, **fields)
100
111
  return Registry(schema_version=schema_version, plugins=entries)
101
112
 
102
113
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "deeptrade-quant"
7
- version = "0.7.0"
7
+ version = "0.8.1"
8
8
  description = "LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,126 @@
1
+ """Unit tests for deeptrade.core.github_fetch.
2
+
3
+ Network calls (``urlopen``) are patched. Tarball extraction is exercised on
4
+ real .tar.gz produced in-memory. Since the v0.8 CDN refactor, github_fetch
5
+ only exposes ``fetch_tarball`` (latest-tag resolution moved to the registry
6
+ ``latest_version`` field).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import tarfile
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from unittest.mock import patch
16
+ from urllib.error import HTTPError
17
+
18
+ import pytest
19
+
20
+ from deeptrade.core.github_fetch import (
21
+ CODELOAD_BASE,
22
+ TarballFetchError,
23
+ fetch_tarball,
24
+ )
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ class _FakeResponse:
32
+ def __init__(self, body: bytes, headers: dict[str, str] | None = None) -> None:
33
+ self._buf = io.BytesIO(body)
34
+ self.headers = headers or {}
35
+
36
+ def read(self, n: int = -1) -> bytes:
37
+ return self._buf.read() if n == -1 else self._buf.read(n)
38
+
39
+ def __enter__(self) -> _FakeResponse:
40
+ return self
41
+
42
+ def __exit__(self, *args: Any) -> None:
43
+ self._buf.close()
44
+
45
+
46
+ def _build_tarball(top_dir: str, files: dict[str, bytes]) -> bytes:
47
+ """Build an in-memory .tar.gz with a single top-level directory."""
48
+ buf = io.BytesIO()
49
+ with tarfile.open(fileobj=buf, mode="w:gz") as tf:
50
+ info = tarfile.TarInfo(name=top_dir)
51
+ info.type = tarfile.DIRTYPE
52
+ info.mode = 0o755
53
+ tf.addfile(info)
54
+ for path, content in files.items():
55
+ info = tarfile.TarInfo(name=f"{top_dir}/{path}")
56
+ info.size = len(content)
57
+ info.mode = 0o644
58
+ tf.addfile(info, io.BytesIO(content))
59
+ return buf.getvalue()
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # fetch_tarball
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def test_fetch_tarball_extracts_and_returns_top_dir(tmp_path: Path) -> None:
68
+ tarball = _build_tarball(
69
+ "owner-repo-abc1234",
70
+ {"deeptrade_plugin.yaml": b"plugin_id: x\n", "data.py": b"# code\n"},
71
+ )
72
+ with patch(
73
+ "deeptrade.core.github_fetch.urlopen",
74
+ return_value=_FakeResponse(tarball),
75
+ ):
76
+ top = fetch_tarball("owner/repo", "v1.0.0", tmp_path)
77
+
78
+ assert top.is_dir()
79
+ assert top.name == "owner-repo-abc1234"
80
+ assert (top / "deeptrade_plugin.yaml").is_file()
81
+ assert (top / "data.py").read_text() == "# code\n"
82
+
83
+
84
+ def test_fetch_tarball_uses_codeload_url(tmp_path: Path) -> None:
85
+ """Sanity-check the new endpoint: no api.github.com, codeload only."""
86
+ tarball = _build_tarball("owner-repo-abc1234", {"x": b""})
87
+ captured: dict[str, str] = {}
88
+
89
+ def fake_urlopen(req: Any, **_: Any) -> _FakeResponse:
90
+ captured["url"] = req.full_url
91
+ return _FakeResponse(tarball)
92
+
93
+ with patch("deeptrade.core.github_fetch.urlopen", side_effect=fake_urlopen):
94
+ fetch_tarball("owner/repo", "limit-up-board/v0.4.0", tmp_path)
95
+
96
+ assert captured["url"].startswith(CODELOAD_BASE + "/")
97
+ assert "api.github.com" not in captured["url"]
98
+ assert "owner/repo/tar.gz/limit-up-board/v0.4.0" in captured["url"]
99
+
100
+
101
+ def test_fetch_tarball_http_error_raises(tmp_path: Path) -> None:
102
+ err = HTTPError("https://x", 404, "Not Found", {}, None) # type: ignore[arg-type]
103
+ with patch("deeptrade.core.github_fetch.urlopen", side_effect=err):
104
+ with pytest.raises(TarballFetchError, match="HTTP 404"):
105
+ fetch_tarball("owner/repo", "v1.0.0", tmp_path)
106
+
107
+
108
+ def test_fetch_tarball_blocks_path_traversal(tmp_path: Path) -> None:
109
+ """A tarball whose members escape dest_dir must be rejected."""
110
+ dest = tmp_path / "extract_here"
111
+ buf = io.BytesIO()
112
+ with tarfile.open(fileobj=buf, mode="w:gz") as tf:
113
+ info = tarfile.TarInfo(name="owner-repo-abc/")
114
+ info.type = tarfile.DIRTYPE
115
+ tf.addfile(info)
116
+ info = tarfile.TarInfo(name="../escaped.txt")
117
+ info.size = 4
118
+ tf.addfile(info, io.BytesIO(b"evil"))
119
+
120
+ with patch(
121
+ "deeptrade.core.github_fetch.urlopen",
122
+ return_value=_FakeResponse(buf.getvalue()),
123
+ ):
124
+ with pytest.raises(TarballFetchError, match="extract"):
125
+ fetch_tarball("owner/repo", "v1.0.0", dest)
126
+ assert not (tmp_path / "escaped.txt").exists()
@@ -22,6 +22,7 @@ from deeptrade.core.llm_client import (
22
22
  LLMTransport,
23
23
  LLMTransportError,
24
24
  LLMValidationError,
25
+ MoonshotTransport,
25
26
  OpenAICompatTransport,
26
27
  OpenAIOfficialTransport,
27
28
  RecordedTransport,
@@ -407,6 +408,96 @@ def test_select_transport_class_routes_dashscope_by_base_url() -> None:
407
408
  )
408
409
 
409
410
 
411
+ # ---------------------------------------------------------------------------
412
+ # Moonshot — server-side temperature constraint sanitization
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def test_base_transport_adjust_temperature_is_identity() -> None:
417
+ """Default hook MUST NOT alter temperature — every non-Moonshot transport
418
+ relies on this. If this regresses, DashScope / DeepSeek / OpenAI / … will
419
+ silently start sending different temperatures than the caller requested.
420
+ """
421
+ t = GenericOpenAITransport(api_key="dummy", base_url="https://api.deepseek.com", timeout=10)
422
+ assert t._adjust_temperature(model="deepseek-chat", temperature=0.0) == 0.0
423
+ assert t._adjust_temperature(model="deepseek-chat", temperature=0.7) == 0.7
424
+ assert t._adjust_temperature(model="anything", temperature=1.5) == 1.5
425
+
426
+
427
+ def test_moonshot_transport_forces_temperature_for_reasoning_variants() -> None:
428
+ """Kimi K2 reasoning variants only accept ``temperature == <forced>`` on
429
+ the wire; any other value returns HTTP 400. The transport must clamp to
430
+ the forced value regardless of what the StageProfile asks for.
431
+ """
432
+ t = MoonshotTransport(api_key="dummy", base_url="https://api.moonshot.cn/v1", timeout=10)
433
+ # forced to 1.0
434
+ assert t._adjust_temperature(model="kimi-k2.6", temperature=0.2) == 1.0
435
+ assert t._adjust_temperature(model="kimi-k2.6-1106", temperature=0.1) == 1.0
436
+ assert t._adjust_temperature(model="kimi-k2-thinking", temperature=0.0) == 1.0
437
+ assert t._adjust_temperature(model="kimi-k2-thinking-128k", temperature=0.5) == 1.0
438
+ assert t._adjust_temperature(model="kimi-k2.5", temperature=0.2) == 1.0
439
+ # forced to 0.6
440
+ assert t._adjust_temperature(model="kimi-for-coding", temperature=0.0) == 0.6
441
+ # no-op when caller already supplied the forced value
442
+ assert t._adjust_temperature(model="kimi-k2.6", temperature=1.0) == 1.0
443
+
444
+
445
+ def test_moonshot_transport_clamps_non_reasoning_to_one() -> None:
446
+ """Non-reasoning Moonshot models accept [0, 1]; values above 1 also 400.
447
+ Pass through inside the range; clamp above."""
448
+ t = MoonshotTransport(api_key="dummy", base_url="https://api.moonshot.cn/v1", timeout=10)
449
+ assert t._adjust_temperature(model="moonshot-v1-32k", temperature=0.1) == 0.1
450
+ assert t._adjust_temperature(model="kimi-k2-instruct-0905", temperature=0.2) == 0.2
451
+ assert t._adjust_temperature(model="moonshot-v1-32k", temperature=1.0) == 1.0
452
+ assert t._adjust_temperature(model="moonshot-v1-32k", temperature=1.5) == 1.0
453
+
454
+
455
+ def test_moonshot_transport_sends_forced_temperature_on_wire(
456
+ monkeypatch: pytest.MonkeyPatch,
457
+ ) -> None:
458
+ """End-to-end wire-shape regression: chat() composes kwargs with the
459
+ *adjusted* temperature, not the caller's original value."""
460
+ from types import SimpleNamespace
461
+
462
+ captured: dict[str, Any] = {}
463
+
464
+ def fake_create(**kwargs: Any) -> Any:
465
+ captured.update(kwargs)
466
+ choice = SimpleNamespace(message=SimpleNamespace(content='{"k": 1}'))
467
+ return SimpleNamespace(
468
+ choices=[choice],
469
+ usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
470
+ )
471
+
472
+ t = MoonshotTransport(api_key="dummy", base_url="https://api.moonshot.cn/v1", timeout=10)
473
+ monkeypatch.setattr(t._client.chat.completions, "create", fake_create)
474
+ t.chat(
475
+ model="kimi-k2.6",
476
+ system="s",
477
+ user="u",
478
+ temperature=0.2,
479
+ max_tokens=64,
480
+ thinking=False,
481
+ reasoning_effort="",
482
+ )
483
+ assert captured["temperature"] == 1.0
484
+
485
+
486
+ def test_select_transport_class_routes_moonshot() -> None:
487
+ """``api.moonshot.cn`` (with or without ``/v1``) routes to MoonshotTransport
488
+ via substring match, same pattern as the other entries in the routing table.
489
+ """
490
+ assert _select_transport_class("https://api.moonshot.cn/v1") is MoonshotTransport
491
+ assert _select_transport_class("https://api.moonshot.cn") is MoonshotTransport
492
+
493
+
494
+ def test_moonshot_transport_inherits_reasoning_effort_default() -> None:
495
+ """Moonshot does not document support for the ``reasoning_effort`` field;
496
+ it inherits the base-class default (False) — confirm we didn't accidentally
497
+ flip it on along with adding the transport."""
498
+ assert MoonshotTransport.supports_reasoning_effort is False
499
+
500
+
410
501
  # ---------------------------------------------------------------------------
411
502
  # v0.6 H5 — reasoning_effort gating
412
503
  # ---------------------------------------------------------------------------
@@ -532,4 +623,4 @@ def test_select_transport_class_defaults_to_generic() -> None:
532
623
  actually reaches the wire; that case is covered separately below.
533
624
  """
534
625
  assert _select_transport_class("https://api.deepseek.com") is GenericOpenAITransport
535
- assert _select_transport_class("https://api.moonshot.cn/v1") is GenericOpenAITransport
626
+ assert _select_transport_class("https://openrouter.ai/api/v1") is GenericOpenAITransport