sk851 Claude Opus 5 (1M context) commited on
Commit
a12bdb9
·
1 Parent(s): eeca60f

test(agent): remove per-capability test boilerplate

Browse files

Adding a capability meant editing five duplicated _FakeService classes
and an exact 30-tuple registry assertion. similarity_search shipped
broken this way and was repaired five days later.

The stubs now live in tests/fakes.py, where an unknown capability
resolves through __getattr__, so a new capability needs no test edit.
Registry-derived parity tests replace the ordered tuple and turn a
missing tool contract, missing parity route, or mismatched
response_model into one named failure instead of a cascade.

Because __getattr__ also hides a typo'd handler from every stub-backed
test, one check builds the registry against the real service.

tests/ goes on sys.path via pythonpath instead of pytest's implicit
conftest insertion, which breaks under --import-mode=importlib, if
tests/__init__.py is added, or once a nested conftest.py shadows the
name.

Also repairs tests/agent/test_session_store_light_metadata.py, which
was silently uncollectable: it imported tests.agent.test_hosted_runtime,
and tests/agent is not an importable package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

pyproject.toml CHANGED
@@ -92,6 +92,14 @@ packages = {find = {where = ["src"], namespaces = false}}
92
  [tool.setuptools.package-data]
93
  "*" = ["*.json"]
94
 
 
 
 
 
 
 
 
 
95
  [tool.ruff]
96
  line-length = 119
97
 
 
92
  [tool.setuptools.package-data]
93
  "*" = ["*.json"]
94
 
95
+ [tool.pytest.ini_options]
96
+ # Put `tests/` on sys.path so shared test doubles live in an ordinary module
97
+ # (`tests/fakes.py`) that every test package can import. Relying on pytest's
98
+ # implicit rootdir insertion instead would break under
99
+ # `--import-mode=importlib`, if `tests/__init__.py` were added, or as soon as a
100
+ # nested `conftest.py` shadowed the name.
101
+ pythonpath = ["tests"]
102
+
103
  [tool.ruff]
104
  line-length = 119
105
 
tests/agent/test_capability_parity.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parity checks between the capability registry and its downstream surfaces.
2
+
3
+ `src/TerraFin/agent/runtime/capability.py` is the single source of truth for the
4
+ agent capability surface. Three things must stay in step with it, and each one
5
+ otherwise fails only at runtime (or silently, in generated docs):
6
+
7
+ * every capability needs a `HOSTED_TOOL_CONTRACTS` entry, because the tool
8
+ adapter looks one up for each registered capability when listing tools
9
+ * every capability that declares an `http_route_path` needs that route to exist,
10
+ since HTTP-only agents depend on the parity route
11
+ * declared metadata (`summary`, `response_model_name`) must be populated and the
12
+ response model must actually exist somewhere in the package
13
+
14
+ These tests are deliberately derived from the registry rather than hardcoded, so
15
+ adding a capability requires no edit here.
16
+ """
17
+
18
+ import ast
19
+ import pathlib
20
+
21
+ import pytest
22
+ from fakes import BaseFakeService, fake_chart_opener
23
+
24
+ from TerraFin.agent.contracts.tool_contracts import HOSTED_TOOL_CONTRACTS
25
+ from TerraFin.agent.runtime import build_default_capability_registry
26
+
27
+
28
+ # Hosted-only tools live outside the capability registry: the three guru
29
+ # consults and `current_view_context` need a live session, so they are wired
30
+ # straight into the tool adapter instead.
31
+ HOSTED_ONLY_TOOLS = frozenset(
32
+ {
33
+ "consult_warren_buffett",
34
+ "consult_howard_marks",
35
+ "consult_stanley_druckenmiller",
36
+ "current_view_context",
37
+ }
38
+ )
39
+
40
+ # `open_chart` mutates hosted session state and has no stateless HTTP surface.
41
+ CAPABILITIES_WITHOUT_ROUTE = frozenset({"open_chart"})
42
+
43
+ # `open_chart` is handed the injected `chart_opener` callable rather than a
44
+ # `TerraFinAgentService` method, so its handler name never matches the
45
+ # capability name.
46
+ CAPABILITIES_WITH_INJECTED_HANDLER = frozenset({"open_chart"})
47
+
48
+ _SRC_ROOT = pathlib.Path(__file__).resolve().parents[2] / "src" / "TerraFin"
49
+
50
+
51
+ @pytest.fixture(scope="module")
52
+ def capabilities():
53
+ registry = build_default_capability_registry(BaseFakeService(), chart_opener=fake_chart_opener)
54
+ return registry.list()
55
+
56
+
57
+ @pytest.fixture(scope="module")
58
+ def live_route_paths() -> set[str | None]:
59
+ """Paths served by the assembled app.
60
+
61
+ Module-scoped because `create_app()` is not side-effect free: it loads the
62
+ repo `.env` into `os.environ`, resets chart/calendar module state, and
63
+ builds process-wide singletons. Build it once here rather than per test.
64
+ """
65
+
66
+ from TerraFin.interface.server import create_app
67
+
68
+ return {getattr(route, "path", None) for route in create_app().routes}
69
+
70
+
71
+ def _declared_class_names() -> set[str]:
72
+ """Collect every class name defined under src/TerraFin without importing it."""
73
+
74
+ names: set[str] = set()
75
+ for path in _SRC_ROOT.rglob("*.py"):
76
+ try:
77
+ tree = ast.parse(path.read_text(encoding="utf-8"))
78
+ except (SyntaxError, UnicodeDecodeError): # pragma: no cover - defensive
79
+ continue
80
+ names.update(node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef))
81
+ return names
82
+
83
+
84
+ def test_every_capability_has_a_tool_contract(capabilities) -> None:
85
+ missing = sorted(c.name for c in capabilities if c.name not in HOSTED_TOOL_CONTRACTS)
86
+ assert not missing, (
87
+ "capabilities registered without a HOSTED_TOOL_CONTRACTS entry "
88
+ f"(tool listing would raise KeyError): {missing}"
89
+ )
90
+
91
+
92
+ def test_every_tool_contract_is_registered_or_hosted_only(capabilities) -> None:
93
+ registered = {c.name for c in capabilities}
94
+ orphans = sorted(set(HOSTED_TOOL_CONTRACTS) - registered - HOSTED_ONLY_TOOLS)
95
+ assert not orphans, f"tool contracts with no registered capability and no hosted-only exemption: {orphans}"
96
+
97
+
98
+ def test_every_capability_declares_generator_metadata(capabilities) -> None:
99
+ missing_summary = sorted(c.name for c in capabilities if not c.summary)
100
+ assert not missing_summary, f"capabilities missing `summary`: {missing_summary}"
101
+
102
+ missing_model = sorted(c.name for c in capabilities if not c.response_model_name)
103
+ assert not missing_model, f"capabilities missing `response_model_name`: {missing_model}"
104
+
105
+ missing_route_path = sorted(
106
+ c.name for c in capabilities if not c.http_route_path and c.name not in CAPABILITIES_WITHOUT_ROUTE
107
+ )
108
+ assert not missing_route_path, f"capabilities missing `http_route_path`: {missing_route_path}"
109
+
110
+
111
+ def test_declared_response_models_exist(capabilities) -> None:
112
+ """Weak existence check: the name matches *some* class under src/TerraFin.
113
+
114
+ Response models are spread across agent contracts, data contracts, private
115
+ provider models, and page route modules, so this only catches an outright
116
+ typo. `test_response_model_names_match_tool_contracts` is the strict check.
117
+ """
118
+
119
+ declared = _declared_class_names()
120
+ unresolved = sorted(
121
+ f"{c.name} -> {c.response_model_name}"
122
+ for c in capabilities
123
+ if c.response_model_name and c.response_model_name not in declared
124
+ )
125
+ assert not unresolved, f"`response_model_name` values with no matching class under src/TerraFin: {unresolved}"
126
+
127
+
128
+ def test_response_model_names_match_tool_contracts(capabilities) -> None:
129
+ """The registry and the tool contract declare the same response model.
130
+
131
+ `HOSTED_TOOL_CONTRACTS[name]["response_model"]` is shipped to the model as
132
+ tool metadata, so a disagreement between the two declarations sends the LLM
133
+ a response shape that does not match the route's.
134
+ """
135
+
136
+ mismatches = sorted(
137
+ f"{c.name}: registry={c.response_model_name!r} contract={HOSTED_TOOL_CONTRACTS[c.name].get('response_model')!r}"
138
+ for c in capabilities
139
+ if c.name in HOSTED_TOOL_CONTRACTS
140
+ and HOSTED_TOOL_CONTRACTS[c.name].get("response_model") != c.response_model_name
141
+ )
142
+ assert not mismatches, f"registry / tool-contract response_model disagreement: {mismatches}"
143
+
144
+
145
+ def test_every_capability_handler_binds_on_the_real_service() -> None:
146
+ """Guard the one thing the shared stub cannot catch.
147
+
148
+ `BaseFakeService.__getattr__` resolves any attribute, so a capability whose
149
+ handler names a method the real `TerraFinAgentService` does not implement
150
+ (a typo, or a handler added before its service method) passes every
151
+ stub-backed test. Building the registry against the real service is the
152
+ check that fails loudly, and it is cheap: no network, no env mutation.
153
+ """
154
+
155
+ from TerraFin.agent.service import TerraFinAgentService
156
+
157
+ registry = build_default_capability_registry(TerraFinAgentService(), chart_opener=fake_chart_opener)
158
+
159
+ mislabelled = sorted(
160
+ f"{c.name} -> {getattr(c.handler, '__name__', repr(c.handler))}"
161
+ for c in registry.list()
162
+ if c.name not in CAPABILITIES_WITH_INJECTED_HANDLER
163
+ and getattr(c.handler, "__name__", None) not in (c.name, None)
164
+ )
165
+ assert not mislabelled, (
166
+ "capability handlers bound to a differently-named service method "
167
+ f"(likely a copy-paste error): {mislabelled}"
168
+ )
169
+
170
+
171
+ def test_declared_http_routes_exist(capabilities, live_route_paths) -> None:
172
+ missing = sorted(
173
+ f"{c.name} -> {c.http_route_path}"
174
+ for c in capabilities
175
+ if c.http_route_path and c.http_route_path not in live_route_paths
176
+ )
177
+ assert not missing, f"capabilities whose declared http_route_path has no live route: {missing}"
tests/agent/test_hosted_runtime.py CHANGED
@@ -2,6 +2,8 @@ import time
2
  from pathlib import Path
3
 
4
  import pytest
 
 
5
 
6
  from TerraFin.agent.definitions import (
7
  DEFAULT_HOSTED_AGENT_NAME,
@@ -21,200 +23,6 @@ from TerraFin.agent.session_store import SQLiteHostedSessionStore
21
  from TerraFin.agent.transcript_store import HostedTranscriptStore
22
 
23
 
24
- def _processing() -> dict[str, object]:
25
- return {
26
- "requestedDepth": "auto",
27
- "resolvedDepth": "full",
28
- "loadedStart": "2024-01-01",
29
- "loadedEnd": "2024-12-31",
30
- "isComplete": True,
31
- "hasOlder": False,
32
- "sourceVersion": "test-source",
33
- "view": "daily",
34
- }
35
-
36
-
37
- class _FakeService:
38
- def resolve(self, query: str) -> dict[str, object]:
39
- return {"type": "stock", "name": query.upper(), "path": f"/stock/{query.upper()}", "processing": _processing()}
40
-
41
- def market_data(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
42
- return {"ticker": name, "seriesType": "candlestick", "count": 1, "data": [], "processing": {**_processing(), "requestedDepth": depth, "view": view}}
43
-
44
- def indicators(
45
- self,
46
- name: str,
47
- indicators: str,
48
- *,
49
- depth: str = "auto",
50
- view: str = "daily",
51
- ) -> dict[str, object]:
52
- return {
53
- "ticker": name,
54
- "indicators": {"rsi": {"name": "rsi", "offset": 0, "values": {"value": 55.0}}},
55
- "unknown": [],
56
- "processing": {**_processing(), "requestedDepth": depth, "view": view, "indicatorQuery": indicators},
57
- }
58
-
59
- def patterns(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
60
- return {"ticker": name, "signals": [], "total": 0, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
61
-
62
- def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
63
- return {
64
- "ticker": name,
65
- "price_action": {"current": 100.0},
66
- "indicators": {"rsi": 55.0},
67
- "market_breadth": [],
68
- "watchlist": [],
69
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
70
- }
71
-
72
- def lppl_analysis(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
73
- return {"name": name, "confidence": 0.2, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
74
-
75
- def company_info(self, ticker: str) -> dict[str, object]:
76
- return {"ticker": ticker, "shortName": f"{ticker} Corp", "processing": _processing()}
77
-
78
- def earnings(self, ticker: str) -> dict[str, object]:
79
- return {"ticker": ticker, "earnings": [], "processing": _processing()}
80
-
81
- def financials(self, ticker: str, *, statement: str = "income", period: str = "annual") -> dict[str, object]:
82
- return {"ticker": ticker, "statement": statement, "period": period, "columns": [], "rows": [], "processing": _processing()}
83
-
84
- def portfolio(self, guru: str) -> dict[str, object]:
85
- return {"guru": guru, "info": {}, "holdings": [], "count": 0, "processing": _processing()}
86
-
87
- def economic(self, indicators: str) -> dict[str, object]:
88
- return {"indicators": {indicators: {"latest_value": 3.0}}, "processing": _processing()}
89
-
90
- def macro_focus(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
91
- return {
92
- "name": name,
93
- "info": {"name": name, "type": "index", "description": "Macro", "currentValue": 1.0, "change": 0.0, "changePercent": 0.0},
94
- "seriesType": "line",
95
- "count": 1,
96
- "data": [],
97
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
98
- }
99
-
100
- def calendar_events(
101
- self,
102
- *,
103
- year: int,
104
- month: int,
105
- categories: str | None = None,
106
- limit: int | None = None,
107
- ) -> dict[str, object]:
108
- return {"events": [], "count": 0, "month": month, "year": year, "categories": categories, "limit": limit, "processing": _processing()}
109
-
110
- def fundamental_screen(self, ticker: str) -> dict[str, object]:
111
- return {
112
- "ticker": ticker,
113
- "moat": {"score": "wide"},
114
- "earnings_quality": {},
115
- "balance_sheet": {},
116
- "capital_allocation": {},
117
- "pricing_power": {},
118
- "warnings": [],
119
- "processing": _processing(),
120
- }
121
-
122
- def risk_profile(self, name: str, *, depth: str = "auto") -> dict[str, object]:
123
- return {
124
- "ticker": name,
125
- "tail_risk": {},
126
- "convexity": {},
127
- "volatility": {"requestedDepth": depth},
128
- "drawdown": {},
129
- "warnings": [],
130
- "processing": _processing(),
131
- }
132
-
133
- def valuation(self, ticker: str) -> dict[str, object]:
134
- return {
135
- "ticker": ticker,
136
- "dcf": {"status": "ready", "intrinsic_value": 120.0},
137
- "reverse_dcf": {"status": "ready", "implied_growth_pct": 8.0},
138
- "relative": {"trailing_pe": 22.0},
139
- "graham_number": 100.0,
140
- "margin_of_safety_pct": 12.0,
141
- "current_price": 107.0,
142
- "processing": _processing(),
143
- }
144
-
145
- def sec_filings(self, ticker: str) -> dict[str, object]:
146
- return {"ticker": ticker, "cik": 1, "forms": [], "filings": [], "processing": _processing()}
147
-
148
- def sec_filing_document(
149
- self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
150
- ) -> dict[str, object]:
151
- return {"ticker": ticker, "accession": accession, "primaryDocument": primaryDocument, "toc": [], "charCount": 0, "indexUrl": "", "documentUrl": "", "processing": _processing()}
152
-
153
- def sec_filing_section(
154
- self, ticker: str, accession: str, primaryDocument: str, sectionSlug: str, *, form: str = "10-Q"
155
- ) -> dict[str, object]:
156
- return {"ticker": ticker, "accession": accession, "sectionSlug": sectionSlug, "sectionTitle": "stub", "markdown": "", "charCount": 0, "documentUrl": "", "processing": _processing()}
157
-
158
- def fcf_history(self, ticker: str, years: int = 10) -> dict[str, object]:
159
- return {
160
- "ticker": ticker,
161
- "years": years,
162
- "rows": [],
163
- "candidates": {"threeYearAvg": None, "latestAnnual": None, "ttm": None},
164
- "autoSelectedSource": "annual",
165
- "processing": _processing(),
166
- }
167
-
168
- def similarity_search(
169
- self,
170
- ticker: str,
171
- universe: str = "sp500+nasdaq100+kospi200",
172
- period: str = "1y",
173
- top_n: int = 20,
174
- ) -> dict[str, object]:
175
- return {"ticker": ticker, "period": period, "pool": {}, "results": [], "count": 0, "processing": _processing()}
176
-
177
- def fear_greed(self) -> dict[str, object]:
178
- return {"score": 50, "rating": "Neutral", "processing": _processing()}
179
-
180
- def sp500_dcf(self) -> dict[str, object]:
181
- return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
182
-
183
- def beta_estimate(self, ticker: str) -> dict[str, object]:
184
- return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
185
-
186
- def top_companies(self) -> dict[str, object]:
187
- return {"companies": [], "count": 0, "processing": _processing()}
188
-
189
- def market_regime(self) -> dict[str, object]:
190
- return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
191
-
192
- def trailing_forward_pe(self) -> dict[str, object]:
193
- return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
194
-
195
- def market_breadth(self) -> dict[str, object]:
196
- return {"metrics": [], "processing": _processing()}
197
-
198
- def watchlist(self) -> dict[str, object]:
199
- return {"items": [], "count": 0, "processing": _processing()}
200
-
201
-
202
- def _fake_chart_opener(
203
- data_or_names,
204
- *,
205
- session_id: str | None = None,
206
- **kwargs,
207
- ) -> dict[str, object]:
208
- _ = kwargs
209
- return {
210
- "ok": True,
211
- "sessionId": session_id or "agent:chart",
212
- "chartUrl": f"http://127.0.0.1:8001/chart?sessionId={session_id or 'agent:chart'}",
213
- "processing": _processing(),
214
- "inputEcho": data_or_names,
215
- }
216
-
217
-
218
  def _runtime(
219
  agent_registry: TerraFinAgentDefinitionRegistry | None = None,
220
  *,
 
2
  from pathlib import Path
3
 
4
  import pytest
5
+ from fakes import BaseFakeService as _FakeService
6
+ from fakes import fake_chart_opener as _fake_chart_opener
7
 
8
  from TerraFin.agent.definitions import (
9
  DEFAULT_HOSTED_AGENT_NAME,
 
23
  from TerraFin.agent.transcript_store import HostedTranscriptStore
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def _runtime(
27
  agent_registry: TerraFinAgentDefinitionRegistry | None = None,
28
  *,
tests/agent/test_loop.py CHANGED
@@ -1,12 +1,14 @@
1
  import json
2
 
3
  import pytest
 
 
4
 
 
5
  from TerraFin.agent.definitions import (
6
  DEFAULT_HOSTED_AGENT_NAME,
7
  build_default_agent_definition_registry,
8
  )
9
- from TerraFin.agent.conversation import is_internal_only_message
10
  from TerraFin.agent.guru import (
11
  GuruResearchMemo,
12
  GuruRoutePlan,
@@ -29,193 +31,12 @@ from TerraFin.agent.session_store import SQLiteHostedSessionStore
29
  from TerraFin.agent.transcript_store import HostedTranscriptStore
30
 
31
 
32
- def _processing() -> dict[str, object]:
33
- return {
34
- "requestedDepth": "auto",
35
- "resolvedDepth": "full",
36
- "loadedStart": "2024-01-01",
37
- "loadedEnd": "2024-12-31",
38
- "isComplete": True,
39
- "hasOlder": False,
40
- "sourceVersion": "test-source",
41
- "view": "daily",
42
- }
43
-
44
-
45
  def _public_roles(messages):
46
  return [message.role for message in messages if not is_internal_only_message(message)]
47
 
48
 
49
- class _FakeService:
50
- def resolve(self, query: str) -> dict[str, object]:
51
- return {"type": "stock", "name": query.upper(), "path": f"/stock/{query.upper()}", "processing": _processing()}
52
-
53
- def market_data(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
54
- return {"ticker": name, "seriesType": "candlestick", "count": 1, "data": [], "processing": {**_processing(), "requestedDepth": depth, "view": view}}
55
-
56
- def patterns(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
57
- return {"ticker": name, "signals": [], "total": 0, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
58
-
59
- def fcf_history(self, ticker: str, years: int = 10) -> dict[str, object]:
60
- return {
61
- "ticker": ticker, "years": years, "rows": [],
62
- "candidates": {"threeYearAvg": None, "latestAnnual": None, "ttm": None},
63
- "autoSelectedSource": "annual", "processing": _processing(),
64
- }
65
-
66
- def similarity_search(self, ticker: str, universe: str = "sp500+nasdaq100+kospi200", period: str = "1y", top_n: int = 20) -> dict[str, object]:
67
- return {"ticker": ticker, "period": period, "pool": {}, "results": [], "count": 0, "processing": _processing()}
68
-
69
- def indicators(
70
- self,
71
- name: str,
72
- indicators: str,
73
- *,
74
- depth: str = "auto",
75
- view: str = "daily",
76
- ) -> dict[str, object]:
77
- return {
78
- "ticker": name,
79
- "indicators": {"rsi": {"name": "rsi", "offset": 0, "values": {"value": 55.0}}},
80
- "unknown": [],
81
- "processing": {**_processing(), "requestedDepth": depth, "view": view, "indicatorQuery": indicators},
82
- }
83
-
84
- def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
85
- return {
86
- "ticker": name,
87
- "price_action": {"current": 100.0},
88
- "indicators": {"rsi": 55.0},
89
- "market_breadth": [],
90
- "watchlist": [],
91
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
92
- }
93
-
94
- def lppl_analysis(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
95
- return {"name": name, "confidence": 0.2, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
96
-
97
- def company_info(self, ticker: str) -> dict[str, object]:
98
- return {"ticker": ticker, "shortName": f"{ticker} Corp", "processing": _processing()}
99
-
100
- def earnings(self, ticker: str) -> dict[str, object]:
101
- return {"ticker": ticker, "earnings": [], "processing": _processing()}
102
-
103
- def financials(self, ticker: str, *, statement: str = "income", period: str = "annual") -> dict[str, object]:
104
- return {"ticker": ticker, "statement": statement, "period": period, "columns": [], "rows": [], "processing": _processing()}
105
-
106
- def portfolio(self, guru: str) -> dict[str, object]:
107
- return {"guru": guru, "info": {}, "holdings": [], "count": 0, "processing": _processing()}
108
-
109
- def economic(self, indicators: str) -> dict[str, object]:
110
- return {"indicators": {indicators: {"latest_value": 3.0}}, "processing": _processing()}
111
 
112
- def macro_focus(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
113
- return {
114
- "name": name,
115
- "info": {"name": name, "type": "index", "description": "Macro", "currentValue": 1.0, "change": 0.0, "changePercent": 0.0},
116
- "seriesType": "line",
117
- "count": 1,
118
- "data": [],
119
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
120
- }
121
-
122
- def calendar_events(
123
- self,
124
- *,
125
- year: int,
126
- month: int,
127
- categories: str | None = None,
128
- limit: int | None = None,
129
- ) -> dict[str, object]:
130
- return {"events": [], "count": 0, "month": month, "year": year, "categories": categories, "limit": limit, "processing": _processing()}
131
-
132
- def fundamental_screen(self, ticker: str) -> dict[str, object]:
133
- return {
134
- "ticker": ticker,
135
- "moat": {"score": "wide"},
136
- "earnings_quality": {},
137
- "balance_sheet": {},
138
- "capital_allocation": {},
139
- "pricing_power": {},
140
- "warnings": [],
141
- "processing": _processing(),
142
- }
143
-
144
- def risk_profile(self, name: str, *, depth: str = "auto") -> dict[str, object]:
145
- return {
146
- "ticker": name,
147
- "tail_risk": {},
148
- "convexity": {},
149
- "volatility": {"requestedDepth": depth},
150
- "drawdown": {},
151
- "warnings": [],
152
- "processing": _processing(),
153
- }
154
-
155
- def valuation(self, ticker: str) -> dict[str, object]:
156
- return {
157
- "ticker": ticker,
158
- "dcf": {"status": "ready", "intrinsic_value": 120.0},
159
- "reverse_dcf": {"status": "ready", "implied_growth_pct": 8.0},
160
- "relative": {"trailing_pe": 22.0},
161
- "graham_number": 100.0,
162
- "margin_of_safety_pct": 12.0,
163
- "current_price": 107.0,
164
- "processing": _processing(),
165
- }
166
-
167
- def sec_filings(self, ticker: str) -> dict[str, object]:
168
- return {"ticker": ticker, "cik": 1, "forms": [], "filings": [], "processing": _processing()}
169
-
170
- def sec_filing_document(
171
- self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
172
- ) -> dict[str, object]:
173
- return {"ticker": ticker, "accession": accession, "primaryDocument": primaryDocument, "toc": [], "charCount": 0, "indexUrl": "", "documentUrl": "", "processing": _processing()}
174
-
175
- def sec_filing_section(
176
- self, ticker: str, accession: str, primaryDocument: str, sectionSlug: str, *, form: str = "10-Q"
177
- ) -> dict[str, object]:
178
- return {"ticker": ticker, "accession": accession, "sectionSlug": sectionSlug, "sectionTitle": "stub", "markdown": "", "charCount": 0, "documentUrl": "", "processing": _processing()}
179
 
180
- def fear_greed(self) -> dict[str, object]:
181
- return {"score": 50, "rating": "Neutral", "processing": _processing()}
182
-
183
- def sp500_dcf(self) -> dict[str, object]:
184
- return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
185
-
186
- def beta_estimate(self, ticker: str) -> dict[str, object]:
187
- return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
188
-
189
- def top_companies(self) -> dict[str, object]:
190
- return {"companies": [], "count": 0, "processing": _processing()}
191
-
192
- def market_regime(self) -> dict[str, object]:
193
- return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
194
-
195
- def trailing_forward_pe(self) -> dict[str, object]:
196
- return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
197
-
198
- def market_breadth(self) -> dict[str, object]:
199
- return {"metrics": [], "processing": _processing()}
200
-
201
- def watchlist(self) -> dict[str, object]:
202
- return {"items": [], "count": 0, "processing": _processing()}
203
-
204
-
205
- def _fake_chart_opener(
206
- data_or_names,
207
- *,
208
- session_id: str | None = None,
209
- **kwargs,
210
- ) -> dict[str, object]:
211
- _ = kwargs
212
- return {
213
- "ok": True,
214
- "sessionId": session_id or "agent:chart",
215
- "chartUrl": f"http://127.0.0.1:8001/chart?sessionId={session_id or 'agent:chart'}",
216
- "processing": _processing(),
217
- "inputEcho": data_or_names,
218
- }
219
 
220
 
221
  def _loop(model_client, *, max_steps: int = 8, service: _FakeService | None = None) -> TerraFinHostedAgentLoop:
 
1
  import json
2
 
3
  import pytest
4
+ from fakes import BaseFakeService as _FakeService
5
+ from fakes import fake_chart_opener as _fake_chart_opener
6
 
7
+ from TerraFin.agent.conversation import is_internal_only_message
8
  from TerraFin.agent.definitions import (
9
  DEFAULT_HOSTED_AGENT_NAME,
10
  build_default_agent_definition_registry,
11
  )
 
12
  from TerraFin.agent.guru import (
13
  GuruResearchMemo,
14
  GuruRoutePlan,
 
31
  from TerraFin.agent.transcript_store import HostedTranscriptStore
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def _public_roles(messages):
35
  return [message.role for message in messages if not is_internal_only_message(message)]
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  def _loop(model_client, *, max_steps: int = 8, service: _FakeService | None = None) -> TerraFinHostedAgentLoop:
tests/agent/test_runtime.py CHANGED
@@ -1,203 +1,22 @@
1
  import pytest
 
 
2
 
3
  import TerraFin.agent.runtime as agent_runtime
4
 
5
 
6
- def _processing() -> dict[str, object]:
7
- return {
8
- "requestedDepth": "auto",
9
- "resolvedDepth": "full",
10
- "loadedStart": "2024-01-01",
11
- "loadedEnd": "2024-12-31",
12
- "isComplete": True,
13
- "hasOlder": False,
14
- "sourceVersion": "test-source",
15
- "view": "daily",
16
- }
17
-
18
-
19
- class _FakeService:
20
- def resolve(self, query: str) -> dict[str, object]:
21
- return {"type": "stock", "name": query.upper(), "path": f"/stock/{query.upper()}", "processing": _processing()}
22
-
23
- def market_data(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
24
- return {"ticker": name, "seriesType": "candlestick", "count": 1, "data": [], "processing": {**_processing(), "requestedDepth": depth, "view": view}}
25
-
26
- def patterns(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
27
- return {"ticker": name, "signals": [], "total": 0, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
28
-
29
- def fcf_history(self, ticker: str, years: int = 10) -> dict[str, object]:
30
- return {
31
- "ticker": ticker, "years": years, "rows": [],
32
- "candidates": {"threeYearAvg": None, "latestAnnual": None, "ttm": None},
33
- "autoSelectedSource": "annual", "processing": _processing(),
34
- }
35
-
36
- def similarity_search(self, ticker: str, universe: str = "sp500+nasdaq100+kospi200", period: str = "1y", top_n: int = 20) -> dict[str, object]:
37
- return {"ticker": ticker, "period": period, "pool": {}, "results": [], "count": 0, "processing": _processing()}
38
-
39
- def indicators(
40
- self,
41
- name: str,
42
- indicators: str,
43
- *,
44
- depth: str = "auto",
45
- view: str = "daily",
46
- ) -> dict[str, object]:
47
- return {
48
- "ticker": name,
49
- "indicators": {"rsi": {"name": "rsi", "offset": 0, "values": {"value": 55.0}}},
50
- "unknown": [],
51
- "processing": {**_processing(), "requestedDepth": depth, "view": view, "indicatorQuery": indicators},
52
- }
53
-
54
- def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
55
- return {
56
- "ticker": name,
57
- "price_action": {"current": 100.0},
58
- "indicators": {"rsi": 55.0},
59
- "market_breadth": [],
60
- "watchlist": [],
61
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
62
- }
63
-
64
- def lppl_analysis(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
65
- return {"name": name, "confidence": 0.2, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
66
-
67
- def company_info(self, ticker: str) -> dict[str, object]:
68
- return {"ticker": ticker, "shortName": f"{ticker} Corp", "processing": _processing()}
69
-
70
- def earnings(self, ticker: str) -> dict[str, object]:
71
- return {"ticker": ticker, "earnings": [], "processing": _processing()}
72
-
73
- def financials(self, ticker: str, *, statement: str = "income", period: str = "annual") -> dict[str, object]:
74
- return {"ticker": ticker, "statement": statement, "period": period, "columns": [], "rows": [], "processing": _processing()}
75
-
76
- def portfolio(self, guru: str) -> dict[str, object]:
77
- return {"guru": guru, "info": {}, "holdings": [], "count": 0, "processing": _processing()}
78
-
79
- def economic(self, indicators: str) -> dict[str, object]:
80
- return {"indicators": {indicators: {"latest_value": 3.0}}, "processing": _processing()}
81
-
82
- def macro_focus(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
83
- return {
84
- "name": name,
85
- "info": {"name": name, "type": "index", "description": "Macro", "currentValue": 1.0, "change": 0.0, "changePercent": 0.0},
86
- "seriesType": "line",
87
- "count": 1,
88
- "data": [],
89
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
90
- }
91
-
92
- def calendar_events(
93
- self,
94
- *,
95
- year: int,
96
- month: int,
97
- categories: str | None = None,
98
- limit: int | None = None,
99
- ) -> dict[str, object]:
100
- return {"events": [], "count": 0, "month": month, "year": year, "categories": categories, "limit": limit, "processing": _processing()}
101
-
102
- def fundamental_screen(self, ticker: str) -> dict[str, object]:
103
- return {
104
- "ticker": ticker,
105
- "moat": {"score": "wide"},
106
- "earnings_quality": {},
107
- "balance_sheet": {},
108
- "capital_allocation": {},
109
- "pricing_power": {},
110
- "warnings": [],
111
- "processing": _processing(),
112
- }
113
-
114
- def risk_profile(self, name: str, *, depth: str = "auto") -> dict[str, object]:
115
- return {
116
- "ticker": name,
117
- "tail_risk": {},
118
- "convexity": {},
119
- "volatility": {"requestedDepth": depth},
120
- "drawdown": {},
121
- "warnings": [],
122
- "processing": _processing(),
123
- }
124
-
125
- def valuation(self, ticker: str) -> dict[str, object]:
126
- return {
127
- "ticker": ticker,
128
- "dcf": {"status": "ready", "intrinsic_value": 120.0},
129
- "reverse_dcf": {"status": "ready", "implied_growth_pct": 8.0},
130
- "relative": {"trailing_pe": 22.0},
131
- "graham_number": 100.0,
132
- "margin_of_safety_pct": 12.0,
133
- "current_price": 107.0,
134
- "processing": _processing(),
135
- }
136
-
137
- def sec_filings(self, ticker: str) -> dict[str, object]:
138
- return {"ticker": ticker, "cik": 1, "forms": [], "filings": [], "processing": _processing()}
139
-
140
- def sec_filing_document(
141
- self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
142
- ) -> dict[str, object]:
143
- return {"ticker": ticker, "accession": accession, "primaryDocument": primaryDocument, "toc": [], "charCount": 0, "indexUrl": "", "documentUrl": "", "processing": _processing()}
144
-
145
- def sec_filing_section(
146
- self, ticker: str, accession: str, primaryDocument: str, sectionSlug: str, *, form: str = "10-Q"
147
- ) -> dict[str, object]:
148
- return {"ticker": ticker, "accession": accession, "sectionSlug": sectionSlug, "sectionTitle": "stub", "markdown": "", "charCount": 0, "documentUrl": "", "processing": _processing()}
149
-
150
- def fear_greed(self) -> dict[str, object]:
151
- return {"score": 50, "rating": "Neutral", "processing": _processing()}
152
-
153
- def sp500_dcf(self) -> dict[str, object]:
154
- return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
155
-
156
- def beta_estimate(self, ticker: str) -> dict[str, object]:
157
- return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
158
-
159
- def top_companies(self) -> dict[str, object]:
160
- return {"companies": [], "count": 0, "processing": _processing()}
161
-
162
- def market_regime(self) -> dict[str, object]:
163
- return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
164
-
165
- def trailing_forward_pe(self) -> dict[str, object]:
166
- return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
167
-
168
- def market_breadth(self) -> dict[str, object]:
169
- return {"metrics": [], "processing": _processing()}
170
-
171
- def watchlist(self) -> dict[str, object]:
172
- return {"items": [], "count": 0, "processing": _processing()}
173
-
174
-
175
  class _ExplodingService(_FakeService):
176
  def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
177
  _ = name, depth, view
178
  raise RuntimeError("snapshot failed")
179
 
180
 
181
- def _fake_chart_opener(
182
- data_or_names,
183
- *,
184
- session_id: str | None = None,
185
- **kwargs,
186
- ) -> dict[str, object]:
187
- _ = kwargs
188
- return {
189
- "ok": True,
190
- "sessionId": session_id or "agent:chart",
191
- "chartUrl": f"http://127.0.0.1:8001/chart?sessionId={session_id or 'agent:chart'}",
192
- "processing": _processing(),
193
- "inputEcho": data_or_names,
194
- }
195
-
196
-
197
- def test_default_capability_registry_contains_kernel_capabilities() -> None:
198
- registry = agent_runtime.build_default_capability_registry(_FakeService(), chart_opener=_fake_chart_opener)
199
-
200
- assert registry.names() == (
201
  "resolve",
202
  "market_data",
203
  "indicators",
@@ -211,9 +30,6 @@ def test_default_capability_registry_contains_kernel_capabilities() -> None:
211
  "economic",
212
  "macro_focus",
213
  "calendar_events",
214
- # Dashboard widget-parity capabilities, inserted before `open_chart` so
215
- # registry ordering tracks grouping (research read-only first, then
216
- # chart-opening, then SEC filings).
217
  "fear_greed",
218
  "sp500_dcf",
219
  "beta_estimate",
@@ -231,7 +47,34 @@ def test_default_capability_registry_contains_kernel_capabilities() -> None:
231
  "sec_filings",
232
  "sec_filing_document",
233
  "sec_filing_section",
234
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
 
237
  def test_context_call_records_focus_and_capability_history() -> None:
 
1
  import pytest
2
+ from fakes import BaseFakeService as _FakeService
3
+ from fakes import fake_chart_opener as _fake_chart_opener
4
 
5
  import TerraFin.agent.runtime as agent_runtime
6
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  class _ExplodingService(_FakeService):
9
  def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
10
  _ = name, depth, view
11
  raise RuntimeError("snapshot failed")
12
 
13
 
14
+ # Capabilities that must always be present. Membership is asserted rather than
15
+ # an exact ordered tuple so that adding a capability does not require editing
16
+ # this list; `test_default_capability_registry_ordering_convention` covers the
17
+ # grouping rule that ordering is actually meant to express.
18
+ KERNEL_CAPABILITIES = frozenset(
19
+ {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  "resolve",
21
  "market_data",
22
  "indicators",
 
30
  "economic",
31
  "macro_focus",
32
  "calendar_events",
 
 
 
33
  "fear_greed",
34
  "sp500_dcf",
35
  "beta_estimate",
 
47
  "sec_filings",
48
  "sec_filing_document",
49
  "sec_filing_section",
50
+ }
51
+ )
52
+
53
+
54
+ def _registry_names() -> tuple[str, ...]:
55
+ registry = agent_runtime.build_default_capability_registry(_FakeService(), chart_opener=_fake_chart_opener)
56
+ return registry.names()
57
+
58
+
59
+ def test_default_capability_registry_contains_kernel_capabilities() -> None:
60
+ names = _registry_names()
61
+
62
+ missing = KERNEL_CAPABILITIES - set(names)
63
+ assert not missing, f"kernel capabilities missing from the registry: {sorted(missing)}"
64
+ assert len(names) == len(set(names)), "registry contains duplicate capability names"
65
+
66
+
67
+ def test_default_capability_registry_ordering_convention() -> None:
68
+ """Registry order groups research read-only, then chart-opening, then SEC filings."""
69
+
70
+ names = _registry_names()
71
+ sec_positions = [index for index, name in enumerate(names) if name.startswith("sec_")]
72
+
73
+ assert sec_positions, "expected SEC filing capabilities in the registry"
74
+ assert sec_positions == list(
75
+ range(sec_positions[0], sec_positions[-1] + 1)
76
+ ), f"SEC filing capabilities must stay contiguous, got positions {sec_positions}"
77
+ assert names.index("open_chart") < sec_positions[0], "`open_chart` must precede the SEC filing group"
78
 
79
 
80
  def test_context_call_records_focus_and_capability_history() -> None:
tests/agent/test_session_store_light_metadata.py CHANGED
@@ -1,5 +1,11 @@
1
  from datetime import UTC, datetime
2
 
 
 
 
 
 
 
3
  from TerraFin.agent.contracts.conversation_state import RUNTIME_MODEL_METADATA_KEY
4
  from TerraFin.agent.runtime import build_default_capability_registry
5
  from TerraFin.agent.runtime.context import create_agent_context
@@ -11,11 +17,6 @@ from TerraFin.agent.session_store import (
11
  TerraFinHostedSessionRecord,
12
  )
13
 
14
- # Reuse the full-featured fake service + chart opener the hosted-runtime tests
15
- # already maintain: build_default_capability_registry needs every service
16
- # method, but list_light_metadata itself never invokes any capability.
17
- from tests.agent.test_hosted_runtime import _FakeService, _fake_chart_opener
18
-
19
 
20
  def _ts(hour: int) -> datetime:
21
  return datetime(2026, 4, 16, hour, 0, tzinfo=UTC)
 
1
  from datetime import UTC, datetime
2
 
3
+ # Reuse the shared stub service + chart opener from tests/conftest.py:
4
+ # build_default_capability_registry needs every service method, but
5
+ # list_light_metadata itself never invokes any capability.
6
+ from fakes import BaseFakeService as _FakeService
7
+ from fakes import fake_chart_opener as _fake_chart_opener
8
+
9
  from TerraFin.agent.contracts.conversation_state import RUNTIME_MODEL_METADATA_KEY
10
  from TerraFin.agent.runtime import build_default_capability_registry
11
  from TerraFin.agent.runtime.context import create_agent_context
 
17
  TerraFinHostedSessionRecord,
18
  )
19
 
 
 
 
 
 
20
 
21
  def _ts(hour: int) -> datetime:
22
  return datetime(2026, 4, 16, hour, 0, tzinfo=UTC)
tests/agent/test_tools.py CHANGED
@@ -1,4 +1,6 @@
1
  import pytest
 
 
2
 
3
  from TerraFin.agent.definitions import (
4
  DEFAULT_HOSTED_AGENT_NAME,
@@ -10,206 +12,9 @@ from TerraFin.agent.runtime import build_default_capability_registry
10
  from TerraFin.agent.tools import TerraFinHostedToolAdapter
11
 
12
 
13
- def _processing() -> dict[str, object]:
14
- return {
15
- "requestedDepth": "auto",
16
- "resolvedDepth": "full",
17
- "loadedStart": "2024-01-01",
18
- "loadedEnd": "2024-12-31",
19
- "isComplete": True,
20
- "hasOlder": False,
21
- "sourceVersion": "test-source",
22
- "view": "daily",
23
- }
24
-
25
-
26
- class _FakeService:
27
- def resolve(self, query: str) -> dict[str, object]:
28
- return {"type": "stock", "name": query.upper(), "path": f"/stock/{query.upper()}", "processing": _processing()}
29
-
30
- def market_data(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
31
- return {"ticker": name, "seriesType": "candlestick", "count": 1, "data": [], "processing": {**_processing(), "requestedDepth": depth, "view": view}}
32
-
33
- def indicators(
34
- self,
35
- name: str,
36
- indicators: str,
37
- *,
38
- depth: str = "auto",
39
- view: str = "daily",
40
- ) -> dict[str, object]:
41
- return {
42
- "ticker": name,
43
- "indicators": {"rsi": {"name": "rsi", "offset": 0, "values": {"value": 55.0}}},
44
- "unknown": [],
45
- "processing": {**_processing(), "requestedDepth": depth, "view": view, "indicatorQuery": indicators},
46
- }
47
-
48
- def patterns(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
49
- return {"ticker": name, "signals": [], "total": 0, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
50
-
51
- def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
52
- return {
53
- "ticker": name,
54
- "price_action": {"current": 100.0},
55
- "indicators": {"rsi": 55.0},
56
- "market_breadth": [],
57
- "watchlist": [],
58
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
59
- }
60
-
61
- def lppl_analysis(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
62
- return {"name": name, "confidence": 0.2, "processing": {**_processing(), "requestedDepth": depth, "view": view}}
63
-
64
- def company_info(self, ticker: str) -> dict[str, object]:
65
- return {"ticker": ticker, "shortName": f"{ticker} Corp", "processing": _processing()}
66
-
67
- def earnings(self, ticker: str) -> dict[str, object]:
68
- return {"ticker": ticker, "earnings": [], "processing": _processing()}
69
-
70
- def financials(self, ticker: str, *, statement: str = "income", period: str = "annual") -> dict[str, object]:
71
- return {"ticker": ticker, "statement": statement, "period": period, "columns": [], "rows": [], "processing": _processing()}
72
-
73
- def portfolio(self, guru: str) -> dict[str, object]:
74
- return {"guru": guru, "info": {}, "holdings": [], "count": 0, "processing": _processing()}
75
-
76
- def economic(self, indicators: str) -> dict[str, object]:
77
- return {"indicators": {indicators: {"latest_value": 3.0}}, "processing": _processing()}
78
-
79
- def macro_focus(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
80
- return {
81
- "name": name,
82
- "info": {"name": name, "type": "index", "description": "Macro", "currentValue": 1.0, "change": 0.0, "changePercent": 0.0},
83
- "seriesType": "line",
84
- "count": 1,
85
- "data": [],
86
- "processing": {**_processing(), "requestedDepth": depth, "view": view},
87
- }
88
-
89
- def calendar_events(
90
- self,
91
- *,
92
- year: int,
93
- month: int,
94
- categories: str | None = None,
95
- limit: int | None = None,
96
- ) -> dict[str, object]:
97
- return {"events": [], "count": 0, "month": month, "year": year, "categories": categories, "limit": limit, "processing": _processing()}
98
-
99
- def fundamental_screen(self, ticker: str) -> dict[str, object]:
100
- return {
101
- "ticker": ticker,
102
- "moat": {"score": "wide"},
103
- "earnings_quality": {},
104
- "balance_sheet": {},
105
- "capital_allocation": {},
106
- "pricing_power": {},
107
- "warnings": [],
108
- "processing": _processing(),
109
- }
110
-
111
- def risk_profile(self, name: str, *, depth: str = "auto") -> dict[str, object]:
112
- return {
113
- "ticker": name,
114
- "tail_risk": {},
115
- "convexity": {},
116
- "volatility": {"requestedDepth": depth},
117
- "drawdown": {},
118
- "warnings": [],
119
- "processing": _processing(),
120
- }
121
-
122
- def valuation(self, ticker: str) -> dict[str, object]:
123
- return {
124
- "ticker": ticker,
125
- "dcf": {"status": "ready", "intrinsic_value": 120.0},
126
- "reverse_dcf": {"status": "ready", "implied_growth_pct": 8.0},
127
- "relative": {"trailing_pe": 22.0},
128
- "graham_number": 100.0,
129
- "margin_of_safety_pct": 12.0,
130
- "current_price": 107.0,
131
- "processing": _processing(),
132
- }
133
-
134
  def sec_filings(self, ticker: str) -> dict[str, object]:
135
- return {"ticker": ticker, "cik": 1, "forms": ["10-K"], "filings": [], "processing": _processing()}
136
-
137
- def sec_filing_document(
138
- self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
139
- ) -> dict[str, object]:
140
- return {
141
- "ticker": ticker,
142
- "accession": accession,
143
- "primaryDocument": primaryDocument,
144
- "toc": [],
145
- "charCount": 0,
146
- "indexUrl": "",
147
- "documentUrl": "",
148
- "processing": _processing(),
149
- }
150
-
151
- def sec_filing_section(
152
- self,
153
- ticker: str,
154
- accession: str,
155
- primaryDocument: str,
156
- sectionSlug: str,
157
- *,
158
- form: str = "10-Q",
159
- ) -> dict[str, object]:
160
- return {
161
- "ticker": ticker,
162
- "accession": accession,
163
- "sectionSlug": sectionSlug,
164
- "sectionTitle": "stub",
165
- "markdown": "",
166
- "charCount": 0,
167
- "documentUrl": "",
168
- "processing": _processing(),
169
- }
170
-
171
- def fcf_history(self, ticker: str, years: int = 10) -> dict[str, object]:
172
- return {
173
- "ticker": ticker,
174
- "years": years,
175
- "rows": [],
176
- "candidates": {"threeYearAvg": None, "latestAnnual": None, "ttm": None},
177
- "autoSelectedSource": "annual",
178
- "processing": _processing(),
179
- }
180
-
181
- def similarity_search(
182
- self,
183
- ticker: str,
184
- universe: str = "sp500+nasdaq100+kospi200",
185
- period: str = "1y",
186
- top_n: int = 20,
187
- ) -> dict[str, object]:
188
- return {"ticker": ticker, "period": period, "pool": {}, "results": [], "count": 0, "processing": _processing()}
189
-
190
- def fear_greed(self) -> dict[str, object]:
191
- return {"score": 50, "rating": "Neutral", "processing": _processing()}
192
-
193
- def sp500_dcf(self) -> dict[str, object]:
194
- return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": _processing()}
195
-
196
- def beta_estimate(self, ticker: str) -> dict[str, object]:
197
- return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": _processing()}
198
-
199
- def top_companies(self) -> dict[str, object]:
200
- return {"companies": [], "count": 0, "processing": _processing()}
201
-
202
- def market_regime(self) -> dict[str, object]:
203
- return {"summary": "stub", "confidence": "low", "signals": [], "processing": _processing()}
204
-
205
- def trailing_forward_pe(self) -> dict[str, object]:
206
- return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": _processing()}
207
-
208
- def market_breadth(self) -> dict[str, object]:
209
- return {"metrics": [], "processing": _processing()}
210
-
211
- def watchlist(self) -> dict[str, object]:
212
- return {"items": [], "count": 0, "processing": _processing()}
213
 
214
 
215
  class _RetryingFakeService(_FakeService):
@@ -238,20 +43,6 @@ class _MacroFocusEquityMisuseService(_FakeService):
238
  raise LookupError(f"Unknown macro instrument: '{name}'")
239
 
240
 
241
- def _fake_chart_opener(
242
- data_or_names,
243
- *,
244
- session_id: str | None = None,
245
- **kwargs,
246
- ) -> dict[str, object]:
247
- _ = kwargs
248
- return {
249
- "ok": True,
250
- "sessionId": session_id or "agent:chart",
251
- "chartUrl": f"http://127.0.0.1:8001/chart?sessionId={session_id or 'agent:chart'}",
252
- "processing": _processing(),
253
- "inputEcho": data_or_names,
254
- }
255
 
256
 
257
  def _adapter(
 
1
  import pytest
2
+ from fakes import BaseFakeService, processing
3
+ from fakes import fake_chart_opener as _fake_chart_opener
4
 
5
  from TerraFin.agent.definitions import (
6
  DEFAULT_HOSTED_AGENT_NAME,
 
12
  from TerraFin.agent.tools import TerraFinHostedToolAdapter
13
 
14
 
15
+ class _FakeService(BaseFakeService):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def sec_filings(self, ticker: str) -> dict[str, object]:
17
+ return {"ticker": ticker, "cik": 1, "forms": ["10-K"], "filings": [], "processing": processing()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
 
20
  class _RetryingFakeService(_FakeService):
 
43
  raise LookupError(f"Unknown macro instrument: '{name}'")
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
 
48
  def _adapter(
tests/fakes.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared stub doubles for the TerraFin test suite.
2
+
3
+ Imported as `from fakes import ...`; `tests/` is on `sys.path` via the
4
+ `pythonpath` setting in `pyproject.toml`.
5
+
6
+ `BaseFakeService` is the single stub service used by every test that builds the
7
+ default capability registry. `build_default_capability_registry` binds
8
+ `service.<method>` eagerly for each registered capability, so a fake that is
9
+ missing a method fails at registry-build time with `AttributeError`.
10
+
11
+ To keep that from turning every new capability into a mechanical edit across
12
+ several test modules, unknown attributes resolve through `__getattr__` to a
13
+ generic stub. Methods are still spelled out explicitly below when a test
14
+ asserts on their payload shape; anything added later works without changes
15
+ here. Override a single method in a subclass when a module needs a different
16
+ payload.
17
+ """
18
+
19
+ from typing import Any
20
+
21
+
22
+ def processing() -> dict[str, object]:
23
+ """Return the standard `processing` metadata block used by stub payloads."""
24
+
25
+ return {
26
+ "requestedDepth": "auto",
27
+ "resolvedDepth": "full",
28
+ "loadedStart": "2024-01-01",
29
+ "loadedEnd": "2024-12-31",
30
+ "isComplete": True,
31
+ "hasOlder": False,
32
+ "sourceVersion": "test-source",
33
+ "view": "daily",
34
+ }
35
+
36
+
37
+ class BaseFakeService:
38
+ """Stub `TerraFinAgentService` covering the default capability registry."""
39
+
40
+ def __getattr__(self, name: str) -> Any:
41
+ """Resolve capabilities with no explicit stub to a generic payload.
42
+
43
+ Private and dunder lookups must still raise so that `copy`, `pickle`,
44
+ and pytest introspection keep working.
45
+ """
46
+
47
+ if name.startswith("_"):
48
+ raise AttributeError(name)
49
+
50
+ def _auto_stub(*args: Any, **kwargs: Any) -> dict[str, object]:
51
+ return {
52
+ "capability": name,
53
+ "args": list(args),
54
+ "kwargs": kwargs,
55
+ "processing": processing(),
56
+ }
57
+
58
+ return _auto_stub
59
+
60
+ def resolve(self, query: str) -> dict[str, object]:
61
+ return {"type": "stock", "name": query.upper(), "path": f"/stock/{query.upper()}", "processing": processing()}
62
+
63
+ def market_data(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
64
+ return {"ticker": name, "seriesType": "candlestick", "count": 1, "data": [], "processing": {**processing(), "requestedDepth": depth, "view": view}}
65
+
66
+ def patterns(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
67
+ return {"ticker": name, "signals": [], "total": 0, "processing": {**processing(), "requestedDepth": depth, "view": view}}
68
+
69
+ def fcf_history(self, ticker: str, years: int = 10) -> dict[str, object]:
70
+ return {
71
+ "ticker": ticker, "years": years, "rows": [],
72
+ "candidates": {"threeYearAvg": None, "latestAnnual": None, "ttm": None},
73
+ "autoSelectedSource": "annual", "processing": processing(),
74
+ }
75
+
76
+ def similarity_search(self, ticker: str, universe: str = "sp500+nasdaq100+kospi200", period: str = "1y", top_n: int = 20) -> dict[str, object]:
77
+ return {"ticker": ticker, "period": period, "pool": {}, "results": [], "count": 0, "processing": processing()}
78
+
79
+ def indicators(
80
+ self,
81
+ name: str,
82
+ indicators: str,
83
+ *,
84
+ depth: str = "auto",
85
+ view: str = "daily",
86
+ ) -> dict[str, object]:
87
+ return {
88
+ "ticker": name,
89
+ "indicators": {"rsi": {"name": "rsi", "offset": 0, "values": {"value": 55.0}}},
90
+ "unknown": [],
91
+ "processing": {**processing(), "requestedDepth": depth, "view": view, "indicatorQuery": indicators},
92
+ }
93
+
94
+ def market_snapshot(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
95
+ return {
96
+ "ticker": name,
97
+ "price_action": {"current": 100.0},
98
+ "indicators": {"rsi": 55.0},
99
+ "market_breadth": [],
100
+ "watchlist": [],
101
+ "processing": {**processing(), "requestedDepth": depth, "view": view},
102
+ }
103
+
104
+ def lppl_analysis(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
105
+ return {"name": name, "confidence": 0.2, "processing": {**processing(), "requestedDepth": depth, "view": view}}
106
+
107
+ def company_info(self, ticker: str) -> dict[str, object]:
108
+ return {"ticker": ticker, "shortName": f"{ticker} Corp", "processing": processing()}
109
+
110
+ def earnings(self, ticker: str) -> dict[str, object]:
111
+ return {"ticker": ticker, "earnings": [], "processing": processing()}
112
+
113
+ def financials(self, ticker: str, *, statement: str = "income", period: str = "annual") -> dict[str, object]:
114
+ return {"ticker": ticker, "statement": statement, "period": period, "columns": [], "rows": [], "processing": processing()}
115
+
116
+ def portfolio(self, guru: str) -> dict[str, object]:
117
+ return {"guru": guru, "info": {}, "holdings": [], "count": 0, "processing": processing()}
118
+
119
+ def economic(self, indicators: str) -> dict[str, object]:
120
+ return {"indicators": {indicators: {"latest_value": 3.0}}, "processing": processing()}
121
+
122
+ def macro_focus(self, name: str, *, depth: str = "auto", view: str = "daily") -> dict[str, object]:
123
+ return {
124
+ "name": name,
125
+ "info": {"name": name, "type": "index", "description": "Macro", "currentValue": 1.0, "change": 0.0, "changePercent": 0.0},
126
+ "seriesType": "line",
127
+ "count": 1,
128
+ "data": [],
129
+ "processing": {**processing(), "requestedDepth": depth, "view": view},
130
+ }
131
+
132
+ def calendar_events(
133
+ self,
134
+ *,
135
+ year: int,
136
+ month: int,
137
+ categories: str | None = None,
138
+ limit: int | None = None,
139
+ ) -> dict[str, object]:
140
+ return {"events": [], "count": 0, "month": month, "year": year, "categories": categories, "limit": limit, "processing": processing()}
141
+
142
+ def fundamental_screen(self, ticker: str) -> dict[str, object]:
143
+ return {
144
+ "ticker": ticker,
145
+ "moat": {"score": "wide"},
146
+ "earnings_quality": {},
147
+ "balance_sheet": {},
148
+ "capital_allocation": {},
149
+ "pricing_power": {},
150
+ "warnings": [],
151
+ "processing": processing(),
152
+ }
153
+
154
+ def risk_profile(self, name: str, *, depth: str = "auto") -> dict[str, object]:
155
+ return {
156
+ "ticker": name,
157
+ "tail_risk": {},
158
+ "convexity": {},
159
+ "volatility": {"requestedDepth": depth},
160
+ "drawdown": {},
161
+ "warnings": [],
162
+ "processing": processing(),
163
+ }
164
+
165
+ def valuation(self, ticker: str) -> dict[str, object]:
166
+ return {
167
+ "ticker": ticker,
168
+ "dcf": {"status": "ready", "intrinsic_value": 120.0},
169
+ "reverse_dcf": {"status": "ready", "implied_growth_pct": 8.0},
170
+ "relative": {"trailing_pe": 22.0},
171
+ "graham_number": 100.0,
172
+ "margin_of_safety_pct": 12.0,
173
+ "current_price": 107.0,
174
+ "processing": processing(),
175
+ }
176
+
177
+ def sec_filings(self, ticker: str) -> dict[str, object]:
178
+ return {"ticker": ticker, "cik": 1, "forms": [], "filings": [], "processing": processing()}
179
+
180
+ def sec_filing_document(
181
+ self, ticker: str, accession: str, primaryDocument: str, *, form: str = "10-Q"
182
+ ) -> dict[str, object]:
183
+ return {"ticker": ticker, "accession": accession, "primaryDocument": primaryDocument, "toc": [], "charCount": 0, "indexUrl": "", "documentUrl": "", "processing": processing()}
184
+
185
+ def sec_filing_section(
186
+ self, ticker: str, accession: str, primaryDocument: str, sectionSlug: str, *, form: str = "10-Q"
187
+ ) -> dict[str, object]:
188
+ return {"ticker": ticker, "accession": accession, "sectionSlug": sectionSlug, "sectionTitle": "stub", "markdown": "", "charCount": 0, "documentUrl": "", "processing": processing()}
189
+
190
+ def fear_greed(self) -> dict[str, object]:
191
+ return {"score": 50, "rating": "Neutral", "processing": processing()}
192
+
193
+ def sp500_dcf(self) -> dict[str, object]:
194
+ return {"status": "ready", "currentIntrinsicValue": 5000.0, "processing": processing()}
195
+
196
+ def beta_estimate(self, ticker: str) -> dict[str, object]:
197
+ return {"symbol": ticker, "beta": 1.0, "adjustedBeta": 1.0, "rSquared": 0.5, "processing": processing()}
198
+
199
+ def top_companies(self) -> dict[str, object]:
200
+ return {"companies": [], "count": 0, "processing": processing()}
201
+
202
+ def market_regime(self) -> dict[str, object]:
203
+ return {"summary": "stub", "confidence": "low", "signals": [], "processing": processing()}
204
+
205
+ def trailing_forward_pe(self) -> dict[str, object]:
206
+ return {"date": "2026-04-01", "latestValue": 0.0, "history": [], "processing": processing()}
207
+
208
+ def market_breadth(self) -> dict[str, object]:
209
+ return {"metrics": [], "processing": processing()}
210
+
211
+ def watchlist(self) -> dict[str, object]:
212
+ return {"items": [], "count": 0, "processing": processing()}
213
+
214
+
215
+ def fake_chart_opener(
216
+ data_or_names,
217
+ *,
218
+ session_id: str | None = None,
219
+ **kwargs,
220
+ ) -> dict[str, object]:
221
+ _ = kwargs
222
+ return {
223
+ "ok": True,
224
+ "sessionId": session_id or "agent:chart",
225
+ "chartUrl": f"http://127.0.0.1:8001/chart?sessionId={session_id or 'agent:chart'}",
226
+ "processing": processing(),
227
+ "inputEcho": data_or_names,
228
+ }