@drico2008/fincli 0.3.1 → 0.4.0

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 (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -65
  36. package/package.json +7 -7
  37. package/pyproject.toml +1 -1
@@ -1,86 +1,103 @@
1
- """Provider reliability contracts and status classification."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import Any
7
-
8
- from fincli.app.utils.errors import RateLimitError
9
-
10
-
11
- STATUS_OK = "ok"
12
- STATUS_AUTH_FAILED = "auth_failed"
13
- STATUS_RATE_LIMITED = "rate_limited"
1
+ """Provider reliability contracts and status classification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from fincli.app.utils.errors import RateLimitError
9
+
10
+
11
+ STATUS_OK = "ok"
12
+ STATUS_AUTH_FAILED = "auth_failed"
13
+ STATUS_RATE_LIMITED = "rate_limited"
14
14
  STATUS_ENTITLEMENT_MISSING = "entitlement_missing"
15
15
  STATUS_PARTIAL_DATA = "partial_data"
16
+ STATUS_EMPTY_DATA = "empty_data"
17
+ STATUS_NETWORK_ERROR = "network_error"
16
18
  STATUS_SCHEDULE_ONLY = "schedule_only"
17
19
  STATUS_UNAVAILABLE = "unavailable"
18
-
19
- GRANULAR_STATUSES = (
20
- STATUS_OK,
21
- STATUS_AUTH_FAILED,
22
- STATUS_RATE_LIMITED,
20
+ STATUS_CIRCUIT_OPEN = "circuit_open"
21
+
22
+ GRANULAR_STATUSES = (
23
+ STATUS_OK,
24
+ STATUS_AUTH_FAILED,
25
+ STATUS_RATE_LIMITED,
23
26
  STATUS_ENTITLEMENT_MISSING,
24
27
  STATUS_PARTIAL_DATA,
28
+ STATUS_EMPTY_DATA,
29
+ STATUS_NETWORK_ERROR,
25
30
  STATUS_SCHEDULE_ONLY,
26
31
  STATUS_UNAVAILABLE,
32
+ STATUS_CIRCUIT_OPEN,
27
33
  )
28
-
29
-
30
- @dataclass(frozen=True, slots=True)
31
- class ProviderResult:
32
- """Standard result envelope for provider calls."""
33
-
34
- provider: str
35
- operation: str
36
- status: str
37
- realtime_label: str = "unknown"
38
- source: str = ""
39
- data_quality: str = "unknown"
40
- missing_fields: tuple[str, ...] = ()
41
- message: str = ""
42
-
43
-
44
- def classify_provider_error(exc: BaseException) -> str:
45
- """Classify provider failures into stable user-facing reliability statuses."""
46
- if isinstance(exc, RateLimitError):
47
- return STATUS_RATE_LIMITED
48
-
49
- text = f"{exc} {getattr(exc, 'help_text', '') or ''}".lower()
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ProviderResult:
38
+ """Standard result envelope for provider calls."""
39
+
40
+ provider: str
41
+ operation: str
42
+ status: str
43
+ realtime_label: str = "unknown"
44
+ source: str = ""
45
+ data_quality: str = "unknown"
46
+ missing_fields: tuple[str, ...] = ()
47
+ message: str = ""
48
+
49
+
50
+ def classify_provider_error(exc: BaseException) -> str:
51
+ """Classify provider failures into stable user-facing reliability statuses."""
52
+ if isinstance(exc, RateLimitError):
53
+ return STATUS_RATE_LIMITED
54
+
55
+ text = f"{exc} {getattr(exc, 'help_text', '') or ''}".lower()
50
56
  if "429" in text or "rate limit" in text or "too many request" in text:
51
57
  return STATUS_RATE_LIMITED
52
58
  if "401" in text or "unauthorized" in text or "invalid key" in text or "api key" in text and "belum" in text:
53
59
  return STATUS_AUTH_FAILED
54
60
  if "403" in text or "entitlement" in text or "plan" in text or "premium" in text or "forbidden" in text:
55
61
  return STATUS_ENTITLEMENT_MISSING
56
- if "empty" in text or "kosong" in text or "no data" in text or "missing" in text:
62
+ if "timeout" in text or "timed out" in text or "network" in text or "connection" in text or "dns" in text:
63
+ return STATUS_NETWORK_ERROR
64
+ if "empty" in text or "kosong" in text or "no data" in text:
65
+ return STATUS_EMPTY_DATA
66
+ if "missing" in text:
57
67
  return STATUS_PARTIAL_DATA
58
68
  return STATUS_UNAVAILABLE
59
-
60
-
61
- def classify_payload(operation: str, payload: Any) -> tuple[str, tuple[str, ...]]:
62
- """Classify successful provider payload completeness."""
69
+
70
+
71
+ def classify_payload(operation: str, payload: Any) -> tuple[str, tuple[str, ...]]:
72
+ """Classify successful provider payload completeness."""
63
73
  if payload is None:
64
- return STATUS_PARTIAL_DATA, (operation,)
74
+ return STATUS_EMPTY_DATA, (operation,)
65
75
  if isinstance(payload, list) and not payload:
66
- return STATUS_PARTIAL_DATA, (operation,)
67
- if operation == "quote" and getattr(payload, "price", None) is None:
68
- return STATUS_PARTIAL_DATA, ("price",)
69
- if operation == "fundamentals":
70
- missing = tuple(
71
- field
72
- for field in ("market_cap", "pe_ratio", "eps", "revenue", "sector", "industry")
73
- if getattr(payload, field, None) in (None, "")
74
- )
75
- return (STATUS_PARTIAL_DATA if missing else STATUS_OK), missing
76
- return STATUS_OK, ()
77
-
78
-
79
- def result_style(status: str) -> str:
80
- """Return a Rich style name for reliability statuses."""
81
- if status == STATUS_OK:
82
- return "green"
83
- if status in {STATUS_AUTH_FAILED, STATUS_RATE_LIMITED, STATUS_ENTITLEMENT_MISSING, STATUS_UNAVAILABLE}:
76
+ return STATUS_EMPTY_DATA, (operation,)
77
+ if operation == "quote" and getattr(payload, "price", None) is None:
78
+ return STATUS_PARTIAL_DATA, ("price",)
79
+ if operation == "fundamentals":
80
+ missing = tuple(
81
+ field
82
+ for field in ("market_cap", "pe_ratio", "eps", "revenue", "sector", "industry")
83
+ if getattr(payload, field, None) in (None, "")
84
+ )
85
+ return (STATUS_PARTIAL_DATA if missing else STATUS_OK), missing
86
+ return STATUS_OK, ()
87
+
88
+
89
+ def result_style(status: str) -> str:
90
+ """Return a Rich style name for reliability statuses."""
91
+ if status == STATUS_OK:
92
+ return "green"
93
+ if status in {
94
+ STATUS_AUTH_FAILED,
95
+ STATUS_RATE_LIMITED,
96
+ STATUS_ENTITLEMENT_MISSING,
97
+ STATUS_UNAVAILABLE,
98
+ STATUS_NETWORK_ERROR,
99
+ STATUS_CIRCUIT_OPEN,
100
+ }:
84
101
  return "red"
85
102
  return "yellow"
86
-
103
+
@@ -1,8 +1,8 @@
1
- """Research workspace package."""
2
-
3
- from fincli.app.research.engine import ResearchEngine
4
- from fincli.app.research.exporter import write_research_report
5
- from fincli.app.research.formatter import format_research_brief
6
- from fincli.app.research.models import ResearchBrief
7
-
8
- __all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief", "write_research_report"]
1
+ """Research workspace package."""
2
+
3
+ from fincli.app.research.engine import ResearchEngine
4
+ from fincli.app.research.exporter import write_research_report
5
+ from fincli.app.research.formatter import format_research_brief
6
+ from fincli.app.research.models import ResearchBrief
7
+
8
+ __all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief", "write_research_report"]
@@ -1,137 +1,144 @@
1
- """Research workspace orchestration."""
2
-
3
- from __future__ import annotations
4
-
5
- from fincli.app.providers.ai.base import AIRequest, BaseAIProvider
1
+ """Research workspace orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.providers.ai.base import AIRequest, BaseAIProvider
6
6
  from fincli.app.research.models import ResearchBrief
7
7
  from fincli.app.research.prompt_builder import build_research_prompt
8
+ from fincli.app.services.data_trust import build_data_trust_gate
8
9
  from fincli.app.services.market_data import MarketDataService
9
- from fincli.app.services.market_overview import MarketOverview, build_market_overview
10
-
11
-
12
- class ResearchEngine:
13
- """Build compact research briefs around the existing market overview service."""
14
-
15
- def __init__(self, market_service: MarketDataService, ai_provider: BaseAIProvider | None = None, model: str = "") -> None:
16
- self.market_service = market_service
17
- self.ai_provider = ai_provider
18
- self.model = model
19
-
20
- async def build(self, symbol: str, timeframe: str = "1d", mode: str = "quick") -> ResearchBrief:
10
+ from fincli.app.services.market_overview import MarketOverview, build_market_overview
11
+
12
+
13
+ class ResearchEngine:
14
+ """Build compact research briefs around the existing market overview service."""
15
+
16
+ def __init__(self, market_service: MarketDataService, ai_provider: BaseAIProvider | None = None, model: str = "") -> None:
17
+ self.market_service = market_service
18
+ self.ai_provider = ai_provider
19
+ self.model = model
20
+
21
+ async def build(self, symbol: str, timeframe: str = "1d", mode: str = "quick") -> ResearchBrief:
21
22
  overview = await build_market_overview(symbol.upper(), self.market_service, timeframe)
22
- brief = _brief_from_overview(overview, mode)
23
+ brief = _brief_from_overview(overview, mode, self.market_service.provider_metrics_snapshot())
23
24
  if mode == "deep" and self.ai_provider is not None:
24
- prompt = build_research_prompt(brief)
25
- response = await self.ai_provider.complete(AIRequest(prompt=prompt, model=self.model))
26
- return ResearchBrief(
27
- symbol=brief.symbol,
28
- mode=brief.mode,
29
- overview=brief.overview,
30
- snapshot=brief.snapshot,
25
+ prompt = build_research_prompt(brief)
26
+ response = await self.ai_provider.complete(AIRequest(prompt=prompt, model=self.model))
27
+ return ResearchBrief(
28
+ symbol=brief.symbol,
29
+ mode=brief.mode,
30
+ overview=brief.overview,
31
+ snapshot=brief.snapshot,
31
32
  signal=brief.signal,
32
33
  risk=brief.risk,
33
34
  missing_data=brief.missing_data,
34
35
  source_quality=brief.source_quality,
36
+ trust_gate=brief.trust_gate,
35
37
  decision_points=brief.decision_points,
36
- risks=brief.risks,
37
- final_summary=brief.final_summary,
38
- ai_summary=response.content,
39
- report_notes=brief.report_notes,
40
- )
41
- return brief
42
-
43
-
44
- def _brief_from_overview(overview: MarketOverview, mode: str) -> ResearchBrief:
38
+ risks=brief.risks,
39
+ final_summary=brief.final_summary,
40
+ ai_summary=response.content,
41
+ report_notes=brief.report_notes,
42
+ )
43
+ return brief
44
+
45
+
46
+ def _brief_from_overview(overview: MarketOverview, mode: str, provider_metrics: dict[str, object] | None = None) -> ResearchBrief:
45
47
  technical = overview.technical
46
48
  structure = overview.structure
47
49
  fundamentals = overview.fundamentals
48
- decision_points = [
49
- f"Price {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status}).",
50
- f"Trend bias {technical.trend_bias}; structure {structure.trend} with {structure.latest_pattern}.",
51
- f"Key levels: support {technical.support}, resistance {technical.resistance}, ATR {technical.atr}.",
52
- f"Momentum: RSI {technical.rsi}, MACD {technical.macd}/{technical.macd_signal}.",
53
- ]
54
- if fundamentals is not None:
55
- decision_points.append(
56
- f"Fundamentals: P/E {fundamentals.pe_ratio}, EPS {fundamentals.eps}, sector {fundamentals.sector or 'N/A'}."
57
- )
58
- if overview.news:
59
- decision_points.append(f"Latest news: {overview.news[0].title} ({overview.news[0].source}).")
60
-
61
- missing_data = ", ".join(overview.data_quality.missing_fields) if overview.data_quality.missing_fields else "none"
62
- source_quality = (
63
- f"{overview.data_quality.score}/100 | reliability={overview.data_quality.reliability_status} | "
64
- f"provider={overview.data_quality.provider}"
65
- )
66
- signal = _research_signal(overview)
67
- risk = _research_risk(overview)
68
- snapshot = (
69
- f"{overview.symbol}: {technical.trend_bias} trend, {structure.trend} structure, "
70
- f"price {overview.quote.price} {overview.quote.currency}, data {overview.data_quality.score}/100."
71
- )
72
-
73
- risks = [
50
+ trust_gate = build_data_trust_gate(overview.data_quality, provider_metrics)
51
+ decision_points = [
52
+ f"Price {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status}).",
53
+ f"Trend bias {technical.trend_bias}; structure {structure.trend} with {structure.latest_pattern}.",
54
+ f"Key levels: support {technical.support}, resistance {technical.resistance}, ATR {technical.atr}.",
55
+ f"Momentum: RSI {technical.rsi}, MACD {technical.macd}/{technical.macd_signal}.",
56
+ ]
57
+ if fundamentals is not None:
58
+ decision_points.append(
59
+ f"Fundamentals: P/E {fundamentals.pe_ratio}, EPS {fundamentals.eps}, sector {fundamentals.sector or 'N/A'}."
60
+ )
61
+ if overview.news:
62
+ decision_points.append(f"Latest news: {overview.news[0].title} ({overview.news[0].source}).")
63
+
64
+ missing_data = ", ".join(overview.data_quality.missing_fields) if overview.data_quality.missing_fields else "none"
65
+ source_quality = (
66
+ f"{overview.data_quality.score}/100 | reliability={overview.data_quality.reliability_status} | "
67
+ f"provider={overview.data_quality.provider}"
68
+ )
69
+ signal = _research_signal(overview, trust_gate.level)
70
+ risk = _research_risk(overview)
71
+ snapshot = (
72
+ f"{overview.symbol}: {technical.trend_bias} trend, {structure.trend} structure, "
73
+ f"price {overview.quote.price} {overview.quote.currency}, data {overview.data_quality.score}/100."
74
+ )
75
+
76
+ risks = [
74
77
  f"Source quality: {source_quality}.",
78
+ f"Trust gate: {trust_gate.compact()}.",
75
79
  "Use confirmation and invalidation; do not treat this brief as financial advice.",
76
80
  ]
77
- if structure.change_of_character:
78
- risks.append("Change of character detected; directional confidence should be reduced.")
79
- if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
80
- risks.append("RSI is at an extreme; avoid chasing without confirmation.")
81
-
82
- final_summary = _final_summary(overview, signal, risk, missing_data)
83
- report_notes = (
84
- f"Snapshot: {snapshot}",
85
- f"Signal: {signal}",
86
- f"Risk: {risk}",
87
- f"Missing data: {missing_data}",
88
- f"Source quality: {source_quality}",
89
- "Not financial advice.",
90
- )
91
- return ResearchBrief(
92
- symbol=overview.symbol,
93
- mode=mode,
94
- overview=overview,
95
- snapshot=snapshot,
96
- signal=signal,
81
+ if structure.change_of_character:
82
+ risks.append("Change of character detected; directional confidence should be reduced.")
83
+ if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
84
+ risks.append("RSI is at an extreme; avoid chasing without confirmation.")
85
+
86
+ final_summary = _final_summary(overview, signal, risk, missing_data)
87
+ report_notes = (
88
+ f"Snapshot: {snapshot}",
89
+ f"Signal: {signal}",
90
+ f"Risk: {risk}",
91
+ f"Missing data: {missing_data}",
92
+ f"Source quality: {source_quality}",
93
+ "Not financial advice.",
94
+ )
95
+ return ResearchBrief(
96
+ symbol=overview.symbol,
97
+ mode=mode,
98
+ overview=overview,
99
+ snapshot=snapshot,
100
+ signal=signal,
97
101
  risk=risk,
98
102
  missing_data=missing_data,
99
103
  source_quality=source_quality,
104
+ trust_gate=trust_gate.compact(),
100
105
  decision_points=decision_points[:6],
101
- risks=risks[:4],
102
- final_summary=final_summary,
103
- report_notes=report_notes,
104
- )
105
-
106
-
107
- def _research_signal(overview: MarketOverview) -> str:
106
+ risks=risks[:4],
107
+ final_summary=final_summary,
108
+ report_notes=report_notes,
109
+ )
110
+
111
+
112
+ def _research_signal(overview: MarketOverview, trust_level: str = "usable") -> str:
108
113
  trend = overview.technical.trend_bias.lower()
109
114
  structure = overview.structure.trend.lower()
110
115
  rsi = overview.technical.rsi
116
+ if trust_level in {"blocked", "limited"}:
117
+ return "CAUTION - data trust gate prevents directional signal; verify provider data first."
111
118
  if overview.data_quality.reliability_status != "ok":
112
119
  return "CAUTION - data incomplete; verify provider source first."
113
- if trend == "bullish" and structure == "bullish" and (rsi is None or rsi < 75):
114
- return "BULLISH WATCH - only after support/retest confirmation."
115
- if trend == "bearish" and structure == "bearish" and (rsi is None or rsi > 25):
116
- return "BEARISH WATCH - only after resistance/rejection confirmation."
117
- return "CAUTION - mixed or extended setup; wait for confirmation."
118
-
119
-
120
- def _research_risk(overview: MarketOverview) -> str:
121
- risks: list[str] = []
122
- if overview.structure.change_of_character:
123
- risks.append("CHoCH detected")
124
- if overview.technical.rsi is not None and overview.technical.rsi > 75:
125
- risks.append("RSI overbought")
126
- if overview.technical.rsi is not None and overview.technical.rsi < 25:
127
- risks.append("RSI oversold")
128
- if overview.data_quality.missing_fields:
129
- risks.append(f"missing {', '.join(overview.data_quality.missing_fields)}")
130
- return "; ".join(risks) if risks else "standard market risk; define invalidation before entry"
131
-
132
-
133
- def _final_summary(overview: MarketOverview, signal: str, risk: str, missing_data: str) -> str:
134
- return (
135
- f"{overview.symbol}: {signal}. Key risk: {risk}. "
136
- f"Missing data: {missing_data}. Treat this as research context, not financial advice."
137
- )
120
+ if trend == "bullish" and structure == "bullish" and (rsi is None or rsi < 75):
121
+ return "BULLISH WATCH - only after support/retest confirmation."
122
+ if trend == "bearish" and structure == "bearish" and (rsi is None or rsi > 25):
123
+ return "BEARISH WATCH - only after resistance/rejection confirmation."
124
+ return "CAUTION - mixed or extended setup; wait for confirmation."
125
+
126
+
127
+ def _research_risk(overview: MarketOverview) -> str:
128
+ risks: list[str] = []
129
+ if overview.structure.change_of_character:
130
+ risks.append("CHoCH detected")
131
+ if overview.technical.rsi is not None and overview.technical.rsi > 75:
132
+ risks.append("RSI overbought")
133
+ if overview.technical.rsi is not None and overview.technical.rsi < 25:
134
+ risks.append("RSI oversold")
135
+ if overview.data_quality.missing_fields:
136
+ risks.append(f"missing {', '.join(overview.data_quality.missing_fields)}")
137
+ return "; ".join(risks) if risks else "standard market risk; define invalidation before entry"
138
+
139
+
140
+ def _final_summary(overview: MarketOverview, signal: str, risk: str, missing_data: str) -> str:
141
+ return (
142
+ f"{overview.symbol}: {signal}. Key risk: {risk}. "
143
+ f"Missing data: {missing_data}. Treat this as research context, not financial advice."
144
+ )
@@ -1,91 +1,91 @@
1
- """Export helpers for Research Engine v2 reports."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- from fincli.app.research.models import ResearchBrief
10
- from fincli.app.utils.errors import CommandError
11
-
12
-
13
- def write_research_report(brief: ResearchBrief, fmt: str, target: str | Path) -> Path:
14
- report_format = fmt.lower()
15
- path = _safe_research_path(target, report_format)
16
- path.parent.mkdir(parents=True, exist_ok=True)
17
- if report_format == "json":
18
- path.write_text(json.dumps(_research_payload(brief), indent=2, default=str), encoding="utf-8")
19
- return path
20
- if report_format in {"md", "markdown"}:
21
- path.write_text(_research_markdown(brief), encoding="utf-8")
22
- return path
23
- raise CommandError("Research export format harus md atau json.")
24
-
25
-
26
- def _safe_research_path(target: str | Path, fmt: str) -> Path:
27
- path = Path(target).expanduser()
28
- if any(part == ".." for part in path.parts):
29
- raise CommandError("Research export path tidak boleh mengandung '..'.")
30
- allowed = {".md", ".json"} if fmt in {"md", "markdown", "json"} else set()
31
- if path.suffix.lower() not in allowed:
32
- raise CommandError("Research export path harus berakhir .md atau .json.")
33
- return path
34
-
35
-
36
- def _research_payload(brief: ResearchBrief) -> dict[str, Any]:
37
- return {
38
- "symbol": brief.symbol,
39
- "mode": brief.mode,
40
- "snapshot": brief.snapshot,
41
- "signal": brief.signal,
42
- "risk": brief.risk,
43
- "missing_data": brief.missing_data,
44
- "source_quality": brief.source_quality,
45
- "decision_points": brief.decision_points,
46
- "risks": brief.risks,
47
- "final_summary": brief.final_summary,
48
- "ai_summary": brief.ai_summary,
49
- "report_notes": list(brief.report_notes),
50
- "disclaimer": "Not financial advice.",
51
- }
52
-
53
-
54
- def _research_markdown(brief: ResearchBrief) -> str:
55
- notes = "\n".join(f"- {item}" for item in brief.report_notes)
56
- points = "\n".join(f"- {item}" for item in brief.decision_points)
57
- risks = "\n".join(f"- {item}" for item in brief.risks)
58
- return "\n".join(
59
- [
60
- f"# FinCLI Research Report: {brief.symbol}",
61
- "",
62
- f"- Mode: {brief.mode}",
63
- f"- Snapshot: {brief.snapshot}",
64
- f"- Signal: {brief.signal}",
65
- f"- Risk: {brief.risk}",
66
- f"- Missing Data: {brief.missing_data}",
67
- f"- Source Quality: {brief.source_quality}",
68
- "",
69
- "## Decision Points",
70
- "",
71
- points or "- None.",
72
- "",
73
- "## Risk Notes",
74
- "",
75
- risks or "- None.",
76
- "",
77
- "## Report Notes",
78
- "",
79
- notes or "- None.",
80
- "",
81
- "## Final Summary",
82
- "",
83
- brief.final_summary,
84
- "",
85
- "## Disclaimer",
86
- "",
87
- "Not financial advice.",
88
- "",
89
- ]
90
- )
91
-
1
+ """Export helpers for Research Engine v2 reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fincli.app.research.models import ResearchBrief
10
+ from fincli.app.utils.errors import CommandError
11
+
12
+
13
+ def write_research_report(brief: ResearchBrief, fmt: str, target: str | Path) -> Path:
14
+ report_format = fmt.lower()
15
+ path = _safe_research_path(target, report_format)
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ if report_format == "json":
18
+ path.write_text(json.dumps(_research_payload(brief), indent=2, default=str), encoding="utf-8")
19
+ return path
20
+ if report_format in {"md", "markdown"}:
21
+ path.write_text(_research_markdown(brief), encoding="utf-8")
22
+ return path
23
+ raise CommandError("Research export format harus md atau json.")
24
+
25
+
26
+ def _safe_research_path(target: str | Path, fmt: str) -> Path:
27
+ path = Path(target).expanduser()
28
+ if any(part == ".." for part in path.parts):
29
+ raise CommandError("Research export path tidak boleh mengandung '..'.")
30
+ allowed = {".md", ".json"} if fmt in {"md", "markdown", "json"} else set()
31
+ if path.suffix.lower() not in allowed:
32
+ raise CommandError("Research export path harus berakhir .md atau .json.")
33
+ return path
34
+
35
+
36
+ def _research_payload(brief: ResearchBrief) -> dict[str, Any]:
37
+ return {
38
+ "symbol": brief.symbol,
39
+ "mode": brief.mode,
40
+ "snapshot": brief.snapshot,
41
+ "signal": brief.signal,
42
+ "risk": brief.risk,
43
+ "missing_data": brief.missing_data,
44
+ "source_quality": brief.source_quality,
45
+ "decision_points": brief.decision_points,
46
+ "risks": brief.risks,
47
+ "final_summary": brief.final_summary,
48
+ "ai_summary": brief.ai_summary,
49
+ "report_notes": list(brief.report_notes),
50
+ "disclaimer": "Not financial advice.",
51
+ }
52
+
53
+
54
+ def _research_markdown(brief: ResearchBrief) -> str:
55
+ notes = "\n".join(f"- {item}" for item in brief.report_notes)
56
+ points = "\n".join(f"- {item}" for item in brief.decision_points)
57
+ risks = "\n".join(f"- {item}" for item in brief.risks)
58
+ return "\n".join(
59
+ [
60
+ f"# FinCLI Research Report: {brief.symbol}",
61
+ "",
62
+ f"- Mode: {brief.mode}",
63
+ f"- Snapshot: {brief.snapshot}",
64
+ f"- Signal: {brief.signal}",
65
+ f"- Risk: {brief.risk}",
66
+ f"- Missing Data: {brief.missing_data}",
67
+ f"- Source Quality: {brief.source_quality}",
68
+ "",
69
+ "## Decision Points",
70
+ "",
71
+ points or "- None.",
72
+ "",
73
+ "## Risk Notes",
74
+ "",
75
+ risks or "- None.",
76
+ "",
77
+ "## Report Notes",
78
+ "",
79
+ notes or "- None.",
80
+ "",
81
+ "## Final Summary",
82
+ "",
83
+ brief.final_summary,
84
+ "",
85
+ "## Disclaimer",
86
+ "",
87
+ "Not financial advice.",
88
+ "",
89
+ ]
90
+ )
91
+