@drico2008/fincli 0.1.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 +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- package/requirements.txt +9 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Multi-perspective technical debate for signal validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.analysis.indicators import TechnicalSummary
|
|
8
|
+
from fincli.app.analysis.market_structure import MarketStructureSummary
|
|
9
|
+
from fincli.app.analysis.technical_signal import TechnicalSignal, evaluate_technical_signal
|
|
10
|
+
from fincli.app.providers.market.base import Candle
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ChooserCase:
|
|
15
|
+
name: str
|
|
16
|
+
stance: str
|
|
17
|
+
score: int
|
|
18
|
+
evidence: list[str]
|
|
19
|
+
objections: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TechnicalDebate:
|
|
24
|
+
bull_case: ChooserCase
|
|
25
|
+
bear_case: ChooserCase
|
|
26
|
+
caution_case: ChooserCase
|
|
27
|
+
judge_signal: TechnicalSignal
|
|
28
|
+
judge_reasoning: list[str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_technical_debate(
|
|
32
|
+
technical: TechnicalSummary,
|
|
33
|
+
structure: MarketStructureSummary,
|
|
34
|
+
candles: list[Candle],
|
|
35
|
+
) -> TechnicalDebate:
|
|
36
|
+
"""Run bull/bear/caution choosers and a deterministic judge.
|
|
37
|
+
|
|
38
|
+
The judge intentionally rewards aligned evidence and penalizes unresolved conflict.
|
|
39
|
+
This keeps /technical from presenting buy/sell labels without an explicit audit trail.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
bull_case = _build_bull_case(technical, structure, candles)
|
|
43
|
+
bear_case = _build_bear_case(technical, structure, candles)
|
|
44
|
+
caution_case = _build_caution_case(technical, structure, candles, bull_case, bear_case)
|
|
45
|
+
base_signal = evaluate_technical_signal(technical, structure, candles)
|
|
46
|
+
judge_signal, judge_reasoning = _judge(base_signal, bull_case, bear_case, caution_case, technical, structure)
|
|
47
|
+
return TechnicalDebate(
|
|
48
|
+
bull_case=bull_case,
|
|
49
|
+
bear_case=bear_case,
|
|
50
|
+
caution_case=caution_case,
|
|
51
|
+
judge_signal=judge_signal,
|
|
52
|
+
judge_reasoning=judge_reasoning,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def format_debate(debate: TechnicalDebate) -> str:
|
|
57
|
+
return (
|
|
58
|
+
"Technical Debate:\n"
|
|
59
|
+
f"{_format_case(debate.bull_case)}\n"
|
|
60
|
+
f"{_format_case(debate.bear_case)}\n"
|
|
61
|
+
f"{_format_case(debate.caution_case)}\n"
|
|
62
|
+
f"Judge Verdict: {debate.judge_signal.label}\n"
|
|
63
|
+
f"Judge Score: {debate.judge_signal.score}\n"
|
|
64
|
+
f"Judge Confidence: {debate.judge_signal.confidence}\n"
|
|
65
|
+
"Judge Reasoning:\n"
|
|
66
|
+
f"{chr(10).join(f'- {reason}' for reason in debate.judge_reasoning)}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_bull_case(
|
|
71
|
+
technical: TechnicalSummary,
|
|
72
|
+
structure: MarketStructureSummary,
|
|
73
|
+
candles: list[Candle],
|
|
74
|
+
) -> ChooserCase:
|
|
75
|
+
score = 0
|
|
76
|
+
evidence: list[str] = []
|
|
77
|
+
objections: list[str] = []
|
|
78
|
+
|
|
79
|
+
if technical.trend_bias == "bullish":
|
|
80
|
+
score += 2
|
|
81
|
+
evidence.append("Trend bias bullish from moving-average alignment.")
|
|
82
|
+
else:
|
|
83
|
+
objections.append(f"Trend bias is {technical.trend_bias}, not bullish.")
|
|
84
|
+
|
|
85
|
+
if structure.trend == "bullish":
|
|
86
|
+
score += 2
|
|
87
|
+
evidence.append(f"Market structure supports upside: {structure.latest_pattern}.")
|
|
88
|
+
else:
|
|
89
|
+
objections.append(f"Market structure is {structure.trend}.")
|
|
90
|
+
|
|
91
|
+
if technical.macd is not None and technical.macd_signal is not None and technical.macd > technical.macd_signal:
|
|
92
|
+
score += 1
|
|
93
|
+
evidence.append("MACD is above signal, showing improving momentum.")
|
|
94
|
+
if technical.rsi is not None and 45 <= technical.rsi <= 70:
|
|
95
|
+
score += 1
|
|
96
|
+
evidence.append("RSI is constructive without severe overbought pressure.")
|
|
97
|
+
elif technical.rsi is not None and technical.rsi > 75:
|
|
98
|
+
objections.append("RSI is extended; bullish continuation needs confirmation.")
|
|
99
|
+
|
|
100
|
+
if _volume_ratio(candles) >= 1.15:
|
|
101
|
+
score += 1
|
|
102
|
+
evidence.append("Latest volume is above recent average.")
|
|
103
|
+
|
|
104
|
+
if not evidence:
|
|
105
|
+
evidence.append("Bull chooser found no strong upside evidence.")
|
|
106
|
+
return ChooserCase("Bull Chooser", "buy candidate", score, evidence[:5], objections[:4])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_bear_case(
|
|
110
|
+
technical: TechnicalSummary,
|
|
111
|
+
structure: MarketStructureSummary,
|
|
112
|
+
candles: list[Candle],
|
|
113
|
+
) -> ChooserCase:
|
|
114
|
+
score = 0
|
|
115
|
+
evidence: list[str] = []
|
|
116
|
+
objections: list[str] = []
|
|
117
|
+
|
|
118
|
+
if technical.trend_bias == "bearish":
|
|
119
|
+
score += 2
|
|
120
|
+
evidence.append("Trend bias bearish from moving-average alignment.")
|
|
121
|
+
else:
|
|
122
|
+
objections.append(f"Trend bias is {technical.trend_bias}, not bearish.")
|
|
123
|
+
|
|
124
|
+
if structure.trend == "bearish":
|
|
125
|
+
score += 2
|
|
126
|
+
evidence.append(f"Market structure supports downside: {structure.latest_pattern}.")
|
|
127
|
+
else:
|
|
128
|
+
objections.append(f"Market structure is {structure.trend}.")
|
|
129
|
+
|
|
130
|
+
if technical.macd is not None and technical.macd_signal is not None and technical.macd < technical.macd_signal:
|
|
131
|
+
score += 1
|
|
132
|
+
evidence.append("MACD is below signal, showing weakening momentum.")
|
|
133
|
+
if technical.rsi is not None and technical.rsi > 75:
|
|
134
|
+
score += 1
|
|
135
|
+
evidence.append("RSI is overbought; pullback risk is elevated.")
|
|
136
|
+
elif technical.rsi is not None and technical.rsi < 30:
|
|
137
|
+
objections.append("RSI is oversold; bearish continuation may be crowded.")
|
|
138
|
+
|
|
139
|
+
if _volume_ratio(candles) >= 1.15 and technical.trend_bias == "bearish":
|
|
140
|
+
score += 1
|
|
141
|
+
evidence.append("Above-average volume supports bearish participation.")
|
|
142
|
+
|
|
143
|
+
if not evidence:
|
|
144
|
+
evidence.append("Bear chooser found no strong downside evidence.")
|
|
145
|
+
return ChooserCase("Bear Chooser", "sell candidate", score, evidence[:5], objections[:4])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_caution_case(
|
|
149
|
+
technical: TechnicalSummary,
|
|
150
|
+
structure: MarketStructureSummary,
|
|
151
|
+
candles: list[Candle],
|
|
152
|
+
bull_case: ChooserCase,
|
|
153
|
+
bear_case: ChooserCase,
|
|
154
|
+
) -> ChooserCase:
|
|
155
|
+
score = 0
|
|
156
|
+
evidence: list[str] = []
|
|
157
|
+
objections: list[str] = []
|
|
158
|
+
|
|
159
|
+
if abs(bull_case.score - bear_case.score) <= 2:
|
|
160
|
+
score += 2
|
|
161
|
+
evidence.append("Bull and bear evidence are close enough to treat the setup as conflicted.")
|
|
162
|
+
if technical.trend_bias == "neutral" or structure.trend == "neutral":
|
|
163
|
+
score += 2
|
|
164
|
+
evidence.append("Trend or structure is neutral/ranging.")
|
|
165
|
+
if structure.change_of_character:
|
|
166
|
+
score += 2
|
|
167
|
+
evidence.append("Change of character detected; market may be transitioning.")
|
|
168
|
+
if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
|
|
169
|
+
score += 1
|
|
170
|
+
evidence.append("RSI is at an extreme; chase risk is elevated.")
|
|
171
|
+
if _atr_percent(technical.atr, technical.latest_close) >= 5:
|
|
172
|
+
score += 1
|
|
173
|
+
evidence.append("ATR is high relative to price; volatility risk is elevated.")
|
|
174
|
+
if _volume_ratio(candles) < 0.75:
|
|
175
|
+
score += 1
|
|
176
|
+
evidence.append("Volume participation is below recent average.")
|
|
177
|
+
|
|
178
|
+
if score < 2:
|
|
179
|
+
objections.append("Caution chooser found limited risk flags.")
|
|
180
|
+
if not evidence:
|
|
181
|
+
evidence.append("No dominant caution flag, but confirmation is still required.")
|
|
182
|
+
return ChooserCase("Caution Chooser", "wait/avoid overconfidence", score, evidence[:5], objections[:4])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _judge(
|
|
186
|
+
base_signal: TechnicalSignal,
|
|
187
|
+
bull_case: ChooserCase,
|
|
188
|
+
bear_case: ChooserCase,
|
|
189
|
+
caution_case: ChooserCase,
|
|
190
|
+
technical: TechnicalSummary,
|
|
191
|
+
structure: MarketStructureSummary,
|
|
192
|
+
) -> tuple[TechnicalSignal, list[str]]:
|
|
193
|
+
reasoning: list[str] = []
|
|
194
|
+
net = bull_case.score - bear_case.score
|
|
195
|
+
|
|
196
|
+
if caution_case.score >= 4 and abs(net) <= 3:
|
|
197
|
+
reasoning.append("Caution wins because directional evidence is mixed while risk flags are elevated.")
|
|
198
|
+
return _replace_signal(base_signal, "CAUTION", net, "medium"), reasoning
|
|
199
|
+
|
|
200
|
+
if bull_case.score >= bear_case.score + 3 and caution_case.score <= 3:
|
|
201
|
+
reasoning.append("Bull wins because upside evidence is materially stronger than downside evidence.")
|
|
202
|
+
reasoning.append("Judge still requires confirmation near key levels and invalidation below support.")
|
|
203
|
+
return _replace_signal(base_signal, "BEST TO BUY", max(net, base_signal.score), base_signal.confidence), reasoning
|
|
204
|
+
|
|
205
|
+
if bear_case.score >= bull_case.score + 3 and caution_case.score <= 3:
|
|
206
|
+
reasoning.append("Bear wins because downside evidence is materially stronger than upside evidence.")
|
|
207
|
+
reasoning.append("Judge still requires confirmation near key levels and invalidation above resistance.")
|
|
208
|
+
return _replace_signal(base_signal, "BEST TO SELL", min(net, base_signal.score), base_signal.confidence), reasoning
|
|
209
|
+
|
|
210
|
+
reasoning.append("Caution wins because bull/bear arguments are mixed or confirmation quality is insufficient.")
|
|
211
|
+
if technical.trend_bias != structure.trend:
|
|
212
|
+
reasoning.append("Trend bias and market structure conflict, so judge avoids a strong directional label.")
|
|
213
|
+
return _replace_signal(base_signal, "CAUTION", net, "low"), reasoning
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _replace_signal(signal: TechnicalSignal, label: str, score: int, confidence: str) -> TechnicalSignal:
|
|
217
|
+
return TechnicalSignal(
|
|
218
|
+
label=label,
|
|
219
|
+
score=score,
|
|
220
|
+
confidence=confidence,
|
|
221
|
+
reasons=signal.reasons,
|
|
222
|
+
risk_notes=signal.risk_notes,
|
|
223
|
+
invalidation=signal.invalidation,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _format_case(case: ChooserCase) -> str:
|
|
228
|
+
evidence = "\n".join(f" + {item}" for item in case.evidence)
|
|
229
|
+
objections = "\n".join(f" - {item}" for item in case.objections) if case.objections else " - No major objection."
|
|
230
|
+
return (
|
|
231
|
+
f"{case.name}: {case.stance} | Score {case.score}\n"
|
|
232
|
+
f"Evidence:\n{evidence}\n"
|
|
233
|
+
f"Objections:\n{objections}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _volume_ratio(candles: list[Candle]) -> float:
|
|
238
|
+
recent = candles[-20:]
|
|
239
|
+
if len(recent) < 2:
|
|
240
|
+
return 1.0
|
|
241
|
+
volumes = [float(candle.volume) for candle in recent]
|
|
242
|
+
average = sum(volumes) / len(volumes)
|
|
243
|
+
if average == 0:
|
|
244
|
+
return 1.0
|
|
245
|
+
return volumes[-1] / average
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _atr_percent(atr: float | None, price: float | None) -> float:
|
|
249
|
+
if atr is None or price is None or price == 0:
|
|
250
|
+
return 0.0
|
|
251
|
+
return abs(atr / price) * 100
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Rule-based technical signal evaluation for FinCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.analysis.indicators import TechnicalSummary
|
|
8
|
+
from fincli.app.analysis.market_structure import MarketStructureSummary
|
|
9
|
+
from fincli.app.providers.market.base import Candle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class TechnicalSignal:
|
|
14
|
+
label: str
|
|
15
|
+
score: int
|
|
16
|
+
confidence: str
|
|
17
|
+
reasons: list[str]
|
|
18
|
+
risk_notes: list[str]
|
|
19
|
+
invalidation: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def evaluate_technical_signal(
|
|
23
|
+
technical: TechnicalSummary,
|
|
24
|
+
structure: MarketStructureSummary,
|
|
25
|
+
candles: list[Candle],
|
|
26
|
+
) -> TechnicalSignal:
|
|
27
|
+
"""Evaluate a transparent buy/sell/caution candidate signal.
|
|
28
|
+
|
|
29
|
+
This is a deterministic decision aid, not a financial recommendation.
|
|
30
|
+
It intentionally favors CAUTION when evidence is mixed or risk flags are high.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
bullish = 0
|
|
34
|
+
bearish = 0
|
|
35
|
+
reasons: list[str] = []
|
|
36
|
+
risk_notes: list[str] = []
|
|
37
|
+
|
|
38
|
+
latest_close = technical.latest_close
|
|
39
|
+
atr_pct = _atr_percent(technical.atr, latest_close)
|
|
40
|
+
avg_volume = _average_volume(candles[-20:])
|
|
41
|
+
latest_volume = technical.volume_latest
|
|
42
|
+
|
|
43
|
+
if technical.trend_bias == "bullish":
|
|
44
|
+
bullish += 2
|
|
45
|
+
reasons.append("Trend bias bullish: price/MA alignment supports upside continuation.")
|
|
46
|
+
elif technical.trend_bias == "bearish":
|
|
47
|
+
bearish += 2
|
|
48
|
+
reasons.append("Trend bias bearish: price/MA alignment supports downside continuation.")
|
|
49
|
+
else:
|
|
50
|
+
risk_notes.append("Trend bias neutral: no clean directional edge from moving averages.")
|
|
51
|
+
|
|
52
|
+
if structure.trend == "bullish":
|
|
53
|
+
bullish += 2
|
|
54
|
+
reasons.append(f"Market structure bullish: latest pattern {structure.latest_pattern}.")
|
|
55
|
+
elif structure.trend == "bearish":
|
|
56
|
+
bearish += 2
|
|
57
|
+
reasons.append(f"Market structure bearish: latest pattern {structure.latest_pattern}.")
|
|
58
|
+
else:
|
|
59
|
+
risk_notes.append(f"Market structure neutral/ranging: latest pattern {structure.latest_pattern}.")
|
|
60
|
+
|
|
61
|
+
if technical.sma_fast is not None and technical.sma_slow is not None:
|
|
62
|
+
if latest_close > technical.sma_fast > technical.sma_slow:
|
|
63
|
+
bullish += 1
|
|
64
|
+
reasons.append("SMA stack bullish: close > SMA fast > SMA slow.")
|
|
65
|
+
elif latest_close < technical.sma_fast < technical.sma_slow:
|
|
66
|
+
bearish += 1
|
|
67
|
+
reasons.append("SMA stack bearish: close < SMA fast < SMA slow.")
|
|
68
|
+
else:
|
|
69
|
+
risk_notes.append("SMA stack mixed: moving-average confirmation is incomplete.")
|
|
70
|
+
|
|
71
|
+
if technical.macd is not None and technical.macd_signal is not None:
|
|
72
|
+
if technical.macd > technical.macd_signal and technical.macd > 0:
|
|
73
|
+
bullish += 1
|
|
74
|
+
reasons.append("MACD bullish: MACD is above signal and positive.")
|
|
75
|
+
elif technical.macd < technical.macd_signal and technical.macd < 0:
|
|
76
|
+
bearish += 1
|
|
77
|
+
reasons.append("MACD bearish: MACD is below signal and negative.")
|
|
78
|
+
elif technical.macd > technical.macd_signal:
|
|
79
|
+
bullish += 1
|
|
80
|
+
reasons.append("MACD improving: MACD is above signal, but trend strength still needs confirmation.")
|
|
81
|
+
elif technical.macd < technical.macd_signal:
|
|
82
|
+
bearish += 1
|
|
83
|
+
reasons.append("MACD weakening: MACD is below signal, but downside strength still needs confirmation.")
|
|
84
|
+
|
|
85
|
+
if technical.rsi is not None:
|
|
86
|
+
if 45 <= technical.rsi <= 68:
|
|
87
|
+
bullish += 1
|
|
88
|
+
reasons.append("RSI constructive: momentum is positive without extreme overbought pressure.")
|
|
89
|
+
elif 32 <= technical.rsi <= 55:
|
|
90
|
+
bearish += 1 if technical.trend_bias == "bearish" else 0
|
|
91
|
+
reasons.append("RSI defensive: momentum is not strongly bullish.")
|
|
92
|
+
elif technical.rsi > 75:
|
|
93
|
+
bearish += 1
|
|
94
|
+
risk_notes.append("RSI overbought: upside may be extended; chase risk is elevated.")
|
|
95
|
+
elif technical.rsi < 25:
|
|
96
|
+
bullish += 1
|
|
97
|
+
risk_notes.append("RSI oversold: downside may be extended; shorting risk is elevated.")
|
|
98
|
+
|
|
99
|
+
if technical.support is not None and technical.resistance is not None:
|
|
100
|
+
range_width = max(technical.resistance - technical.support, 0.0)
|
|
101
|
+
if range_width > 0:
|
|
102
|
+
position = (latest_close - technical.support) / range_width
|
|
103
|
+
if position <= 0.35:
|
|
104
|
+
bullish += 1
|
|
105
|
+
reasons.append("Price is closer to support than resistance, improving long risk/reward if support holds.")
|
|
106
|
+
elif position >= 0.65:
|
|
107
|
+
bearish += 1
|
|
108
|
+
risk_notes.append("Price is closer to resistance than support; breakout confirmation is needed.")
|
|
109
|
+
|
|
110
|
+
if avg_volume is not None and latest_volume is not None and avg_volume > 0:
|
|
111
|
+
volume_ratio = latest_volume / avg_volume
|
|
112
|
+
if volume_ratio >= 1.2:
|
|
113
|
+
if bullish >= bearish:
|
|
114
|
+
bullish += 1
|
|
115
|
+
reasons.append("Volume confirmation: latest volume is above recent average.")
|
|
116
|
+
else:
|
|
117
|
+
bearish += 1
|
|
118
|
+
reasons.append("Volume confirmation: selling pressure has above-average participation.")
|
|
119
|
+
elif volume_ratio < 0.7:
|
|
120
|
+
risk_notes.append("Low participation: latest volume is below recent average.")
|
|
121
|
+
|
|
122
|
+
if structure.change_of_character:
|
|
123
|
+
risk_notes.append("Change of character detected: direction may be transitioning, avoid overconfidence.")
|
|
124
|
+
if atr_pct is not None:
|
|
125
|
+
if atr_pct >= 5:
|
|
126
|
+
risk_notes.append(f"High volatility: ATR is about {atr_pct:.2f}% of price.")
|
|
127
|
+
elif atr_pct <= 0.5:
|
|
128
|
+
risk_notes.append(f"Low volatility: ATR is about {atr_pct:.2f}% of price; false breakouts possible.")
|
|
129
|
+
|
|
130
|
+
net_score = bullish - bearish
|
|
131
|
+
label = "CAUTION"
|
|
132
|
+
if net_score >= 4 and len(risk_notes) <= 2:
|
|
133
|
+
label = "BEST TO BUY"
|
|
134
|
+
elif net_score <= -4 and len(risk_notes) <= 2:
|
|
135
|
+
label = "BEST TO SELL"
|
|
136
|
+
|
|
137
|
+
confidence = _confidence(abs(net_score), risk_notes)
|
|
138
|
+
invalidation = _invalidation(label, technical, structure)
|
|
139
|
+
|
|
140
|
+
if not reasons:
|
|
141
|
+
reasons.append("No dominant technical edge found from current indicators.")
|
|
142
|
+
if not risk_notes:
|
|
143
|
+
risk_notes.append("No major rule-based risk flag, but market conditions can change quickly.")
|
|
144
|
+
|
|
145
|
+
return TechnicalSignal(
|
|
146
|
+
label=label,
|
|
147
|
+
score=net_score,
|
|
148
|
+
confidence=confidence,
|
|
149
|
+
reasons=reasons[:6],
|
|
150
|
+
risk_notes=risk_notes[:5],
|
|
151
|
+
invalidation=invalidation,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def format_signal(signal: TechnicalSignal) -> str:
|
|
156
|
+
reasons = "\n".join(f"- {reason}" for reason in signal.reasons)
|
|
157
|
+
risk_notes = "\n".join(f"- {note}" for note in signal.risk_notes)
|
|
158
|
+
return (
|
|
159
|
+
f"Signal: {signal.label}\n"
|
|
160
|
+
f"Signal Score: {signal.score}\n"
|
|
161
|
+
f"Confidence: {signal.confidence}\n"
|
|
162
|
+
"Signal Reasoning:\n"
|
|
163
|
+
f"{reasons}\n"
|
|
164
|
+
"Signal Risk Notes:\n"
|
|
165
|
+
f"{risk_notes}\n"
|
|
166
|
+
f"Invalidation / Caution Level: {signal.invalidation}\n"
|
|
167
|
+
"Signal Disclaimer: scenario-based technical signal, not financial advice."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _confidence(score_abs: int, risk_notes: list[str]) -> str:
|
|
172
|
+
if score_abs >= 6 and len(risk_notes) <= 1:
|
|
173
|
+
return "high"
|
|
174
|
+
if score_abs >= 4 and len(risk_notes) <= 3:
|
|
175
|
+
return "medium"
|
|
176
|
+
return "low"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _invalidation(label: str, technical: TechnicalSummary, structure: MarketStructureSummary) -> str:
|
|
180
|
+
if label == "BEST TO BUY":
|
|
181
|
+
return f"Bias weakens below support {_fmt(technical.support or structure.support)} or if RSI/MACD momentum rolls over."
|
|
182
|
+
if label == "BEST TO SELL":
|
|
183
|
+
return f"Bias weakens above resistance {_fmt(technical.resistance or structure.resistance)} or if MACD/structure flips bullish."
|
|
184
|
+
return "Wait for cleaner trend, structure confirmation, or better location near support/resistance."
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _atr_percent(atr: float | None, price: float | None) -> float | None:
|
|
188
|
+
if atr is None or price is None or price == 0:
|
|
189
|
+
return None
|
|
190
|
+
return abs(atr / price) * 100
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _average_volume(candles: list[Candle]) -> float | None:
|
|
194
|
+
if not candles:
|
|
195
|
+
return None
|
|
196
|
+
volumes = [float(candle.volume) for candle in candles]
|
|
197
|
+
return sum(volumes) / len(volumes)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fmt(value: float | None) -> str:
|
|
201
|
+
if value is None:
|
|
202
|
+
return "N/A"
|
|
203
|
+
return f"{value:.4f}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Autocomplete helpers for the command input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fincli.app.cli.commands import CommandRegistry, CommandSpec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SlashAutocomplete:
|
|
9
|
+
"""Return command suggestions while the user types."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, registry: CommandRegistry) -> None:
|
|
12
|
+
self.registry = registry
|
|
13
|
+
|
|
14
|
+
def suggestions_for(self, text: str) -> list[CommandSpec]:
|
|
15
|
+
if not text.startswith("/"):
|
|
16
|
+
return []
|
|
17
|
+
return self.registry.suggest(text, limit=50)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Slash command registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class CommandSpec:
|
|
10
|
+
name: str
|
|
11
|
+
description: str
|
|
12
|
+
example: str
|
|
13
|
+
group: str = "General"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COMMANDS: tuple[CommandSpec, ...] = (
|
|
17
|
+
CommandSpec("/help", "Tampilkan bantuan, command list, dan contoh.", "/help"),
|
|
18
|
+
CommandSpec("/dashboard", "Tampilkan dashboard compact FinCLI.", "/dashboard", "General"),
|
|
19
|
+
CommandSpec("/ai_model", "Lihat atau ganti AI provider/model.", "/ai_model openrouter openai/gpt-4o-mini", "AI"),
|
|
20
|
+
CommandSpec("/news_model", "Buka selector provider market/news dan fallback.", "/news_model", "Provider"),
|
|
21
|
+
CommandSpec("/market", "Ringkasan market profesional untuk instrumen.", "/market AAPL 1d", "Market"),
|
|
22
|
+
CommandSpec("/news", "Tampilkan news/fundamental terbaru untuk instrumen.", "/news AAPL", "Market"),
|
|
23
|
+
CommandSpec("/technical", "Analisis teknikal instrumen.", "/technical BTC-USD 1d", "Analysis"),
|
|
24
|
+
CommandSpec("/structure", "Analisis struktur pasar instrumen.", "/structure BTC-USD 1d", "Analysis"),
|
|
25
|
+
CommandSpec("/funda", "Fundamental ringkas instrumen.", "/funda MSFT", "Market"),
|
|
26
|
+
CommandSpec("/yahoo", "Tampilkan tabel Yahoo Finance untuk history/statistics/profile/financials/analysis/holders.", "/yahoo BBRI statistics", "Market"),
|
|
27
|
+
CommandSpec("/ai", "Free chat dengan AI assistant.", "/ai ringkas risiko AAPL", "AI"),
|
|
28
|
+
CommandSpec("/analyze", "AI menganalisis struktur pasar instrumen.", "/analyze ETH-USD 4h", "Analysis"),
|
|
29
|
+
CommandSpec("/watchlist", "Tampilkan watchlist.", "/watchlist", "Watchlist"),
|
|
30
|
+
CommandSpec("/watchlist add", "Tambahkan instrumen ke watchlist.", "/watchlist add AAPL", "Watchlist"),
|
|
31
|
+
CommandSpec("/watchlist remove", "Hapus instrumen dari watchlist.", "/watchlist remove AAPL", "Watchlist"),
|
|
32
|
+
CommandSpec("/portfolio", "Tampilkan portfolio lokal.", "/portfolio", "Portfolio"),
|
|
33
|
+
CommandSpec("/portfolio add", "Tambahkan posisi/aset.", "/portfolio add BTC-USD 0.05 65000", "Portfolio"),
|
|
34
|
+
CommandSpec("/portfolio remove", "Hapus posisi/aset.", "/portfolio remove BTC-USD", "Portfolio"),
|
|
35
|
+
CommandSpec("/portfolio performance", "Tampilkan performa portfolio.", "/portfolio performance", "Portfolio"),
|
|
36
|
+
CommandSpec("/tx", "Tampilkan transaction ledger.", "/tx list", "Portfolio"),
|
|
37
|
+
CommandSpec("/tx add", "Tambahkan transaksi buy/sell.", "/tx add buy AAPL 10 185", "Portfolio"),
|
|
38
|
+
CommandSpec("/journal", "Tampilkan journal trading/investasi.", "/journal", "Journal"),
|
|
39
|
+
CommandSpec("/journal add", "Tambahkan catatan journal singkat.", '/journal add BTC-USD bullish "Breakout gagal, tunggu konfirmasi"', "Journal"),
|
|
40
|
+
CommandSpec("/journal stats", "Tampilkan statistik journal.", "/journal stats", "Journal"),
|
|
41
|
+
CommandSpec("/journal review", "AI review kebiasaan journal.", "/journal review", "Journal"),
|
|
42
|
+
CommandSpec("/config", "Tampilkan konfigurasi aktif tanpa membocorkan API key.", "/config"),
|
|
43
|
+
CommandSpec("/price", "Tampilkan harga instrumen.", "/price NVDA", "Market"),
|
|
44
|
+
CommandSpec("/quote", "Alias harga/quote instrumen.", "/quote NVDA", "Market"),
|
|
45
|
+
CommandSpec("/scan", "Scanner watchlist dengan filter indikator.", "/scan watchlist rsi<30", "Market"),
|
|
46
|
+
CommandSpec("/calendar", "Economic calendar provider/fallback.", "/calendar week US high", "Market"),
|
|
47
|
+
CommandSpec("/provider status", "Tampilkan status provider aktif.", "/provider status", "Provider"),
|
|
48
|
+
CommandSpec("/provider list", "Tampilkan semua provider market yang tersedia.", "/provider list", "Provider"),
|
|
49
|
+
CommandSpec("/provider test", "Test quote provider aktif untuk symbol.", "/provider test AAPL", "Provider"),
|
|
50
|
+
CommandSpec("/provider key status", "Tampilkan status API key market provider.", "/provider key status", "Provider"),
|
|
51
|
+
CommandSpec("/cache stats", "Tampilkan statistik cache market persistent.", "/cache stats", "System"),
|
|
52
|
+
CommandSpec("/cache clear", "Bersihkan runtime dan persistent market cache.", "/cache clear", "System"),
|
|
53
|
+
CommandSpec("/export journal", "Export journal. Phase 1 menyiapkan command.", "/export journal", "Export"),
|
|
54
|
+
CommandSpec("/export portfolio", "Export portfolio. Phase 1 menyiapkan command.", "/export portfolio", "Export"),
|
|
55
|
+
CommandSpec("/clear", "Bersihkan output terminal.", "/clear"),
|
|
56
|
+
CommandSpec("/exit", "Keluar dari aplikasi.", "/exit"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CommandRegistry:
|
|
61
|
+
"""Lookup and autocomplete slash commands."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, commands: tuple[CommandSpec, ...] = COMMANDS) -> None:
|
|
64
|
+
self.commands = commands
|
|
65
|
+
|
|
66
|
+
def suggest(self, query: str, limit: int = 8) -> list[CommandSpec]:
|
|
67
|
+
normalized = query.strip().lower()
|
|
68
|
+
if not normalized:
|
|
69
|
+
return list(self.commands[:limit])
|
|
70
|
+
if not normalized.startswith("/"):
|
|
71
|
+
normalized = f"/{normalized}"
|
|
72
|
+
|
|
73
|
+
exact = [cmd for cmd in self.commands if cmd.name.lower().startswith(normalized)]
|
|
74
|
+
fuzzy = [cmd for cmd in self.commands if normalized.replace("/", "") in cmd.name.lower().replace("/", "")]
|
|
75
|
+
merged: list[CommandSpec] = []
|
|
76
|
+
for cmd in [*exact, *fuzzy]:
|
|
77
|
+
if cmd not in merged:
|
|
78
|
+
merged.append(cmd)
|
|
79
|
+
return merged[:limit]
|
|
80
|
+
|
|
81
|
+
def all(self) -> tuple[CommandSpec, ...]:
|
|
82
|
+
return self.commands
|