deeptrade-quant 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deeptrade/__init__.py +8 -0
- deeptrade/channels_builtin/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +25 -0
- deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql +13 -0
- deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
- deeptrade/channels_builtin/stdout/stdout_channel/channel.py +180 -0
- deeptrade/cli.py +214 -0
- deeptrade/cli_config.py +396 -0
- deeptrade/cli_data.py +33 -0
- deeptrade/cli_plugin.py +176 -0
- deeptrade/core/__init__.py +8 -0
- deeptrade/core/config.py +344 -0
- deeptrade/core/config_migrations.py +138 -0
- deeptrade/core/db.py +176 -0
- deeptrade/core/llm_client.py +591 -0
- deeptrade/core/llm_manager.py +174 -0
- deeptrade/core/logging_config.py +61 -0
- deeptrade/core/migrations/__init__.py +0 -0
- deeptrade/core/migrations/core/20260427_001_init.sql +121 -0
- deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +10 -0
- deeptrade/core/migrations/core/__init__.py +0 -0
- deeptrade/core/notifier.py +302 -0
- deeptrade/core/paths.py +49 -0
- deeptrade/core/plugin_manager.py +616 -0
- deeptrade/core/run_status.py +29 -0
- deeptrade/core/secrets.py +152 -0
- deeptrade/core/tushare_client.py +824 -0
- deeptrade/plugins_api/__init__.py +44 -0
- deeptrade/plugins_api/base.py +66 -0
- deeptrade/plugins_api/channel.py +42 -0
- deeptrade/plugins_api/events.py +61 -0
- deeptrade/plugins_api/llm.py +46 -0
- deeptrade/plugins_api/metadata.py +84 -0
- deeptrade/plugins_api/notify.py +67 -0
- deeptrade/strategies_builtin/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +101 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +65 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +269 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +76 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +1191 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +869 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +30 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +85 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +485 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +890 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +1087 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +172 -0
- deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +178 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql +150 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +8 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +36 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +18 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +46 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +53 -0
- deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +17 -0
- deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
- deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +59 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql +94 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +44 -0
- deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +13 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +52 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +247 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +2154 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +327 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +22 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +49 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +187 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +84 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +906 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +772 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +90 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +97 -0
- deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +174 -0
- deeptrade/theme.py +48 -0
- deeptrade_quant-0.0.2.dist-info/METADATA +166 -0
- deeptrade_quant-0.0.2.dist-info/RECORD +83 -0
- deeptrade_quant-0.0.2.dist-info/WHEEL +4 -0
- deeptrade_quant-0.0.2.dist-info/entry_points.txt +2 -0
- deeptrade_quant-0.0.2.dist-info/licenses/LICENSE +21 -0
deeptrade/core/config.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Configuration management.
|
|
2
|
+
|
|
3
|
+
Layered priority (DESIGN §7.2):
|
|
4
|
+
env var > secret_store (for secrets) > app_config (for non-secrets) > Pydantic default
|
|
5
|
+
|
|
6
|
+
v0.6 — LLM 配置抬升为框架层基础能力(DESIGN §0.7 / §10):
|
|
7
|
+
* `llm.providers` — JSON dict 形态,多 provider 同时存在;app_config
|
|
8
|
+
* `llm.<name>.api_key` — 每 provider 一把,secret_store;前缀匹配路由
|
|
9
|
+
* `llm.audit_full_payload` — 全局审计 verbosity;app_config
|
|
10
|
+
|
|
11
|
+
v0.7 — stage 概念彻底归插件:删除 ``DS_STAGES`` / ``DeepSeekProfileSet`` /
|
|
12
|
+
``PROFILES_DEFAULT`` / ``ConfigService.get_profile()``;preset 名仍框架级,
|
|
13
|
+
但"preset → 各 stage tuning"由插件自己维护。配置键 ``deepseek.profile``
|
|
14
|
+
更名为 ``app.profile``(vendor-agnostic);旧键自动迁移见
|
|
15
|
+
``config_migrations.migrate_legacy_deepseek_profile_key``。环境变量
|
|
16
|
+
``DEEPTRADE_DEEPSEEK_PROFILE`` 在 v0.7 直接断代,启动时检测到旧 env
|
|
17
|
+
而新 env 未设会**报错退出**,避免静默用错配置。
|
|
18
|
+
|
|
19
|
+
Secrets are stored in the ``secret_store`` table (encrypted via keyring or
|
|
20
|
+
plaintext fallback) and never written to ``app_config``. The reverse is also
|
|
21
|
+
true: non-secrets never go into ``secret_store``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from datetime import time
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
33
|
+
|
|
34
|
+
from deeptrade.core.db import Database
|
|
35
|
+
from deeptrade.core.secrets import SecretStore
|
|
36
|
+
from deeptrade.plugins_api.llm import StageProfile
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Schema definitions
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# StageProfile is imported from plugins_api.llm (v0.7 — stage 概念归插件)。
|
|
43
|
+
# Re-imported here so `from deeptrade.core.config import StageProfile` keeps
|
|
44
|
+
# working through the migration window; new code should import directly from
|
|
45
|
+
# ``deeptrade.plugins_api``.
|
|
46
|
+
_ = StageProfile # silence ruff F401 — symbol is intentionally re-exported
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMProviderConfig(BaseModel):
|
|
50
|
+
"""One LLM provider entry — connection metadata only.
|
|
51
|
+
|
|
52
|
+
The api_key is NOT stored here; it lives in ``secret_store`` under the
|
|
53
|
+
key ``llm.<name>.api_key`` and is routed via ``is_secret_key()``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
base_url: str
|
|
58
|
+
model: str
|
|
59
|
+
timeout: int = Field(default=180, ge=10)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AppConfig(BaseModel):
|
|
63
|
+
"""Top-level non-secret config. DESIGN §7.1.
|
|
64
|
+
|
|
65
|
+
Defaults are designed so a freshly-installed CLI works on first
|
|
66
|
+
`init`; secrets (``tushare.token``, ``llm.<name>.api_key``) are
|
|
67
|
+
intentionally not part of this model — they live in SecretStore.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(extra="forbid")
|
|
71
|
+
|
|
72
|
+
# app.*
|
|
73
|
+
app_timezone: str = "Asia/Shanghai"
|
|
74
|
+
app_locale: str = "zh_CN"
|
|
75
|
+
app_log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
76
|
+
# S2 fix: data close threshold is configurable (used by §12.2)
|
|
77
|
+
app_close_after: time = time(18, 0)
|
|
78
|
+
|
|
79
|
+
# tushare.* (token lives in secret_store)
|
|
80
|
+
tushare_rps: float = Field(default=6.0, gt=0)
|
|
81
|
+
tushare_timeout: int = Field(default=30, ge=1)
|
|
82
|
+
|
|
83
|
+
# Global preset name. v0.7 — renamed from ``deepseek.profile``; semantics
|
|
84
|
+
# are vendor-agnostic. Per-stage tuning is resolved by each plugin's
|
|
85
|
+
# ``profiles.py`` from this preset string.
|
|
86
|
+
app_profile: Literal["fast", "balanced", "quality"] = "balanced"
|
|
87
|
+
|
|
88
|
+
# v0.6 multi-provider LLM config
|
|
89
|
+
llm_providers: dict[str, LLMProviderConfig] = Field(default_factory=dict)
|
|
90
|
+
# When False (default), llm_calls stores prompt_hash + a short response
|
|
91
|
+
# excerpt only; full prompt/response always go to
|
|
92
|
+
# ~/.deeptrade/reports/<run_id>/llm_calls.jsonl. When True (debug), DB rows
|
|
93
|
+
# also keep the full payloads.
|
|
94
|
+
llm_audit_full_payload: bool = False
|
|
95
|
+
|
|
96
|
+
@field_validator("app_close_after", mode="before")
|
|
97
|
+
@classmethod
|
|
98
|
+
def _parse_close_after(cls, v: Any) -> Any:
|
|
99
|
+
if isinstance(v, str):
|
|
100
|
+
# Accept "HH:MM" or "HH:MM:SS"
|
|
101
|
+
parts = v.split(":")
|
|
102
|
+
if len(parts) == 2:
|
|
103
|
+
return time(int(parts[0]), int(parts[1]))
|
|
104
|
+
if len(parts) == 3:
|
|
105
|
+
return time(int(parts[0]), int(parts[1]), int(parts[2]))
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
@field_validator("llm_providers", mode="before")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _parse_llm_providers(cls, v: Any) -> Any:
|
|
111
|
+
# env var path delivers a JSON string; DB path delivers an already-parsed dict
|
|
112
|
+
if isinstance(v, str):
|
|
113
|
+
return json.loads(v)
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Key namespace mapping
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Translates dotted keys (user-facing) ↔ AppConfig field names
|
|
122
|
+
_DOT_TO_FIELD: dict[str, str] = {
|
|
123
|
+
"app.timezone": "app_timezone",
|
|
124
|
+
"app.locale": "app_locale",
|
|
125
|
+
"app.log_level": "app_log_level",
|
|
126
|
+
"app.close_after": "app_close_after",
|
|
127
|
+
"tushare.rps": "tushare_rps",
|
|
128
|
+
"tushare.timeout": "tushare_timeout",
|
|
129
|
+
"app.profile": "app_profile",
|
|
130
|
+
"llm.providers": "llm_providers",
|
|
131
|
+
"llm.audit_full_payload": "llm_audit_full_payload",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Per-provider api_key keys are matched dynamically; only static secret keys
|
|
135
|
+
# enumerated here for `known_keys()` / show.
|
|
136
|
+
_STATIC_SECRET_KEYS: frozenset[str] = frozenset({"tushare.token"})
|
|
137
|
+
|
|
138
|
+
# Pattern for per-provider api_key routing: llm.<name>.api_key where <name>
|
|
139
|
+
# is non-empty and contains no dot. Any key matching this routes to
|
|
140
|
+
# secret_store; others fall through to app_config.
|
|
141
|
+
_LLM_API_KEY_RE = re.compile(r"^llm\.([^.]+)\.api_key$")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_secret_key(key: str) -> bool:
|
|
145
|
+
"""True iff ``key`` should route to secret_store instead of app_config.
|
|
146
|
+
|
|
147
|
+
Static secrets: ``tushare.token``.
|
|
148
|
+
Dynamic secrets: ``llm.<name>.api_key`` for any provider name.
|
|
149
|
+
"""
|
|
150
|
+
if key in _STATIC_SECRET_KEYS:
|
|
151
|
+
return True
|
|
152
|
+
return bool(_LLM_API_KEY_RE.match(key))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def llm_api_key_name(key: str) -> str | None:
|
|
156
|
+
"""If ``key`` is an ``llm.<name>.api_key``, return ``<name>``; else None."""
|
|
157
|
+
m = _LLM_API_KEY_RE.match(key)
|
|
158
|
+
return m.group(1) if m else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def env_var_for(key: str) -> str:
|
|
162
|
+
"""Map dotted key → DEEPTRADE_<UPPER_SNAKE> env var name."""
|
|
163
|
+
return "DEEPTRADE_" + key.upper().replace(".", "_")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def known_keys() -> list[str]:
|
|
167
|
+
"""Static known keys. Per-provider ``llm.<name>.api_key`` entries are
|
|
168
|
+
dynamic and not enumerated here; CLI `set-llm` handles those.
|
|
169
|
+
"""
|
|
170
|
+
return sorted(list(_DOT_TO_FIELD.keys()) + list(_STATIC_SECRET_KEYS))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# ConfigService — read/write with layered priority
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ConfigService:
|
|
179
|
+
"""Read & write config with layered priority + automatic routing.
|
|
180
|
+
|
|
181
|
+
Priority for non-secrets: env var > app_config table > Pydantic default
|
|
182
|
+
Priority for secrets: env var > secret_store
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, db: Database, secret_store: SecretStore | None = None) -> None:
|
|
186
|
+
self._db = db
|
|
187
|
+
self._secrets = secret_store if secret_store is not None else SecretStore(db)
|
|
188
|
+
|
|
189
|
+
# --- read ----------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def get(self, key: str) -> Any:
|
|
192
|
+
"""Return the resolved value for *key* (None if absent and no default)."""
|
|
193
|
+
env = os.environ.get(env_var_for(key))
|
|
194
|
+
if env is not None:
|
|
195
|
+
# llm.providers env override is a JSON string; decode for callers
|
|
196
|
+
if key == "llm.providers":
|
|
197
|
+
return json.loads(env)
|
|
198
|
+
return env
|
|
199
|
+
|
|
200
|
+
if is_secret_key(key):
|
|
201
|
+
return self._secrets.get(key)
|
|
202
|
+
|
|
203
|
+
# Non-secret: check app_config, then fall back to AppConfig default
|
|
204
|
+
row = self._db.fetchone("SELECT value_json FROM app_config WHERE key = ?", (key,))
|
|
205
|
+
if row is not None:
|
|
206
|
+
return json.loads(row[0])
|
|
207
|
+
|
|
208
|
+
# Pydantic default
|
|
209
|
+
defaults = AppConfig().model_dump(mode="json")
|
|
210
|
+
field = _DOT_TO_FIELD.get(key)
|
|
211
|
+
if field is None:
|
|
212
|
+
return None
|
|
213
|
+
return defaults.get(field)
|
|
214
|
+
|
|
215
|
+
def source_of(self, key: str) -> Literal["env", "secret_store", "app_config", "default"]:
|
|
216
|
+
if os.environ.get(env_var_for(key)) is not None:
|
|
217
|
+
return "env"
|
|
218
|
+
if is_secret_key(key):
|
|
219
|
+
return "secret_store" if self._secrets.get(key) is not None else "default"
|
|
220
|
+
row = self._db.fetchone("SELECT value_json FROM app_config WHERE key = ?", (key,))
|
|
221
|
+
return "app_config" if row is not None else "default"
|
|
222
|
+
|
|
223
|
+
def get_app_config(self) -> AppConfig:
|
|
224
|
+
"""Materialize a fully-resolved AppConfig (env > db > default)."""
|
|
225
|
+
# v0.7 — env var DEEPTRADE_DEEPSEEK_PROFILE was renamed to
|
|
226
|
+
# DEEPTRADE_APP_PROFILE. Hard-stop on the legacy name to prevent
|
|
227
|
+
# silently using the (Pydantic) default when the user thinks they've
|
|
228
|
+
# configured something. DB rows are migrated automatically (see
|
|
229
|
+
# config_migrations.migrate_legacy_deepseek_profile_key); env vars
|
|
230
|
+
# cannot be auto-migrated, so we surface the error explicitly.
|
|
231
|
+
if "DEEPTRADE_DEEPSEEK_PROFILE" in os.environ and "DEEPTRADE_APP_PROFILE" not in os.environ:
|
|
232
|
+
raise RuntimeError(
|
|
233
|
+
"DEEPTRADE_DEEPSEEK_PROFILE was renamed to DEEPTRADE_APP_PROFILE in "
|
|
234
|
+
"v0.7 and is no longer recognized. Update your environment to set "
|
|
235
|
+
"DEEPTRADE_APP_PROFILE (or unset DEEPTRADE_DEEPSEEK_PROFILE)."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
overrides: dict[str, Any] = {}
|
|
239
|
+
for dotted, field in _DOT_TO_FIELD.items():
|
|
240
|
+
env = os.environ.get(env_var_for(dotted))
|
|
241
|
+
if env is not None:
|
|
242
|
+
overrides[field] = env
|
|
243
|
+
continue
|
|
244
|
+
row = self._db.fetchone("SELECT value_json FROM app_config WHERE key = ?", (dotted,))
|
|
245
|
+
if row is not None:
|
|
246
|
+
overrides[field] = json.loads(row[0])
|
|
247
|
+
return AppConfig(**overrides)
|
|
248
|
+
|
|
249
|
+
# v0.7 — get_profile() removed. Stage 概念已归插件;调用方读取
|
|
250
|
+
# ``get_app_config().app_profile`` 拿 preset 字符串后,由插件本地
|
|
251
|
+
# 的 ``profiles.py`` 解析为 ``StageProfile``。
|
|
252
|
+
|
|
253
|
+
# --- write ---------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def set(self, key: str, value: Any) -> None:
|
|
256
|
+
"""Route to secret_store or app_config based on key namespace."""
|
|
257
|
+
if is_secret_key(key):
|
|
258
|
+
self._secrets.set(key, str(value))
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if key not in _DOT_TO_FIELD:
|
|
262
|
+
raise ValueError(f"unknown config key: {key!r}; see `deeptrade config show`")
|
|
263
|
+
|
|
264
|
+
# Validate by constructing a partial AppConfig; capture normalized JSON
|
|
265
|
+
# representation so nested Pydantic models / time / dicts all serialize
|
|
266
|
+
# cleanly without ad-hoc isinstance branches.
|
|
267
|
+
field = _DOT_TO_FIELD[key]
|
|
268
|
+
validated = AppConfig(**{field: value})
|
|
269
|
+
normalized = validated.model_dump(mode="json").get(field)
|
|
270
|
+
payload = json.dumps(normalized)
|
|
271
|
+
|
|
272
|
+
with self._db.transaction():
|
|
273
|
+
self._db.execute("DELETE FROM app_config WHERE key = ?", (key,))
|
|
274
|
+
self._db.execute(
|
|
275
|
+
"INSERT INTO app_config(key, value_json, is_secret) VALUES (?, ?, ?)",
|
|
276
|
+
(key, payload, False),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def delete(self, key: str) -> None:
|
|
280
|
+
if is_secret_key(key):
|
|
281
|
+
self._secrets.delete(key)
|
|
282
|
+
else:
|
|
283
|
+
self._db.execute("DELETE FROM app_config WHERE key = ?", (key,))
|
|
284
|
+
|
|
285
|
+
# --- listing -------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def list_all(self) -> list[tuple[str, Any, str]]:
|
|
288
|
+
"""Return [(key, value (masked for secrets), source)] for `config show`.
|
|
289
|
+
|
|
290
|
+
Includes all static known keys and one row per configured LLM provider's
|
|
291
|
+
api_key (so the user sees every secret slot at a glance).
|
|
292
|
+
"""
|
|
293
|
+
out: list[tuple[str, Any, str]] = []
|
|
294
|
+
for key in known_keys():
|
|
295
|
+
value = self.get(key)
|
|
296
|
+
source = self.source_of(key)
|
|
297
|
+
if is_secret_key(key) and value:
|
|
298
|
+
value = f"********{str(value)[-4:]}"
|
|
299
|
+
out.append((key, value, source))
|
|
300
|
+
# Per-provider api_key rows
|
|
301
|
+
cfg = self.get_app_config()
|
|
302
|
+
for provider_name in sorted(cfg.llm_providers.keys()):
|
|
303
|
+
secret_key = f"llm.{provider_name}.api_key"
|
|
304
|
+
value = self._secrets.get(secret_key)
|
|
305
|
+
source = "secret_store" if value else "default"
|
|
306
|
+
display: Any = f"********{str(value)[-4:]}" if value else None
|
|
307
|
+
out.append((secret_key, display, source))
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
# --- LLM provider CRUD --------------------------------------------
|
|
311
|
+
|
|
312
|
+
def set_llm_provider(
|
|
313
|
+
self,
|
|
314
|
+
name: str,
|
|
315
|
+
*,
|
|
316
|
+
base_url: str,
|
|
317
|
+
model: str,
|
|
318
|
+
timeout: int = 180,
|
|
319
|
+
api_key: str | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Insert or update a provider entry. If ``api_key`` is given, also
|
|
322
|
+
persist it to secret_store under ``llm.<name>.api_key``.
|
|
323
|
+
"""
|
|
324
|
+
if not name or "." in name:
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"invalid provider name: {name!r}; must be non-empty and contain no '.'"
|
|
327
|
+
)
|
|
328
|
+
current_raw = self.get("llm.providers")
|
|
329
|
+
current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
|
|
330
|
+
current[name] = {"base_url": base_url, "model": model, "timeout": timeout}
|
|
331
|
+
self.set("llm.providers", current)
|
|
332
|
+
if api_key is not None:
|
|
333
|
+
self.set(f"llm.{name}.api_key", api_key)
|
|
334
|
+
|
|
335
|
+
def delete_llm_provider(self, name: str) -> None:
|
|
336
|
+
"""Remove a provider entry plus its api_key. Idempotent on missing name."""
|
|
337
|
+
current_raw = self.get("llm.providers")
|
|
338
|
+
current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
|
|
339
|
+
current.pop(name, None)
|
|
340
|
+
if current:
|
|
341
|
+
self.set("llm.providers", current)
|
|
342
|
+
else:
|
|
343
|
+
self.delete("llm.providers")
|
|
344
|
+
self.delete(f"llm.{name}.api_key")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Idempotent data migrations for the framework's app_config / secret_store.
|
|
2
|
+
|
|
3
|
+
Schema-shape migrations live as SQL files in ``migrations/core/``. This module
|
|
4
|
+
holds *data-shape* migrations — ones that rewrite existing rows into a new
|
|
5
|
+
key namespace without touching DDL. Each function is idempotent (short-
|
|
6
|
+
circuits when its target state is already present), so re-running on a fresh
|
|
7
|
+
or already-migrated DB is a no-op.
|
|
8
|
+
|
|
9
|
+
v0.6 — deepseek.* → llm.providers / llm.<name>.api_key (DESIGN §0.7 / §10.5).
|
|
10
|
+
v0.7 — deepseek.profile → app.profile (DESIGN §10.1 update).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
19
|
+
from deeptrade.core.db import Database
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_LEGACY_KEYS = (
|
|
23
|
+
"deepseek.base_url",
|
|
24
|
+
"deepseek.model",
|
|
25
|
+
"deepseek.timeout",
|
|
26
|
+
"deepseek.audit_full_payload",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def migrate_legacy_deepseek_keys(db: Database) -> bool:
|
|
31
|
+
"""Migrate legacy ``deepseek.*`` config keys to the v0.6 ``llm.*`` schema.
|
|
32
|
+
|
|
33
|
+
Idempotent: if ``llm.providers`` is already present, skip everything.
|
|
34
|
+
Otherwise:
|
|
35
|
+
|
|
36
|
+
1. Read any of ``deepseek.base_url`` / ``deepseek.model`` /
|
|
37
|
+
``deepseek.timeout`` from app_config; if all three are absent, treat
|
|
38
|
+
this as a fresh DB (no legacy data) and return False.
|
|
39
|
+
2. Build ``llm.providers["deepseek"] = {base_url, model, timeout}`` using
|
|
40
|
+
AppConfig defaults for any field not present.
|
|
41
|
+
3. If ``deepseek.audit_full_payload`` is present, copy to
|
|
42
|
+
``llm.audit_full_payload``.
|
|
43
|
+
4. Rename the secret_store row ``deepseek.api_key`` →
|
|
44
|
+
``llm.deepseek.api_key`` (preserving encrypted_value /
|
|
45
|
+
encryption_method).
|
|
46
|
+
5. Delete the four legacy app_config rows.
|
|
47
|
+
|
|
48
|
+
``deepseek.profile`` migration is handled separately in v0.7 by
|
|
49
|
+
:func:`migrate_legacy_deepseek_profile_key`.
|
|
50
|
+
|
|
51
|
+
Returns True iff a migration was performed.
|
|
52
|
+
"""
|
|
53
|
+
# Idempotency: if llm.providers exists, we've already migrated (or the user
|
|
54
|
+
# has set up v0.6 fresh). Don't touch anything.
|
|
55
|
+
row = db.fetchone("SELECT 1 FROM app_config WHERE key = 'llm.providers'")
|
|
56
|
+
if row is not None:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
legacy_rows = db.fetchall(
|
|
60
|
+
"SELECT key, value_json FROM app_config WHERE key IN (?, ?, ?, ?)",
|
|
61
|
+
_LEGACY_KEYS,
|
|
62
|
+
)
|
|
63
|
+
if not legacy_rows:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
legacy: dict[str, object] = {k: json.loads(v) for k, v in legacy_rows}
|
|
67
|
+
|
|
68
|
+
# Defaults match AppConfig pre-v0.6 defaults so a partially-set DB
|
|
69
|
+
# (e.g. user only ever set deepseek.api_key) still produces a usable
|
|
70
|
+
# llm.providers["deepseek"] entry.
|
|
71
|
+
base_url = legacy.get("deepseek.base_url", "https://api.deepseek.com")
|
|
72
|
+
model = legacy.get("deepseek.model", "deepseek-v4-pro")
|
|
73
|
+
timeout = legacy.get("deepseek.timeout", 180)
|
|
74
|
+
audit_full = legacy.get("deepseek.audit_full_payload")
|
|
75
|
+
|
|
76
|
+
providers = {
|
|
77
|
+
"deepseek": {
|
|
78
|
+
"base_url": base_url,
|
|
79
|
+
"model": model,
|
|
80
|
+
"timeout": timeout,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
with db.transaction():
|
|
85
|
+
db.execute(
|
|
86
|
+
"INSERT INTO app_config(key, value_json, is_secret) VALUES (?, ?, ?)",
|
|
87
|
+
("llm.providers", json.dumps(providers), False),
|
|
88
|
+
)
|
|
89
|
+
if audit_full is not None:
|
|
90
|
+
db.execute(
|
|
91
|
+
"INSERT INTO app_config(key, value_json, is_secret) VALUES (?, ?, ?)",
|
|
92
|
+
("llm.audit_full_payload", json.dumps(audit_full), False),
|
|
93
|
+
)
|
|
94
|
+
# Rename secret. UPDATE-only avoids re-encrypting; if the destination
|
|
95
|
+
# row already exists (extremely unlikely on a non-migrated DB), prefer
|
|
96
|
+
# the legacy value as canonical and overwrite.
|
|
97
|
+
existing_dest = db.fetchone(
|
|
98
|
+
"SELECT 1 FROM secret_store WHERE key = 'llm.deepseek.api_key'"
|
|
99
|
+
)
|
|
100
|
+
if existing_dest is not None:
|
|
101
|
+
db.execute("DELETE FROM secret_store WHERE key = 'llm.deepseek.api_key'")
|
|
102
|
+
db.execute(
|
|
103
|
+
"UPDATE secret_store SET key = 'llm.deepseek.api_key' WHERE key = 'deepseek.api_key'"
|
|
104
|
+
)
|
|
105
|
+
db.execute(
|
|
106
|
+
"DELETE FROM app_config WHERE key IN (?, ?, ?, ?)",
|
|
107
|
+
_LEGACY_KEYS,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def migrate_legacy_deepseek_profile_key(db: Database) -> bool:
|
|
114
|
+
"""Migrate ``deepseek.profile`` → ``app.profile`` (v0.7).
|
|
115
|
+
|
|
116
|
+
v0.7 promoted the global stage-profile preset name from a vendor-prefixed
|
|
117
|
+
key (``deepseek.profile``) to a vendor-agnostic one (``app.profile``).
|
|
118
|
+
Stage 调参档已彻底归插件,preset 名仍框架级,但键名重命名以反映其与
|
|
119
|
+
DeepSeek 无关。
|
|
120
|
+
|
|
121
|
+
Idempotent: skip when ``app.profile`` already exists; otherwise copy the
|
|
122
|
+
value and delete the legacy row.
|
|
123
|
+
|
|
124
|
+
Returns True iff a migration was performed.
|
|
125
|
+
"""
|
|
126
|
+
new_row = db.fetchone("SELECT 1 FROM app_config WHERE key = 'app.profile'")
|
|
127
|
+
if new_row is not None:
|
|
128
|
+
return False
|
|
129
|
+
legacy = db.fetchone("SELECT value_json FROM app_config WHERE key = 'deepseek.profile'")
|
|
130
|
+
if legacy is None:
|
|
131
|
+
return False
|
|
132
|
+
with db.transaction():
|
|
133
|
+
db.execute(
|
|
134
|
+
"INSERT INTO app_config(key, value_json, is_secret) VALUES (?, ?, ?)",
|
|
135
|
+
("app.profile", legacy[0], False),
|
|
136
|
+
)
|
|
137
|
+
db.execute("DELETE FROM app_config WHERE key = 'deepseek.profile'")
|
|
138
|
+
return True
|
deeptrade/core/db.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""DuckDB connection + migrations management.
|
|
2
|
+
|
|
3
|
+
Concurrency model (DESIGN §13.3): single-process single-writer connection
|
|
4
|
+
held by AppContext; writes are serialized on the runner main thread.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import duckdb
|
|
18
|
+
|
|
19
|
+
from deeptrade.core import paths
|
|
20
|
+
|
|
21
|
+
_MIGRATION_FILENAME_RE = re.compile(r"^(\d{8}_\d{3,})_.+\.sql$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Database:
|
|
25
|
+
"""Thin wrapper around a single DuckDB connection.
|
|
26
|
+
|
|
27
|
+
NOT thread-safe by design. The CLI is a single-process tool; if any future
|
|
28
|
+
iteration introduces background workers, route writes through a queue
|
|
29
|
+
consumed by the main thread (DESIGN §13.3).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, db_file: Path | None = None) -> None:
|
|
33
|
+
self._path = db_file or paths.db_path()
|
|
34
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._conn: duckdb.DuckDBPyConnection = duckdb.connect(str(self._path))
|
|
36
|
+
# RLock (re-entrant) is required because transaction() acquires the lock
|
|
37
|
+
# and the user code inside `with transaction()` calls execute() which
|
|
38
|
+
# also acquires the lock. A plain Lock would self-deadlock.
|
|
39
|
+
self._write_lock = threading.RLock()
|
|
40
|
+
self._tx_depth = 0 # for reentrant transaction(); only outermost BEGIN/COMMIT
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def path(self) -> Path:
|
|
44
|
+
return self._path
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def conn(self) -> duckdb.DuckDBPyConnection:
|
|
48
|
+
return self._conn
|
|
49
|
+
|
|
50
|
+
# --- query helpers -----------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
|
|
53
|
+
with self._write_lock:
|
|
54
|
+
if params is None:
|
|
55
|
+
return self._conn.execute(sql)
|
|
56
|
+
return self._conn.execute(sql, params)
|
|
57
|
+
|
|
58
|
+
def fetchone(
|
|
59
|
+
self, sql: str, params: tuple[Any, ...] | list[Any] | None = None
|
|
60
|
+
) -> tuple[Any, ...] | None:
|
|
61
|
+
# Lock must span execute + fetch: duckdb's `_conn.execute()` returns the
|
|
62
|
+
# connection itself with the result set attached. Releasing the lock
|
|
63
|
+
# between execute and fetch lets another thread issue a new execute on
|
|
64
|
+
# the same connection, overwriting our pending result and triggering a
|
|
65
|
+
# native heap corruption (Windows 0xC0000374) on fetchone.
|
|
66
|
+
with self._write_lock:
|
|
67
|
+
if params is None:
|
|
68
|
+
return self._conn.execute(sql).fetchone()
|
|
69
|
+
return self._conn.execute(sql, params).fetchone()
|
|
70
|
+
|
|
71
|
+
def fetchall(
|
|
72
|
+
self, sql: str, params: tuple[Any, ...] | list[Any] | None = None
|
|
73
|
+
) -> list[tuple[Any, ...]]:
|
|
74
|
+
with self._write_lock:
|
|
75
|
+
if params is None:
|
|
76
|
+
return self._conn.execute(sql).fetchall()
|
|
77
|
+
return self._conn.execute(sql, params).fetchall()
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def transaction(self) -> Iterator[None]:
|
|
81
|
+
"""Short transaction. Wraps BEGIN / COMMIT, rolls back on exception.
|
|
82
|
+
|
|
83
|
+
Reentrant: nested ``with db.transaction():`` blocks do NOT start
|
|
84
|
+
nested DuckDB transactions (which would error). Only the outermost
|
|
85
|
+
block commits or rolls back. If any inner block raises, the
|
|
86
|
+
outermost block sees the exception and rolls back.
|
|
87
|
+
"""
|
|
88
|
+
with self._write_lock:
|
|
89
|
+
outermost = self._tx_depth == 0
|
|
90
|
+
if outermost:
|
|
91
|
+
self._conn.execute("BEGIN")
|
|
92
|
+
self._tx_depth += 1
|
|
93
|
+
try:
|
|
94
|
+
yield
|
|
95
|
+
if outermost:
|
|
96
|
+
self._conn.execute("COMMIT")
|
|
97
|
+
except Exception:
|
|
98
|
+
if outermost:
|
|
99
|
+
self._conn.execute("ROLLBACK")
|
|
100
|
+
raise
|
|
101
|
+
finally:
|
|
102
|
+
self._tx_depth -= 1
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
self._conn.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Migrations
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _list_core_migrations() -> list[tuple[str, str]]:
|
|
114
|
+
"""Return [(version, sql_text), ...] sorted by version.
|
|
115
|
+
|
|
116
|
+
Reads SQL files from the packaged ``deeptrade.core.migrations.core`` resource
|
|
117
|
+
so it works whether installed as wheel or run from source.
|
|
118
|
+
"""
|
|
119
|
+
pkg = resources.files("deeptrade.core.migrations.core")
|
|
120
|
+
migrations: list[tuple[str, str]] = []
|
|
121
|
+
for entry in pkg.iterdir():
|
|
122
|
+
name = entry.name
|
|
123
|
+
match = _MIGRATION_FILENAME_RE.match(name)
|
|
124
|
+
if not match:
|
|
125
|
+
continue
|
|
126
|
+
version = match.group(1)
|
|
127
|
+
migrations.append((version, entry.read_text(encoding="utf-8")))
|
|
128
|
+
migrations.sort(key=lambda item: item[0])
|
|
129
|
+
return migrations
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _applied_versions(db: Database) -> set[str]:
|
|
133
|
+
# If schema_migrations doesn't exist yet (fresh DB), short-circuit.
|
|
134
|
+
rows = db.fetchall(
|
|
135
|
+
"SELECT table_name FROM information_schema.tables "
|
|
136
|
+
"WHERE table_schema='main' AND table_name='schema_migrations'"
|
|
137
|
+
)
|
|
138
|
+
if not rows:
|
|
139
|
+
return set()
|
|
140
|
+
applied = db.fetchall("SELECT version FROM schema_migrations")
|
|
141
|
+
return {row[0] for row in applied}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def apply_core_migrations(db: Database) -> list[str]:
|
|
145
|
+
"""Apply core migrations not yet recorded. Returns versions newly applied.
|
|
146
|
+
|
|
147
|
+
After SQL migrations, runs idempotent data migrations (e.g. v0.6
|
|
148
|
+
deepseek.* → llm.providers). Data migrations are idempotent by inspection
|
|
149
|
+
of current state (not tracked in schema_migrations) so a re-run on a
|
|
150
|
+
clean v0.6 DB is a no-op.
|
|
151
|
+
"""
|
|
152
|
+
applied = _applied_versions(db)
|
|
153
|
+
newly: list[str] = []
|
|
154
|
+
for version, sql_text in _list_core_migrations():
|
|
155
|
+
if version in applied:
|
|
156
|
+
continue
|
|
157
|
+
with db.transaction():
|
|
158
|
+
db.execute(sql_text)
|
|
159
|
+
db.execute(
|
|
160
|
+
"INSERT INTO schema_migrations(version) VALUES (?)",
|
|
161
|
+
(version,),
|
|
162
|
+
)
|
|
163
|
+
newly.append(version)
|
|
164
|
+
|
|
165
|
+
# v0.6 data migration — convert legacy deepseek.* config to llm.providers.
|
|
166
|
+
from deeptrade.core.config_migrations import (
|
|
167
|
+
migrate_legacy_deepseek_keys,
|
|
168
|
+
migrate_legacy_deepseek_profile_key,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if migrate_legacy_deepseek_keys(db):
|
|
172
|
+
newly.append("data:v06_llm_providers")
|
|
173
|
+
# v0.7 data migration — rename deepseek.profile → app.profile.
|
|
174
|
+
if migrate_legacy_deepseek_profile_key(db):
|
|
175
|
+
newly.append("data:v07_app_profile")
|
|
176
|
+
return newly
|