deeptrade-quant 0.4.0__tar.gz → 0.4.2__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 (65) hide show
  1. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/PKG-INFO +1 -1
  2. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/__init__.py +1 -1
  3. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/cli.py +24 -3
  4. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/db.py +24 -1
  5. deeptrade_quant-0.4.2/deeptrade/core/migrations/core/20260512_001_drop_legacy_tushare_cache.sql +10 -0
  6. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/tushare_client.py +40 -3
  7. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/plugins_api/__init__.py +4 -0
  8. deeptrade_quant-0.4.2/deeptrade/plugins_api/errors.py +45 -0
  9. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/pyproject.toml +1 -1
  10. deeptrade_quant-0.4.2/tests/core/test_db.py +252 -0
  11. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_tushare_client.py +34 -0
  12. deeptrade_quant-0.4.2/tests/plugins_api/test_errors.py +77 -0
  13. deeptrade_quant-0.4.0/tests/core/test_db.py +0 -127
  14. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/.gitignore +0 -0
  15. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/LICENSE +0 -0
  16. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/README.md +0 -0
  17. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/cli_config.py +0 -0
  18. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/cli_data.py +0 -0
  19. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/cli_plugin.py +0 -0
  20. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/__init__.py +0 -0
  21. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/config.py +0 -0
  22. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/config_migrations.py +0 -0
  23. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/dep_installer.py +0 -0
  24. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/github_fetch.py +0 -0
  25. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/llm_client.py +0 -0
  26. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/llm_manager.py +0 -0
  27. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/logging_config.py +0 -0
  28. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/migrations/__init__.py +0 -0
  29. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/migrations/core/20260509_001_init.sql +0 -0
  30. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/migrations/core/__init__.py +0 -0
  31. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/paths.py +0 -0
  32. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/plugin_manager.py +0 -0
  33. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/plugin_source.py +0 -0
  34. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/registry.py +0 -0
  35. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/run_status.py +0 -0
  36. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/core/secrets.py +0 -0
  37. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/plugins_api/base.py +0 -0
  38. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/plugins_api/events.py +0 -0
  39. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/plugins_api/llm.py +0 -0
  40. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/plugins_api/metadata.py +0 -0
  41. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/deeptrade/theme.py +0 -0
  42. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/__init__.py +0 -0
  43. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/cli/__init__.py +0 -0
  44. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/cli/test_config_cmd.py +0 -0
  45. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/cli/test_plugin_cmd.py +0 -0
  46. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/cli/test_routing.py +0 -0
  47. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/conftest.py +0 -0
  48. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/__init__.py +0 -0
  49. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_config.py +0 -0
  50. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_config_migrations.py +0 -0
  51. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_github_fetch.py +0 -0
  52. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_llm_client.py +0 -0
  53. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_llm_manager.py +0 -0
  54. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_paths.py +0 -0
  55. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_plugin_dependencies.py +0 -0
  56. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_plugin_install.py +0 -0
  57. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_plugin_source.py +0 -0
  58. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_plugin_upgrade.py +0 -0
  59. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_registry.py +0 -0
  60. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_secrets.py +0 -0
  61. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_tushare_classifier.py +0 -0
  62. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/core/test_tushare_retry_r1.py +0 -0
  63. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/plugins_api/__init__.py +0 -0
  64. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/plugins_api/test_protocol.py +0 -0
  65. {deeptrade_quant-0.4.0 → deeptrade_quant-0.4.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.4.0"
5
+ __version__ = "0.4.2"
6
6
  __all__ = ["__version__"]
@@ -126,7 +126,20 @@ def _build_plugin_command(plugin_id: str) -> click.Command | None:
126
126
  if not hasattr(plugin, "dispatch"):
127
127
  typer.echo(f"✘ plugin {plugin_id!r} does not implement dispatch()")
128
128
  raise typer.Exit(2)
129
- rc = plugin.dispatch(list(ctx.args))
129
+ try:
130
+ rc = plugin.dispatch(list(ctx.args))
131
+ except (SystemExit, KeyboardInterrupt):
132
+ # Exit codes / Ctrl-C must propagate unaltered.
133
+ raise
134
+ except BaseException as e: # noqa: BLE001 — final safety net
135
+ # Plugins are encouraged to install their own dispatch-tail handler
136
+ # (see plugins_api.render_exception). This catch only fires when a
137
+ # plugin lets an exception escape — we still want DEEPTRADE_DEBUG=1
138
+ # to surface the traceback rather than a bare crash.
139
+ from deeptrade.plugins_api import render_exception
140
+
141
+ sys.stderr.write(render_exception(e) + "\n")
142
+ raise typer.Exit(1) from e
130
143
  raise typer.Exit(rc or 0)
131
144
 
132
145
  return _dispatch
@@ -181,10 +194,16 @@ def init(
181
194
  paths.ensure_layout()
182
195
  db_file = paths.db_path()
183
196
  fresh = not db_file.exists()
184
- db = Database(db_file)
197
+ # auto_migrate=False so we can collect & print the precise list of versions
198
+ # newly applied below; the auto-migrate path swallows that information by
199
+ # design (it runs unconditionally on every Database open).
200
+ db = Database(db_file, auto_migrate=False)
185
201
  try:
202
+ applied = apply_core_migrations(db)
186
203
  if fresh:
187
204
  typer.echo(f"✔ Database created: {db_file}")
205
+ for v in applied:
206
+ typer.echo(f"✔ Schema applied: {v}")
188
207
  finally:
189
208
  db.close()
190
209
 
@@ -219,7 +238,9 @@ def db_init() -> None:
219
238
  paths.ensure_layout()
220
239
  db_file = paths.db_path()
221
240
  fresh = not db_file.exists()
222
- db = Database(db_file)
241
+ # auto_migrate=False so this command reports which versions it applied;
242
+ # the Database auto-migrate path returns nothing.
243
+ db = Database(db_file, auto_migrate=False)
223
244
  try:
224
245
  applied = apply_core_migrations(db)
225
246
  if fresh:
@@ -6,6 +6,7 @@ held by AppContext; writes are serialized on the runner main thread.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  import re
10
11
  import threading
11
12
  from collections.abc import Iterator
@@ -20,6 +21,20 @@ from deeptrade.core import paths
20
21
 
21
22
  _MIGRATION_FILENAME_RE = re.compile(r"^(\d{8}_\d{3,})_.+\.sql$")
22
23
 
24
+ # Escape hatch: set DEEPTRADE_SKIP_AUTO_MIGRATE=1 to bypass the auto-migrate
25
+ # step in Database.__init__. Intended for recovery (e.g. a corrupt schema
26
+ # blocking every CLI command); not part of the documented main flow.
27
+ _SKIP_AUTO_MIGRATE_ENV = "DEEPTRADE_SKIP_AUTO_MIGRATE"
28
+
29
+
30
+ def _skip_auto_migrate() -> bool:
31
+ return os.environ.get(_SKIP_AUTO_MIGRATE_ENV, "").strip().lower() in {
32
+ "1",
33
+ "true",
34
+ "yes",
35
+ "on",
36
+ }
37
+
23
38
 
24
39
  class Database:
25
40
  """Thin wrapper around a single DuckDB connection.
@@ -29,7 +44,7 @@ class Database:
29
44
  consumed by the main thread (DESIGN §13.3).
30
45
  """
31
46
 
32
- def __init__(self, db_file: Path | None = None) -> None:
47
+ def __init__(self, db_file: Path | None = None, *, auto_migrate: bool = True) -> None:
33
48
  self._path = db_file or paths.db_path()
34
49
  self._path.parent.mkdir(parents=True, exist_ok=True)
35
50
  self._conn: duckdb.DuckDBPyConnection = duckdb.connect(str(self._path))
@@ -39,6 +54,14 @@ class Database:
39
54
  self._write_lock = threading.RLock()
40
55
  self._tx_depth = 0 # for reentrant transaction(); only outermost BEGIN/COMMIT
41
56
 
57
+ # Auto-migrate: any CLI / SDK code path that opens the DB pulls schema
58
+ # forward so wheel upgrades don't strand stale rows for the new
59
+ # readers (see v0.4.1 tushare_cache_blob format change). The CLI's own
60
+ # `init` / `db init` / `db upgrade` commands opt out so they can print
61
+ # the precise list of versions newly applied.
62
+ if auto_migrate and not _skip_auto_migrate():
63
+ apply_core_migrations(self)
64
+
42
65
  @property
43
66
  def path(self) -> Path:
44
67
  return self._path
@@ -0,0 +1,10 @@
1
+ -- v0.4.1 — drop legacy tushare_cache_blob entries.
2
+ --
3
+ -- Pre-v0.4.1 payloads were stored as a bare JSON array, then read back via
4
+ -- pd.read_json which triggered a pandas FutureWarning on string columns whose
5
+ -- names matched its date heuristic (trade_date, cal_date, ...). The cache
6
+ -- read/write path now wraps payloads as {version:1, schema:{...}, data:[...]}
7
+ -- and restores dtypes explicitly. Old rows are incompatible with that reader,
8
+ -- so wipe the table; TushareClient._ensure_cache_table re-creates it lazily on
9
+ -- the next write.
10
+ DROP TABLE IF EXISTS tushare_cache_blob;
@@ -19,7 +19,6 @@ Cache class buckets:
19
19
  from __future__ import annotations
20
20
 
21
21
  import hashlib
22
- import io
23
22
  import json
24
23
  import logging
25
24
  import threading
@@ -896,6 +895,15 @@ class TushareClient:
896
895
  ")"
897
896
  )
898
897
 
898
+ # Payload wrapper format (v0.4.1+):
899
+ # {"version": 1, "schema": {col: dtype_str}, "data": [...records...]}
900
+ # Bypasses pd.read_json's date-column heuristic (which warns on string
901
+ # columns named like dates — trade_date / cal_date / ann_date) and
902
+ # restores dtypes explicitly from the recorded schema. Pre-v0.4.1 rows
903
+ # were a bare records array; those are wiped by core migration
904
+ # 20260512_001_drop_legacy_tushare_cache.sql, so no legacy branch here.
905
+ _CACHE_PAYLOAD_VERSION = 1
906
+
899
907
  def _write_cached(
900
908
  self,
901
909
  api_name: str,
@@ -906,7 +914,14 @@ class TushareClient:
906
914
  self._ensure_cache_table()
907
915
  body = json.dumps(params, sort_keys=True, default=str)
908
916
  h = hashlib.sha256(body.encode("utf-8")).hexdigest()
909
- payload = df.to_json(orient="records", date_format="iso")
917
+ records_json = df.to_json(orient="records", date_format="iso")
918
+ payload = json.dumps(
919
+ {
920
+ "version": self._CACHE_PAYLOAD_VERSION,
921
+ "schema": {col: str(dt) for col, dt in df.dtypes.items()},
922
+ "data": json.loads(records_json) if records_json else [],
923
+ }
924
+ )
910
925
  with self._db.transaction():
911
926
  self._db.execute(
912
927
  "DELETE FROM tushare_cache_blob "
@@ -937,12 +952,34 @@ class TushareClient:
937
952
  )
938
953
  if row is None:
939
954
  return pd.DataFrame()
940
- df = pd.read_json(io.StringIO(row[0]), orient="records")
955
+ wrapper = json.loads(row[0])
956
+ df = self._restore_cached_frame(wrapper)
941
957
  if fields:
942
958
  cols = [c.strip() for c in fields.split(",") if c.strip() in df.columns]
943
959
  df = df[cols]
944
960
  return df
945
961
 
962
+ @classmethod
963
+ def _restore_cached_frame(cls, wrapper: dict[str, Any]) -> pd.DataFrame:
964
+ schema: dict[str, str] = wrapper["schema"]
965
+ data: list[dict[str, Any]] = wrapper["data"]
966
+ df = pd.DataFrame.from_records(data, columns=list(schema.keys()))
967
+ for col, dtype_str in schema.items():
968
+ if col not in df.columns:
969
+ continue
970
+ if dtype_str.startswith("datetime"):
971
+ df[col] = pd.to_datetime(df[col], errors="coerce")
972
+ elif dtype_str == "object":
973
+ continue
974
+ else:
975
+ try:
976
+ df[col] = df[col].astype(dtype_str)
977
+ except (TypeError, ValueError):
978
+ # Best-effort: if a numeric/bool column can't be coerced
979
+ # back (e.g. all-null), leave the inferred dtype.
980
+ pass
981
+ return df
982
+
946
983
 
947
984
  # ---------------------------------------------------------------------------
948
985
  # Fallback predicate (DESIGN §13.2 + S4)
@@ -6,11 +6,13 @@ Stable surface (api_version = "1"):
6
6
  - Plugin (Protocol), PluginContext — every plugin's contract: metadata + validate_static + dispatch
7
7
  - PluginMetadata, TableSpec, MigrationSpec, PluginPermissions, ...
8
8
  - StageProfile — LLM 调参档;插件持有 preset → stage 映射表,自行解析
9
+ - render_exception — uniform DEEPTRADE_DEBUG-aware error formatter for dispatch tails
9
10
  """
10
11
 
11
12
  from __future__ import annotations
12
13
 
13
14
  from deeptrade.plugins_api.base import Plugin, PluginContext
15
+ from deeptrade.plugins_api.errors import debug_enabled, render_exception
14
16
  from deeptrade.plugins_api.llm import StageProfile
15
17
  from deeptrade.plugins_api.metadata import (
16
18
  MigrationSpec,
@@ -29,4 +31,6 @@ __all__ = [
29
31
  "StageProfile",
30
32
  "TableSpec",
31
33
  "TushareApiPermissions",
34
+ "debug_enabled",
35
+ "render_exception",
32
36
  ]
@@ -0,0 +1,45 @@
1
+ """Error rendering helpers for plugin dispatch.
2
+
3
+ Plugins commonly install a generic ``except Exception`` at the top of their
4
+ ``dispatch`` to translate any uncaught error into a non-zero exit code while
5
+ keeping the user-facing output short. The downside is the loss of the
6
+ traceback when something unexpected goes wrong.
7
+
8
+ ``render_exception`` centralizes that one-liner/traceback decision behind a
9
+ single environment variable so users can opt into a full stack without
10
+ plugins inventing their own conventions:
11
+
12
+ DEEPTRADE_DEBUG=1 → traceback (chained causes included)
13
+ unset / "0" / "" → one-line "{glyph} {ExcType}: {msg}"
14
+
15
+ Both the framework (``deeptrade/cli.py::_dispatch``) and plugin dispatch
16
+ tails should use this helper so the toggle is honored uniformly.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import traceback
23
+
24
+ _DEBUG_ENV = "DEEPTRADE_DEBUG"
25
+
26
+
27
+ def debug_enabled() -> bool:
28
+ """True when ``DEEPTRADE_DEBUG`` is set to a truthy value."""
29
+ return os.environ.get(_DEBUG_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
30
+
31
+
32
+ def render_exception(exc: BaseException, *, header_glyph: str = "✘") -> str:
33
+ """Format ``exc`` for stderr.
34
+
35
+ With ``DEEPTRADE_DEBUG=1`` returns the full ``traceback.format_exception``
36
+ output (which already includes chained causes and exception groups)
37
+ prefixed by ``header_glyph``. Otherwise returns the one-liner
38
+ ``"{header_glyph} {ExcType}: {message}"``.
39
+
40
+ The output has no trailing newline; callers append their own.
41
+ """
42
+ if debug_enabled():
43
+ body = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
44
+ return f"{header_glyph} {body.rstrip()}"
45
+ return f"{header_glyph} {type(exc).__name__}: {exc}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "deeptrade-quant"
7
- version = "0.4.0"
7
+ version = "0.4.2"
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,252 @@
1
+ """Core schema invariants — framework-only tables (Plan A pure isolation).
2
+
3
+ After the v0.5 reshape the framework owns ONLY:
4
+ app_config, secret_store, schema_migrations,
5
+ plugins, plugin_tables, plugin_schema_migrations,
6
+ llm_calls, tushare_sync_state, tushare_calls
7
+
8
+ All business / strategy data is plugin-owned.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+ from typer.testing import CliRunner
17
+
18
+ from deeptrade.cli import app
19
+ from deeptrade.core.db import Database, apply_core_migrations
20
+
21
+
22
+ @pytest.fixture
23
+ def fresh_db(tmp_path: Path) -> Database:
24
+ # auto_migrate=False keeps the "fresh" semantics tests depend on: each
25
+ # test exercises apply_core_migrations explicitly to assert what it does.
26
+ return Database(tmp_path / "test.duckdb", auto_migrate=False)
27
+
28
+
29
+ # --- init creates DB + dirs ----------------------------------------------
30
+
31
+
32
+ def test_init_creates_db_file_and_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
33
+ monkeypatch.setenv("DEEPTRADE_HOME", str(tmp_path))
34
+ runner = CliRunner()
35
+ result = runner.invoke(app, ["init", "--no-prompts"])
36
+ assert result.exit_code == 0, result.stdout
37
+ assert (tmp_path / "deeptrade.duckdb").is_file()
38
+ assert (tmp_path / "logs").is_dir()
39
+
40
+
41
+ def test_init_is_idempotent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
42
+ monkeypatch.setenv("DEEPTRADE_HOME", str(tmp_path))
43
+ runner = CliRunner()
44
+ runner.invoke(app, ["init", "--no-prompts"])
45
+ result = runner.invoke(app, ["init", "--no-prompts"])
46
+ assert result.exit_code == 0
47
+
48
+
49
+ # --- migrations record version --------------------------------------------
50
+
51
+
52
+ def test_apply_core_migrations_records_version(fresh_db: Database) -> None:
53
+ """Framework owns no business tables."""
54
+ applied = apply_core_migrations(fresh_db)
55
+ assert applied == ["20260509_001", "20260512_001"]
56
+ rows = fresh_db.fetchall("SELECT version FROM schema_migrations ORDER BY version")
57
+ assert tuple(rows) == (("20260509_001",), ("20260512_001",))
58
+
59
+
60
+ def test_apply_core_migrations_skips_applied_versions(fresh_db: Database) -> None:
61
+ apply_core_migrations(fresh_db)
62
+ second = apply_core_migrations(fresh_db)
63
+ assert second == []
64
+
65
+
66
+ # --- framework tables exist; business tables do NOT -----------------------
67
+
68
+
69
+ def test_framework_owns_only_minimal_table_set(fresh_db: Database) -> None:
70
+ apply_core_migrations(fresh_db)
71
+ tables = {
72
+ r[0]
73
+ for r in fresh_db.fetchall(
74
+ "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
75
+ )
76
+ }
77
+ expected = {
78
+ "app_config",
79
+ "secret_store",
80
+ "schema_migrations",
81
+ "plugins",
82
+ "plugin_tables",
83
+ "plugin_schema_migrations",
84
+ "llm_calls",
85
+ "tushare_sync_state",
86
+ "tushare_calls",
87
+ }
88
+ assert tables == expected, f"unexpected drift: {tables - expected} missing: {expected - tables}"
89
+
90
+
91
+ # --- tushare_sync_state has plugin_id in PK (Plan A pure isolation) -------
92
+
93
+
94
+ def test_tushare_sync_state_has_plugin_id_column_and_pk(fresh_db: Database) -> None:
95
+ apply_core_migrations(fresh_db)
96
+ cols = [
97
+ r[0]
98
+ for r in fresh_db.fetchall(
99
+ "SELECT column_name FROM information_schema.columns "
100
+ "WHERE table_name='tushare_sync_state' ORDER BY ordinal_position"
101
+ )
102
+ ]
103
+ # plugin_id is the first PK column
104
+ assert cols[0] == "plugin_id"
105
+ assert "data_completeness" in cols
106
+
107
+
108
+ def test_tushare_sync_state_default_data_completeness(fresh_db: Database) -> None:
109
+ apply_core_migrations(fresh_db)
110
+ fresh_db.execute(
111
+ "INSERT INTO tushare_sync_state(plugin_id, api_name, trade_date, status) "
112
+ "VALUES (?, ?, ?, ?)",
113
+ ("test-plugin", "stock_basic", "*", "ok"),
114
+ )
115
+ row = fresh_db.fetchone(
116
+ "SELECT data_completeness FROM tushare_sync_state WHERE api_name='stock_basic'"
117
+ )
118
+ assert row is not None and row[0] == "final"
119
+
120
+
121
+ def test_tushare_calls_has_plugin_id_column(fresh_db: Database) -> None:
122
+ apply_core_migrations(fresh_db)
123
+ cols = {
124
+ r[0]
125
+ for r in fresh_db.fetchall(
126
+ "SELECT column_name FROM information_schema.columns WHERE table_name='tushare_calls'"
127
+ )
128
+ }
129
+ assert "plugin_id" in cols
130
+
131
+
132
+ # --- Database.__init__ auto-migrate (v0.4.2) -----------------------------
133
+
134
+
135
+ def test_database_init_runs_pending_migrations(tmp_path: Path) -> None:
136
+ """A fresh Database() with default auto_migrate=True applies every core
137
+ migration on first open — no separate `db upgrade` call needed."""
138
+ db = Database(tmp_path / "auto.duckdb")
139
+ try:
140
+ rows = db.fetchall("SELECT version FROM schema_migrations ORDER BY version")
141
+ assert tuple(rows) == (("20260509_001",), ("20260512_001",))
142
+ finally:
143
+ db.close()
144
+
145
+
146
+ def test_database_init_auto_migrate_false_skips(tmp_path: Path) -> None:
147
+ """auto_migrate=False is the documented opt-out for the CLI's
148
+ `db init` / `db upgrade` commands that want to collect the applied
149
+ list themselves."""
150
+ db = Database(tmp_path / "manual.duckdb", auto_migrate=False)
151
+ try:
152
+ rows = db.fetchall(
153
+ "SELECT table_name FROM information_schema.tables "
154
+ "WHERE table_schema='main' AND table_name='schema_migrations'"
155
+ )
156
+ assert rows == []
157
+ finally:
158
+ db.close()
159
+
160
+
161
+ def test_database_init_env_var_skips_auto_migrate(
162
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
163
+ ) -> None:
164
+ """DEEPTRADE_SKIP_AUTO_MIGRATE=1 is the recovery escape hatch."""
165
+ monkeypatch.setenv("DEEPTRADE_SKIP_AUTO_MIGRATE", "1")
166
+ db = Database(tmp_path / "skip.duckdb")
167
+ try:
168
+ rows = db.fetchall(
169
+ "SELECT table_name FROM information_schema.tables "
170
+ "WHERE table_schema='main' AND table_name='schema_migrations'"
171
+ )
172
+ assert rows == []
173
+ finally:
174
+ db.close()
175
+
176
+
177
+ def test_database_init_migration_failure_propagates(
178
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
179
+ ) -> None:
180
+ """A failing migration must bubble out of Database() as a hard error so
181
+ callers can't proceed against a half-migrated schema."""
182
+ from deeptrade.core import db as db_module
183
+
184
+ boom = RuntimeError("synthetic migration failure")
185
+
186
+ def _failing(_db: Database) -> list[str]:
187
+ raise boom
188
+
189
+ monkeypatch.setattr(db_module, "apply_core_migrations", _failing)
190
+
191
+ with pytest.raises(RuntimeError, match="synthetic migration failure"):
192
+ Database(tmp_path / "broken.duckdb")
193
+
194
+
195
+ def test_legacy_tushare_cache_wiped_on_first_open(tmp_path: Path) -> None:
196
+ """v0.4.1 introduced a wrapped tushare cache payload format and a
197
+ migration that drops the legacy table. Auto-migrate must apply that
198
+ migration on first open so a pre-0.4.1 DB doesn't poison the v0.4.1+
199
+ reader. Regression-locks the TypeError reported against
200
+ `deeptrade limit-up-board lgb train`.
201
+ """
202
+ db_file = tmp_path / "legacy.duckdb"
203
+
204
+ # Stage 1: simulate a pre-0.4.1 DB — only the very first migration is on
205
+ # record, and the legacy cache table has a bare-array payload row.
206
+ db = Database(db_file, auto_migrate=False)
207
+ try:
208
+ # Apply only the v0.4.0 migration (init schema) by name; intentionally
209
+ # leave the v0.4.1 drop migration unrecorded.
210
+ from deeptrade.core.db import _list_core_migrations
211
+
212
+ for version, sql_text in _list_core_migrations():
213
+ if version != "20260509_001":
214
+ continue
215
+ with db.transaction():
216
+ db.execute(sql_text)
217
+ db.execute(
218
+ "INSERT INTO schema_migrations(version) VALUES (?)",
219
+ (version,),
220
+ )
221
+ db.execute(
222
+ "CREATE TABLE tushare_cache_blob ("
223
+ " plugin_id VARCHAR NOT NULL,"
224
+ " api_name VARCHAR NOT NULL,"
225
+ " trade_date VARCHAR NOT NULL,"
226
+ " params_hash VARCHAR NOT NULL,"
227
+ " payload_json VARCHAR NOT NULL,"
228
+ " cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
229
+ " PRIMARY KEY (plugin_id, api_name, trade_date, params_hash)"
230
+ ")"
231
+ )
232
+ db.execute(
233
+ "INSERT INTO tushare_cache_blob(plugin_id, api_name, trade_date, "
234
+ "params_hash, payload_json) VALUES (?, ?, ?, ?, ?)",
235
+ ("legacy-plugin", "trade_cal", "*", "0" * 64, '[{"cal_date":"20260101"}]'),
236
+ )
237
+ finally:
238
+ db.close()
239
+
240
+ # Stage 2: re-open with auto_migrate=True → the drop migration must run
241
+ # and the legacy table must be gone.
242
+ db = Database(db_file)
243
+ try:
244
+ rows = db.fetchall(
245
+ "SELECT table_name FROM information_schema.tables "
246
+ "WHERE table_schema='main' AND table_name='tushare_cache_blob'"
247
+ )
248
+ assert rows == [], "legacy tushare_cache_blob should be dropped by auto-migrate"
249
+ applied = {r[0] for r in db.fetchall("SELECT version FROM schema_migrations")}
250
+ assert "20260512_001" in applied
251
+ finally:
252
+ db.close()
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import warnings
8
9
  from datetime import datetime, timedelta
9
10
  from pathlib import Path
10
11
 
@@ -452,3 +453,36 @@ def test_cache_hit_requires_payload_present(
452
453
  client.call("limit_list_d", trade_date="20260427")
453
454
  # Should hit transport (because no payload despite state=ok)
454
455
  assert len(transport.calls) == 1
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # Cache payload round-trip — dtypes preserved, no pandas FutureWarning
460
+ # ---------------------------------------------------------------------------
461
+
462
+
463
+ def test_cache_payload_round_trip_preserves_dtypes(client: TushareClient) -> None:
464
+ """v0.4.1 wrapper format restores dtypes explicitly so plugins don't see
465
+ pandas' read_json date-heuristic warnings on tushare-style date columns."""
466
+ df = pd.DataFrame(
467
+ {
468
+ "trade_date": ["20260427", "20260428"], # YYYYMMDD strings — triggers heuristic
469
+ "ts_code": ["000001.SZ", "000002.SZ"],
470
+ "close": [12.5, 14.7],
471
+ "vol": [1000, 2000],
472
+ "is_st": [True, False],
473
+ "ann_dt": pd.to_datetime(["2026-04-27", "2026-04-28"]), # real datetime64[ns]
474
+ }
475
+ )
476
+ api, td, params = "limit_list_d", "20260427", {"trade_date": "20260427"}
477
+
478
+ client._write_cached(api, td, params, df) # noqa: SLF001 — exercising internal contract
479
+
480
+ with warnings.catch_warnings():
481
+ warnings.simplefilter("error", FutureWarning)
482
+ out = client._read_cached(api, td, params, fields=None) # noqa: SLF001
483
+
484
+ assert list(out.columns) == list(df.columns)
485
+ for col in df.columns:
486
+ assert out[col].dtype == df[col].dtype, f"dtype drift on {col}: {out[col].dtype}"
487
+ assert out["trade_date"].tolist() == ["20260427", "20260428"]
488
+ assert (out["ann_dt"] == df["ann_dt"]).all()
@@ -0,0 +1,77 @@
1
+ """Tests for ``deeptrade.plugins_api.errors`` — DEEPTRADE_DEBUG-aware
2
+ exception rendering for plugin dispatch tails."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import pytest
7
+
8
+ from deeptrade.plugins_api import debug_enabled, render_exception
9
+
10
+
11
+ def _make_chained_error() -> Exception:
12
+ """Build an exception with both ``__traceback__`` and ``__cause__`` set,
13
+ matching what a real `try/except ... raise X from e` would yield."""
14
+ try:
15
+ try:
16
+ raise ValueError("inner cause")
17
+ except ValueError as inner:
18
+ raise RuntimeError("outer failure") from inner
19
+ except RuntimeError as e:
20
+ return e
21
+
22
+
23
+ # --- default mode (no DEBUG) ---------------------------------------------
24
+
25
+
26
+ def test_render_exception_one_liner_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
27
+ monkeypatch.delenv("DEEPTRADE_DEBUG", raising=False)
28
+ try:
29
+ raise TypeError("list indices must be integers or slices, not str")
30
+ except TypeError as e:
31
+ out = render_exception(e)
32
+ assert out == "✘ TypeError: list indices must be integers or slices, not str"
33
+ assert "\n" not in out, "default mode must be a single line"
34
+
35
+
36
+ def test_render_exception_custom_glyph(monkeypatch: pytest.MonkeyPatch) -> None:
37
+ monkeypatch.delenv("DEEPTRADE_DEBUG", raising=False)
38
+ e = ValueError("bad input")
39
+ out = render_exception(e, header_glyph="!!")
40
+ assert out == "!! ValueError: bad input"
41
+
42
+
43
+ # --- DEBUG mode ----------------------------------------------------------
44
+
45
+
46
+ @pytest.mark.parametrize("truthy", ["1", "true", "yes", "on", "TRUE", "On"])
47
+ def test_render_exception_traceback_when_debug(
48
+ monkeypatch: pytest.MonkeyPatch, truthy: str
49
+ ) -> None:
50
+ monkeypatch.setenv("DEEPTRADE_DEBUG", truthy)
51
+ e = _make_chained_error()
52
+ out = render_exception(e)
53
+ # Header glyph still present
54
+ assert out.startswith("✘ ")
55
+ # Outer exception + message present
56
+ assert "RuntimeError: outer failure" in out
57
+ # Chained cause surfaced (this is the user-facing payoff over the one-liner)
58
+ assert "ValueError: inner cause" in out
59
+ # Traceback frames present (file + lineno)
60
+ assert "Traceback" in out
61
+ # No trailing newline — caller appends its own
62
+ assert not out.endswith("\n")
63
+
64
+
65
+ def test_render_exception_falsy_env_still_one_liner(monkeypatch: pytest.MonkeyPatch) -> None:
66
+ monkeypatch.setenv("DEEPTRADE_DEBUG", "0")
67
+ e = RuntimeError("x")
68
+ assert render_exception(e) == "✘ RuntimeError: x"
69
+
70
+
71
+ def test_debug_enabled_reflects_env(monkeypatch: pytest.MonkeyPatch) -> None:
72
+ monkeypatch.delenv("DEEPTRADE_DEBUG", raising=False)
73
+ assert debug_enabled() is False
74
+ monkeypatch.setenv("DEEPTRADE_DEBUG", "1")
75
+ assert debug_enabled() is True
76
+ monkeypatch.setenv("DEEPTRADE_DEBUG", "0")
77
+ assert debug_enabled() is False
@@ -1,127 +0,0 @@
1
- """Core schema invariants — framework-only tables (Plan A pure isolation).
2
-
3
- After the v0.5 reshape the framework owns ONLY:
4
- app_config, secret_store, schema_migrations,
5
- plugins, plugin_tables, plugin_schema_migrations,
6
- llm_calls, tushare_sync_state, tushare_calls
7
-
8
- All business / strategy data is plugin-owned.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from pathlib import Path
14
-
15
- import pytest
16
- from typer.testing import CliRunner
17
-
18
- from deeptrade.cli import app
19
- from deeptrade.core.db import Database, apply_core_migrations
20
-
21
-
22
- @pytest.fixture
23
- def fresh_db(tmp_path: Path) -> Database:
24
- return Database(tmp_path / "test.duckdb")
25
-
26
-
27
- # --- init creates DB + dirs ----------------------------------------------
28
-
29
-
30
- def test_init_creates_db_file_and_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
31
- monkeypatch.setenv("DEEPTRADE_HOME", str(tmp_path))
32
- runner = CliRunner()
33
- result = runner.invoke(app, ["init", "--no-prompts"])
34
- assert result.exit_code == 0, result.stdout
35
- assert (tmp_path / "deeptrade.duckdb").is_file()
36
- assert (tmp_path / "logs").is_dir()
37
-
38
-
39
- def test_init_is_idempotent(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
40
- monkeypatch.setenv("DEEPTRADE_HOME", str(tmp_path))
41
- runner = CliRunner()
42
- runner.invoke(app, ["init", "--no-prompts"])
43
- result = runner.invoke(app, ["init", "--no-prompts"])
44
- assert result.exit_code == 0
45
-
46
-
47
- # --- migrations record version --------------------------------------------
48
-
49
-
50
- def test_apply_core_migrations_records_version(fresh_db: Database) -> None:
51
- """Framework owns no business tables."""
52
- applied = apply_core_migrations(fresh_db)
53
- assert applied == ["20260509_001"]
54
- rows = fresh_db.fetchall("SELECT version FROM schema_migrations ORDER BY version")
55
- assert tuple(rows) == (("20260509_001",),)
56
-
57
-
58
- def test_apply_core_migrations_skips_applied_versions(fresh_db: Database) -> None:
59
- apply_core_migrations(fresh_db)
60
- second = apply_core_migrations(fresh_db)
61
- assert second == []
62
-
63
-
64
- # --- framework tables exist; business tables do NOT -----------------------
65
-
66
-
67
- def test_framework_owns_only_minimal_table_set(fresh_db: Database) -> None:
68
- apply_core_migrations(fresh_db)
69
- tables = {
70
- r[0]
71
- for r in fresh_db.fetchall(
72
- "SELECT table_name FROM information_schema.tables WHERE table_schema='main'"
73
- )
74
- }
75
- expected = {
76
- "app_config",
77
- "secret_store",
78
- "schema_migrations",
79
- "plugins",
80
- "plugin_tables",
81
- "plugin_schema_migrations",
82
- "llm_calls",
83
- "tushare_sync_state",
84
- "tushare_calls",
85
- }
86
- assert tables == expected, f"unexpected drift: {tables - expected} missing: {expected - tables}"
87
-
88
-
89
- # --- tushare_sync_state has plugin_id in PK (Plan A pure isolation) -------
90
-
91
-
92
- def test_tushare_sync_state_has_plugin_id_column_and_pk(fresh_db: Database) -> None:
93
- apply_core_migrations(fresh_db)
94
- cols = [
95
- r[0]
96
- for r in fresh_db.fetchall(
97
- "SELECT column_name FROM information_schema.columns "
98
- "WHERE table_name='tushare_sync_state' ORDER BY ordinal_position"
99
- )
100
- ]
101
- # plugin_id is the first PK column
102
- assert cols[0] == "plugin_id"
103
- assert "data_completeness" in cols
104
-
105
-
106
- def test_tushare_sync_state_default_data_completeness(fresh_db: Database) -> None:
107
- apply_core_migrations(fresh_db)
108
- fresh_db.execute(
109
- "INSERT INTO tushare_sync_state(plugin_id, api_name, trade_date, status) "
110
- "VALUES (?, ?, ?, ?)",
111
- ("test-plugin", "stock_basic", "*", "ok"),
112
- )
113
- row = fresh_db.fetchone(
114
- "SELECT data_completeness FROM tushare_sync_state WHERE api_name='stock_basic'"
115
- )
116
- assert row is not None and row[0] == "final"
117
-
118
-
119
- def test_tushare_calls_has_plugin_id_column(fresh_db: Database) -> None:
120
- apply_core_migrations(fresh_db)
121
- cols = {
122
- r[0]
123
- for r in fresh_db.fetchall(
124
- "SELECT column_name FROM information_schema.columns WHERE table_name='tushare_calls'"
125
- )
126
- }
127
- assert "plugin_id" in cols
File without changes