@drico2008/fincli 0.2.2 → 0.3.1

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.
@@ -0,0 +1,305 @@
1
+ """Portfolio Risk v2 analytics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.modules.user_profile import UserProfile
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class AssetClassExposure:
12
+ asset_class: str
13
+ market_value: float
14
+ weight: float
15
+ count: int
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class CurrencyExposure:
20
+ currency: str
21
+ market_value: float
22
+ weight: float
23
+ count: int
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ConcentrationRisk:
28
+ top_symbol: str
29
+ top_weight: float
30
+ level: str
31
+ note: str
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class PortfolioHealth:
36
+ score: int
37
+ label: str
38
+ notes: tuple[str, ...]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class AssetClassWarning:
43
+ asset_class: str
44
+ weight: float
45
+ cap: float
46
+ level: str
47
+ note: str
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class RiskBudget:
52
+ profile_gameplay: str
53
+ equity: float
54
+ currency: str
55
+ risk_per_trade: float
56
+ max_portfolio_risk: float
57
+ note: str
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class PortfolioRiskReport:
62
+ total_cost_basis: float
63
+ total_market_value: float
64
+ realized_pnl: float
65
+ unrealized_pnl: float
66
+ total_pnl: float
67
+ exposure_by_asset_class: dict[str, AssetClassExposure]
68
+ currency_exposure: dict[str, CurrencyExposure]
69
+ concentration: ConcentrationRisk
70
+ health: PortfolioHealth
71
+ drawdown_estimate: float
72
+ asset_class_warnings: tuple[AssetClassWarning, ...]
73
+ risk_budget: RiskBudget
74
+
75
+
76
+ def build_portfolio_risk(
77
+ positions: list[dict[str, object]],
78
+ market_values: dict[str, tuple[float | None, float | None, float | None]],
79
+ realized_pnl: float,
80
+ profile: UserProfile | None = None,
81
+ ) -> PortfolioRiskReport:
82
+ """Build exposure, concentration, PnL, and health score from positions."""
83
+ total_cost_basis = 0.0
84
+ total_market_value = 0.0
85
+ unrealized_pnl = 0.0
86
+ exposure_values: dict[str, float] = {}
87
+ exposure_counts: dict[str, int] = {}
88
+ currency_values: dict[str, float] = {}
89
+ currency_counts: dict[str, int] = {}
90
+ symbol_values: dict[str, float] = {}
91
+ missing_prices = 0
92
+
93
+ for row in positions:
94
+ symbol = str(row["symbol"]).upper()
95
+ quantity = float(row["quantity"])
96
+ average_price = float(row["average_price"])
97
+ cost_basis = quantity * average_price
98
+ total_cost_basis += cost_basis
99
+
100
+ current_price, pnl, _pnl_percent = market_values.get(symbol, (None, None, None))
101
+ if current_price is None:
102
+ missing_prices += 1
103
+ market_value = cost_basis
104
+ else:
105
+ market_value = quantity * current_price
106
+ total_market_value += market_value
107
+ if pnl is not None:
108
+ unrealized_pnl += pnl
109
+
110
+ asset_class = classify_asset_class(symbol)
111
+ exposure_values[asset_class] = exposure_values.get(asset_class, 0.0) + market_value
112
+ exposure_counts[asset_class] = exposure_counts.get(asset_class, 0) + 1
113
+ currency = str(row.get("currency", "USD")).upper()
114
+ currency_values[currency] = currency_values.get(currency, 0.0) + market_value
115
+ currency_counts[currency] = currency_counts.get(currency, 0) + 1
116
+ symbol_values[symbol] = market_value
117
+
118
+ exposure_by_asset_class = {
119
+ asset_class: AssetClassExposure(
120
+ asset_class=asset_class,
121
+ market_value=value,
122
+ weight=_weight(value, total_market_value),
123
+ count=exposure_counts.get(asset_class, 0),
124
+ )
125
+ for asset_class, value in sorted(exposure_values.items())
126
+ }
127
+ currency_exposure = {
128
+ currency: CurrencyExposure(
129
+ currency=currency,
130
+ market_value=value,
131
+ weight=_weight(value, total_market_value),
132
+ count=currency_counts.get(currency, 0),
133
+ )
134
+ for currency, value in sorted(currency_values.items())
135
+ }
136
+ concentration = _concentration(symbol_values, total_market_value)
137
+ total_pnl = realized_pnl + unrealized_pnl
138
+ drawdown_estimate = _drawdown_estimate(unrealized_pnl, total_cost_basis)
139
+ warnings = _asset_class_warnings(exposure_by_asset_class)
140
+ risk_budget = _risk_budget(profile)
141
+ health = _health_score(
142
+ positions_count=len(positions),
143
+ top_weight=concentration.top_weight,
144
+ missing_prices=missing_prices,
145
+ total_pnl=total_pnl,
146
+ total_cost_basis=total_cost_basis,
147
+ asset_class_count=len(exposure_by_asset_class),
148
+ drawdown_estimate=drawdown_estimate,
149
+ warning_count=len(warnings),
150
+ )
151
+ return PortfolioRiskReport(
152
+ total_cost_basis=total_cost_basis,
153
+ total_market_value=total_market_value,
154
+ realized_pnl=realized_pnl,
155
+ unrealized_pnl=unrealized_pnl,
156
+ total_pnl=total_pnl,
157
+ exposure_by_asset_class=exposure_by_asset_class,
158
+ currency_exposure=currency_exposure,
159
+ concentration=concentration,
160
+ health=health,
161
+ drawdown_estimate=drawdown_estimate,
162
+ asset_class_warnings=warnings,
163
+ risk_budget=risk_budget,
164
+ )
165
+
166
+
167
+ def classify_asset_class(symbol: str) -> str:
168
+ normalized = symbol.upper()
169
+ if normalized.endswith("-USD") or normalized.endswith("USDT") or normalized in {"BTC", "ETH", "SOL", "BNB"}:
170
+ return "crypto"
171
+ if normalized.endswith("=X") or len(normalized) == 6 and normalized.isalpha():
172
+ return "forex"
173
+ if normalized.startswith("^") or normalized in {"SPX", "NASDAQ", "DJI", "DAX", "NIKKEI", "HSI"}:
174
+ return "index"
175
+ if normalized.endswith("=F") or normalized in {"XAUUSD", "XAGUSD", "WTI", "BRENT", "GOLD", "SILVER"}:
176
+ return "commodity"
177
+ if normalized.endswith(".JK") or normalized.endswith(".L") or normalized.endswith(".TO") or normalized.isalpha():
178
+ return "equity"
179
+ return "other"
180
+
181
+
182
+ def _concentration(symbol_values: dict[str, float], total_market_value: float) -> ConcentrationRisk:
183
+ if not symbol_values or total_market_value <= 0:
184
+ return ConcentrationRisk("-", 0.0, "empty", "No market value available.")
185
+ top_symbol, top_value = max(symbol_values.items(), key=lambda item: item[1])
186
+ top_weight = _weight(top_value, total_market_value)
187
+ if top_weight >= 60:
188
+ level = "high"
189
+ note = "Top position dominates portfolio."
190
+ elif top_weight >= 35:
191
+ level = "medium"
192
+ note = "Top position needs monitoring."
193
+ else:
194
+ level = "healthy"
195
+ note = "No single position dominates."
196
+ return ConcentrationRisk(top_symbol, top_weight, level, note)
197
+
198
+
199
+ def _health_score(
200
+ positions_count: int,
201
+ top_weight: float,
202
+ missing_prices: int,
203
+ total_pnl: float,
204
+ total_cost_basis: float,
205
+ asset_class_count: int,
206
+ drawdown_estimate: float,
207
+ warning_count: int,
208
+ ) -> PortfolioHealth:
209
+ score = 100
210
+ notes: list[str] = []
211
+ if positions_count == 0:
212
+ return PortfolioHealth(0, "empty", ("No positions.",))
213
+ if positions_count < 3:
214
+ score -= 15
215
+ notes.append("few positions")
216
+ if asset_class_count < 2:
217
+ score -= 10
218
+ notes.append("single asset class")
219
+ if top_weight >= 60:
220
+ score -= 25
221
+ notes.append("high concentration")
222
+ elif top_weight >= 35:
223
+ score -= 10
224
+ notes.append("medium concentration")
225
+ if missing_prices:
226
+ score -= min(30, missing_prices * 10)
227
+ notes.append(f"{missing_prices} missing price(s)")
228
+ if warning_count:
229
+ score -= min(20, warning_count * 8)
230
+ notes.append(f"{warning_count} asset-class cap warning(s)")
231
+ if drawdown_estimate <= -25:
232
+ score -= 15
233
+ notes.append("deep drawdown estimate")
234
+ pnl_ratio = (total_pnl / total_cost_basis * 100) if total_cost_basis else 0.0
235
+ if pnl_ratio <= -20:
236
+ score -= 20
237
+ notes.append("large drawdown")
238
+ elif pnl_ratio < 0:
239
+ score -= 8
240
+ notes.append("negative total PnL")
241
+
242
+ score = max(0, min(100, score))
243
+ if score >= 80:
244
+ label = "healthy"
245
+ elif score >= 60:
246
+ label = "watch"
247
+ elif score >= 40:
248
+ label = "caution"
249
+ else:
250
+ label = "high risk"
251
+ return PortfolioHealth(score, label, tuple(notes) or ("balanced baseline",))
252
+
253
+
254
+ def _weight(value: float, total: float) -> float:
255
+ return (value / total * 100) if total else 0.0
256
+
257
+
258
+ def _drawdown_estimate(unrealized_pnl: float, total_cost_basis: float) -> float:
259
+ if total_cost_basis <= 0:
260
+ return 0.0
261
+ return min(0.0, unrealized_pnl / total_cost_basis * 100)
262
+
263
+
264
+ def _asset_class_warnings(exposures: dict[str, AssetClassExposure]) -> tuple[AssetClassWarning, ...]:
265
+ caps = {"crypto": 25.0, "forex": 40.0, "commodity": 35.0, "index": 50.0, "equity": 70.0, "other": 25.0}
266
+ warnings: list[AssetClassWarning] = []
267
+ for exposure in exposures.values():
268
+ cap = caps.get(exposure.asset_class, 25.0)
269
+ if exposure.weight > cap:
270
+ warnings.append(
271
+ AssetClassWarning(
272
+ asset_class=exposure.asset_class,
273
+ weight=exposure.weight,
274
+ cap=cap,
275
+ level="high" if exposure.weight >= cap + 20 else "watch",
276
+ note=f"{exposure.asset_class} exposure {exposure.weight:.2f}% exceeds cap {cap:.2f}%.",
277
+ )
278
+ )
279
+ return tuple(warnings)
280
+
281
+
282
+ def _risk_budget(profile: UserProfile | None) -> RiskBudget:
283
+ if profile is None:
284
+ return RiskBudget("unprofiled", 0.0, "USD", 0.0, 0.0, "Run /profile set to enable risk budget.")
285
+ gameplay = profile.gameplay
286
+ if gameplay == "Scalper":
287
+ per_trade_pct = 1.0
288
+ max_portfolio_pct = 5.0
289
+ elif gameplay == "Intra day":
290
+ per_trade_pct = 1.25
291
+ max_portfolio_pct = 7.5
292
+ elif gameplay == "Day trade":
293
+ per_trade_pct = 1.5
294
+ max_portfolio_pct = 10.0
295
+ else:
296
+ per_trade_pct = 2.0
297
+ max_portfolio_pct = 12.0
298
+ return RiskBudget(
299
+ profile_gameplay=gameplay,
300
+ equity=profile.equity,
301
+ currency=profile.currency,
302
+ risk_per_trade=profile.equity * per_trade_pct / 100,
303
+ max_portfolio_risk=profile.equity * max_portfolio_pct / 100,
304
+ note=f"{per_trade_pct:.2f}% per trade, {max_portfolio_pct:.2f}% max portfolio risk budget.",
305
+ )
@@ -1,6 +1,6 @@
1
1
  """Local plugin discovery for FinCLI.
2
2
 
3
- Plugins are intentionally manifest-first in v0.2.2: FinCLI reads metadata and
3
+ Plugins are intentionally manifest-first in v0.3.1: FinCLI reads metadata and
4
4
  exposes status, but does not execute plugin code yet. This keeps the plugin
5
5
  surface useful without creating a security footgun.
6
6
  """
@@ -0,0 +1,86 @@
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
+ STATUS_ENTITLEMENT_MISSING = "entitlement_missing"
15
+ STATUS_PARTIAL_DATA = "partial_data"
16
+ STATUS_SCHEDULE_ONLY = "schedule_only"
17
+ STATUS_UNAVAILABLE = "unavailable"
18
+
19
+ GRANULAR_STATUSES = (
20
+ STATUS_OK,
21
+ STATUS_AUTH_FAILED,
22
+ STATUS_RATE_LIMITED,
23
+ STATUS_ENTITLEMENT_MISSING,
24
+ STATUS_PARTIAL_DATA,
25
+ STATUS_SCHEDULE_ONLY,
26
+ STATUS_UNAVAILABLE,
27
+ )
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()
50
+ if "429" in text or "rate limit" in text or "too many request" in text:
51
+ return STATUS_RATE_LIMITED
52
+ if "401" in text or "unauthorized" in text or "invalid key" in text or "api key" in text and "belum" in text:
53
+ return STATUS_AUTH_FAILED
54
+ if "403" in text or "entitlement" in text or "plan" in text or "premium" in text or "forbidden" in text:
55
+ return STATUS_ENTITLEMENT_MISSING
56
+ if "empty" in text or "kosong" in text or "no data" in text or "missing" in text:
57
+ return STATUS_PARTIAL_DATA
58
+ return STATUS_UNAVAILABLE
59
+
60
+
61
+ def classify_payload(operation: str, payload: Any) -> tuple[str, tuple[str, ...]]:
62
+ """Classify successful provider payload completeness."""
63
+ if payload is None:
64
+ return STATUS_PARTIAL_DATA, (operation,)
65
+ 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}:
84
+ return "red"
85
+ return "yellow"
86
+
@@ -1,7 +1,8 @@
1
1
  """Research workspace package."""
2
2
 
3
3
  from fincli.app.research.engine import ResearchEngine
4
+ from fincli.app.research.exporter import write_research_report
4
5
  from fincli.app.research.formatter import format_research_brief
5
6
  from fincli.app.research.models import ResearchBrief
6
7
 
7
- __all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief"]
8
+ __all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief", "write_research_report"]
@@ -27,10 +27,16 @@ class ResearchEngine:
27
27
  symbol=brief.symbol,
28
28
  mode=brief.mode,
29
29
  overview=brief.overview,
30
+ snapshot=brief.snapshot,
31
+ signal=brief.signal,
32
+ risk=brief.risk,
33
+ missing_data=brief.missing_data,
34
+ source_quality=brief.source_quality,
30
35
  decision_points=brief.decision_points,
31
36
  risks=brief.risks,
32
37
  final_summary=brief.final_summary,
33
38
  ai_summary=response.content,
39
+ report_notes=brief.report_notes,
34
40
  )
35
41
  return brief
36
42
 
@@ -52,8 +58,20 @@ def _brief_from_overview(overview: MarketOverview, mode: str) -> ResearchBrief:
52
58
  if overview.news:
53
59
  decision_points.append(f"Latest news: {overview.news[0].title} ({overview.news[0].source}).")
54
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
+
55
73
  risks = [
56
- f"Data quality {overview.data_quality.score}/100; provider label {overview.data_quality.provider}.",
74
+ f"Source quality: {source_quality}.",
57
75
  "Use confirmation and invalidation; do not treat this brief as financial advice.",
58
76
  ]
59
77
  if structure.change_of_character:
@@ -61,15 +79,59 @@ def _brief_from_overview(overview: MarketOverview, mode: str) -> ResearchBrief:
61
79
  if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
62
80
  risks.append("RSI is at an extreme; avoid chasing without confirmation.")
63
81
 
64
- final_summary = (
65
- f"{overview.symbol} is a {technical.trend_bias} / {structure.trend} setup with "
66
- f"{overview.data_quality.score}/100 data quality. Focus on support/resistance reaction and news/fundamental confirmation."
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.",
67
90
  )
68
91
  return ResearchBrief(
69
92
  symbol=overview.symbol,
70
93
  mode=mode,
71
94
  overview=overview,
95
+ snapshot=snapshot,
96
+ signal=signal,
97
+ risk=risk,
98
+ missing_data=missing_data,
99
+ source_quality=source_quality,
72
100
  decision_points=decision_points[:6],
73
101
  risks=risks[:4],
74
102
  final_summary=final_summary,
103
+ report_notes=report_notes,
104
+ )
105
+
106
+
107
+ def _research_signal(overview: MarketOverview) -> str:
108
+ trend = overview.technical.trend_bias.lower()
109
+ structure = overview.structure.trend.lower()
110
+ rsi = overview.technical.rsi
111
+ if overview.data_quality.reliability_status != "ok":
112
+ 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."
75
137
  )
@@ -0,0 +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
+
@@ -9,14 +9,19 @@ from fincli.app.utils.formatting import semantic_text
9
9
 
10
10
 
11
11
  def format_research_brief(brief: ResearchBrief) -> Table:
12
- table = Table(title=f"Research Brief: {brief.symbol} | {brief.mode}", expand=True)
12
+ table = Table(title=f"Research Center: {brief.symbol} | {brief.mode} | Research Brief v2", expand=True)
13
13
  table.add_column("Section", style="cyan", no_wrap=True)
14
14
  table.add_column("Description", overflow="fold")
15
- table.add_row("Data Quality", semantic_text(f"{brief.overview.data_quality.score}/100 | {brief.overview.data_quality.provider}"))
16
- table.add_row("Decision Points", semantic_text("\n".join(f"- {point}" for point in brief.decision_points)))
17
- table.add_row("Risks", semantic_text("\n".join(f"- {risk}" for risk in brief.risks)))
15
+ table.add_row("Snapshot", semantic_text(brief.snapshot))
16
+ table.add_row("Signal", semantic_text(brief.signal))
17
+ table.add_row("Risk", semantic_text(brief.risk))
18
+ table.add_row("Missing Data", semantic_text(brief.missing_data))
19
+ table.add_row("Source Quality", semantic_text(brief.source_quality))
20
+ table.add_row("Decision Points", semantic_text(" | ".join(brief.decision_points[:2])))
21
+ if brief.mode == "report":
22
+ table.add_row("Report Notes", semantic_text("\n".join(f"- {item}" for item in brief.report_notes)))
18
23
  if brief.ai_summary:
19
24
  table.add_row("AI Summary", brief.ai_summary)
20
25
  table.add_row("Final Summary", semantic_text(brief.final_summary))
21
- table.caption = "Research output is informational only, not financial advice."
26
+ table.caption = "Research output is informational only. Not financial advice."
22
27
  return table
@@ -12,7 +12,13 @@ class ResearchBrief:
12
12
  symbol: str
13
13
  mode: str
14
14
  overview: MarketOverview
15
+ snapshot: str
16
+ signal: str
17
+ risk: str
18
+ missing_data: str
19
+ source_quality: str
15
20
  decision_points: list[str]
16
21
  risks: list[str]
17
22
  final_summary: str
18
23
  ai_summary: str = ""
24
+ report_notes: tuple[str, ...] = ()
@@ -6,10 +6,11 @@ from fincli.app.research.models import ResearchBrief
6
6
 
7
7
 
8
8
  RESEARCH_WORKSPACE_PROMPT = """
9
- You are FinCLI Research Workspace.
9
+ You are FinCLI Research Workspace, operating as Research Engine v2.
10
10
 
11
11
  Rules:
12
12
  - Build a concise investment/trading research note from the provided data only.
13
+ - Output must focus on snapshot, signal, risk, missing data, source quality.
13
14
  - Do not invent price, news, fundamentals, or certainty.
14
15
  - Do not copy the opening summary as the final summary.
15
16
  - Prioritize decision-useful points over long explanation.
@@ -36,6 +37,12 @@ def build_research_prompt(brief: ResearchBrief) -> str:
36
37
  f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
37
38
  f"Symbol: {brief.symbol}\n"
38
39
  f"Mode: {brief.mode}\n"
40
+ "Required focus: snapshot, signal, risk, missing data, source quality.\n"
41
+ f"Snapshot: {brief.snapshot}\n"
42
+ f"Signal: {brief.signal}\n"
43
+ f"Risk: {brief.risk}\n"
44
+ f"Missing Data: {brief.missing_data}\n"
45
+ f"Source Quality: {brief.source_quality}\n"
39
46
  f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
40
47
  f"Data Quality: {overview.data_quality.score}/100; OHLCV={overview.data_quality.ohlcv}; News={overview.data_quality.news}; Fundamentals={overview.data_quality.fundamentals}\n"
41
48
  f"Technical: trend={overview.technical.trend_bias}; rsi={overview.technical.rsi}; macd={overview.technical.macd}; support={overview.technical.support}; resistance={overview.technical.resistance}; atr={overview.technical.atr}\n"
@@ -19,7 +19,7 @@ class MacroIndicator:
19
19
  class MacroDataService:
20
20
  """Return macro context from free fallback datasets.
21
21
 
22
- v0.2.2 keeps this deterministic/offline so /macro remains usable without API keys.
22
+ v0.3.1 keeps this deterministic/offline so /macro remains usable without API keys.
23
23
  Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
24
24
  """
25
25