deeptrade-quant 0.0.2__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/.gitignore +3 -0
  2. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/PKG-INFO +2 -1
  3. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/__init__.py +1 -1
  4. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +2 -2
  5. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli.py +33 -6
  6. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli_config.py +66 -2
  7. deeptrade_quant-0.1.0/deeptrade/cli_plugin.py +330 -0
  8. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/config.py +86 -3
  9. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/config_migrations.py +44 -0
  10. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/db.py +4 -0
  11. deeptrade_quant-0.1.0/deeptrade/core/github_fetch.py +218 -0
  12. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/llm_manager.py +24 -7
  13. deeptrade_quant-0.0.2/deeptrade/core/migrations/core/20260427_001_init.sql → deeptrade_quant-0.1.0/deeptrade/core/migrations/core/20260509_001_init.sql +2 -10
  14. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/notifier.py +4 -2
  15. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/plugin_manager.py +40 -7
  16. deeptrade_quant-0.1.0/deeptrade/core/plugin_source.py +194 -0
  17. deeptrade_quant-0.1.0/deeptrade/core/registry.py +191 -0
  18. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +3 -21
  19. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +7 -15
  20. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql → deeptrade_quant-0.1.0/deeptrade/strategies_builtin/limit_up_board/migrations/20260509_001_init.sql +57 -19
  21. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +3 -9
  22. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql → deeptrade_quant-0.1.0/deeptrade/strategies_builtin/volume_anomaly/migrations/20260509_001_init.sql +35 -18
  23. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +6 -7
  24. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +5 -2
  25. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +1 -1
  26. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +6 -12
  27. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/pyproject.toml +2 -1
  28. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/test_config_cmd.py +1 -0
  29. deeptrade_quant-0.1.0/tests/cli/test_plugin_cmd.py +320 -0
  30. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/test_routing.py +0 -6
  31. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_config.py +101 -0
  32. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_config_migrations.py +57 -0
  33. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_db.py +3 -8
  34. deeptrade_quant-0.1.0/tests/core/test_github_fetch.py +206 -0
  35. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_llm_manager.py +33 -0
  36. deeptrade_quant-0.1.0/tests/core/test_plugin_source.py +256 -0
  37. deeptrade_quant-0.1.0/tests/core/test_plugin_upgrade.py +142 -0
  38. deeptrade_quant-0.1.0/tests/core/test_registry.py +261 -0
  39. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_candidate_features.py +0 -1
  40. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_prompt_consistency.py +0 -1
  41. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_realized_returns.py +0 -2
  42. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_screen_rules.py +0 -1
  43. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_stats_query.py +0 -1
  44. deeptrade_quant-0.0.2/deeptrade/cli_plugin.py +0 -176
  45. deeptrade_quant-0.0.2/deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +0 -10
  46. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +0 -8
  47. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +0 -36
  48. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +0 -18
  49. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +0 -46
  50. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +0 -53
  51. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +0 -17
  52. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +0 -44
  53. deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +0 -13
  54. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/LICENSE +0 -0
  55. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/README.md +0 -0
  56. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/__init__.py +0 -0
  57. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/__init__.py +0 -0
  58. /deeptrade_quant-0.0.2/deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql → /deeptrade_quant-0.1.0/deeptrade/channels_builtin/stdout/migrations/20260509_001_init.sql +0 -0
  59. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
  60. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/stdout_channel/channel.py +0 -0
  61. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli_data.py +0 -0
  62. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/__init__.py +0 -0
  63. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/llm_client.py +0 -0
  64. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/logging_config.py +0 -0
  65. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/migrations/__init__.py +0 -0
  66. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/migrations/core/__init__.py +0 -0
  67. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/paths.py +0 -0
  68. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/run_status.py +0 -0
  69. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/secrets.py +0 -0
  70. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/tushare_client.py +0 -0
  71. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/__init__.py +0 -0
  72. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/base.py +0 -0
  73. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/channel.py +0 -0
  74. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/events.py +0 -0
  75. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/llm.py +0 -0
  76. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/metadata.py +0 -0
  77. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/notify.py +0 -0
  78. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/__init__.py +0 -0
  79. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
  80. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
  81. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +0 -0
  82. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +0 -0
  83. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +0 -0
  84. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +0 -0
  85. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +0 -0
  86. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +0 -0
  87. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +0 -0
  88. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +0 -0
  89. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +0 -0
  90. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +0 -0
  91. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +0 -0
  92. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
  93. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
  94. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +0 -0
  95. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +0 -0
  96. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +0 -0
  97. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +0 -0
  98. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +0 -0
  99. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +0 -0
  100. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +0 -0
  101. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +0 -0
  102. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +0 -0
  103. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/theme.py +0 -0
  104. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/__init__.py +0 -0
  105. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/__init__.py +0 -0
  106. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/conftest.py +0 -0
  107. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/__init__.py +0 -0
  108. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_llm_client.py +0 -0
  109. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_notifier.py +0 -0
  110. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_paths.py +0 -0
  111. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_plugin_install.py +0 -0
  112. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_secrets.py +0 -0
  113. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_tushare_client.py +0 -0
  114. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/__init__.py +0 -0
  115. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/test_notify.py +0 -0
  116. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/test_protocol.py +0 -0
  117. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/__init__.py +0 -0
  118. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/__init__.py +0 -0
  119. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_phase_a_factors.py +0 -0
  120. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_phase_b_factors.py +0 -0
  121. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_v04_settings.py +0 -0
  122. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/__init__.py +0 -0
  123. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_alpha_features.py +0 -0
  124. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_dimension_scores.py +0 -0
  125. {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/test_smoke.py +0 -0
@@ -28,6 +28,9 @@ htmlcov/
28
28
  *.swp
29
29
  *.swo
30
30
 
31
+ # Claude Code workspace
32
+ .claude/
33
+
31
34
  # Environment
32
35
  .env
33
36
  .env.local
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deeptrade-quant
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI
5
5
  Project-URL: Homepage, https://github.com/ty19880929/deeptrade
6
6
  Project-URL: Repository, https://github.com/ty19880929/deeptrade
@@ -14,6 +14,7 @@ Requires-Dist: click>=8.1
14
14
  Requires-Dist: duckdb>=1.0
15
15
  Requires-Dist: keyring>=25.0
16
16
  Requires-Dist: openai>=1.0
17
+ Requires-Dist: packaging>=23.0
17
18
  Requires-Dist: pandas>=2.2
18
19
  Requires-Dist: pydantic>=2.7
19
20
  Requires-Dist: pyyaml>=6.0
@@ -4,5 +4,5 @@ from __future__ import annotations
4
4
 
5
5
  from deeptrade.core.notifier import notification_session, notify
6
6
 
7
- __version__ = "0.0.1"
7
+ __version__ = "0.1.0"
8
8
  __all__ = ["__version__", "notification_session", "notify"]
@@ -15,8 +15,8 @@ permissions:
15
15
  llm_tools: false
16
16
 
17
17
  migrations:
18
- - version: "20260429_001"
19
- file: migrations/20260429_001_init.sql
18
+ - version: "20260509_001"
19
+ file: migrations/20260509_001_init.sql
20
20
  checksum: "sha256:7317d23d4ac91b9a1259e2ba4d990863491de8fe1115e19052b02f83939399cc"
21
21
 
22
22
  tables:
@@ -184,14 +184,8 @@ def init(
184
184
  fresh = not db_file.exists()
185
185
  db = Database(db_file)
186
186
  try:
187
- applied = apply_core_migrations(db)
188
187
  if fresh:
189
188
  typer.echo(f"✔ Database created: {db_file}")
190
- if applied:
191
- for v in applied:
192
- typer.echo(f"✔ Schema applied: {v}")
193
- else:
194
- typer.echo("✔ Database already initialized; schema up-to-date")
195
189
  finally:
196
190
  db.close()
197
191
 
@@ -210,5 +204,38 @@ def init(
210
204
  cmd_set_llm()
211
205
 
212
206
 
207
+
208
+ @app.command(name="db", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
209
+ def db_cmd(ctx: click.Context) -> None:
210
+ """Database migration and management commands (legacy stub; use `deeptrade db init` via group if added)."""
211
+ pass
212
+
213
+ db_app = typer.Typer(name="db", help="Database migration and management commands.")
214
+ app.add_typer(db_app, name="db")
215
+
216
+ @db_app.command("init")
217
+ def db_init() -> None:
218
+ """Initialize the core database tables and apply migrations."""
219
+ paths.ensure_layout()
220
+ db_file = paths.db_path()
221
+ fresh = not db_file.exists()
222
+ db = Database(db_file)
223
+ try:
224
+ applied = apply_core_migrations(db)
225
+ if fresh:
226
+ typer.echo(f"✔ Database created: {db_file}")
227
+ if applied:
228
+ for v in applied:
229
+ typer.echo(f"✔ Schema applied: {v}")
230
+ else:
231
+ typer.echo("✔ Database already initialized; schema up-to-date")
232
+ finally:
233
+ db.close()
234
+
235
+ @db_app.command("upgrade")
236
+ def db_upgrade() -> None:
237
+ """Apply any pending core migrations."""
238
+ db_init()
239
+
213
240
  if __name__ == "__main__":
214
241
  app()
@@ -191,13 +191,35 @@ def _set_llm_new(svc: ConfigService) -> None:
191
191
  typer.echo(f"Provider {name!r} already exists; pick edit instead.")
192
192
  raise typer.Exit(2)
193
193
  default_base = _DEFAULT_BASE_URLS.get(name.split("-")[0], "")
194
- _prompt_and_save_provider(svc, name, defaults=None, default_base_url=default_base)
194
+ # Adding into an empty dict auto-promotes to default; offer the choice
195
+ # only when a default is already in place.
196
+ promote_default: bool | None = None
197
+ if cfg.llm_providers:
198
+ promote_default = bool(
199
+ questionary.confirm(
200
+ f"Set {name!r} as the default LLM provider?",
201
+ default=False,
202
+ ).ask()
203
+ )
204
+ _prompt_and_save_provider(
205
+ svc,
206
+ name,
207
+ defaults=None,
208
+ default_base_url=default_base,
209
+ is_default=promote_default,
210
+ )
195
211
 
196
212
 
197
213
  def _set_llm_edit(svc: ConfigService, name: str) -> None:
198
214
  cfg = svc.get_app_config()
199
215
  cur = cfg.llm_providers[name]
200
- _prompt_and_save_provider(svc, name, defaults=cur.model_dump(), default_base_url=cur.base_url)
216
+ _prompt_and_save_provider(
217
+ svc,
218
+ name,
219
+ defaults=cur.model_dump(),
220
+ default_base_url=cur.base_url,
221
+ is_default=None,
222
+ )
201
223
 
202
224
 
203
225
  def _prompt_and_save_provider(
@@ -206,6 +228,7 @@ def _prompt_and_save_provider(
206
228
  *,
207
229
  defaults: dict | None,
208
230
  default_base_url: str,
231
+ is_default: bool | None = None,
209
232
  ) -> None:
210
233
  base_url_default = defaults.get("base_url", default_base_url) if defaults else default_base_url
211
234
  model_default = defaults.get("model", "") if defaults else ""
@@ -251,6 +274,7 @@ def _prompt_and_save_provider(
251
274
  model=model,
252
275
  timeout=timeout,
253
276
  api_key=api_key if api_key else None,
277
+ is_default=is_default,
254
278
  )
255
279
  except ValueError as e:
256
280
  typer.echo(f"Invalid provider: {e}")
@@ -274,8 +298,48 @@ def _set_llm_delete(svc: ConfigService, existing: list[str]) -> None:
274
298
  ).ask()
275
299
  if not confirm:
276
300
  raise typer.Exit(1)
301
+ prior_default = svc.get_default_llm_provider()
277
302
  svc.delete_llm_provider(name)
278
303
  typer.echo(f"✔ Deleted LLM provider {name!r}")
304
+ new_default = svc.get_default_llm_provider()
305
+ if prior_default == name and new_default is not None:
306
+ typer.echo(f"✔ Default LLM provider auto-switched to {new_default!r}")
307
+
308
+
309
+ @app.command("set-default-llm")
310
+ def cmd_set_default_llm(
311
+ name: str = typer.Argument(..., help="Provider name to mark as default."),
312
+ ) -> None:
313
+ """Mark ``name`` as the default LLM provider.
314
+
315
+ The default is consumed by ``LLMManager.get_client()`` when callers
316
+ don't name a provider (non-debate plugin path). Switching default
317
+ clears the flag on every other provider so the
318
+ "exactly-one-default" invariant holds.
319
+ """
320
+ db, svc = _open_service()
321
+ try:
322
+ cfg = svc.get_app_config()
323
+ provider = cfg.llm_providers.get(name)
324
+ if provider is None:
325
+ typer.echo(
326
+ f"Unknown LLM provider: {name!r}; configured providers: "
327
+ + (", ".join(sorted(cfg.llm_providers.keys())) or "(none)")
328
+ )
329
+ raise typer.Exit(2)
330
+ if provider.is_default:
331
+ typer.echo(f"{name!r} is already the default LLM provider")
332
+ return
333
+ svc.set_llm_provider(
334
+ name,
335
+ base_url=provider.base_url,
336
+ model=provider.model,
337
+ timeout=provider.timeout,
338
+ is_default=True,
339
+ )
340
+ typer.echo(f"✔ Default LLM provider set to {name!r}")
341
+ finally:
342
+ db.close()
279
343
 
280
344
 
281
345
  @app.command("list-llm")
@@ -0,0 +1,330 @@
1
+ """`deeptrade plugin` subcommand group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import questionary
6
+ import typer
7
+ import yaml
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from deeptrade.core import paths
12
+ from deeptrade.core.db import Database
13
+ from deeptrade.core.github_fetch import (
14
+ GitHubFetchError,
15
+ NoMatchingReleaseError,
16
+ TarballFetchError,
17
+ )
18
+ from deeptrade.core.plugin_manager import (
19
+ PluginInstallError,
20
+ PluginManager,
21
+ PluginNotFoundError,
22
+ UpgradeNoop,
23
+ _load_metadata_yaml,
24
+ summarize_for_install,
25
+ )
26
+ from deeptrade.core.plugin_source import (
27
+ ResolvedSource,
28
+ SourceResolveError,
29
+ SourceResolver,
30
+ )
31
+ from deeptrade.core.registry import (
32
+ RegistryClient,
33
+ RegistryFetchError,
34
+ RegistryNotFoundError,
35
+ RegistrySchemaError,
36
+ )
37
+
38
+ app = typer.Typer(help="Install / manage plugins", no_args_is_help=True)
39
+
40
+ _RESOLVE_ERRORS: tuple[type[Exception], ...] = (
41
+ RegistryNotFoundError,
42
+ RegistryFetchError,
43
+ RegistrySchemaError,
44
+ NoMatchingReleaseError,
45
+ TarballFetchError,
46
+ GitHubFetchError,
47
+ SourceResolveError,
48
+ )
49
+
50
+
51
+ def _open() -> tuple[Database, PluginManager]:
52
+ db = Database(paths.db_path())
53
+ return db, PluginManager(db)
54
+
55
+
56
+ def _format_origin(resolved: ResolvedSource) -> str:
57
+ d = resolved.origin_detail
58
+ if resolved.origin == "local":
59
+ return f"本地路径 ({d.get('local_path', resolved.path)})"
60
+ if resolved.origin == "github_registry":
61
+ return (
62
+ f"GitHub 注册表 ({d['repo']}@{d['ref']}, subdir={d['subdir']})"
63
+ )
64
+ if resolved.origin == "github_url":
65
+ return f"GitHub URL ({d['repo']}@{d['ref']})"
66
+ return resolved.origin
67
+
68
+
69
+ @app.command("install")
70
+ def cmd_install(
71
+ source: str = typer.Argument(
72
+ ..., help="短名(注册表)/ 本地路径 / GitHub URL"
73
+ ),
74
+ ref: str | None = typer.Option(
75
+ None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
76
+ ),
77
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt"),
78
+ ) -> None:
79
+ """Install a plugin from the registry, a GitHub URL, or a local directory."""
80
+ resolver = SourceResolver()
81
+ try:
82
+ resolved = resolver.resolve(source, ref)
83
+ except _RESOLVE_ERRORS as e:
84
+ typer.echo(f"✘ {e}")
85
+ raise typer.Exit(2) from e
86
+
87
+ try:
88
+ try:
89
+ meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
90
+ except PluginInstallError as e:
91
+ typer.echo(f"✘ {e}")
92
+ raise typer.Exit(2) from e
93
+
94
+ typer.echo("─── 即将安装 ─────────────────────────────")
95
+ typer.echo(f"来源: {_format_origin(resolved)}")
96
+ typer.echo(summarize_for_install(meta, resolved.path))
97
+ typer.echo("──────────────────────────────────────────")
98
+ if not yes:
99
+ ok = questionary.confirm("确认安装?", default=False).ask()
100
+ if not ok:
101
+ typer.echo("Aborted.")
102
+ raise typer.Exit(1)
103
+
104
+ db, mgr = _open()
105
+ try:
106
+ rec = mgr.install(resolved.path)
107
+ except PluginInstallError as e:
108
+ typer.echo(f"✘ Install failed: {e}")
109
+ raise typer.Exit(2) from e
110
+ finally:
111
+ db.close()
112
+
113
+ typer.echo(f"✔ 已安装: {rec.plugin_id} v{rec.version}")
114
+ finally:
115
+ if resolved.cleanup is not None:
116
+ resolved.cleanup()
117
+
118
+
119
+ @app.command("list")
120
+ def cmd_list() -> None:
121
+ """List installed plugins."""
122
+ db, mgr = _open()
123
+ try:
124
+ records = mgr.list_all()
125
+ finally:
126
+ db.close()
127
+
128
+ console = Console()
129
+ table = Table(title="Installed Plugins")
130
+ table.add_column("plugin_id", style="cyan")
131
+ table.add_column("name")
132
+ table.add_column("version")
133
+ table.add_column("enabled", style="green")
134
+ if not records:
135
+ typer.echo("(no plugins installed)")
136
+ return
137
+ for r in records:
138
+ table.add_row(r.plugin_id, r.name, r.version, "yes" if r.enabled else "no")
139
+ console.print(table)
140
+
141
+
142
+ @app.command("info")
143
+ def cmd_info(plugin_id: str = typer.Argument(...)) -> None:
144
+ """Show metadata for a plugin.
145
+
146
+ If installed locally: shows the full installed metadata.yaml.
147
+ If not installed: falls back to the registry entry (with an install hint).
148
+ """
149
+ db, mgr = _open()
150
+ try:
151
+ try:
152
+ rec = mgr.info(plugin_id)
153
+ typer.echo(
154
+ yaml.safe_dump(rec.metadata.model_dump(mode="json"), allow_unicode=True)
155
+ )
156
+ return
157
+ except PluginNotFoundError:
158
+ pass # fall through to registry lookup
159
+ finally:
160
+ db.close()
161
+
162
+ try:
163
+ entry = RegistryClient().resolve(plugin_id)
164
+ except RegistryNotFoundError as e:
165
+ typer.echo(f"✘ {plugin_id} 既未安装,也不在注册表中")
166
+ raise typer.Exit(2) from e
167
+ except (RegistryFetchError, RegistrySchemaError) as e:
168
+ typer.echo(f"✘ {plugin_id} 未安装;查询注册表失败: {e}")
169
+ raise typer.Exit(2) from e
170
+
171
+ typer.echo(f"(未安装) {entry.plugin_id}")
172
+ typer.echo(f" name: {entry.name}")
173
+ typer.echo(f" type: {entry.type}")
174
+ typer.echo(f" description: {entry.description}")
175
+ typer.echo(f" repo: {entry.repo}")
176
+ typer.echo(f" subdir: {entry.subdir}")
177
+ typer.echo(f" install: deeptrade plugin install {entry.plugin_id}")
178
+
179
+
180
+ @app.command("disable")
181
+ def cmd_disable(plugin_id: str = typer.Argument(...)) -> None:
182
+ db, mgr = _open()
183
+ try:
184
+ try:
185
+ mgr.disable(plugin_id)
186
+ except PluginNotFoundError as e:
187
+ typer.echo(f"✘ {plugin_id} not installed")
188
+ raise typer.Exit(2) from e
189
+ typer.echo(f"✔ disabled: {plugin_id}")
190
+ finally:
191
+ db.close()
192
+
193
+
194
+ @app.command("enable")
195
+ def cmd_enable(plugin_id: str = typer.Argument(...)) -> None:
196
+ db, mgr = _open()
197
+ try:
198
+ try:
199
+ mgr.enable(plugin_id)
200
+ except PluginNotFoundError as e:
201
+ typer.echo(f"✘ {plugin_id} not installed")
202
+ raise typer.Exit(2) from e
203
+ typer.echo(f"✔ enabled: {plugin_id}")
204
+ finally:
205
+ db.close()
206
+
207
+
208
+ @app.command("uninstall")
209
+ def cmd_uninstall(
210
+ plugin_id: str = typer.Argument(...),
211
+ purge: bool = typer.Option(False, "--purge", help="DROP plugin tables and forget all data"),
212
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
213
+ ) -> None:
214
+ """Uninstall a plugin. Default: keep tables (just disable). --purge: drop tables."""
215
+ db, mgr = _open()
216
+ try:
217
+ try:
218
+ rec = mgr.info(plugin_id)
219
+ except PluginNotFoundError as e:
220
+ typer.echo(f"✘ {plugin_id} not installed")
221
+ raise typer.Exit(2) from e
222
+
223
+ if purge and not yes:
224
+ tables = [t.name for t in rec.metadata.tables if t.purge_on_uninstall]
225
+ typer.echo(f"将删除以下表(不可恢复): {tables}")
226
+ ok = questionary.confirm("确认 --purge?", default=False).ask()
227
+ if not ok:
228
+ typer.echo("Aborted.")
229
+ raise typer.Exit(1)
230
+
231
+ result = mgr.uninstall(plugin_id, purge=purge)
232
+ action = "purged" if purge else "disabled"
233
+ typer.echo(f"✔ {action}: {plugin_id} (dropped tables: {result['purged_tables']})")
234
+ finally:
235
+ db.close()
236
+
237
+
238
+ @app.command("upgrade")
239
+ def cmd_upgrade(
240
+ source: str = typer.Argument(
241
+ ..., help="短名(注册表)/ 本地路径 / GitHub URL"
242
+ ),
243
+ ref: str | None = typer.Option(
244
+ None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
245
+ ),
246
+ ) -> None:
247
+ """Upgrade an installed plugin.
248
+
249
+ Exit codes:
250
+ 0 — upgraded, or already at the candidate version
251
+ 2 — not installed / candidate < installed (downgrade forbidden) /
252
+ network or registry failure
253
+ """
254
+ resolver = SourceResolver()
255
+ try:
256
+ resolved = resolver.resolve(source, ref)
257
+ except _RESOLVE_ERRORS as e:
258
+ typer.echo(f"✘ {e}")
259
+ raise typer.Exit(2) from e
260
+
261
+ try:
262
+ db, mgr = _open()
263
+ try:
264
+ try:
265
+ result = mgr.upgrade(resolved.path)
266
+ except PluginNotFoundError as e:
267
+ try:
268
+ meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
269
+ pid = meta.plugin_id
270
+ except PluginInstallError:
271
+ pid = source
272
+ typer.echo(
273
+ f'✘ 插件 "{pid}" 未安装,请先执行 deeptrade plugin install'
274
+ )
275
+ raise typer.Exit(2) from e
276
+ except PluginInstallError as e:
277
+ typer.echo(f"✘ Upgrade failed: {e}")
278
+ raise typer.Exit(2) from e
279
+
280
+ if isinstance(result, UpgradeNoop):
281
+ typer.echo(f"已是最新版本 v{result.version}")
282
+ return
283
+ typer.echo(f"✔ upgraded: {result.plugin_id} → v{result.version}")
284
+ finally:
285
+ db.close()
286
+ finally:
287
+ if resolved.cleanup is not None:
288
+ resolved.cleanup()
289
+
290
+
291
+ @app.command("search")
292
+ def cmd_search(
293
+ keyword: str | None = typer.Argument(
294
+ None, help="可选过滤关键词(匹配 plugin_id / name / description)"
295
+ ),
296
+ no_cache: bool = typer.Option(
297
+ False, "--no-cache", help="强制刷新注册表(旁路 ETag 缓存)"
298
+ ),
299
+ ) -> None:
300
+ """List plugins available in the official registry."""
301
+ try:
302
+ registry = RegistryClient().fetch(force=no_cache)
303
+ except (RegistryFetchError, RegistrySchemaError) as e:
304
+ typer.echo(f"✘ {e}")
305
+ raise typer.Exit(2) from e
306
+
307
+ rows = list(registry.plugins.values())
308
+ if keyword:
309
+ kw = keyword.lower()
310
+ rows = [
311
+ entry
312
+ for entry in rows
313
+ if kw in entry.plugin_id.lower()
314
+ or kw in entry.name.lower()
315
+ or kw in entry.description.lower()
316
+ ]
317
+
318
+ if not rows:
319
+ typer.echo("(no plugins matched)" if keyword else "(registry is empty)")
320
+ return
321
+
322
+ console = Console()
323
+ table = Table(title="Available Plugins")
324
+ table.add_column("plugin_id", style="cyan")
325
+ table.add_column("name")
326
+ table.add_column("type", style="green")
327
+ table.add_column("description")
328
+ for entry in sorted(rows, key=lambda x: x.plugin_id):
329
+ table.add_row(entry.plugin_id, entry.name, entry.type, entry.description)
330
+ console.print(table)
@@ -51,12 +51,20 @@ class LLMProviderConfig(BaseModel):
51
51
 
52
52
  The api_key is NOT stored here; it lives in ``secret_store`` under the
53
53
  key ``llm.<name>.api_key`` and is routed via ``is_secret_key()``.
54
+
55
+ ``is_default`` marks the provider used by ``LLMManager.get_client()``
56
+ when the caller does not name a provider (non-debate plugin path).
57
+ Invariant: while ``llm_providers`` is non-empty, exactly one entry
58
+ has ``is_default=True``. ``ConfigService.set_llm_provider`` /
59
+ ``delete_llm_provider`` maintain this invariant; legacy configs are
60
+ repaired by ``config_migrations``.
54
61
  """
55
62
 
56
63
  model_config = ConfigDict(extra="forbid")
57
64
  base_url: str
58
65
  model: str
59
66
  timeout: int = Field(default=180, ge=10)
67
+ is_default: bool = False
60
68
 
61
69
 
62
70
  class AppConfig(BaseModel):
@@ -317,9 +325,23 @@ class ConfigService:
317
325
  model: str,
318
326
  timeout: int = 180,
319
327
  api_key: str | None = None,
328
+ is_default: bool | None = None,
320
329
  ) -> None:
321
330
  """Insert or update a provider entry. If ``api_key`` is given, also
322
331
  persist it to secret_store under ``llm.<name>.api_key``.
332
+
333
+ Default-provider invariant (one entry has ``is_default=True`` while
334
+ ``llm.providers`` is non-empty) is maintained here:
335
+
336
+ * Adding the first provider auto-marks it default regardless of
337
+ the ``is_default`` argument.
338
+ * ``is_default=True`` on a non-first add or update promotes this
339
+ provider and clears the flag on every other entry.
340
+ * ``is_default=None`` on update preserves the existing flag (no
341
+ silent demotion); on a non-first add it defaults to False.
342
+ * ``is_default=False`` is honored only when another provider
343
+ already holds the default flag — never used to leave the dict
344
+ with zero defaults.
323
345
  """
324
346
  if not name or "." in name:
325
347
  raise ValueError(
@@ -327,18 +349,79 @@ class ConfigService:
327
349
  )
328
350
  current_raw = self.get("llm.providers")
329
351
  current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
330
- current[name] = {"base_url": base_url, "model": model, "timeout": timeout}
352
+ is_first = len(current) == 0
353
+ existing = dict(current.get(name) or {})
354
+ prior_default = bool(existing.get("is_default", False))
355
+
356
+ # Resolve the new is_default flag for this provider.
357
+ if is_first:
358
+ new_default = True
359
+ elif is_default is True:
360
+ new_default = True
361
+ elif is_default is None:
362
+ new_default = prior_default
363
+ else: # is_default is False
364
+ # Only honor an explicit demotion if some other provider is
365
+ # already default; otherwise we'd leave the dict with no
366
+ # default at all. Preserve prior_default in that case.
367
+ other_has_default = any(
368
+ k != name and bool((v or {}).get("is_default"))
369
+ for k, v in current.items()
370
+ )
371
+ new_default = prior_default if not other_has_default else False
372
+
373
+ # If this provider is becoming default, demote every other entry so
374
+ # the invariant "at most one default" holds.
375
+ if new_default:
376
+ for other_name, other_cfg in current.items():
377
+ if other_name != name and isinstance(other_cfg, dict) and other_cfg.get("is_default"):
378
+ current[other_name] = {**other_cfg, "is_default": False}
379
+
380
+ current[name] = {
381
+ "base_url": base_url,
382
+ "model": model,
383
+ "timeout": timeout,
384
+ "is_default": new_default,
385
+ }
331
386
  self.set("llm.providers", current)
332
387
  if api_key is not None:
333
388
  self.set(f"llm.{name}.api_key", api_key)
334
389
 
335
390
  def delete_llm_provider(self, name: str) -> None:
336
- """Remove a provider entry plus its api_key. Idempotent on missing name."""
391
+ """Remove a provider entry plus its api_key. Idempotent on missing name.
392
+
393
+ If the removed provider held ``is_default=True`` and ≥1 entry
394
+ survives, the first remaining entry (insertion order) is auto-
395
+ promoted to default to maintain the invariant.
396
+ """
337
397
  current_raw = self.get("llm.providers")
338
398
  current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
339
- current.pop(name, None)
399
+ removed = current.pop(name, None)
400
+ removed_was_default = bool(isinstance(removed, dict) and removed.get("is_default"))
401
+
402
+ if removed_was_default and current:
403
+ first_key = next(iter(current.keys()))
404
+ first_cfg = current[first_key]
405
+ if isinstance(first_cfg, dict):
406
+ current[first_key] = {**first_cfg, "is_default": True}
407
+
340
408
  if current:
341
409
  self.set("llm.providers", current)
342
410
  else:
343
411
  self.delete("llm.providers")
344
412
  self.delete(f"llm.{name}.api_key")
413
+
414
+ def get_default_llm_provider(self) -> str | None:
415
+ """Return the name of the provider with ``is_default=True``, or None
416
+ if no providers are configured.
417
+
418
+ Used by :class:`LLMManager.get_client` to resolve ``name=None`` calls
419
+ from non-debate plugins. Not exposed as a CLI; the CLI sets the
420
+ default via ``set-default-llm`` which routes through
421
+ :meth:`set_llm_provider`.
422
+ """
423
+ cfg = self.get_app_config()
424
+ for provider_name, provider in cfg.llm_providers.items():
425
+ if provider.is_default:
426
+ return provider_name
427
+ return None