@drico2008/fincli 0.1.9 → 0.2.2

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -718
  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 +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  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 +105 -83
  14. package/fincli/app/cli/router.py +2123 -1294
  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/reports.py +151 -0
  21. package/fincli/app/modules/scanner.py +111 -93
  22. package/fincli/app/modules/transactions.py +84 -84
  23. package/fincli/app/modules/user_profile.py +84 -0
  24. package/fincli/app/plugins/loader.py +72 -0
  25. package/fincli/app/providers/ai/manager.py +60 -60
  26. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  27. package/fincli/app/providers/market/base.py +98 -77
  28. package/fincli/app/providers/market/custom_provider.py +186 -169
  29. package/fincli/app/providers/market/manager.py +84 -1
  30. package/fincli/app/providers/market/symbols.py +143 -0
  31. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  32. package/fincli/app/research/__init__.py +7 -0
  33. package/fincli/app/research/engine.py +75 -0
  34. package/fincli/app/research/formatter.py +22 -0
  35. package/fincli/app/research/models.py +18 -0
  36. package/fincli/app/research/prompt_builder.py +47 -0
  37. package/fincli/app/services/macro_data.py +50 -0
  38. package/fincli/app/services/market_data.py +203 -203
  39. package/fincli/app/services/news_aggregator.py +90 -0
  40. package/fincli/app/services/web_research.py +267 -267
  41. package/fincli/app/storage/config.py +122 -88
  42. package/fincli/app/storage/database.py +200 -101
  43. package/fincli/app/storage/secrets.py +8 -2
  44. package/fincli/app/tui/components.py +68 -50
  45. package/fincli/app/tui/layout.py +269 -258
  46. package/fincli/app/tui/market_provider_selector.py +3 -1
  47. package/fincli/app/tui/theme.py +134 -74
  48. package/fincli/app/utils/formatting.py +123 -60
  49. package/package.json +23 -23
  50. package/pyproject.toml +35 -35
@@ -1,187 +1,188 @@
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:
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.2.2.
10
+
11
+ Identity and scope:
12
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
- - If Web Research Context is provided, use it as current public context, mention source URLs, and separate sourced facts from interpretation.
19
- - Use probabilistic language: scenario, bias, confirmation, invalidation, risk, caution.
20
- - Do not promise profit and do not present aggressive entries as guaranteed signals.
21
- - For technical analysis, weigh trend, momentum, volatility, support/resistance, market structure, and data quality.
22
- - For fundamental analysis, separate valuation, growth, profitability, sector context, balance-sheet risk, and missing-data limitations.
23
- - Always include a short non-financial-advice reminder when discussing instruments, portfolio, or trading decisions.
24
-
25
- Coding boundary:
26
- - Do not provide coding, debugging, refactoring, implementation plans, source code, scripts, commands for software builds, or programming architecture.
27
- - If asked for coding help, refuse briefly and redirect to FinCLI usage, market analysis, provider setup, risk management, or journal/portfolio workflow.
28
-
29
- Response style:
30
- - Be concise, structured, and practical.
31
- - Prefer bullets for market analysis.
32
- - If the user asks casual non-market questions, answer normally while staying within the coding boundary.
33
- """.strip()
34
-
35
-
36
- _CODING_PATTERNS = tuple(
37
- re.compile(pattern, re.IGNORECASE)
38
- for pattern in (
39
- 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",
40
- r"\b(code|kode|source code|script|programming|pemrograman|debugging|traceback|stack trace)\b",
41
- r"\b(python|javascript|typescript|react|next\.?js|node\.?js|npm|pip|django|flask|fastapi|sql|docker|kubernetes|regex)\b",
42
- r"\b(git|commit|pull request|unit test|pytest|lint|ci/cd|api endpoint|sdk|library|framework)\b",
43
- r"\b(error di kode|bug di kode|perbaiki kode|arsitektur software|software architecture)\b",
44
- )
45
- )
46
-
47
-
48
- _SYMBOL_PATTERN = re.compile(r"\b[A-Z][A-Z0-9./:_-]{1,14}\b", re.IGNORECASE)
49
- _MARKET_KEYWORDS = {
50
- "AAPL",
51
- "MSFT",
52
- "NVDA",
53
- "TSLA",
54
- "AMZN",
55
- "GOOGL",
56
- "META",
57
- "BTC",
58
- "ETH",
59
- "BTCUSD",
60
- "BTCUSDT",
61
- "ETHUSD",
62
- "ETHUSDT",
63
- "EURUSD",
64
- "GBPUSD",
65
- "USDJPY",
66
- "AUDUSD",
67
- "USDCAD",
68
- "USDCHF",
69
- "XAUUSD",
70
- "XAGUSD",
71
- "GOLD",
72
- "SILVER",
73
- "WTI",
74
- "BRENT",
75
- "SPX",
76
- "SP500",
77
- "NASDAQ",
78
- "NDX",
79
- "DOW",
80
- "DAX",
81
- "FTSE",
82
- "NIKKEI",
83
- "HSI",
84
- }
85
- _SYMBOL_STOPWORDS = {
86
- "AI",
87
- "API",
88
- "CLI",
89
- "TUI",
90
- "FINCLI",
91
- "BUY",
92
- "SELL",
93
- "CAUTION",
94
- "RSI",
95
- "MACD",
96
- "EMA",
97
- "SMA",
98
- "ATR",
99
- "PE",
100
- "EPS",
101
- "USD",
102
- "EUR",
103
- "GBP",
104
- "JPY",
105
- "IDR",
106
- }
107
-
108
-
109
- def is_coding_request(prompt: str) -> bool:
110
- """Return True when a free-chat prompt asks for programming help."""
111
- normalized = prompt.strip()
112
- if not normalized:
113
- return False
114
- return any(pattern.search(normalized) for pattern in _CODING_PATTERNS)
115
-
116
-
117
- def coding_refusal() -> str:
118
- """Return FinCLI-specific refusal text for coding prompts."""
119
- return (
120
- "Aku FinCLI AI Assistance untuk market, portfolio, journal, provider, dan risk workflow. "
121
- "Aku tidak menangani coding, debugging, refactor, atau pembuatan software di dalam FinCLI. "
122
- "Kamu bisa tanya analisis market, fundamental, technical setup, watchlist, portfolio, journal, "
123
- "atau cara memakai command FinCLI."
124
- )
125
-
126
-
127
- def extract_market_symbols(prompt: str, limit: int = 3) -> list[str]:
128
- """Extract explicit market symbols from a user prompt."""
129
- found: list[str] = []
130
- for match in _SYMBOL_PATTERN.finditer(prompt):
131
- raw_symbol = match.group(0).strip(".,?!:;()[]{}")
132
- symbol = raw_symbol.upper()
133
- if symbol in _SYMBOL_STOPWORDS:
134
- continue
135
- if not raw_symbol.isupper() and symbol not in _MARKET_KEYWORDS:
136
- continue
137
- if len(symbol) <= 2 and symbol not in _MARKET_KEYWORDS:
138
- continue
139
- if symbol.isdigit():
140
- continue
141
- if symbol not in found:
142
- found.append(symbol)
143
- if len(found) >= limit:
144
- break
145
- return found
146
-
147
-
148
- def build_fincli_assistant_prompt(user_prompt: str, market_context: str = "") -> str:
149
- """Build the final prompt sent to the configured AI provider."""
150
- context = market_context.strip() or "No explicit market context was available for this free-chat prompt."
151
- return (
152
- f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
153
- "Runtime Market Context:\n"
154
- f"{context}\n\n"
155
- "User Prompt:\n"
156
- f"{user_prompt.strip()}\n\n"
157
- "Instruction:\n"
158
- "- Answer the user's prompt directly.\n"
159
- "- If market or web context is present, cite provider/data-quality limitations and source URLs when available.\n"
160
- "- If market context is missing and the user asks about an instrument, say what data is missing.\n"
161
- "- Keep the coding boundary enforced.\n"
162
- )
163
-
164
-
165
- def build_web_research_answer_prompt(user_prompt: str, web_context: str) -> str:
166
- """Build a prompt that turns gathered web context into an answer, not a source dump."""
167
- context = web_context.strip() or "Web Research: no public web context returned."
168
- return (
169
- f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
170
- "Web Search Skill Result:\n"
171
- f"{context}\n\n"
172
- "User Prompt:\n"
173
- f"{user_prompt.strip()}\n\n"
174
- "Instruction:\n"
175
- "- You already have web search context above. Do not answer by only listing articles or links.\n"
176
- "- Synthesize the sources into a useful explanation/summary for the user.\n"
177
- "- Prioritize facts found in the web context, then clearly label interpretation.\n"
178
- "- If sources disagree or are thin, say that the evidence is limited.\n"
179
- "- Use this output structure when relevant:\n"
180
- " 1. Ringkasan singkat\n"
181
- " 2. Poin utama/penyebab\n"
182
- " 3. Dampak atau implikasi\n"
183
- " 4. Risiko dan hal yang perlu diverifikasi\n"
184
- " 5. Sumber singkat\n"
185
- "- Keep source citations compact: source title or URL only where useful.\n"
186
- "- Do not provide financial advice or certainty about market direction.\n"
187
- )
13
+ - FinCLI commands start with slash commands inside the TUI, for example /help, /research AAPL --quick, /macro US, /profile, /technical AAPL, and /analyze XAUUSD.
14
+ - Your role is to help users understand markets, risk, portfolio context, trading journal patterns, FinCLI commands, and general non-coding questions.
15
+ - Free chat is allowed, but you must keep your identity as FinCLI's assistant and be clear when market data is unavailable or delayed.
16
+
17
+ Financial analysis rules:
18
+ - Analyze from provided market context first. Do not invent prices, news, fundamentals, provider status, or certainty.
19
+ - If Web Research Context is provided, use it as current public context, mention source URLs, and separate sourced facts from interpretation.
20
+ - Use probabilistic language: scenario, bias, confirmation, invalidation, risk, caution.
21
+ - Do not promise profit and do not present aggressive entries as guaranteed signals.
22
+ - For technical analysis, weigh trend, momentum, volatility, support/resistance, market structure, and data quality.
23
+ - For fundamental analysis, separate valuation, growth, profitability, sector context, balance-sheet risk, and missing-data limitations.
24
+ - Always include a short non-financial-advice reminder when discussing instruments, portfolio, or trading decisions.
25
+
26
+ Coding boundary:
27
+ - Do not provide coding, debugging, refactoring, implementation plans, source code, scripts, commands for software builds, or programming architecture.
28
+ - If asked for coding help, refuse briefly and redirect to FinCLI usage, market analysis, provider setup, risk management, or journal/portfolio workflow.
29
+
30
+ Response style:
31
+ - Be concise, structured, and practical.
32
+ - Prefer bullets for market analysis.
33
+ - If the user asks casual non-market questions, answer normally while staying within the coding boundary.
34
+ """.strip()
35
+
36
+
37
+ _CODING_PATTERNS = tuple(
38
+ re.compile(pattern, re.IGNORECASE)
39
+ for pattern in (
40
+ 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",
41
+ r"\b(code|kode|source code|script|programming|pemrograman|debugging|traceback|stack trace)\b",
42
+ r"\b(python|javascript|typescript|react|next\.?js|node\.?js|npm|pip|django|flask|fastapi|sql|docker|kubernetes|regex)\b",
43
+ r"\b(git|commit|pull request|unit test|pytest|lint|ci/cd|api endpoint|sdk|library|framework)\b",
44
+ r"\b(error di kode|bug di kode|perbaiki kode|arsitektur software|software architecture)\b",
45
+ )
46
+ )
47
+
48
+
49
+ _SYMBOL_PATTERN = re.compile(r"\b[A-Z][A-Z0-9./:_-]{1,14}\b", re.IGNORECASE)
50
+ _MARKET_KEYWORDS = {
51
+ "AAPL",
52
+ "MSFT",
53
+ "NVDA",
54
+ "TSLA",
55
+ "AMZN",
56
+ "GOOGL",
57
+ "META",
58
+ "BTC",
59
+ "ETH",
60
+ "BTCUSD",
61
+ "BTCUSDT",
62
+ "ETHUSD",
63
+ "ETHUSDT",
64
+ "EURUSD",
65
+ "GBPUSD",
66
+ "USDJPY",
67
+ "AUDUSD",
68
+ "USDCAD",
69
+ "USDCHF",
70
+ "XAUUSD",
71
+ "XAGUSD",
72
+ "GOLD",
73
+ "SILVER",
74
+ "WTI",
75
+ "BRENT",
76
+ "SPX",
77
+ "SP500",
78
+ "NASDAQ",
79
+ "NDX",
80
+ "DOW",
81
+ "DAX",
82
+ "FTSE",
83
+ "NIKKEI",
84
+ "HSI",
85
+ }
86
+ _SYMBOL_STOPWORDS = {
87
+ "AI",
88
+ "API",
89
+ "CLI",
90
+ "TUI",
91
+ "FINCLI",
92
+ "BUY",
93
+ "SELL",
94
+ "CAUTION",
95
+ "RSI",
96
+ "MACD",
97
+ "EMA",
98
+ "SMA",
99
+ "ATR",
100
+ "PE",
101
+ "EPS",
102
+ "USD",
103
+ "EUR",
104
+ "GBP",
105
+ "JPY",
106
+ "IDR",
107
+ }
108
+
109
+
110
+ def is_coding_request(prompt: str) -> bool:
111
+ """Return True when a free-chat prompt asks for programming help."""
112
+ normalized = prompt.strip()
113
+ if not normalized:
114
+ return False
115
+ return any(pattern.search(normalized) for pattern in _CODING_PATTERNS)
116
+
117
+
118
+ def coding_refusal() -> str:
119
+ """Return FinCLI-specific refusal text for coding prompts."""
120
+ return (
121
+ "Aku FinCLI AI Assistance untuk market, portfolio, journal, provider, dan risk workflow. "
122
+ "Aku tidak menangani coding, debugging, refactor, atau pembuatan software di dalam FinCLI. "
123
+ "Kamu bisa tanya analisis market, fundamental, technical setup, watchlist, portfolio, journal, "
124
+ "atau cara memakai command FinCLI."
125
+ )
126
+
127
+
128
+ def extract_market_symbols(prompt: str, limit: int = 3) -> list[str]:
129
+ """Extract explicit market symbols from a user prompt."""
130
+ found: list[str] = []
131
+ for match in _SYMBOL_PATTERN.finditer(prompt):
132
+ raw_symbol = match.group(0).strip(".,?!:;()[]{}")
133
+ symbol = raw_symbol.upper()
134
+ if symbol in _SYMBOL_STOPWORDS:
135
+ continue
136
+ if not raw_symbol.isupper() and symbol not in _MARKET_KEYWORDS:
137
+ continue
138
+ if len(symbol) <= 2 and symbol not in _MARKET_KEYWORDS:
139
+ continue
140
+ if symbol.isdigit():
141
+ continue
142
+ if symbol not in found:
143
+ found.append(symbol)
144
+ if len(found) >= limit:
145
+ break
146
+ return found
147
+
148
+
149
+ def build_fincli_assistant_prompt(user_prompt: str, market_context: str = "") -> str:
150
+ """Build the final prompt sent to the configured AI provider."""
151
+ context = market_context.strip() or "No explicit market context was available for this free-chat prompt."
152
+ return (
153
+ f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
154
+ "Runtime Market Context:\n"
155
+ f"{context}\n\n"
156
+ "User Prompt:\n"
157
+ f"{user_prompt.strip()}\n\n"
158
+ "Instruction:\n"
159
+ "- Answer the user's prompt directly.\n"
160
+ "- If market or web context is present, cite provider/data-quality limitations and source URLs when available.\n"
161
+ "- If market context is missing and the user asks about an instrument, say what data is missing.\n"
162
+ "- Keep the coding boundary enforced.\n"
163
+ )
164
+
165
+
166
+ def build_web_research_answer_prompt(user_prompt: str, web_context: str) -> str:
167
+ """Build a prompt that turns gathered web context into an answer, not a source dump."""
168
+ context = web_context.strip() or "Web Research: no public web context returned."
169
+ return (
170
+ f"{FINCLI_ASSISTANT_SYSTEM_PROMPT}\n\n"
171
+ "Web Search Skill Result:\n"
172
+ f"{context}\n\n"
173
+ "User Prompt:\n"
174
+ f"{user_prompt.strip()}\n\n"
175
+ "Instruction:\n"
176
+ "- You already have web search context above. Do not answer by only listing articles or links.\n"
177
+ "- Synthesize the sources into a useful explanation/summary for the user.\n"
178
+ "- Prioritize facts found in the web context, then clearly label interpretation.\n"
179
+ "- If sources disagree or are thin, say that the evidence is limited.\n"
180
+ "- Use this output structure when relevant:\n"
181
+ " 1. Ringkasan singkat\n"
182
+ " 2. Poin utama/penyebab\n"
183
+ " 3. Dampak atau implikasi\n"
184
+ " 4. Risiko dan hal yang perlu diverifikasi\n"
185
+ " 5. Sumber singkat\n"
186
+ "- Keep source citations compact: source title or URL only where useful.\n"
187
+ "- Do not provide financial advice or certainty about market direction.\n"
188
+ )
@@ -0,0 +1,179 @@
1
+ """Lightweight rule-based backtesting for FinCLI."""
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 BacktestTrade:
12
+ entry_index: int
13
+ exit_index: int
14
+ entry_price: float
15
+ exit_price: float
16
+ pnl_percent: float
17
+ reason: str
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class BacktestResult:
22
+ symbol: str
23
+ strategy: str
24
+ interval: str
25
+ candles: int
26
+ trades: list[BacktestTrade]
27
+ total_return_percent: float
28
+ win_rate: float
29
+ max_drawdown_percent: float
30
+ exposure_percent: float
31
+ notes: tuple[str, ...]
32
+
33
+
34
+ def run_backtest(
35
+ symbol: str,
36
+ candles: list[Candle],
37
+ strategy: str = "sma_cross",
38
+ interval: str = "1d",
39
+ ) -> BacktestResult:
40
+ if len(candles) < 30:
41
+ raise ValueError("Backtest needs at least 30 candles.")
42
+
43
+ normalized = strategy.lower().strip()
44
+ if normalized in {"sma", "sma_cross", "ma_cross"}:
45
+ trades = _sma_cross_trades(candles)
46
+ elif normalized in {"rsi", "rsi_reversion", "mean_reversion"}:
47
+ trades = _rsi_reversion_trades(candles)
48
+ else:
49
+ raise ValueError("Unknown strategy. Use sma_cross or rsi_reversion.")
50
+
51
+ equity_curve = _equity_curve(candles, trades)
52
+ total_return = (equity_curve[-1] - 1.0) * 100
53
+ max_drawdown = _max_drawdown(equity_curve)
54
+ wins = sum(1 for trade in trades if trade.pnl_percent > 0)
55
+ win_rate = (wins / len(trades) * 100) if trades else 0.0
56
+ exposure = _exposure(candles, trades)
57
+ notes = _result_notes(trades, total_return, max_drawdown)
58
+ return BacktestResult(
59
+ symbol=symbol.upper(),
60
+ strategy=normalized,
61
+ interval=interval,
62
+ candles=len(candles),
63
+ trades=trades,
64
+ total_return_percent=total_return,
65
+ win_rate=win_rate,
66
+ max_drawdown_percent=max_drawdown,
67
+ exposure_percent=exposure,
68
+ notes=notes,
69
+ )
70
+
71
+
72
+ def _sma_cross_trades(candles: list[Candle], fast: int = 10, slow: int = 30) -> list[BacktestTrade]:
73
+ closes = [float(candle.close) for candle in candles]
74
+ position_entry: tuple[int, float] | None = None
75
+ trades: list[BacktestTrade] = []
76
+ for index in range(slow, len(closes)):
77
+ fast_ma = _sma(closes[: index + 1], fast)
78
+ slow_ma = _sma(closes[: index + 1], slow)
79
+ previous_fast = _sma(closes[:index], fast)
80
+ previous_slow = _sma(closes[:index], slow)
81
+ if None in {fast_ma, slow_ma, previous_fast, previous_slow}:
82
+ continue
83
+ bullish_cross = previous_fast <= previous_slow and fast_ma > slow_ma
84
+ bearish_cross = previous_fast >= previous_slow and fast_ma < slow_ma
85
+ if position_entry is None and bullish_cross:
86
+ position_entry = (index, closes[index])
87
+ elif position_entry is not None and bearish_cross:
88
+ entry_index, entry_price = position_entry
89
+ trades.append(_trade(entry_index, index, entry_price, closes[index], "sma bearish cross"))
90
+ position_entry = None
91
+ if position_entry is not None:
92
+ entry_index, entry_price = position_entry
93
+ trades.append(_trade(entry_index, len(closes) - 1, entry_price, closes[-1], "end of test"))
94
+ return trades
95
+
96
+
97
+ def _rsi_reversion_trades(candles: list[Candle], buy_level: float = 30, sell_level: float = 55) -> list[BacktestTrade]:
98
+ closes = [float(candle.close) for candle in candles]
99
+ position_entry: tuple[int, float] | None = None
100
+ trades: list[BacktestTrade] = []
101
+ for index in range(15, len(closes)):
102
+ rsi = _rsi(closes[: index + 1], 14)
103
+ if rsi is None:
104
+ continue
105
+ if position_entry is None and rsi < buy_level:
106
+ position_entry = (index, closes[index])
107
+ elif position_entry is not None and rsi > sell_level:
108
+ entry_index, entry_price = position_entry
109
+ trades.append(_trade(entry_index, index, entry_price, closes[index], "rsi mean reversion exit"))
110
+ position_entry = None
111
+ if position_entry is not None:
112
+ entry_index, entry_price = position_entry
113
+ trades.append(_trade(entry_index, len(closes) - 1, entry_price, closes[-1], "end of test"))
114
+ return trades
115
+
116
+
117
+ def _trade(entry_index: int, exit_index: int, entry_price: float, exit_price: float, reason: str) -> BacktestTrade:
118
+ pnl = ((exit_price / entry_price) - 1.0) * 100
119
+ return BacktestTrade(entry_index, exit_index, entry_price, exit_price, pnl, reason)
120
+
121
+
122
+ def _equity_curve(candles: list[Candle], trades: list[BacktestTrade]) -> list[float]:
123
+ equity = 1.0
124
+ curve = [equity]
125
+ trade_by_exit = {trade.exit_index: trade for trade in trades}
126
+ for index in range(1, len(candles)):
127
+ if index in trade_by_exit:
128
+ equity *= 1 + (trade_by_exit[index].pnl_percent / 100)
129
+ curve.append(equity)
130
+ return curve
131
+
132
+
133
+ def _max_drawdown(equity_curve: list[float]) -> float:
134
+ peak = equity_curve[0]
135
+ max_drawdown = 0.0
136
+ for value in equity_curve:
137
+ peak = max(peak, value)
138
+ drawdown = ((value / peak) - 1.0) * 100
139
+ max_drawdown = min(max_drawdown, drawdown)
140
+ return abs(max_drawdown)
141
+
142
+
143
+ def _exposure(candles: list[Candle], trades: list[BacktestTrade]) -> float:
144
+ if not candles:
145
+ return 0.0
146
+ bars = sum(max(0, trade.exit_index - trade.entry_index) for trade in trades)
147
+ return min(100.0, (bars / len(candles)) * 100)
148
+
149
+
150
+ def _result_notes(trades: list[BacktestTrade], total_return: float, max_drawdown: float) -> tuple[str, ...]:
151
+ notes = ["Backtest is educational and ignores fees, slippage, spreads, liquidity, and survivorship bias."]
152
+ if not trades:
153
+ notes.append("No trades were generated by the selected strategy.")
154
+ if max_drawdown > abs(total_return) and trades:
155
+ notes.append("Drawdown is large relative to return; treat the result as fragile.")
156
+ return tuple(notes)
157
+
158
+
159
+ def _sma(values: list[float], window: int) -> float | None:
160
+ if len(values) < window:
161
+ return None
162
+ return sum(values[-window:]) / window
163
+
164
+
165
+ def _rsi(values: list[float], window: int) -> float | None:
166
+ if len(values) <= window:
167
+ return None
168
+ gains: list[float] = []
169
+ losses: list[float] = []
170
+ for previous, current in zip(values[-window - 1 : -1], values[-window:]):
171
+ delta = current - previous
172
+ gains.append(max(delta, 0.0))
173
+ losses.append(abs(min(delta, 0.0)))
174
+ average_gain = sum(gains) / window
175
+ average_loss = sum(losses) / window
176
+ if average_loss == 0:
177
+ return 100.0
178
+ rs = average_gain / average_loss
179
+ return 100 - (100 / (1 + rs))
@@ -0,0 +1,79 @@
1
+ """Translate local user profile into risk constraints for AI analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.modules.user_profile import UserProfile
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class GameplayPlan:
12
+ gameplay: str
13
+ asset_class: str
14
+ max_sl: str
15
+ minimum_rr: str
16
+ note: str
17
+
18
+
19
+ def build_gameplay_plan(profile: UserProfile | None, symbol: str) -> GameplayPlan | None:
20
+ if profile is None:
21
+ return None
22
+
23
+ asset_class = infer_asset_class(symbol)
24
+ gameplay = profile.gameplay
25
+ max_sl = _max_sl(gameplay, asset_class, symbol)
26
+ note = (
27
+ f"Profile equity {profile.equity:g} {profile.currency}, leverage {profile.leverage}, "
28
+ f"experience {profile.years_in_investment:g} years. Use risk-aware scenarios, not guarantees."
29
+ )
30
+ return GameplayPlan(gameplay=gameplay, asset_class=asset_class, max_sl=max_sl, minimum_rr="1:1.5", note=note)
31
+
32
+
33
+ def format_gameplay_context(profile: UserProfile | None, symbol: str) -> str:
34
+ plan = build_gameplay_plan(profile, symbol)
35
+ if profile is None or plan is None:
36
+ return (
37
+ "User Gameplay Profile: not configured.\n"
38
+ "Ask user to run /profile set <name> <equity> <currency> <leverage> <years> for tailored SL/TP context."
39
+ )
40
+ return (
41
+ "User Gameplay Profile:\n"
42
+ f"- Name: {profile.name}\n"
43
+ f"- Equity: {profile.equity:g} {profile.currency}\n"
44
+ f"- Leverage: {profile.leverage}\n"
45
+ f"- Experience: {profile.years_in_investment:g} years\n"
46
+ f"- Gameplay: {plan.gameplay}\n"
47
+ f"- Asset Class: {plan.asset_class}\n"
48
+ f"- Max SL Guide: {plan.max_sl}\n"
49
+ f"- Minimum RR: {plan.minimum_rr}\n"
50
+ f"- Risk Note: {plan.note}"
51
+ )
52
+
53
+
54
+ def infer_asset_class(symbol: str) -> str:
55
+ normalized = symbol.upper()
56
+ if any(token in normalized for token in ("XAU", "GOLD", "XAG", "SILVER", "WTI", "BRENT", "OIL")):
57
+ return "commodities"
58
+ if any(token in normalized for token in ("SPX", "SP500", "NAS", "NDX", "DAX", "FTSE", "NIKKEI", "HSI", "DJI", "US30")):
59
+ return "indices"
60
+ compact = normalized.replace("/", "").replace("-", "").replace("=", "")
61
+ if len(compact) == 6 and compact[:3].isalpha() and compact[3:].isalpha():
62
+ return "forex"
63
+ return "stocks/crypto"
64
+
65
+
66
+ def _max_sl(gameplay: str, asset_class: str, symbol: str) -> str:
67
+ if asset_class == "forex":
68
+ return {"Scalper": "< 15 pips", "Intra day": "< 20 pips", "Day trade": "< 25 pips"}.get(gameplay, "profile-dependent")
69
+ if asset_class == "commodities":
70
+ if gameplay == "Scalper":
71
+ return "< 31 pips"
72
+ if gameplay == "Intra day":
73
+ return "< 60 pips"
74
+ if gameplay == "Day trade":
75
+ return "< 120 pips for gold, < 60 pips for other commodities"
76
+ return "profile-dependent"
77
+ if asset_class == "indices":
78
+ return {"Scalper": "< 51 pips", "Intra day": "< 71 pips", "Day trade": "< 120 pips"}.get(gameplay, "profile-dependent")
79
+ return "use ATR/support-resistance based invalidation; keep RR >= 1:1.5"