tradingcodex 0.1.0__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 (254) hide show
  1. apps/__init__.py +1 -0
  2. apps/audit/__init__.py +1 -0
  3. apps/audit/admin.py +6 -0
  4. apps/audit/apps.py +8 -0
  5. apps/audit/migrations/0001_initial.py +35 -0
  6. apps/audit/migrations/__init__.py +0 -0
  7. apps/audit/models.py +22 -0
  8. apps/harness/__init__.py +1 -0
  9. apps/harness/admin.py +6 -0
  10. apps/harness/apps.py +8 -0
  11. apps/harness/migrations/0001_initial.py +35 -0
  12. apps/harness/migrations/__init__.py +0 -0
  13. apps/harness/models.py +22 -0
  14. apps/harness/templatetags/__init__.py +1 -0
  15. apps/integrations/__init__.py +1 -0
  16. apps/integrations/admin.py +6 -0
  17. apps/integrations/apps.py +8 -0
  18. apps/integrations/migrations/0001_initial.py +29 -0
  19. apps/integrations/migrations/__init__.py +0 -0
  20. apps/integrations/models.py +16 -0
  21. apps/integrations/services.py +31 -0
  22. apps/mcp/__init__.py +1 -0
  23. apps/mcp/admin.py +20 -0
  24. apps/mcp/apps.py +8 -0
  25. apps/mcp/migrations/0001_initial.py +168 -0
  26. apps/mcp/migrations/__init__.py +0 -0
  27. apps/mcp/models.py +154 -0
  28. apps/mcp/services.py +327 -0
  29. apps/orders/__init__.py +1 -0
  30. apps/orders/admin.py +6 -0
  31. apps/orders/apps.py +8 -0
  32. apps/orders/migrations/0001_initial.py +79 -0
  33. apps/orders/migrations/__init__.py +0 -0
  34. apps/orders/models.py +66 -0
  35. apps/orders/services.py +107 -0
  36. apps/policy/__init__.py +1 -0
  37. apps/policy/admin.py +6 -0
  38. apps/policy/apps.py +8 -0
  39. apps/policy/migrations/0001_initial.py +75 -0
  40. apps/policy/migrations/__init__.py +0 -0
  41. apps/policy/models.py +61 -0
  42. apps/policy/services.py +110 -0
  43. apps/portfolio/__init__.py +1 -0
  44. apps/portfolio/admin.py +6 -0
  45. apps/portfolio/apps.py +8 -0
  46. apps/portfolio/migrations/0001_initial.py +67 -0
  47. apps/portfolio/migrations/__init__.py +0 -0
  48. apps/portfolio/models.py +53 -0
  49. apps/research/__init__.py +1 -0
  50. apps/research/admin.py +1 -0
  51. apps/research/apps.py +8 -0
  52. apps/research/migrations/__init__.py +0 -0
  53. apps/research/models.py +1 -0
  54. apps/workflows/__init__.py +1 -0
  55. apps/workflows/admin.py +6 -0
  56. apps/workflows/apps.py +8 -0
  57. apps/workflows/migrations/0001_initial.py +51 -0
  58. apps/workflows/migrations/__init__.py +0 -0
  59. apps/workflows/models.py +44 -0
  60. tradingcodex-0.1.0.dist-info/METADATA +337 -0
  61. tradingcodex-0.1.0.dist-info/RECORD +254 -0
  62. tradingcodex-0.1.0.dist-info/WHEEL +5 -0
  63. tradingcodex-0.1.0.dist-info/entry_points.txt +2 -0
  64. tradingcodex-0.1.0.dist-info/licenses/LICENSE +202 -0
  65. tradingcodex-0.1.0.dist-info/licenses/NOTICE +24 -0
  66. tradingcodex-0.1.0.dist-info/top_level.txt +4 -0
  67. tradingcodex_cli/__init__.py +1 -0
  68. tradingcodex_cli/__main__.py +124 -0
  69. tradingcodex_cli/commands/__init__.py +2 -0
  70. tradingcodex_cli/commands/bootstrap.py +157 -0
  71. tradingcodex_cli/commands/db.py +36 -0
  72. tradingcodex_cli/commands/doctor.py +230 -0
  73. tradingcodex_cli/commands/mcp.py +186 -0
  74. tradingcodex_cli/commands/orders.py +89 -0
  75. tradingcodex_cli/commands/policy.py +21 -0
  76. tradingcodex_cli/commands/profile.py +110 -0
  77. tradingcodex_cli/commands/research.py +76 -0
  78. tradingcodex_cli/commands/skills.py +93 -0
  79. tradingcodex_cli/commands/strategies.py +67 -0
  80. tradingcodex_cli/commands/subagents.py +106 -0
  81. tradingcodex_cli/commands/utils.py +134 -0
  82. tradingcodex_cli/commands/workspaces.py +53 -0
  83. tradingcodex_cli/generator.py +234 -0
  84. tradingcodex_cli/mcp_stdio.py +26 -0
  85. tradingcodex_cli/service_autostart.py +127 -0
  86. tradingcodex_service/__init__.py +5 -0
  87. tradingcodex_service/admin.py +1 -0
  88. tradingcodex_service/api.py +486 -0
  89. tradingcodex_service/application/__init__.py +2 -0
  90. tradingcodex_service/application/agents.py +1470 -0
  91. tradingcodex_service/application/audit.py +71 -0
  92. tradingcodex_service/application/common.py +88 -0
  93. tradingcodex_service/application/components.py +299 -0
  94. tradingcodex_service/application/harness.py +747 -0
  95. tradingcodex_service/application/markdown_preview.py +179 -0
  96. tradingcodex_service/application/orders.py +404 -0
  97. tradingcodex_service/application/policy.py +150 -0
  98. tradingcodex_service/application/portfolio.py +166 -0
  99. tradingcodex_service/application/research.py +356 -0
  100. tradingcodex_service/application/runtime.py +321 -0
  101. tradingcodex_service/asgi.py +6 -0
  102. tradingcodex_service/mcp_http.py +59 -0
  103. tradingcodex_service/mcp_runtime.py +565 -0
  104. tradingcodex_service/settings.py +91 -0
  105. tradingcodex_service/templates/web/activity.html +24 -0
  106. tradingcodex_service/templates/web/agent_skills.html +111 -0
  107. tradingcodex_service/templates/web/agents.html +121 -0
  108. tradingcodex_service/templates/web/base.html +150 -0
  109. tradingcodex_service/templates/web/dashboard.html +109 -0
  110. tradingcodex_service/templates/web/fragments/role_inspector.html +81 -0
  111. tradingcodex_service/templates/web/fragments/starter_prompt.html +24 -0
  112. tradingcodex_service/templates/web/fragments/topology_canvas.html +85 -0
  113. tradingcodex_service/templates/web/harness.html +87 -0
  114. tradingcodex_service/templates/web/mcp_router.html +250 -0
  115. tradingcodex_service/templates/web/orders.html +68 -0
  116. tradingcodex_service/templates/web/policy.html +81 -0
  117. tradingcodex_service/templates/web/portfolio.html +52 -0
  118. tradingcodex_service/templates/web/research.html +73 -0
  119. tradingcodex_service/templates/web/starter_prompt.html +40 -0
  120. tradingcodex_service/templates/web/strategies.html +74 -0
  121. tradingcodex_service/urls.py +42 -0
  122. tradingcodex_service/version.py +1 -0
  123. tradingcodex_service/web.py +885 -0
  124. tradingcodex_service/wsgi.py +6 -0
  125. workspace_templates/__init__.py +1 -0
  126. workspace_templates/modules/audit/files/.tradingcodex/audit/README.md +5 -0
  127. workspace_templates/modules/audit/files/trading/audit/.gitkeep +1 -0
  128. workspace_templates/modules/audit/module.json +16 -0
  129. workspace_templates/modules/codex-base/files/.codex/config.toml +272 -0
  130. workspace_templates/modules/codex-base/files/.codex/hooks/tradingcodex_hook.py +173 -0
  131. workspace_templates/modules/codex-base/files/.codex/hooks.json +105 -0
  132. workspace_templates/modules/codex-base/files/.codex/prompts/base_instructions/head-manager.md +129 -0
  133. workspace_templates/modules/codex-base/files/.codex/rules/tradingcodex.rules +50 -0
  134. workspace_templates/modules/codex-base/files/.tradingcodex/capabilities.yaml +56 -0
  135. workspace_templates/modules/codex-base/files/.tradingcodex/cli.py +16 -0
  136. workspace_templates/modules/codex-base/files/.tradingcodex/config.yaml +53 -0
  137. workspace_templates/modules/codex-base/files/.tradingcodex/policies/policy-bindings.yaml +16 -0
  138. workspace_templates/modules/codex-base/files/.tradingcodex/policies/principals.yaml +17 -0
  139. workspace_templates/modules/codex-base/files/.tradingcodex/policies/roles.yaml +27 -0
  140. workspace_templates/modules/codex-base/files/AGENTS.md +56 -0
  141. workspace_templates/modules/codex-base/files/pyproject.toml +13 -0
  142. workspace_templates/modules/codex-base/files/tcx +35 -0
  143. workspace_templates/modules/codex-base/module.json +17 -0
  144. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/policies/access-policies.yaml +39 -0
  145. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/policies/restricted-list.yaml +6 -0
  146. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/approval_receipt.schema.json +14 -0
  147. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/audit_event.schema.json +11 -0
  148. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/evidence_pack.schema.json +16 -0
  149. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/execution_result.schema.json +12 -0
  150. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/fundamental_report.schema.json +15 -0
  151. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/news_report.schema.json +15 -0
  152. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/order_intent.schema.json +30 -0
  153. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/portfolio_review.schema.json +15 -0
  154. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/postmortem_report.schema.json +14 -0
  155. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/risk_report.schema.json +15 -0
  156. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/technical_report.schema.json +15 -0
  157. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/thesis.schema.json +15 -0
  158. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/schemas/valuation.schema.json +15 -0
  159. workspace_templates/modules/enforcement-guardrails/files/.tradingcodex/scripts/validate-order-intent.py +15 -0
  160. workspace_templates/modules/enforcement-guardrails/module.json +19 -0
  161. workspace_templates/modules/fixed-subagents/files/.codex/agents/execution-operator.toml +71 -0
  162. workspace_templates/modules/fixed-subagents/files/.codex/agents/fundamental-analyst.toml +62 -0
  163. workspace_templates/modules/fixed-subagents/files/.codex/agents/instrument-analyst.toml +64 -0
  164. workspace_templates/modules/fixed-subagents/files/.codex/agents/macro-analyst.toml +64 -0
  165. workspace_templates/modules/fixed-subagents/files/.codex/agents/news-analyst.toml +63 -0
  166. workspace_templates/modules/fixed-subagents/files/.codex/agents/portfolio-manager.toml +62 -0
  167. workspace_templates/modules/fixed-subagents/files/.codex/agents/risk-manager.toml +65 -0
  168. workspace_templates/modules/fixed-subagents/files/.codex/agents/technical-analyst.toml +63 -0
  169. workspace_templates/modules/fixed-subagents/files/.codex/agents/valuation-analyst.toml +59 -0
  170. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/head-manager.yaml +66 -0
  171. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/skill-change-proposals/.gitkeep +1 -0
  172. workspace_templates/modules/fixed-subagents/files/.tradingcodex/mainagent/subagent-registry.yaml +56 -0
  173. workspace_templates/modules/fixed-subagents/module.json +23 -0
  174. workspace_templates/modules/guidance-guardrails/files/.tradingcodex/guidance/guardrails.md +17 -0
  175. workspace_templates/modules/guidance-guardrails/files/.tradingcodex/guidance/task-quality-checklist.md +37 -0
  176. workspace_templates/modules/guidance-guardrails/module.json +18 -0
  177. workspace_templates/modules/information-barriers/files/.tradingcodex/policies/information-barriers.yaml +211 -0
  178. workspace_templates/modules/information-barriers/files/.tradingcodex/secrets.md +9 -0
  179. workspace_templates/modules/information-barriers/files/trading/approvals/.gitkeep +1 -0
  180. workspace_templates/modules/information-barriers/files/trading/market-data/.gitkeep +1 -0
  181. workspace_templates/modules/information-barriers/files/trading/orders/approved/.gitkeep +1 -0
  182. workspace_templates/modules/information-barriers/files/trading/orders/draft/.gitkeep +1 -0
  183. workspace_templates/modules/information-barriers/files/trading/orders/executed/.gitkeep +1 -0
  184. workspace_templates/modules/information-barriers/files/trading/orders/rejected/.gitkeep +1 -0
  185. workspace_templates/modules/information-barriers/files/trading/portfolio/.gitkeep +1 -0
  186. workspace_templates/modules/information-barriers/files/trading/reports/fundamental/.gitkeep +1 -0
  187. workspace_templates/modules/information-barriers/files/trading/reports/instrument/.gitkeep +1 -0
  188. workspace_templates/modules/information-barriers/files/trading/reports/macro/.gitkeep +1 -0
  189. workspace_templates/modules/information-barriers/files/trading/reports/news/.gitkeep +1 -0
  190. workspace_templates/modules/information-barriers/files/trading/reports/policy/.gitkeep +1 -0
  191. workspace_templates/modules/information-barriers/files/trading/reports/portfolio/.gitkeep +1 -0
  192. workspace_templates/modules/information-barriers/files/trading/reports/postmortem/.gitkeep +1 -0
  193. workspace_templates/modules/information-barriers/files/trading/reports/risk/.gitkeep +1 -0
  194. workspace_templates/modules/information-barriers/files/trading/reports/technical/.gitkeep +1 -0
  195. workspace_templates/modules/information-barriers/files/trading/reports/valuation/.gitkeep +1 -0
  196. workspace_templates/modules/information-barriers/files/trading/research/.gitkeep +1 -0
  197. workspace_templates/modules/information-barriers/module.json +19 -0
  198. workspace_templates/modules/paper-trading/files/.tradingcodex/mcp/adapters/paper-trading.py +4 -0
  199. workspace_templates/modules/paper-trading/module.json +16 -0
  200. workspace_templates/modules/postmortem/files/.tradingcodex/workflows/postmortem.yaml +12 -0
  201. workspace_templates/modules/postmortem/module.json +16 -0
  202. workspace_templates/modules/repo-skills/files/.agents/skills/investment-workflow-map/SKILL.md +106 -0
  203. workspace_templates/modules/repo-skills/files/.agents/skills/investment-workflow-map/agents/openai.yaml +6 -0
  204. workspace_templates/modules/repo-skills/files/.agents/skills/manage-optional-skills/SKILL.md +101 -0
  205. workspace_templates/modules/repo-skills/files/.agents/skills/manage-optional-skills/agents/openai.yaml +6 -0
  206. workspace_templates/modules/repo-skills/files/.agents/skills/manage-subagents/SKILL.md +140 -0
  207. workspace_templates/modules/repo-skills/files/.agents/skills/manage-subagents/agents/openai.yaml +6 -0
  208. workspace_templates/modules/repo-skills/files/.agents/skills/orchestrate-workflow/SKILL.md +140 -0
  209. workspace_templates/modules/repo-skills/files/.agents/skills/orchestrate-workflow/agents/openai.yaml +6 -0
  210. workspace_templates/modules/repo-skills/files/.agents/skills/postmortem/SKILL.md +31 -0
  211. workspace_templates/modules/repo-skills/files/.agents/skills/postmortem/agents/openai.yaml +6 -0
  212. workspace_templates/modules/repo-skills/files/.agents/skills/scenario-quality-gates/SKILL.md +138 -0
  213. workspace_templates/modules/repo-skills/files/.agents/skills/scenario-quality-gates/agents/openai.yaml +6 -0
  214. workspace_templates/modules/repo-skills/files/.agents/skills/strategy-creator/SKILL.md +109 -0
  215. workspace_templates/modules/repo-skills/files/.agents/skills/strategy-creator/agents/openai.yaml +6 -0
  216. workspace_templates/modules/repo-skills/files/.agents/skills/synthesize-decision/SKILL.md +54 -0
  217. workspace_templates/modules/repo-skills/files/.agents/skills/synthesize-decision/agents/openai.yaml +6 -0
  218. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/execution-operator/execute-paper-order/SKILL.md +35 -0
  219. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/execution-operator/execute-paper-order/agents/openai.yaml +6 -0
  220. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/fundamental-analyst/fundamental-analysis/SKILL.md +46 -0
  221. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/fundamental-analyst/fundamental-analysis/agents/openai.yaml +6 -0
  222. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/instrument-analyst/instrument-analysis/SKILL.md +40 -0
  223. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/instrument-analyst/instrument-analysis/agents/openai.yaml +6 -0
  224. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/macro-analyst/macro-analysis/SKILL.md +40 -0
  225. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/macro-analyst/macro-analysis/agents/openai.yaml +6 -0
  226. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/news-analyst/news-analysis/SKILL.md +43 -0
  227. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/news-analyst/news-analysis/agents/openai.yaml +6 -0
  228. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/create-order-intent/SKILL.md +46 -0
  229. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/create-order-intent/agents/openai.yaml +6 -0
  230. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/portfolio-review/SKILL.md +44 -0
  231. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/portfolio-manager/portfolio-review/agents/openai.yaml +6 -0
  232. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/approve-order/SKILL.md +38 -0
  233. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/approve-order/agents/openai.yaml +6 -0
  234. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/policy-review/SKILL.md +43 -0
  235. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/policy-review/agents/openai.yaml +6 -0
  236. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/review-risk/SKILL.md +45 -0
  237. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/risk-manager/review-risk/agents/openai.yaml +6 -0
  238. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/collect-evidence/SKILL.md +46 -0
  239. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/collect-evidence/agents/openai.yaml +6 -0
  240. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/external-data-source-gate/SKILL.md +66 -0
  241. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/shared/external-data-source-gate/agents/openai.yaml +6 -0
  242. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/technical-analyst/technical-analysis/SKILL.md +43 -0
  243. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/technical-analyst/technical-analysis/agents/openai.yaml +6 -0
  244. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/valuation-analyst/valuation-review/SKILL.md +47 -0
  245. workspace_templates/modules/repo-skills/files/.tradingcodex/subagents/skills/valuation-analyst/valuation-review/agents/openai.yaml +6 -0
  246. workspace_templates/modules/repo-skills/module.json +37 -0
  247. workspace_templates/modules/stub-execution/files/.tradingcodex/mcp/adapters/stub-execution.py +4 -0
  248. workspace_templates/modules/stub-execution/module.json +15 -0
  249. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/adapters/live-adapter.contract.md +25 -0
  250. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/enforcer/README.md +5 -0
  251. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/gateway/README.md +8 -0
  252. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/server.py +27 -0
  253. workspace_templates/modules/tradingcodex-mcp/files/.tradingcodex/mcp/smoke-call.py +18 -0
  254. workspace_templates/modules/tradingcodex-mcp/module.json +24 -0
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from tradingcodex_service.application.audit import write_policy_decision_if_available
9
+ from tradingcodex_service.application.common import _number, _safe_read
10
+ from tradingcodex_service.application.runtime import ensure_runtime_database, workspace_context_payload
11
+
12
+ DEFAULT_MAX_SINGLE_ORDER_KRW = 100_000_000
13
+ DEFAULT_ALLOWED_ADAPTERS = {"stub-execution", "paper-trading"}
14
+ EXPLICIT_DENY_ACTIONS = {
15
+ "api_key.read",
16
+ "api_key.rotate",
17
+ "secret.read",
18
+ "broker.raw_api",
19
+ "broker_api.direct_call",
20
+ "approval.self_issue",
21
+ "approval_receipt.self_issue",
22
+ "execute_order",
23
+ "order.execute",
24
+ "trade.execute",
25
+ "trading.execute",
26
+ "cash.withdraw",
27
+ "cash.transfer",
28
+ "permissions.write",
29
+ "policy.write",
30
+ "mcp.tradingcodex.write_policy_and_execute",
31
+ }
32
+
33
+ @dataclass(frozen=True)
34
+ class RuntimePolicy:
35
+ max_single_order_krw: int = DEFAULT_MAX_SINGLE_ORDER_KRW
36
+ allowed_adapters: frozenset[str] = frozenset(DEFAULT_ALLOWED_ADAPTERS)
37
+ source: tuple[str, ...] = ("default-runtime-policy",)
38
+
39
+
40
+ def read_runtime_policy(workspace_root: Path | str) -> RuntimePolicy:
41
+ root = Path(workspace_root)
42
+ max_single_order = DEFAULT_MAX_SINGLE_ORDER_KRW
43
+ allowed_adapters = set(DEFAULT_ALLOWED_ADAPTERS)
44
+ source = [".tradingcodex/policies/access-policies.yaml", ".tradingcodex/config.yaml"]
45
+
46
+ access_text = _safe_read(root / ".tradingcodex" / "policies" / "access-policies.yaml")
47
+ max_match = re.search(r"order\.estimated_notional_krw\s*<=\s*(\d+)", access_text)
48
+ if max_match:
49
+ max_single_order = int(max_match.group(1))
50
+ brokers_match = re.search(r"order\.broker\s+in\s+\[([^\]]+)\]", access_text)
51
+ if brokers_match:
52
+ parsed = re.findall(r'"([^"]+)"', brokers_match.group(1))
53
+ if parsed:
54
+ allowed_adapters = set(parsed)
55
+
56
+ config_text = _safe_read(root / ".tradingcodex" / "config.yaml")
57
+ section = re.search(r"enabled_adapters:[ \t]*\n((?:[ \t]*-[ \t]*[A-Za-z0-9._-]+[ \t]*(?:\n|$))+)", config_text)
58
+ if section:
59
+ configured = set(re.findall(r"^[ \t]*-[ \t]*([A-Za-z0-9._-]+)[ \t]*$", section.group(1), flags=re.M))
60
+ if configured:
61
+ allowed_adapters &= configured
62
+
63
+ return RuntimePolicy(max_single_order, frozenset(allowed_adapters), tuple(source))
64
+
65
+
66
+ def read_restricted_symbols(workspace_root: Path | str) -> set[str]:
67
+ text = _safe_read(Path(workspace_root) / ".tradingcodex" / "policies" / "restricted-list.yaml")
68
+ symbols: set[str] = set()
69
+ try:
70
+ ensure_runtime_database(workspace_root)
71
+ from apps.policy.models import RestrictedSymbol
72
+
73
+ symbols.update(symbol.upper() for symbol in RestrictedSymbol.objects.filter(active=True).values_list("symbol", flat=True))
74
+ except Exception:
75
+ pass
76
+ inline = re.search(r"restricted_symbols\s*:\s*\[([^\]]*)\]", text)
77
+ if inline:
78
+ for raw in inline.group(1).split(","):
79
+ symbol = raw.strip().strip("'\"")
80
+ if symbol:
81
+ symbols.add(symbol.upper())
82
+ block = re.search(r"restricted_symbols\s*:\s*\n((?:[ \t]*-[ \t]*[A-Za-z0-9_.:-]+[ \t]*(?:\n|$))+)", text)
83
+ if block:
84
+ symbols.update(symbol.upper() for symbol in re.findall(r"^[ \t]*-[ \t]*([A-Za-z0-9_.:-]+)[ \t]*$", block.group(1), flags=re.M))
85
+ symbols.update(symbol.upper() for symbol in re.findall(r"\bsymbol\s*:\s*['\"]?([A-Za-z0-9_.:-]+)['\"]?", text))
86
+ return symbols
87
+
88
+
89
+ def evaluate_policy(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
90
+ ensure_runtime_database(workspace_root)
91
+ from apps.policy.services import capability_check, sync_builtin_principals_and_capabilities
92
+ from tradingcodex_service.application.orders import resolve_approval_receipt, resolve_order_intent
93
+
94
+ sync_builtin_principals_and_capabilities()
95
+ policy = read_runtime_policy(workspace_root)
96
+ order = resolve_order_intent(Path(workspace_root), args)
97
+ receipt = resolve_approval_receipt(Path(workspace_root), args, order)
98
+ principal_id = args.get("principal_id") or "unknown"
99
+ action = args.get("action") or "unknown"
100
+ reasons: list[str] = []
101
+ capability_allowed, capability_reasons = capability_check(principal_id, action, args.get("resource"))
102
+ if not capability_allowed:
103
+ reasons.extend(capability_reasons)
104
+
105
+ if action in EXPLICIT_DENY_ACTIONS:
106
+ reasons.append(f"explicit deny action: {action}")
107
+ if action.startswith(("broker_api.", "broker.")):
108
+ reasons.append("direct broker API actions are explicitly denied")
109
+ if "live" in action.lower() and re.search(r"order|execution|submit|broker", action.lower()):
110
+ reasons.append("live execution actions are disabled in the initial core")
111
+ if "live" in str(args.get("resource") or "").lower() and re.search(r"order|execution|submit|broker", action.lower()):
112
+ reasons.append("live execution resources are disabled in the initial core")
113
+ if action in {"approval.create", "approval_receipt.create"} and principal_id != "risk-manager":
114
+ reasons.append("only risk-manager can create approval receipts")
115
+ if action == "mcp.tradingcodex.submit_approved_order" and principal_id != "execution-operator":
116
+ reasons.append("only execution-operator can submit approved orders")
117
+ if order.get("broker") == "live" and not (Path(workspace_root) / ".tradingcodex" / "mcp" / "adapters" / "live.py").exists():
118
+ reasons.append("live broker adapter is not installed in this workspace")
119
+ if order.get("broker") and order["broker"] not in policy.allowed_adapters:
120
+ reasons.append(f"adapter not enabled: {order['broker']}")
121
+
122
+ notional = _number(order.get("estimated_notional_krw"))
123
+ if order.get("estimated_notional_krw") not in (None, "") and (notional is None or notional <= 0):
124
+ reasons.append("estimated_notional_krw must be a positive number")
125
+ elif notional is not None and notional > policy.max_single_order_krw:
126
+ reasons.append(f"estimated_notional_krw exceeds {policy.max_single_order_krw}")
127
+
128
+ if order.get("symbol") and str(order["symbol"]).upper() in read_restricted_symbols(workspace_root):
129
+ reasons.append(f"symbol is restricted: {order['symbol']}")
130
+ if args.get("require_approval_check") and receipt.get("valid") is not True:
131
+ reasons.append("approval_receipt.valid == false")
132
+
133
+ decision = "allow" if not reasons else "deny"
134
+ result = {
135
+ "decision": decision,
136
+ "reasons": reasons,
137
+ "enforced_by": ["TradingCodex MCP"],
138
+ "policy_source": list(policy.source),
139
+ "principal_id": principal_id,
140
+ "action": action,
141
+ "resource": args.get("resource"),
142
+ "db_canonical": True,
143
+ "workspace_context": workspace_context_payload(workspace_root),
144
+ }
145
+ write_policy_decision_if_available(workspace_root, result)
146
+ return result
147
+
148
+
149
+ def simulate_policy(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
150
+ return evaluate_policy(workspace_root, args)
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tradingcodex_service.application.common import now_iso
7
+ from tradingcodex_service.application.runtime import active_profile_for_workspace, ensure_runtime_database, workspace_context_payload
8
+
9
+ DEFAULT_PAPER_CASH_KRW = 100_000_000
10
+ DEFAULT_PORTFOLIO_ID = "default-paper"
11
+ DEFAULT_ACCOUNT_ID = "local-paper"
12
+ DEFAULT_STRATEGY_ID = "default-strategy"
13
+
14
+ def submit_paper_order(root: Path, order: dict[str, Any]) -> dict[str, Any]:
15
+ portfolio_id, account_id, strategy_id = portfolio_keys(order, root)
16
+ state = load_paper_portfolio_state(root, portfolio_id, account_id, strategy_id)
17
+ symbol = str(order["symbol"]).upper()
18
+ quantity = float(order["quantity"])
19
+ price = float(order["limit_price"])
20
+ notional = quantity * price
21
+ current = state.setdefault("positions", {}).get(symbol, {"quantity": 0, "average_price": 0, "currency": order.get("currency", "KRW")})
22
+ if order["side"] == "buy":
23
+ if float(state.get("cash_krw", 0)) < notional:
24
+ raise ValueError(f"insufficient paper cash: required {notional}, available {state.get('cash_krw', 0)}")
25
+ next_quantity = float(current.get("quantity", 0)) + quantity
26
+ current["average_price"] = 0 if next_quantity == 0 else ((float(current.get("quantity", 0)) * float(current.get("average_price", 0))) + notional) / next_quantity
27
+ current["quantity"] = next_quantity
28
+ state["cash_krw"] = float(state.get("cash_krw", 0)) - notional
29
+ else:
30
+ if float(current.get("quantity", 0)) < quantity:
31
+ raise ValueError(f"insufficient paper position: required {quantity}, available {current.get('quantity', 0)}")
32
+ current["quantity"] = float(current.get("quantity", 0)) - quantity
33
+ state["cash_krw"] = float(state.get("cash_krw", 0)) + notional
34
+ if current["quantity"] == 0:
35
+ current["average_price"] = 0
36
+ state["positions"][symbol] = current
37
+ state["updated_at"] = now_iso()
38
+ persist_paper_portfolio_state(root, state, portfolio_id, account_id, strategy_id, source="paper-trading")
39
+ return {
40
+ "adapter": "paper-trading",
41
+ "broker_order_id": f"paper-{order['id']}",
42
+ "status": "filled",
43
+ "filled_quantity": quantity,
44
+ "average_price": price,
45
+ "submitted_at": state["updated_at"],
46
+ "portfolio_id": portfolio_id,
47
+ "account_id": account_id,
48
+ "strategy_id": strategy_id,
49
+ }
50
+
51
+
52
+ def portfolio_keys(args: dict[str, Any], workspace_root: Path | str | None = None) -> tuple[str, str, str]:
53
+ profile = active_profile_for_workspace(workspace_root)
54
+ return (
55
+ str(args.get("portfolio_id") or profile.get("portfolio_id") or DEFAULT_PORTFOLIO_ID),
56
+ str(args.get("account_id") or profile.get("account_id") or DEFAULT_ACCOUNT_ID),
57
+ str(args.get("strategy_id") or profile.get("strategy_id") or DEFAULT_STRATEGY_ID),
58
+ )
59
+
60
+
61
+ def default_paper_portfolio_state(portfolio_id: str = DEFAULT_PORTFOLIO_ID, account_id: str = DEFAULT_ACCOUNT_ID, strategy_id: str = DEFAULT_STRATEGY_ID) -> dict[str, Any]:
62
+ return {
63
+ "cash_krw": DEFAULT_PAPER_CASH_KRW,
64
+ "positions": {},
65
+ "updated_at": now_iso(),
66
+ "portfolio_id": portfolio_id,
67
+ "account_id": account_id,
68
+ "strategy_id": strategy_id,
69
+ "source": "central-db",
70
+ "db_canonical": True,
71
+ }
72
+
73
+
74
+ def load_paper_portfolio_state(
75
+ workspace_root: Path | str | None = None,
76
+ portfolio_id: str = DEFAULT_PORTFOLIO_ID,
77
+ account_id: str = DEFAULT_ACCOUNT_ID,
78
+ strategy_id: str = DEFAULT_STRATEGY_ID,
79
+ ) -> dict[str, Any]:
80
+ ensure_runtime_database(workspace_root)
81
+ from apps.portfolio.models import PortfolioSnapshot
82
+
83
+ snapshot = (
84
+ PortfolioSnapshot.objects.filter(
85
+ portfolio_id=portfolio_id,
86
+ account_id=account_id,
87
+ strategy_id=strategy_id,
88
+ source="paper-trading",
89
+ )
90
+ .order_by("-created_at", "-id")
91
+ .first()
92
+ )
93
+ if snapshot is None:
94
+ state = default_paper_portfolio_state(portfolio_id, account_id, strategy_id)
95
+ state["workspace_context"] = workspace_context_payload(workspace_root)
96
+ persist_paper_portfolio_state(workspace_root, state, portfolio_id, account_id, strategy_id, source="paper-trading")
97
+ return state
98
+ state = dict(snapshot.payload or {})
99
+ state.setdefault("cash_krw", 0)
100
+ state.setdefault("positions", {})
101
+ state.setdefault("updated_at", snapshot.created_at.isoformat())
102
+ state.update({
103
+ "portfolio_id": portfolio_id,
104
+ "account_id": account_id,
105
+ "strategy_id": strategy_id,
106
+ "source": "central-db",
107
+ "db_canonical": True,
108
+ "workspace_context": workspace_context_payload(workspace_root),
109
+ })
110
+ return state
111
+
112
+
113
+ def persist_paper_portfolio_state(
114
+ workspace_root: Path | str | None,
115
+ state: dict[str, Any],
116
+ portfolio_id: str,
117
+ account_id: str,
118
+ strategy_id: str,
119
+ source: str = "paper-trading",
120
+ ) -> None:
121
+ ensure_runtime_database(workspace_root)
122
+ from apps.portfolio.models import CashBalance, PortfolioSnapshot, Position
123
+
124
+ state = dict(state)
125
+ state.update({
126
+ "portfolio_id": portfolio_id,
127
+ "account_id": account_id,
128
+ "strategy_id": strategy_id,
129
+ "source": "central-db",
130
+ "db_canonical": True,
131
+ "workspace_context": workspace_context_payload(workspace_root),
132
+ })
133
+ snapshot = PortfolioSnapshot.objects.create(
134
+ source=source,
135
+ portfolio_id=portfolio_id,
136
+ account_id=account_id,
137
+ strategy_id=strategy_id,
138
+ workspace_context=workspace_context_payload(workspace_root),
139
+ payload=state,
140
+ )
141
+ CashBalance.objects.create(
142
+ snapshot=snapshot,
143
+ currency="KRW",
144
+ amount=state.get("cash_krw", 0),
145
+ portfolio_id=portfolio_id,
146
+ account_id=account_id,
147
+ strategy_id=strategy_id,
148
+ )
149
+ for symbol, position in sorted((state.get("positions") or {}).items()):
150
+ if float(position.get("quantity", 0)) == 0:
151
+ continue
152
+ Position.objects.create(
153
+ snapshot=snapshot,
154
+ symbol=str(symbol).upper(),
155
+ quantity=position.get("quantity", 0),
156
+ average_price=position.get("average_price", 0),
157
+ currency=position.get("currency") or "KRW",
158
+ portfolio_id=portfolio_id,
159
+ account_id=account_id,
160
+ strategy_id=strategy_id,
161
+ )
162
+
163
+
164
+ def list_positions(workspace_root: Path | str) -> dict[str, Any]:
165
+ portfolio_id, account_id, strategy_id = portfolio_keys({}, workspace_root)
166
+ return load_paper_portfolio_state(Path(workspace_root), portfolio_id, account_id, strategy_id)
@@ -0,0 +1,356 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from tradingcodex_service.application.common import _resolve_path, sanitize_id
10
+ from tradingcodex_service.application.markdown_preview import split_markdown_frontmatter
11
+ from tradingcodex_service.application.runtime import workspace_context_payload
12
+
13
+ RESEARCH_FILE_ROOTS = (Path("trading/research"), Path("trading/reports"))
14
+ SOURCE_SNAPSHOT_ROOT = Path("trading/research/source-snapshots")
15
+
16
+
17
+ def list_workflow_artifacts(workspace_root: Path | str) -> dict[str, Any]:
18
+ root = Path(workspace_root)
19
+ files = []
20
+ for prefix in ["trading/research", "trading/reports", "trading/orders", "trading/approvals"]:
21
+ base = root / prefix
22
+ if base.exists():
23
+ files.extend(str(path.relative_to(root)) for path in base.rglob("*") if path.is_file() and path.name != ".gitkeep")
24
+ return {
25
+ "artifacts": sorted(files),
26
+ "research_artifacts": list_research_artifacts(root, {"include_markdown": False}).get("artifacts", []),
27
+ "workspace_native": True,
28
+ "workspace_context": workspace_context_payload(root),
29
+ }
30
+
31
+
32
+ def create_research_artifact(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
33
+ root = Path(workspace_root)
34
+ markdown = args.get("markdown")
35
+ markdown_path = args.get("markdown_path") or args.get("markdown_file")
36
+ if not markdown and markdown_path:
37
+ markdown = _resolve_path(root, markdown_path).read_text(encoding="utf-8")
38
+ if not markdown:
39
+ raise ValueError("research artifact markdown is required")
40
+
41
+ source_document = split_markdown_frontmatter(str(markdown))
42
+ source_frontmatter = source_document.frontmatter
43
+ markdown_body = source_document.body or str(markdown)
44
+ artifact_type = str(args.get("artifact_type") or args.get("type") or source_frontmatter.get("artifact_type") or source_frontmatter.get("type") or "research_memo")
45
+ title = str(args.get("title") or source_frontmatter.get("title") or source_document.heading or args.get("artifact_id") or "Untitled research artifact")
46
+ symbol = str(args.get("symbol") or source_frontmatter.get("symbol") or "").upper()
47
+ content_hash = hashlib.sha256(markdown_body.encode("utf-8")).hexdigest()
48
+ artifact_id = str(args.get("artifact_id") or source_frontmatter.get("artifact_id") or f"{sanitize_id(artifact_type)}-{sanitize_id(symbol or title)}-{content_hash[:12]}")
49
+ metadata = args.get("metadata") if isinstance(args.get("metadata"), dict) else {}
50
+ created_by = str(args.get("created_by") or args.get("principal_id") or source_frontmatter.get("created_by") or "system")
51
+ existing = find_workspace_research_artifact(root, artifact_id)
52
+ if existing and existing.get("content_hash") != content_hash and not args.get("_append_version"):
53
+ raise ValueError("research artifact already exists in this workspace; use append_research_artifact_version to create a new version")
54
+
55
+ existing_version = _int_value(existing.get("version") if existing else None, default=0)
56
+ version = existing_version + 1 if args.get("_append_version") else existing_version or 1
57
+ export_path = str(args.get("export_path") or (existing.get("path") if existing else "") or default_research_export_path_from_values(artifact_id, artifact_type, metadata))
58
+ frontmatter = {
59
+ **source_frontmatter,
60
+ "artifact_id": artifact_id,
61
+ "artifact_type": artifact_type,
62
+ "universe": args.get("universe") or source_frontmatter.get("universe") or "public_equity",
63
+ "workflow_type": args.get("workflow_type") or source_frontmatter.get("workflow_type") or "",
64
+ "role": args.get("role") or metadata.get("role") or source_frontmatter.get("role") or _role_alias_from_actor(created_by),
65
+ "symbol": symbol,
66
+ "title": title,
67
+ "source_as_of": args.get("source_as_of") or source_frontmatter.get("source_as_of") or "",
68
+ "readiness_label": args.get("readiness_label") or source_frontmatter.get("readiness_label") or "",
69
+ "version": version,
70
+ "content_hash": content_hash,
71
+ "workspace_native": True,
72
+ "created_by": created_by,
73
+ }
74
+ path = _resolve_path(root, export_path)
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(_render_research_markdown(frontmatter, markdown_body), encoding="utf-8")
77
+
78
+ result = {
79
+ "status": "updated" if existing else "stored",
80
+ "db_canonical": False,
81
+ "file_sot": True,
82
+ "workspace_native": True,
83
+ "artifact_id": artifact_id,
84
+ "version": version,
85
+ "content_hash": content_hash,
86
+ "export_path": path.relative_to(root).as_posix(),
87
+ "workspace_context": workspace_context_payload(root),
88
+ }
89
+ return result
90
+
91
+
92
+ def append_research_artifact_version(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
93
+ if not args.get("artifact_id"):
94
+ raise ValueError("artifact_id is required")
95
+ current = get_research_artifact(workspace_root, {"artifact_id": args["artifact_id"]})
96
+ payload = {
97
+ **current,
98
+ **args,
99
+ "_append_version": True,
100
+ "metadata": args.get("metadata") or current.get("metadata") or {},
101
+ "export_path": args.get("export_path") or current.get("path") or current.get("export_path"),
102
+ }
103
+ if args.get("markdown"):
104
+ payload["markdown"] = args["markdown"]
105
+ elif args.get("markdown_path") or args.get("markdown_file"):
106
+ payload.pop("markdown", None)
107
+ else:
108
+ payload["markdown"] = current.get("markdown")
109
+ return create_research_artifact(workspace_root, payload)
110
+
111
+
112
+ def get_research_artifact(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
113
+ artifact_id = args.get("artifact_id") or args.get("id")
114
+ if not artifact_id:
115
+ raise ValueError("artifact_id is required")
116
+ artifact = find_workspace_research_artifact(Path(workspace_root), str(artifact_id))
117
+ if not artifact:
118
+ raise ValueError(f"research artifact not found in workspace: {artifact_id}")
119
+ if args.get("include_markdown", True) is not False:
120
+ artifact["markdown"] = _read_research_markdown_body(Path(workspace_root) / artifact["path"])
121
+ return artifact
122
+
123
+
124
+ def list_research_artifacts(workspace_root: Path | str, args: dict[str, Any] | None = None) -> dict[str, Any]:
125
+ args = args or {}
126
+ artifacts = list_workspace_research_artifacts(Path(workspace_root), include_markdown=args.get("include_markdown") is True)
127
+ for field in ["artifact_type", "universe", "workflow_type", "symbol", "readiness_label", "created_by"]:
128
+ value = args.get(field)
129
+ if value:
130
+ artifacts = [artifact for artifact in artifacts if str(artifact.get(field) or "").lower() == str(value).lower()]
131
+ limit = max(1, min(int(args.get("limit") or 50), 200))
132
+ return {
133
+ "db_canonical": False,
134
+ "file_sot": True,
135
+ "workspace_native": True,
136
+ "workspace_context": workspace_context_payload(workspace_root),
137
+ "artifacts": artifacts[:limit],
138
+ }
139
+
140
+
141
+ def search_research_artifacts(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
142
+ query = str(args.get("query") or args.get("q") or "").strip()
143
+ if not query:
144
+ raise ValueError("query is required")
145
+ artifacts = list_workspace_research_artifacts(Path(workspace_root), include_markdown=True)
146
+ query_lower = query.lower()
147
+ artifacts = [
148
+ artifact
149
+ for artifact in artifacts
150
+ if query_lower in str(artifact.get("title") or "").lower()
151
+ or query_lower in str(artifact.get("symbol") or "").lower()
152
+ or query_lower in str(artifact.get("markdown") or "").lower()
153
+ ]
154
+ for field in ["universe", "artifact_type"]:
155
+ if args.get(field):
156
+ artifacts = [artifact for artifact in artifacts if str(artifact.get(field) or "").lower() == str(args[field]).lower()]
157
+ limit = max(1, min(int(args.get("limit") or 20), 100))
158
+ for artifact in artifacts:
159
+ artifact.pop("markdown", None)
160
+ return {
161
+ "query": query,
162
+ "db_canonical": False,
163
+ "file_sot": True,
164
+ "workspace_native": True,
165
+ "workspace_context": workspace_context_payload(workspace_root),
166
+ "artifacts": artifacts[:limit],
167
+ }
168
+
169
+
170
+ def export_research_artifact_md(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
171
+ root = Path(workspace_root)
172
+ artifact_id = args.get("artifact_id") or args.get("id")
173
+ if not artifact_id:
174
+ raise ValueError("artifact_id is required")
175
+ artifact = get_research_artifact(root, {"artifact_id": artifact_id, "include_markdown": True})
176
+ target_rel = str(args.get("export_path") or artifact["path"])
177
+ target = _resolve_path(root, target_rel)
178
+ source = root / artifact["path"]
179
+ if target.resolve() != source.resolve():
180
+ target.parent.mkdir(parents=True, exist_ok=True)
181
+ target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
182
+ return {
183
+ "status": "exported",
184
+ "artifact_id": artifact["artifact_id"],
185
+ "export_path": target.relative_to(root).as_posix(),
186
+ "db_canonical": False,
187
+ "file_sot": True,
188
+ "workspace_native": True,
189
+ "workspace_context": workspace_context_payload(root),
190
+ }
191
+
192
+
193
+ def record_source_snapshot(workspace_root: Path | str, args: dict[str, Any]) -> dict[str, Any]:
194
+ root = Path(workspace_root)
195
+ recorded_at = datetime.now(timezone.utc).isoformat()
196
+ payload = {
197
+ "provider": args.get("provider") or "unknown",
198
+ "source_category": args.get("source_category") or args.get("category") or "unknown",
199
+ "as_of": args.get("as_of") or "",
200
+ "artifact_id": args.get("artifact_id") or "",
201
+ "warnings": args.get("warnings") if isinstance(args.get("warnings"), list) else [],
202
+ "payload": args.get("payload") if isinstance(args.get("payload"), dict) else {},
203
+ "created_by": args.get("principal_id") or args.get("created_by") or "system",
204
+ "recorded_at": recorded_at,
205
+ "workspace_native": True,
206
+ }
207
+ snapshot_id = _source_snapshot_id(payload)
208
+ rel_path = SOURCE_SNAPSHOT_ROOT / f"{snapshot_id}.json"
209
+ path = _resolve_path(root, rel_path)
210
+ path.parent.mkdir(parents=True, exist_ok=True)
211
+ path.write_text(json.dumps({**payload, "snapshot_id": snapshot_id}, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
212
+ result = {
213
+ "status": "recorded",
214
+ "snapshot_id": snapshot_id,
215
+ "artifact_id": payload["artifact_id"],
216
+ "provider": payload["provider"],
217
+ "source_category": payload["source_category"],
218
+ "export_path": path.relative_to(root).as_posix(),
219
+ "db_canonical": False,
220
+ "file_sot": True,
221
+ "workspace_native": True,
222
+ "workspace_context": workspace_context_payload(root),
223
+ }
224
+ return result
225
+
226
+
227
+ def list_workspace_research_artifacts(root: Path, *, include_markdown: bool = False) -> list[dict[str, Any]]:
228
+ records: list[dict[str, Any]] = []
229
+ for rel_root in RESEARCH_FILE_ROOTS:
230
+ base = root / rel_root
231
+ if not base.exists():
232
+ continue
233
+ for path in sorted(base.rglob("*.md")):
234
+ if path.name == ".gitkeep":
235
+ continue
236
+ records.append(_research_file_payload(root, path, include_markdown=include_markdown))
237
+ return sorted(records, key=lambda item: item["updated_at"], reverse=True)
238
+
239
+
240
+ def find_workspace_research_artifact(root: Path, artifact_id: str) -> dict[str, Any] | None:
241
+ direct = root / artifact_id
242
+ if direct.exists() and direct.is_file():
243
+ return _research_file_payload(root, direct, include_markdown=False)
244
+ for artifact in list_workspace_research_artifacts(root, include_markdown=False):
245
+ if artifact["artifact_id"] == artifact_id or artifact["path"] == artifact_id:
246
+ return artifact
247
+ return None
248
+
249
+
250
+ def _research_file_payload(root: Path, path: Path, *, include_markdown: bool = False) -> dict[str, Any]:
251
+ rel = path.relative_to(root).as_posix()
252
+ frontmatter, heading, body = _research_file_parts(path)
253
+ content_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
254
+ artifact_id = str(frontmatter.get("artifact_id") or rel)
255
+ payload = {
256
+ "artifact_id": artifact_id,
257
+ "path": rel,
258
+ "export_path": rel,
259
+ "artifact_type": str(frontmatter.get("artifact_type") or _infer_research_artifact_type(path)),
260
+ "universe": str(frontmatter.get("universe") or _infer_research_universe(path)),
261
+ "workflow_type": str(frontmatter.get("workflow_type") or ""),
262
+ "role": str(frontmatter.get("role") or ""),
263
+ "symbol": str(frontmatter.get("symbol") or ""),
264
+ "title": str(frontmatter.get("title") or heading or path.stem.replace("-", " ").title()),
265
+ "metadata": {},
266
+ "workspace_context": workspace_context_payload(root),
267
+ "source_as_of": str(frontmatter.get("source_as_of") or ""),
268
+ "readiness_label": str(frontmatter.get("readiness_label") or frontmatter.get("handoff_state") or "workspace-file"),
269
+ "created_by": str(frontmatter.get("created_by") or "workspace"),
270
+ "content_hash": str(frontmatter.get("content_hash") or content_hash),
271
+ "version": _int_value(frontmatter.get("version"), default=1),
272
+ "parent_artifact_id": str(frontmatter.get("parent_artifact_id") or ""),
273
+ "updated_at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
274
+ "created_at": datetime.fromtimestamp(path.stat().st_ctime, tz=timezone.utc),
275
+ "db_canonical": False,
276
+ "file_sot": True,
277
+ "workspace_native": True,
278
+ }
279
+ if include_markdown:
280
+ payload["markdown"] = body
281
+ return payload
282
+
283
+
284
+ def _research_file_parts(path: Path) -> tuple[dict[str, Any], str, str]:
285
+ try:
286
+ text = path.read_text(encoding="utf-8")
287
+ except Exception:
288
+ return {}, "", ""
289
+ document = split_markdown_frontmatter(text)
290
+ return document.frontmatter, document.heading, document.body
291
+
292
+
293
+ def _read_research_markdown_body(path: Path) -> str:
294
+ return _research_file_parts(path)[2]
295
+
296
+
297
+ def _render_research_markdown(frontmatter: dict[str, Any], markdown: str) -> str:
298
+ header = "---\n" + "\n".join(f"{key}: {json.dumps(value, ensure_ascii=False)}" for key, value in frontmatter.items()) + "\n---\n\n"
299
+ return header + markdown.rstrip() + "\n"
300
+
301
+
302
+ def _int_value(value: Any, *, default: int) -> int:
303
+ try:
304
+ return int(value)
305
+ except Exception:
306
+ return default
307
+
308
+
309
+ def _infer_research_universe(path: Path) -> str:
310
+ text = path.as_posix().lower()
311
+ if "crypto" in text:
312
+ return "public_crypto"
313
+ if "macro" in text:
314
+ return "macro"
315
+ return "workspace"
316
+
317
+
318
+ def _infer_research_artifact_type(path: Path) -> str:
319
+ parts = path.as_posix().split("/")
320
+ if "reports" in parts:
321
+ index = parts.index("reports")
322
+ if len(parts) > index + 1:
323
+ return f"{parts[index + 1]}_report"
324
+ return "role_report"
325
+ if path.name.endswith(".evidence.md"):
326
+ return "evidence_pack"
327
+ return "research_handoff"
328
+
329
+
330
+ def _role_alias_from_actor(actor: str) -> str:
331
+ return actor.replace("-analyst", "").replace("-manager", "").replace("-operator", "")
332
+
333
+
334
+ def _source_snapshot_id(payload: dict[str, Any]) -> str:
335
+ base = "-".join(
336
+ filter(
337
+ None,
338
+ [
339
+ sanitize_id(str(payload.get("provider") or "unknown")),
340
+ sanitize_id(str(payload.get("source_category") or "unknown")),
341
+ sanitize_id(str(payload.get("artifact_id") or "")),
342
+ ],
343
+ )
344
+ )
345
+ digest = hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:12]
346
+ return f"{base or 'source-snapshot'}-{digest}"
347
+
348
+
349
+ def default_research_export_path_from_values(artifact_id: str, artifact_type: str, metadata: dict[str, Any]) -> str:
350
+ stem = sanitize_id(artifact_id)
351
+ role = metadata.get("role") if isinstance(metadata, dict) else ""
352
+ if artifact_type == "evidence_pack":
353
+ return f"trading/research/{stem}.evidence.md"
354
+ if role in {"fundamental", "technical", "news", "macro", "instrument", "valuation", "portfolio", "risk", "policy"}:
355
+ return f"trading/reports/{role}/{stem}.md"
356
+ return f"trading/research/{stem}.md"