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.
Files changed (83) hide show
  1. deeptrade/__init__.py +8 -0
  2. deeptrade/channels_builtin/__init__.py +0 -0
  3. deeptrade/channels_builtin/stdout/__init__.py +0 -0
  4. deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +25 -0
  5. deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql +13 -0
  6. deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
  7. deeptrade/channels_builtin/stdout/stdout_channel/channel.py +180 -0
  8. deeptrade/cli.py +214 -0
  9. deeptrade/cli_config.py +396 -0
  10. deeptrade/cli_data.py +33 -0
  11. deeptrade/cli_plugin.py +176 -0
  12. deeptrade/core/__init__.py +8 -0
  13. deeptrade/core/config.py +344 -0
  14. deeptrade/core/config_migrations.py +138 -0
  15. deeptrade/core/db.py +176 -0
  16. deeptrade/core/llm_client.py +591 -0
  17. deeptrade/core/llm_manager.py +174 -0
  18. deeptrade/core/logging_config.py +61 -0
  19. deeptrade/core/migrations/__init__.py +0 -0
  20. deeptrade/core/migrations/core/20260427_001_init.sql +121 -0
  21. deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +10 -0
  22. deeptrade/core/migrations/core/__init__.py +0 -0
  23. deeptrade/core/notifier.py +302 -0
  24. deeptrade/core/paths.py +49 -0
  25. deeptrade/core/plugin_manager.py +616 -0
  26. deeptrade/core/run_status.py +29 -0
  27. deeptrade/core/secrets.py +152 -0
  28. deeptrade/core/tushare_client.py +824 -0
  29. deeptrade/plugins_api/__init__.py +44 -0
  30. deeptrade/plugins_api/base.py +66 -0
  31. deeptrade/plugins_api/channel.py +42 -0
  32. deeptrade/plugins_api/events.py +61 -0
  33. deeptrade/plugins_api/llm.py +46 -0
  34. deeptrade/plugins_api/metadata.py +84 -0
  35. deeptrade/plugins_api/notify.py +67 -0
  36. deeptrade/strategies_builtin/__init__.py +0 -0
  37. deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
  38. deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +101 -0
  39. deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
  40. deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +65 -0
  41. deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +269 -0
  42. deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +76 -0
  43. deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +1191 -0
  44. deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +869 -0
  45. deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +30 -0
  46. deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +85 -0
  47. deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +485 -0
  48. deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +890 -0
  49. deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +1087 -0
  50. deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +172 -0
  51. deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +178 -0
  52. deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql +150 -0
  53. deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +8 -0
  54. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +36 -0
  55. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +18 -0
  56. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +46 -0
  57. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +53 -0
  58. deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +17 -0
  59. deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
  60. deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +59 -0
  61. deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql +94 -0
  62. deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +44 -0
  63. deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +13 -0
  64. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
  65. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +52 -0
  66. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +247 -0
  67. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +2154 -0
  68. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +327 -0
  69. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +22 -0
  70. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +49 -0
  71. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +187 -0
  72. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +84 -0
  73. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +906 -0
  74. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +772 -0
  75. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +90 -0
  76. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +97 -0
  77. deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +174 -0
  78. deeptrade/theme.py +48 -0
  79. deeptrade_quant-0.0.2.dist-info/METADATA +166 -0
  80. deeptrade_quant-0.0.2.dist-info/RECORD +83 -0
  81. deeptrade_quant-0.0.2.dist-info/WHEEL +4 -0
  82. deeptrade_quant-0.0.2.dist-info/entry_points.txt +2 -0
  83. deeptrade_quant-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -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