@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,119 @@
|
|
|
1
|
+
"""Analysis prompt orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fincli.app.analysis.ai_prompts import MARKET_ANALYSIS_PROMPT
|
|
6
|
+
from fincli.app.analysis.indicators import TechnicalSummary
|
|
7
|
+
from fincli.app.analysis.market_structure import MarketStructureSummary
|
|
8
|
+
from fincli.app.analysis.technical_debate import format_debate, run_technical_debate
|
|
9
|
+
from fincli.app.analysis.technical_signal import format_signal
|
|
10
|
+
from fincli.app.providers.market.base import Candle
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def market_analysis_prompt() -> str:
|
|
14
|
+
"""Return the configured market analysis prompt template."""
|
|
15
|
+
return MARKET_ANALYSIS_PROMPT.strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_market_analysis_prompt(
|
|
19
|
+
symbol: str,
|
|
20
|
+
timeframe: str,
|
|
21
|
+
candles: list[Candle],
|
|
22
|
+
technical: TechnicalSummary,
|
|
23
|
+
structure: MarketStructureSummary | None = None,
|
|
24
|
+
news_context: str = "No news/fundamental context provided.",
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Build a structured AI prompt from market data and computed indicators."""
|
|
27
|
+
recent = candles[-10:]
|
|
28
|
+
ohlcv_lines = [
|
|
29
|
+
(
|
|
30
|
+
f"- {candle.timestamp.isoformat(timespec='seconds')}: "
|
|
31
|
+
f"O={candle.open:.4f} H={candle.high:.4f} L={candle.low:.4f} "
|
|
32
|
+
f"C={candle.close:.4f} V={candle.volume:.0f}"
|
|
33
|
+
)
|
|
34
|
+
for candle in recent
|
|
35
|
+
]
|
|
36
|
+
indicator_lines = [
|
|
37
|
+
f"Latest Close: {_fmt(technical.latest_close)}",
|
|
38
|
+
f"Trend Bias: {technical.trend_bias}",
|
|
39
|
+
f"SMA 5: {_fmt(technical.sma_fast)}",
|
|
40
|
+
f"SMA 20: {_fmt(technical.sma_slow)}",
|
|
41
|
+
f"EMA 12: {_fmt(technical.ema_fast)}",
|
|
42
|
+
f"RSI 14: {_fmt(technical.rsi)}",
|
|
43
|
+
f"MACD: {_fmt(technical.macd)}",
|
|
44
|
+
f"MACD Signal: {_fmt(technical.macd_signal)}",
|
|
45
|
+
f"Bollinger Upper: {_fmt(technical.bollinger_upper)}",
|
|
46
|
+
f"Bollinger Lower: {_fmt(technical.bollinger_lower)}",
|
|
47
|
+
f"ATR 14: {_fmt(technical.atr)}",
|
|
48
|
+
f"Support: {_fmt(technical.support)}",
|
|
49
|
+
f"Resistance: {_fmt(technical.resistance)}",
|
|
50
|
+
]
|
|
51
|
+
debate = run_technical_debate(technical, structure, candles) if structure is not None else None
|
|
52
|
+
return (
|
|
53
|
+
f"{market_analysis_prompt()}\n\n"
|
|
54
|
+
f"Instrument: {symbol}\n"
|
|
55
|
+
f"Timeframe: {timeframe}\n"
|
|
56
|
+
f"Data Quality: {len(candles)} candles available from provider.\n\n"
|
|
57
|
+
"Recent OHLCV:\n"
|
|
58
|
+
f"{chr(10).join(ohlcv_lines)}\n\n"
|
|
59
|
+
"Computed Indicators:\n"
|
|
60
|
+
f"{chr(10).join(indicator_lines)}\n\n"
|
|
61
|
+
"Market Structure:\n"
|
|
62
|
+
f"{_format_structure(structure)}\n\n"
|
|
63
|
+
"Signal Assessment:\n"
|
|
64
|
+
f"{format_signal(debate.judge_signal) if debate is not None else 'No signal assessment available.'}\n\n"
|
|
65
|
+
"Technical Debate:\n"
|
|
66
|
+
f"{format_debate(debate) if debate is not None else 'No technical debate available.'}\n\n"
|
|
67
|
+
"News/Fundamental Context:\n"
|
|
68
|
+
f"{news_context}\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_technical_ai_summary(symbol: str, timeframe: str, candles: list[Candle]) -> str:
|
|
73
|
+
"""Build a concise technical summary intended as AI assistant context."""
|
|
74
|
+
from fincli.app.analysis.indicators import summarize_technical_indicators
|
|
75
|
+
from fincli.app.analysis.market_structure import analyze_market_structure
|
|
76
|
+
|
|
77
|
+
technical = summarize_technical_indicators(candles)
|
|
78
|
+
structure = analyze_market_structure(candles)
|
|
79
|
+
debate = run_technical_debate(technical, structure, candles)
|
|
80
|
+
signal = debate.judge_signal
|
|
81
|
+
return (
|
|
82
|
+
"AI Assistance Summary:\n"
|
|
83
|
+
f"Instrument: {symbol}\n"
|
|
84
|
+
f"Timeframe: {timeframe}\n"
|
|
85
|
+
f"Data Quality: {len(candles)} candles\n"
|
|
86
|
+
f"Latest Close: {_fmt(technical.latest_close)}\n"
|
|
87
|
+
f"Trend Bias: {technical.trend_bias}\n"
|
|
88
|
+
f"RSI 14: {_fmt(technical.rsi)}\n"
|
|
89
|
+
f"MACD/Signal: {_fmt(technical.macd)} / {_fmt(technical.macd_signal)}\n"
|
|
90
|
+
f"Support/Resistance: {_fmt(technical.support)} / {_fmt(technical.resistance)}\n"
|
|
91
|
+
f"ATR 14: {_fmt(technical.atr)}\n"
|
|
92
|
+
f"Market Structure: {structure.trend}; {structure.latest_pattern}\n"
|
|
93
|
+
f"Signal: {signal.label} | Score {signal.score} | Confidence {signal.confidence}\n"
|
|
94
|
+
f"Signal Reasoning: {'; '.join(signal.reasons[:3])}\n"
|
|
95
|
+
f"Debate Judge: {signal.label}; {'; '.join(debate.judge_reasoning[:2])}\n"
|
|
96
|
+
f"Risk Notes: volatility={_fmt(technical.atr)}, liquidity={structure.liquidity_area or 'N/A'}, risk_zone={structure.risk_zone or 'N/A'}\n"
|
|
97
|
+
"Use this as context for scenario analysis. This is informational, not financial advice."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fmt(value: float | None) -> str:
|
|
102
|
+
if value is None:
|
|
103
|
+
return "N/A"
|
|
104
|
+
return f"{value:.4f}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _format_structure(structure: MarketStructureSummary | None) -> str:
|
|
108
|
+
if structure is None:
|
|
109
|
+
return "No market structure context provided."
|
|
110
|
+
return (
|
|
111
|
+
f"Trend: {structure.trend}\n"
|
|
112
|
+
f"Latest Pattern: {structure.latest_pattern}\n"
|
|
113
|
+
f"Break of Structure: {structure.break_of_structure}\n"
|
|
114
|
+
f"Change of Character: {structure.change_of_character}\n"
|
|
115
|
+
f"Support: {_fmt(structure.support)}\n"
|
|
116
|
+
f"Resistance: {_fmt(structure.resistance)}\n"
|
|
117
|
+
f"Liquidity Area: {structure.liquidity_area or 'N/A'}\n"
|
|
118
|
+
f"Risk Zone: {structure.risk_zone or 'N/A'}"
|
|
119
|
+
)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""FinCLI AI assistant prompt and safety helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
FINCLI_ASSISTANT_SYSTEM_PROMPT = """
|
|
9
|
+
You are FinCLI AI Assistance, the embedded assistant inside FinCLI v0.1.
|
|
10
|
+
|
|
11
|
+
Identity and scope:
|
|
12
|
+
- FinCLI is a terminal-first financial dashboard for market data, news/fundamentals, technical analysis, watchlists, portfolios, journals, and provider configuration.
|
|
13
|
+
- Your role is to help users understand markets, risk, portfolio context, trading journal patterns, FinCLI commands, and general non-coding questions.
|
|
14
|
+
- Free chat is allowed, but you must keep your identity as FinCLI's assistant and be clear when market data is unavailable or delayed.
|
|
15
|
+
|
|
16
|
+
Financial analysis rules:
|
|
17
|
+
- Analyze from provided market context first. Do not invent prices, news, fundamentals, provider status, or certainty.
|
|
18
|
+
- Use probabilistic language: scenario, bias, confirmation, invalidation, risk, caution.
|
|
19
|
+
- Do not promise profit and do not present aggressive entries as guaranteed signals.
|
|
20
|
+
- For technical analysis, weigh trend, momentum, volatility, support/resistance, market structure, and data quality.
|
|
21
|
+
- For fundamental analysis, separate valuation, growth, profitability, sector context, balance-sheet risk, and missing-data limitations.
|
|
22
|
+
- Always include a short non-financial-advice reminder when discussing instruments, portfolio, or trading decisions.
|
|
23
|
+
|
|
24
|
+
Coding boundary:
|
|
25
|
+
- Do not provide coding, debugging, refactoring, implementation plans, source code, scripts, commands for software builds, or programming architecture.
|
|
26
|
+
- If asked for coding help, refuse briefly and redirect to FinCLI usage, market analysis, provider setup, risk management, or journal/portfolio workflow.
|
|
27
|
+
|
|
28
|
+
Response style:
|
|
29
|
+
- Be concise, structured, and practical.
|
|
30
|
+
- Prefer bullets for market analysis.
|
|
31
|
+
- If the user asks casual non-market questions, answer normally while staying within the coding boundary.
|
|
32
|
+
""".strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_CODING_PATTERNS = tuple(
|
|
36
|
+
re.compile(pattern, re.IGNORECASE)
|
|
37
|
+
for pattern in (
|
|
38
|
+
r"\b(write|generate|buat|bikin|create|implement|refactor|debug|fix|compile|build)\s+(code|kode|script|program|function|fungsi|class|module|modul|app|website|backend|frontend)\b",
|
|
39
|
+
r"\b(code|kode|source code|script|programming|pemrograman|debugging|traceback|stack trace)\b",
|
|
40
|
+
r"\b(python|javascript|typescript|react|next\.?js|node\.?js|npm|pip|django|flask|fastapi|sql|docker|kubernetes|regex)\b",
|
|
41
|
+
r"\b(git|commit|pull request|unit test|pytest|lint|ci/cd|api endpoint|sdk|library|framework)\b",
|
|
42
|
+
r"\b(error di kode|bug di kode|perbaiki kode|arsitektur software|software architecture)\b",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_SYMBOL_PATTERN = re.compile(r"\b[A-Z][A-Z0-9./:_-]{1,14}\b", re.IGNORECASE)
|
|
48
|
+
_MARKET_KEYWORDS = {
|
|
49
|
+
"AAPL",
|
|
50
|
+
"MSFT",
|
|
51
|
+
"NVDA",
|
|
52
|
+
"TSLA",
|
|
53
|
+
"AMZN",
|
|
54
|
+
"GOOGL",
|
|
55
|
+
"META",
|
|
56
|
+
"BTC",
|
|
57
|
+
"ETH",
|
|
58
|
+
"BTCUSD",
|
|
59
|
+
"BTCUSDT",
|
|
60
|
+
"ETHUSD",
|
|
61
|
+
"ETHUSDT",
|
|
62
|
+
"EURUSD",
|
|
63
|
+
"GBPUSD",
|
|
64
|
+
"USDJPY",
|
|
65
|
+
"AUDUSD",
|
|
66
|
+
"USDCAD",
|
|
67
|
+
"USDCHF",
|
|
68
|
+
"XAUUSD",
|
|
69
|
+
"XAGUSD",
|
|
70
|
+
"GOLD",
|
|
71
|
+
"SILVER",
|
|
72
|
+
"WTI",
|
|
73
|
+
"BRENT",
|
|
74
|
+
"SPX",
|
|
75
|
+
"SP500",
|
|
76
|
+
"NASDAQ",
|
|
77
|
+
"NDX",
|
|
78
|
+
"DOW",
|
|
79
|
+
"DAX",
|
|
80
|
+
"FTSE",
|
|
81
|
+
"NIKKEI",
|
|
82
|
+
"HSI",
|
|
83
|
+
}
|
|
84
|
+
_SYMBOL_STOPWORDS = {
|
|
85
|
+
"AI",
|
|
86
|
+
"API",
|
|
87
|
+
"CLI",
|
|
88
|
+
"TUI",
|
|
89
|
+
"FINCLI",
|
|
90
|
+
"BUY",
|
|
91
|
+
"SELL",
|
|
92
|
+
"CAUTION",
|
|
93
|
+
"RSI",
|
|
94
|
+
"MACD",
|
|
95
|
+
"EMA",
|
|
96
|
+
"SMA",
|
|
97
|
+
"ATR",
|
|
98
|
+
"PE",
|
|
99
|
+
"EPS",
|
|
100
|
+
"USD",
|
|
101
|
+
"EUR",
|
|
102
|
+
"GBP",
|
|
103
|
+
"JPY",
|
|
104
|
+
"IDR",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_coding_request(prompt: str) -> bool:
|
|
109
|
+
"""Return True when a free-chat prompt asks for programming help."""
|
|
110
|
+
normalized = prompt.strip()
|
|
111
|
+
if not normalized:
|
|
112
|
+
return False
|
|
113
|
+
return any(pattern.search(normalized) for pattern in _CODING_PATTERNS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def coding_refusal() -> str:
|
|
117
|
+
"""Return FinCLI-specific refusal text for coding prompts."""
|
|
118
|
+
return (
|
|
119
|
+
"Aku FinCLI AI Assistance untuk market, portfolio, journal, provider, dan risk workflow. "
|
|
120
|
+
"Aku tidak menangani coding, debugging, refactor, atau pembuatan software di dalam FinCLI. "
|
|
121
|
+
"Kamu bisa tanya analisis market, fundamental, technical setup, watchlist, portfolio, journal, "
|
|
122
|
+
"atau cara memakai command FinCLI."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def extract_market_symbols(prompt: str, limit: int = 3) -> list[str]:
|
|
127
|
+
"""Extract explicit market symbols from a user prompt."""
|
|
128
|
+
found: list[str] = []
|
|
129
|
+
for match in _SYMBOL_PATTERN.finditer(prompt):
|
|
130
|
+
raw_symbol = match.group(0).strip(".,?!:;()[]{}")
|
|
131
|
+
symbol = raw_symbol.upper()
|
|
132
|
+
if symbol in _SYMBOL_STOPWORDS:
|
|
133
|
+
continue
|
|
134
|
+
if not raw_symbol.isupper() and symbol not in _MARKET_KEYWORDS:
|
|
135
|
+
continue
|
|
136
|
+
if len(symbol) <= 2 and symbol not in _MARKET_KEYWORDS:
|
|
137
|
+
continue
|
|
138
|
+
if symbol.isdigit():
|
|
139
|
+
continue
|
|
140
|
+
if symbol not in found:
|
|
141
|
+
found.append(symbol)
|
|
142
|
+
if len(found) >= limit:
|
|
143
|
+
break
|
|
144
|
+
return found
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_fincli_assistant_prompt(user_prompt: str, market_context: str = "") -> str:
|
|
148
|
+
"""Build the final prompt sent to the configured AI provider."""
|
|
149
|
+
context = market_context.strip() or "No explicit market context was available for this free-chat prompt."
|
|
150
|
+
return (
|
|
151
|
+
f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
|
|
152
|
+
"Runtime Market Context:\n"
|
|
153
|
+
f"{context}\n\n"
|
|
154
|
+
"User Prompt:\n"
|
|
155
|
+
f"{user_prompt.strip()}\n\n"
|
|
156
|
+
"Instruction:\n"
|
|
157
|
+
"- Answer the user's prompt directly.\n"
|
|
158
|
+
"- If market context is present, cite provider/data-quality limitations.\n"
|
|
159
|
+
"- If market context is missing and the user asks about an instrument, say what data is missing.\n"
|
|
160
|
+
"- Keep the coding boundary enforced.\n"
|
|
161
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Technical indicator calculations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.providers.market.base import Candle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class TechnicalSummary:
|
|
12
|
+
latest_close: float
|
|
13
|
+
sma_fast: float | None
|
|
14
|
+
sma_slow: float | None
|
|
15
|
+
ema_fast: float | None
|
|
16
|
+
rsi: float | None
|
|
17
|
+
macd: float | None
|
|
18
|
+
macd_signal: float | None
|
|
19
|
+
bollinger_upper: float | None
|
|
20
|
+
bollinger_lower: float | None
|
|
21
|
+
atr: float | None
|
|
22
|
+
support: float | None
|
|
23
|
+
resistance: float | None
|
|
24
|
+
volume_latest: float | None
|
|
25
|
+
trend_bias: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def phase_one_indicator_status() -> str:
|
|
29
|
+
return "Indicator engine scaffold ready. pandas/numpy/yfinance integration planned for Phase 2."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summarize_technical_indicators(candles: list[Candle]) -> TechnicalSummary:
|
|
33
|
+
"""Calculate a compact technical summary from OHLCV candles."""
|
|
34
|
+
if not candles:
|
|
35
|
+
raise ValueError("Data candle kosong.")
|
|
36
|
+
|
|
37
|
+
closes = [float(candle.close) for candle in candles]
|
|
38
|
+
highs = [float(candle.high) for candle in candles]
|
|
39
|
+
lows = [float(candle.low) for candle in candles]
|
|
40
|
+
volumes = [float(candle.volume) for candle in candles]
|
|
41
|
+
|
|
42
|
+
sma_fast = _sma(closes, 5)
|
|
43
|
+
sma_slow = _sma(closes, 20)
|
|
44
|
+
ema_fast_series = _ema_series(closes, 12)
|
|
45
|
+
ema_slow_series = _ema_series(closes, 26)
|
|
46
|
+
ema_fast = ema_fast_series[-1] if ema_fast_series else None
|
|
47
|
+
rsi = _rsi(closes, 14)
|
|
48
|
+
macd, macd_signal = _macd(ema_fast_series, ema_slow_series)
|
|
49
|
+
bollinger_upper, bollinger_lower = _bollinger(closes, 20)
|
|
50
|
+
atr = _atr(highs, lows, closes, 14)
|
|
51
|
+
support = min(lows[-20:]) if lows else None
|
|
52
|
+
resistance = max(highs[-20:]) if highs else None
|
|
53
|
+
|
|
54
|
+
trend_bias = "neutral"
|
|
55
|
+
if sma_fast is not None and sma_slow is not None:
|
|
56
|
+
if closes[-1] > sma_fast > sma_slow:
|
|
57
|
+
trend_bias = "bullish"
|
|
58
|
+
elif closes[-1] < sma_fast < sma_slow:
|
|
59
|
+
trend_bias = "bearish"
|
|
60
|
+
|
|
61
|
+
return TechnicalSummary(
|
|
62
|
+
latest_close=closes[-1],
|
|
63
|
+
sma_fast=sma_fast,
|
|
64
|
+
sma_slow=sma_slow,
|
|
65
|
+
ema_fast=ema_fast,
|
|
66
|
+
rsi=rsi,
|
|
67
|
+
macd=macd,
|
|
68
|
+
macd_signal=macd_signal,
|
|
69
|
+
bollinger_upper=bollinger_upper,
|
|
70
|
+
bollinger_lower=bollinger_lower,
|
|
71
|
+
atr=atr,
|
|
72
|
+
support=support,
|
|
73
|
+
resistance=resistance,
|
|
74
|
+
volume_latest=volumes[-1] if volumes else None,
|
|
75
|
+
trend_bias=trend_bias,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sma(values: list[float], window: int) -> float | None:
|
|
80
|
+
if len(values) < window:
|
|
81
|
+
return None
|
|
82
|
+
return sum(values[-window:]) / window
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ema_series(values: list[float], span: int) -> list[float]:
|
|
86
|
+
if not values:
|
|
87
|
+
return []
|
|
88
|
+
alpha = 2 / (span + 1)
|
|
89
|
+
result = [values[0]]
|
|
90
|
+
for value in values[1:]:
|
|
91
|
+
result.append((value * alpha) + (result[-1] * (1 - alpha)))
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _rsi(values: list[float], window: int) -> float | None:
|
|
96
|
+
if len(values) <= window:
|
|
97
|
+
return None
|
|
98
|
+
gains: list[float] = []
|
|
99
|
+
losses: list[float] = []
|
|
100
|
+
for previous, current in zip(values[-window - 1 : -1], values[-window:]):
|
|
101
|
+
delta = current - previous
|
|
102
|
+
gains.append(max(delta, 0.0))
|
|
103
|
+
losses.append(abs(min(delta, 0.0)))
|
|
104
|
+
average_gain = sum(gains) / window
|
|
105
|
+
average_loss = sum(losses) / window
|
|
106
|
+
if average_loss == 0:
|
|
107
|
+
return 100.0
|
|
108
|
+
rs = average_gain / average_loss
|
|
109
|
+
return 100 - (100 / (1 + rs))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _macd(ema_fast: list[float], ema_slow: list[float]) -> tuple[float | None, float | None]:
|
|
113
|
+
if not ema_fast or not ema_slow:
|
|
114
|
+
return None, None
|
|
115
|
+
length = min(len(ema_fast), len(ema_slow))
|
|
116
|
+
macd_series = [ema_fast[-length + index] - ema_slow[-length + index] for index in range(length)]
|
|
117
|
+
signal = _ema_series(macd_series, 9)
|
|
118
|
+
return macd_series[-1], signal[-1] if signal else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _bollinger(values: list[float], window: int) -> tuple[float | None, float | None]:
|
|
122
|
+
if len(values) < window:
|
|
123
|
+
return None, None
|
|
124
|
+
subset = values[-window:]
|
|
125
|
+
mean = sum(subset) / window
|
|
126
|
+
variance = sum((value - mean) ** 2 for value in subset) / window
|
|
127
|
+
stddev = variance**0.5
|
|
128
|
+
return mean + (2 * stddev), mean - (2 * stddev)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _atr(highs: list[float], lows: list[float], closes: list[float], window: int) -> float | None:
|
|
132
|
+
if len(highs) <= window or len(lows) <= window or len(closes) <= window:
|
|
133
|
+
return None
|
|
134
|
+
true_ranges: list[float] = []
|
|
135
|
+
for index in range(1, len(closes)):
|
|
136
|
+
true_ranges.append(
|
|
137
|
+
max(
|
|
138
|
+
highs[index] - lows[index],
|
|
139
|
+
abs(highs[index] - closes[index - 1]),
|
|
140
|
+
abs(lows[index] - closes[index - 1]),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return sum(true_ranges[-window:]) / window
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Simple market structure analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.providers.market.base import Candle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class MarketStructureSummary:
|
|
12
|
+
trend: str
|
|
13
|
+
latest_pattern: str
|
|
14
|
+
break_of_structure: bool
|
|
15
|
+
change_of_character: bool
|
|
16
|
+
support: float | None
|
|
17
|
+
resistance: float | None
|
|
18
|
+
liquidity_area: str | None
|
|
19
|
+
risk_zone: str | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def phase_one_structure_status() -> str:
|
|
23
|
+
return "Market structure scaffold ready. HH/HL/LH/LL detection planned for Phase 2."
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def analyze_market_structure(candles: list[Candle], lookback: int = 20) -> MarketStructureSummary:
|
|
27
|
+
"""Detect a compact HH/HL/LH/LL-style market structure summary."""
|
|
28
|
+
if not candles:
|
|
29
|
+
raise ValueError("Data candle kosong.")
|
|
30
|
+
|
|
31
|
+
recent = candles[-lookback:]
|
|
32
|
+
highs = [float(candle.high) for candle in recent]
|
|
33
|
+
lows = [float(candle.low) for candle in recent]
|
|
34
|
+
closes = [float(candle.close) for candle in recent]
|
|
35
|
+
|
|
36
|
+
previous_high = max(highs[:-1]) if len(highs) > 1 else highs[-1]
|
|
37
|
+
previous_low = min(lows[:-1]) if len(lows) > 1 else lows[-1]
|
|
38
|
+
latest_high = highs[-1]
|
|
39
|
+
latest_low = lows[-1]
|
|
40
|
+
latest_close = closes[-1]
|
|
41
|
+
|
|
42
|
+
higher_high = latest_high > previous_high
|
|
43
|
+
higher_low = len(lows) < 3 or latest_low > min(lows[-4:-1])
|
|
44
|
+
lower_high = len(highs) < 3 or latest_high < max(highs[-4:-1])
|
|
45
|
+
lower_low = latest_low < previous_low
|
|
46
|
+
|
|
47
|
+
if higher_high and higher_low:
|
|
48
|
+
latest_pattern = "HH/HL"
|
|
49
|
+
trend = "bullish"
|
|
50
|
+
elif lower_high and lower_low:
|
|
51
|
+
latest_pattern = "LH/LL"
|
|
52
|
+
trend = "bearish"
|
|
53
|
+
elif higher_high:
|
|
54
|
+
latest_pattern = "HH"
|
|
55
|
+
trend = "bullish"
|
|
56
|
+
elif lower_low:
|
|
57
|
+
latest_pattern = "LL"
|
|
58
|
+
trend = "bearish"
|
|
59
|
+
else:
|
|
60
|
+
latest_pattern = "range"
|
|
61
|
+
trend = "neutral"
|
|
62
|
+
|
|
63
|
+
break_of_structure = latest_close > previous_high or latest_close < previous_low
|
|
64
|
+
prior_window = recent[:-2] if len(recent) >= 5 else recent[:-1]
|
|
65
|
+
prior_bias = _prior_bias(prior_window)
|
|
66
|
+
change_of_character = (prior_bias == "bullish" and trend == "bearish") or (prior_bias == "bearish" and trend == "bullish")
|
|
67
|
+
|
|
68
|
+
support = min(lows)
|
|
69
|
+
resistance = max(highs)
|
|
70
|
+
liquidity_area = f"Above {_fmt(resistance)} / below {_fmt(support)}"
|
|
71
|
+
risk_zone = f"Invalidation near {_fmt(support if trend == 'bullish' else resistance)}"
|
|
72
|
+
|
|
73
|
+
return MarketStructureSummary(
|
|
74
|
+
trend=trend,
|
|
75
|
+
latest_pattern=latest_pattern,
|
|
76
|
+
break_of_structure=break_of_structure,
|
|
77
|
+
change_of_character=change_of_character,
|
|
78
|
+
support=support,
|
|
79
|
+
resistance=resistance,
|
|
80
|
+
liquidity_area=liquidity_area,
|
|
81
|
+
risk_zone=risk_zone,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prior_bias(candles: list[Candle]) -> str:
|
|
86
|
+
if len(candles) < 3:
|
|
87
|
+
return "neutral"
|
|
88
|
+
highs = [float(candle.high) for candle in candles]
|
|
89
|
+
lows = [float(candle.low) for candle in candles]
|
|
90
|
+
if highs[-1] > max(highs[:-1]) and lows[-1] > min(lows[:-1]):
|
|
91
|
+
return "bullish"
|
|
92
|
+
if lows[-1] < min(lows[:-1]) and highs[-1] < max(highs[:-1]):
|
|
93
|
+
return "bearish"
|
|
94
|
+
first_close = float(candles[0].close)
|
|
95
|
+
last_close = float(candles[-1].close)
|
|
96
|
+
if last_close > first_close:
|
|
97
|
+
return "bullish"
|
|
98
|
+
if last_close < first_close:
|
|
99
|
+
return "bearish"
|
|
100
|
+
return "neutral"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fmt(value: float | None) -> str:
|
|
104
|
+
if value is None:
|
|
105
|
+
return "N/A"
|
|
106
|
+
return f"{value:.4f}"
|