@drico2008/fincli 0.3.0 → 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.
- package/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -42
- package/package.json +7 -7
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 "
|
|
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
|
|
74
|
+
return STATUS_EMPTY_DATA, (operation,)
|
|
65
75
|
if isinstance(payload, list) and not payload:
|
|
66
|
-
return
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
f"
|
|
51
|
-
f"
|
|
52
|
-
f"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
f"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
f"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
|