@drico2008/fincli 0.1.9 → 0.3.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -625
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +26 -14
  7. package/fincli/app/analysis/analyzer.py +107 -96
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +108 -81
  14. package/fincli/app/cli/router.py +2327 -1237
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/portfolio_risk.py +305 -0
  21. package/fincli/app/modules/reports.py +151 -0
  22. package/fincli/app/modules/scanner.py +111 -93
  23. package/fincli/app/modules/transactions.py +84 -84
  24. package/fincli/app/modules/user_profile.py +84 -0
  25. package/fincli/app/plugins/loader.py +72 -0
  26. package/fincli/app/providers/ai/manager.py +60 -60
  27. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  28. package/fincli/app/providers/market/base.py +98 -77
  29. package/fincli/app/providers/market/custom_provider.py +186 -169
  30. package/fincli/app/providers/market/manager.py +84 -1
  31. package/fincli/app/providers/market/symbols.py +143 -0
  32. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  33. package/fincli/app/providers/reliability.py +86 -0
  34. package/fincli/app/research/__init__.py +8 -0
  35. package/fincli/app/research/engine.py +137 -0
  36. package/fincli/app/research/exporter.py +91 -0
  37. package/fincli/app/research/formatter.py +27 -0
  38. package/fincli/app/research/models.py +24 -0
  39. package/fincli/app/research/prompt_builder.py +54 -0
  40. package/fincli/app/services/macro_data.py +50 -0
  41. package/fincli/app/services/market_data.py +274 -169
  42. package/fincli/app/services/market_overview.py +42 -1
  43. package/fincli/app/services/news_aggregator.py +95 -0
  44. package/fincli/app/services/web_research.py +267 -267
  45. package/fincli/app/storage/config.py +122 -88
  46. package/fincli/app/storage/database.py +209 -99
  47. package/fincli/app/storage/provider_metrics.py +61 -0
  48. package/fincli/app/storage/secrets.py +26 -2
  49. package/fincli/app/tui/components.py +68 -50
  50. package/fincli/app/tui/layout.py +269 -258
  51. package/fincli/app/tui/market_provider_selector.py +3 -1
  52. package/fincli/app/tui/theme.py +134 -74
  53. package/fincli/app/utils/formatting.py +123 -60
  54. package/package.json +22 -20
  55. package/pyproject.toml +35 -35
@@ -0,0 +1,137 @@
1
+ """Research workspace orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.providers.ai.base import AIRequest, BaseAIProvider
6
+ from fincli.app.research.models import ResearchBrief
7
+ from fincli.app.research.prompt_builder import build_research_prompt
8
+ 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:
21
+ overview = await build_market_overview(symbol.upper(), self.market_service, timeframe)
22
+ brief = _brief_from_overview(overview, mode)
23
+ 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,
31
+ signal=brief.signal,
32
+ risk=brief.risk,
33
+ missing_data=brief.missing_data,
34
+ source_quality=brief.source_quality,
35
+ 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:
45
+ technical = overview.technical
46
+ structure = overview.structure
47
+ 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 = [
74
+ f"Source quality: {source_quality}.",
75
+ "Use confirmation and invalidation; do not treat this brief as financial advice.",
76
+ ]
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,
97
+ risk=risk,
98
+ missing_data=missing_data,
99
+ source_quality=source_quality,
100
+ 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:
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."
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
+
@@ -0,0 +1,27 @@
1
+ """Rich renderers for research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+
7
+ from fincli.app.research.models import ResearchBrief
8
+ from fincli.app.utils.formatting import semantic_text
9
+
10
+
11
+ def format_research_brief(brief: ResearchBrief) -> Table:
12
+ table = Table(title=f"Research Center: {brief.symbol} | {brief.mode} | Research Brief v2", expand=True)
13
+ table.add_column("Section", style="cyan", no_wrap=True)
14
+ table.add_column("Description", overflow="fold")
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)))
23
+ if brief.ai_summary:
24
+ table.add_row("AI Summary", brief.ai_summary)
25
+ table.add_row("Final Summary", semantic_text(brief.final_summary))
26
+ table.caption = "Research output is informational only. Not financial advice."
27
+ return table
@@ -0,0 +1,24 @@
1
+ """Models for FinCLI research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.services.market_overview import MarketOverview
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ResearchBrief:
12
+ symbol: str
13
+ mode: str
14
+ overview: MarketOverview
15
+ snapshot: str
16
+ signal: str
17
+ risk: str
18
+ missing_data: str
19
+ source_quality: str
20
+ decision_points: list[str]
21
+ risks: list[str]
22
+ final_summary: str
23
+ ai_summary: str = ""
24
+ report_notes: tuple[str, ...] = ()
@@ -0,0 +1,54 @@
1
+ """Prompt builder for deep research mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.research.models import ResearchBrief
6
+
7
+
8
+ RESEARCH_WORKSPACE_PROMPT = """
9
+ You are FinCLI Research Workspace, operating as Research Engine v2.
10
+
11
+ Rules:
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.
14
+ - Do not invent price, news, fundamentals, or certainty.
15
+ - Do not copy the opening summary as the final summary.
16
+ - Prioritize decision-useful points over long explanation.
17
+ - Separate facts, interpretation, and risk.
18
+ - Keep output short: max 8 bullets plus final summary.
19
+ - Use slash-command context correctly: FinCLI commands start with "/", not "fincli".
20
+ - This is educational market research, not financial advice.
21
+ """.strip()
22
+
23
+
24
+ def build_research_prompt(brief: ResearchBrief) -> str:
25
+ overview = brief.overview
26
+ news = "\n".join(f"- {item.title} ({item.source}) {item.summary}" for item in overview.news) or "- No news."
27
+ fundamentals = overview.fundamentals
28
+ fundamentals_text = (
29
+ "No fundamentals."
30
+ if fundamentals is None
31
+ else (
32
+ f"market_cap={fundamentals.market_cap}; pe={fundamentals.pe_ratio}; eps={fundamentals.eps}; "
33
+ f"revenue={fundamentals.revenue}; sector={fundamentals.sector}; industry={fundamentals.industry}"
34
+ )
35
+ )
36
+ return (
37
+ f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
38
+ f"Symbol: {brief.symbol}\n"
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"
46
+ f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
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"
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"
49
+ f"Structure: trend={overview.structure.trend}; pattern={overview.structure.latest_pattern}; bos={overview.structure.break_of_structure}; choch={overview.structure.change_of_character}\n"
50
+ f"Decision Points:\n{chr(10).join(f'- {point}' for point in brief.decision_points)}\n"
51
+ f"Risks:\n{chr(10).join(f'- {risk}' for risk in brief.risks)}\n"
52
+ f"News:\n{news}\n"
53
+ f"Fundamentals: {fundamentals_text}\n"
54
+ )
@@ -0,0 +1,50 @@
1
+ """Macro data service with offline-first fallback rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class MacroIndicator:
11
+ name: str
12
+ region: str
13
+ value: str
14
+ period: str
15
+ source: str
16
+ note: str
17
+
18
+
19
+ class MacroDataService:
20
+ """Return macro context from free fallback datasets.
21
+
22
+ v0.3.0 keeps this deterministic/offline so /macro remains usable without API keys.
23
+ Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
24
+ """
25
+
26
+ def indicators(self, query: str = "") -> list[MacroIndicator]:
27
+ normalized = query.strip().lower()
28
+ rows = _fallback_rows()
29
+ if not normalized:
30
+ return rows
31
+ filtered = [
32
+ row
33
+ for row in rows
34
+ if normalized in row.region.lower()
35
+ or normalized in row.name.lower()
36
+ or normalized in row.note.lower()
37
+ ]
38
+ return filtered or rows[:5]
39
+
40
+
41
+ def _fallback_rows() -> list[MacroIndicator]:
42
+ period = date.today().strftime("%Y")
43
+ return [
44
+ MacroIndicator("Policy Rate", "United States", "provider required", period, "Fallback", "Watch FRED/Fed data for rate path."),
45
+ MacroIndicator("Inflation", "United States", "provider required", period, "Fallback", "CPI trend drives USD, yields, and risk assets."),
46
+ MacroIndicator("GDP Growth", "World", "provider required", period, "Fallback", "Use World Bank/IMF for actual country values."),
47
+ MacroIndicator("Policy Rate", "Indonesia", "provider required", period, "Fallback", "BI rate, USD strength, and capital flow affect IDR."),
48
+ MacroIndicator("Inflation", "Indonesia", "provider required", period, "Fallback", "Inflation surprise can affect BI policy expectations."),
49
+ MacroIndicator("PMI", "Euro Area", "provider required", period, "Fallback", "Growth momentum proxy for EUR and European equities."),
50
+ ]