@drico2008/fincli 0.1.3 → 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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -684
  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 -160
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/indicators.py +1 -1
  12. package/fincli/app/analysis/market_structure.py +1 -1
  13. package/fincli/app/analysis/multi_timeframe.py +180 -0
  14. package/fincli/app/analysis/trading_methods.py +144 -0
  15. package/fincli/app/cli/commands.py +105 -77
  16. package/fincli/app/cli/router.py +2143 -1121
  17. package/fincli/app/connectors/__init__.py +5 -0
  18. package/fincli/app/connectors/catalog.py +148 -0
  19. package/fincli/app/connectors/news_connectors.py +412 -0
  20. package/fincli/app/modules/alerts.py +80 -0
  21. package/fincli/app/modules/economic_calendar.py +374 -1
  22. package/fincli/app/modules/reports.py +151 -0
  23. package/fincli/app/modules/scanner.py +111 -93
  24. package/fincli/app/modules/session_history.py +113 -0
  25. package/fincli/app/modules/transactions.py +84 -84
  26. package/fincli/app/modules/user_profile.py +84 -0
  27. package/fincli/app/plugins/loader.py +72 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +8 -7
  29. package/fincli/app/providers/ai/gemini_provider.py +8 -7
  30. package/fincli/app/providers/ai/groq_provider.py +8 -7
  31. package/fincli/app/providers/ai/huggingface_provider.py +8 -7
  32. package/fincli/app/providers/ai/manager.py +60 -60
  33. package/fincli/app/providers/ai/openai_provider.py +8 -7
  34. package/fincli/app/providers/ai/openrouter_provider.py +8 -7
  35. package/fincli/app/providers/ai/together_provider.py +8 -7
  36. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  37. package/fincli/app/providers/market/base.py +98 -77
  38. package/fincli/app/providers/market/custom_provider.py +186 -169
  39. package/fincli/app/providers/market/manager.py +85 -2
  40. package/fincli/app/providers/market/news_provider.py +4 -4
  41. package/fincli/app/providers/market/symbols.py +143 -0
  42. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  43. package/fincli/app/providers/market/yfinance_provider.py +1 -1
  44. package/fincli/app/research/__init__.py +7 -0
  45. package/fincli/app/research/engine.py +75 -0
  46. package/fincli/app/research/formatter.py +22 -0
  47. package/fincli/app/research/models.py +18 -0
  48. package/fincli/app/research/prompt_builder.py +47 -0
  49. package/fincli/app/services/macro_data.py +50 -0
  50. package/fincli/app/services/market_data.py +203 -203
  51. package/fincli/app/services/news_aggregator.py +90 -0
  52. package/fincli/app/services/web_research.py +267 -0
  53. package/fincli/app/storage/cache.py +2 -2
  54. package/fincli/app/storage/config.py +122 -88
  55. package/fincli/app/storage/database.py +201 -85
  56. package/fincli/app/storage/secrets.py +12 -3
  57. package/fincli/app/tui/components.py +68 -50
  58. package/fincli/app/tui/layout.py +270 -258
  59. package/fincli/app/tui/market_provider_selector.py +6 -1
  60. package/fincli/app/tui/model_selector.py +11 -3
  61. package/fincli/app/tui/theme.py +134 -74
  62. package/fincli/app/utils/formatting.py +125 -12
  63. package/npm/bin/fincli.js +9 -2
  64. package/package.json +23 -23
  65. package/pyproject.toml +35 -35
@@ -1,105 +1,123 @@
1
- """Watchlist scanner with simple technical filters."""
2
-
3
- from __future__ import annotations
4
-
1
+ """Watchlist scanner with simple technical filters."""
2
+
3
+ from __future__ import annotations
4
+
5
5
  import asyncio
6
6
  from dataclasses import dataclass
7
+ import re
7
8
 
8
9
  from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
9
10
  from fincli.app.providers.market.base import BaseMarketProvider
11
+ from fincli.app.utils.errors import CommandError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ScanResult:
16
+ symbol: str
17
+ latest_close: float
18
+ rsi: float | None
19
+ trend_bias: str
20
+ support: float | None
21
+ resistance: float | None
22
+ matched: bool
23
+ reason: str
24
+
25
+
26
+ async def scan_symbols(
27
+ symbols: list[str],
28
+ provider: BaseMarketProvider,
29
+ filter_expression: str = "",
30
+ interval: str = "1d",
31
+ batch_size: int = 25,
32
+ ) -> list[ScanResult]:
33
+ """Scan symbols in bounded async batches."""
34
+ results: list[ScanResult] = []
35
+ for index in range(0, len(symbols), batch_size):
36
+ batch = symbols[index : index + batch_size]
37
+ scanned = await asyncio.gather(
38
+ *[_scan_symbol(symbol, provider, filter_expression, interval) for symbol in batch],
39
+ return_exceptions=True,
40
+ )
41
+ for item in scanned:
42
+ if isinstance(item, ScanResult) and item.matched:
43
+ results.append(item)
44
+ return results
45
+
46
+
47
+ async def _scan_symbol(
48
+ symbol: str,
49
+ provider: BaseMarketProvider,
50
+ filter_expression: str,
51
+ interval: str,
52
+ ) -> ScanResult:
53
+ candles = await provider.history(symbol, period="6mo", interval=interval)
54
+ summary = summarize_technical_indicators(candles)
55
+ matched, reason = _matches_filter(summary, filter_expression)
56
+ return ScanResult(
57
+ symbol=symbol.upper(),
58
+ latest_close=summary.latest_close,
59
+ rsi=summary.rsi,
60
+ trend_bias=summary.trend_bias,
61
+ support=summary.support,
62
+ resistance=summary.resistance,
63
+ matched=matched,
64
+ reason=reason,
65
+ )
66
+
67
+
68
+ def _matches_filter(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
69
+ return matches_filter_expression(summary, expression)
10
70
 
11
71
 
12
- @dataclass(frozen=True, slots=True)
13
- class ScanResult:
14
- symbol: str
15
- latest_close: float
16
- rsi: float | None
17
- trend_bias: str
18
- support: float | None
19
- resistance: float | None
20
- matched: bool
21
- reason: str
22
-
23
-
24
- async def scan_symbols(
25
- symbols: list[str],
26
- provider: BaseMarketProvider,
27
- filter_expression: str = "",
28
- interval: str = "1d",
29
- batch_size: int = 25,
30
- ) -> list[ScanResult]:
31
- """Scan symbols in bounded async batches."""
32
- results: list[ScanResult] = []
33
- for index in range(0, len(symbols), batch_size):
34
- batch = symbols[index : index + batch_size]
35
- scanned = await asyncio.gather(
36
- *[_scan_symbol(symbol, provider, filter_expression, interval) for symbol in batch],
37
- return_exceptions=True,
38
- )
39
- for item in scanned:
40
- if isinstance(item, ScanResult) and item.matched:
41
- results.append(item)
42
- return results
43
-
44
-
45
- async def _scan_symbol(
46
- symbol: str,
47
- provider: BaseMarketProvider,
48
- filter_expression: str,
49
- interval: str,
50
- ) -> ScanResult:
51
- candles = await provider.history(symbol, period="6mo", interval=interval)
52
- summary = summarize_technical_indicators(candles)
53
- matched, reason = _matches_filter(summary, filter_expression)
54
- return ScanResult(
55
- symbol=symbol.upper(),
56
- latest_close=summary.latest_close,
57
- rsi=summary.rsi,
58
- trend_bias=summary.trend_bias,
59
- support=summary.support,
60
- resistance=summary.resistance,
61
- matched=matched,
62
- reason=reason,
63
- )
64
-
72
+ def matches_filter_expression(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
73
+ """Evaluate a small, explicit scan expression language.
65
74
 
66
- def _matches_filter(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
75
+ Supported terms: trend=<bias>, rsi<number, rsi>number.
76
+ Supported operators: and, or. Comma is treated as and.
77
+ """
67
78
  expr = expression.strip().lower()
68
79
  if not expr:
69
80
  return True, "all"
70
81
 
71
- parts = expr.split()
72
- if len(parts) > 1:
73
- evaluations = [_matches_single_filter(summary, part) for part in parts]
74
- return all(item[0] for item in evaluations), "; ".join(item[1] for item in evaluations)
75
-
76
- return _matches_single_filter(summary, expr)
77
-
78
-
79
- def _matches_single_filter(summary: TechnicalSummary, expr: str) -> tuple[bool, str]:
80
- if expr.startswith("trend="):
81
- expected = expr.split("=", 1)[1].strip()
82
- return summary.trend_bias == expected, f"trend={summary.trend_bias}"
83
-
84
- if expr.startswith("rsi<"):
85
- threshold = _parse_threshold(expr, "rsi<")
86
- return summary.rsi is not None and summary.rsi < threshold, f"rsi={_fmt(summary.rsi)} < {threshold:g}"
87
-
88
- if expr.startswith("rsi>"):
89
- threshold = _parse_threshold(expr, "rsi>")
90
- return summary.rsi is not None and summary.rsi > threshold, f"rsi={_fmt(summary.rsi)} > {threshold:g}"
91
-
92
- return True, f"unsupported filter treated as all: {expr}"
93
-
94
-
95
- def _parse_threshold(expression: str, prefix: str) -> float:
96
- try:
97
- return float(expression.replace(prefix, "", 1).strip())
98
- except ValueError:
99
- return 0.0
100
-
101
-
102
- def _fmt(value: float | None) -> str:
103
- if value is None:
104
- return "N/A"
105
- return f"{value:.2f}"
82
+ normalized = expr.replace(",", " and ")
83
+ or_groups = [group.strip() for group in re.split(r"\s+or\s+", normalized) if group.strip()]
84
+ group_results: list[tuple[bool, str]] = []
85
+ for group in or_groups:
86
+ terms = [term.strip() for term in re.split(r"\s+and\s+|\s+", group) if term.strip()]
87
+ evaluations = [_matches_single_filter(summary, term) for term in terms]
88
+ group_results.append((all(item[0] for item in evaluations), "; ".join(item[1] for item in evaluations)))
89
+
90
+ matched = any(item[0] for item in group_results)
91
+ return matched, " OR ".join(item[1] for item in group_results)
92
+
93
+
94
+ def _matches_single_filter(summary: TechnicalSummary, expr: str) -> tuple[bool, str]:
95
+ if expr.startswith("trend="):
96
+ expected = expr.split("=", 1)[1].strip()
97
+ return summary.trend_bias == expected, f"trend={summary.trend_bias}"
98
+
99
+ if expr.startswith("rsi<"):
100
+ threshold = _parse_threshold(expr, "rsi<")
101
+ return summary.rsi is not None and summary.rsi < threshold, f"rsi={_fmt(summary.rsi)} < {threshold:g}"
102
+
103
+ if expr.startswith("rsi>"):
104
+ threshold = _parse_threshold(expr, "rsi>")
105
+ return summary.rsi is not None and summary.rsi > threshold, f"rsi={_fmt(summary.rsi)} > {threshold:g}"
106
+
107
+ raise CommandError(
108
+ f"Filter scan tidak dikenal: {expr}",
109
+ "Gunakan filter seperti trend=bullish, rsi<30, rsi>70, atau gabungkan dengan and/or.",
110
+ )
111
+
112
+
113
+ def _parse_threshold(expression: str, prefix: str) -> float:
114
+ try:
115
+ return float(expression.replace(prefix, "", 1).strip())
116
+ except ValueError:
117
+ return 0.0
118
+
119
+
120
+ def _fmt(value: float | None) -> str:
121
+ if value is None:
122
+ return "N/A"
123
+ return f"{value:.2f}"
@@ -0,0 +1,113 @@
1
+ """Session history service for FinCLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import re
7
+ from uuid import uuid4
8
+
9
+ from fincli.app.storage.database import FinCLIDatabase
10
+
11
+
12
+ _AI_NEWS_KEY_PATTERN = re.compile(r"^/(ai_model|news_model)\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
13
+ _PROVIDER_KEY_PATTERN = re.compile(r"^/provider\s+key\s+(\S+)\s+(.+)$", re.IGNORECASE)
14
+ _SECRET_VALUE_PATTERNS = (
15
+ re.compile(r"(?i)(api[_ -]?key|token|secret|password)\s*[:=]\s*\S+"),
16
+ )
17
+
18
+
19
+ class SessionHistoryService:
20
+ """Persist local command sessions and sanitized command events."""
21
+
22
+ def __init__(self, db: FinCLIDatabase) -> None:
23
+ self.db = db
24
+
25
+ def start_session(self, title: str = "FinCLI session") -> str:
26
+ session_id = uuid4().hex[:12]
27
+ now = _now()
28
+ self.db.execute(
29
+ "INSERT INTO sessions (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
30
+ (session_id, title, now, now),
31
+ )
32
+ return session_id
33
+
34
+ def save_session(self, session_id: str, title: str) -> None:
35
+ self.db.execute(
36
+ "UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
37
+ (title.strip() or "FinCLI session", _now(), session_id),
38
+ )
39
+
40
+ def record_event(self, session_id: str, command: str, status: str, output_preview: str = "") -> None:
41
+ sanitized_command = sanitize_history_text(command.strip())
42
+ sanitized_output = sanitize_history_text(output_preview.strip())[:1200]
43
+ if not sanitized_command:
44
+ return
45
+ self.db.execute(
46
+ """
47
+ INSERT INTO session_events (session_id, command, status, output_preview, created_at)
48
+ VALUES (?, ?, ?, ?, ?)
49
+ """,
50
+ (session_id, sanitized_command, status, sanitized_output, _now()),
51
+ )
52
+ self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
53
+
54
+ def list_sessions(self, limit: int = 20) -> list[dict[str, object]]:
55
+ rows = self.db.query(
56
+ """
57
+ SELECT s.id, s.title, s.created_at, s.updated_at, COUNT(e.id) AS event_count
58
+ FROM sessions s
59
+ LEFT JOIN session_events e ON e.session_id = s.id
60
+ GROUP BY s.id
61
+ ORDER BY s.updated_at DESC
62
+ LIMIT ?
63
+ """,
64
+ (limit,),
65
+ )
66
+ return [dict(row) for row in rows]
67
+
68
+ def get_events(self, session_id: str, limit: int = 100) -> list[dict[str, object]]:
69
+ rows = self.db.query(
70
+ """
71
+ SELECT id, command, status, output_preview, created_at
72
+ FROM session_events
73
+ WHERE session_id = ?
74
+ ORDER BY id ASC
75
+ LIMIT ?
76
+ """,
77
+ (session_id, limit),
78
+ )
79
+ return [dict(row) for row in rows]
80
+
81
+ def get_session(self, session_id: str) -> dict[str, object] | None:
82
+ rows = self.db.query("SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?", (session_id,))
83
+ return dict(rows[0]) if rows else None
84
+
85
+ def delete_session(self, session_id: str) -> int:
86
+ self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
87
+ self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
88
+ return 1
89
+
90
+ def clear_events(self, session_id: str) -> None:
91
+ self.db.execute("DELETE FROM session_events WHERE session_id = ?", (session_id,))
92
+ self.db.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), session_id))
93
+
94
+ def clear_all(self) -> None:
95
+ self.db.execute("DELETE FROM session_events")
96
+ self.db.execute("DELETE FROM sessions")
97
+
98
+
99
+ def sanitize_history_text(value: str) -> str:
100
+ sanitized = value
101
+ match = _AI_NEWS_KEY_PATTERN.match(sanitized)
102
+ if match:
103
+ return f"/{match.group(1)} key {match.group(2)} <redacted>"
104
+ match = _PROVIDER_KEY_PATTERN.match(sanitized)
105
+ if match:
106
+ return f"/provider key {match.group(1)} <redacted>"
107
+ for pattern in _SECRET_VALUE_PATTERNS:
108
+ sanitized = pattern.sub(lambda match: f"{match.group(1)}=<redacted>", sanitized)
109
+ return sanitized
110
+
111
+
112
+ def _now() -> str:
113
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -1,84 +1,84 @@
1
- """Portfolio transaction ledger."""
2
-
3
- from __future__ import annotations
4
-
5
- from fincli.app.modules.portfolio import PortfolioService
6
- from fincli.app.storage.database import FinCLIDatabase
7
- from fincli.app.utils.errors import CommandError
8
- from fincli.app.utils.formatting import normalize_symbol
9
-
10
-
11
- class TransactionService:
12
- def __init__(self, db: FinCLIDatabase, portfolio: PortfolioService) -> None:
13
- self.db = db
14
- self.portfolio = portfolio
15
-
16
- def add(self, action: str, symbol: str, quantity: float, price: float, currency: str = "USD") -> dict[str, object]:
17
- normalized_action = action.lower()
18
- normalized_symbol = normalize_symbol(symbol)
19
- if normalized_action not in {"buy", "sell"}:
20
- raise CommandError("Action transaksi harus buy atau sell.")
21
- if quantity <= 0 or price <= 0:
22
- raise CommandError("Quantity dan price harus lebih besar dari 0.")
23
-
24
- current = self._position(normalized_symbol)
25
- realized_pnl = 0.0
26
-
27
- if normalized_action == "buy":
28
- old_qty = float(current["quantity"]) if current else 0.0
29
- old_avg = float(current["average_price"]) if current else 0.0
30
- new_qty = old_qty + quantity
31
- new_avg = ((old_qty * old_avg) + (quantity * price)) / new_qty
32
- self.portfolio.add(normalized_symbol, new_qty, new_avg, currency)
33
- else:
34
- if current is None:
35
- raise CommandError(f"Tidak ada posisi {normalized_symbol} untuk dijual.")
36
- old_qty = float(current["quantity"])
37
- old_avg = float(current["average_price"])
38
- if quantity > old_qty:
39
- raise CommandError(f"Quantity sell melebihi posisi {normalized_symbol}.")
40
- realized_pnl = (price - old_avg) * quantity
41
- remaining = old_qty - quantity
42
- if remaining == 0:
43
- self.portfolio.remove(normalized_symbol)
44
- else:
45
- self.portfolio.add(normalized_symbol, remaining, old_avg, currency)
46
-
47
- self.db.execute(
48
- """
49
- INSERT INTO portfolio_transactions(action, symbol, quantity, price, currency, realized_pnl)
50
- VALUES (?, ?, ?, ?, ?, ?)
51
- """,
52
- (normalized_action, normalized_symbol, quantity, price, currency.upper(), realized_pnl),
53
- )
54
- return {
55
- "action": normalized_action,
56
- "symbol": normalized_symbol,
57
- "quantity": quantity,
58
- "price": price,
59
- "currency": currency.upper(),
60
- "realized_pnl": realized_pnl,
61
- }
62
-
63
- def list(self, limit: int = 50) -> list[dict[str, object]]:
64
- rows = self.db.query(
65
- """
66
- SELECT id, action, symbol, quantity, price, currency, realized_pnl, created_at
67
- FROM portfolio_transactions
68
- ORDER BY id DESC
69
- LIMIT ?
70
- """,
71
- (limit,),
72
- )
73
- return [dict(row) for row in rows]
74
-
75
- def realized_pnl_total(self) -> float:
76
- rows = self.db.query("SELECT COALESCE(SUM(realized_pnl), 0) AS total FROM portfolio_transactions")
77
- return float(rows[0]["total"]) if rows else 0.0
78
-
79
- def _position(self, symbol: str) -> dict[str, object] | None:
80
- rows = self.db.query(
81
- "SELECT symbol, quantity, average_price, currency FROM portfolio_positions WHERE symbol = ?",
82
- (symbol,),
83
- )
84
- return dict(rows[0]) if rows else None
1
+ """Portfolio transaction ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.modules.portfolio import PortfolioService
6
+ from fincli.app.storage.database import FinCLIDatabase
7
+ from fincli.app.utils.errors import CommandError
8
+ from fincli.app.utils.formatting import normalize_symbol
9
+
10
+
11
+ class TransactionService:
12
+ def __init__(self, db: FinCLIDatabase, portfolio: PortfolioService) -> None:
13
+ self.db = db
14
+ self.portfolio = portfolio
15
+
16
+ def add(self, action: str, symbol: str, quantity: float, price: float, currency: str = "USD") -> dict[str, object]:
17
+ normalized_action = action.lower()
18
+ normalized_symbol = normalize_symbol(symbol)
19
+ if normalized_action not in {"buy", "sell"}:
20
+ raise CommandError("Action transaksi harus buy atau sell.")
21
+ if quantity <= 0 or price <= 0:
22
+ raise CommandError("Quantity dan price harus lebih besar dari 0.")
23
+
24
+ current = self._position(normalized_symbol)
25
+ realized_pnl = 0.0
26
+
27
+ if normalized_action == "buy":
28
+ old_qty = float(current["quantity"]) if current else 0.0
29
+ old_avg = float(current["average_price"]) if current else 0.0
30
+ new_qty = old_qty + quantity
31
+ new_avg = ((old_qty * old_avg) + (quantity * price)) / new_qty
32
+ self.portfolio.add(normalized_symbol, new_qty, new_avg, currency)
33
+ else:
34
+ if current is None:
35
+ raise CommandError(f"Tidak ada posisi {normalized_symbol} untuk dijual.")
36
+ old_qty = float(current["quantity"])
37
+ old_avg = float(current["average_price"])
38
+ if quantity > old_qty:
39
+ raise CommandError(f"Quantity sell melebihi posisi {normalized_symbol}.")
40
+ realized_pnl = (price - old_avg) * quantity
41
+ remaining = old_qty - quantity
42
+ if remaining == 0:
43
+ self.portfolio.remove(normalized_symbol)
44
+ else:
45
+ self.portfolio.add(normalized_symbol, remaining, old_avg, currency)
46
+
47
+ self.db.execute(
48
+ """
49
+ INSERT INTO portfolio_transactions(action, symbol, quantity, price, currency, realized_pnl)
50
+ VALUES (?, ?, ?, ?, ?, ?)
51
+ """,
52
+ (normalized_action, normalized_symbol, quantity, price, currency.upper(), realized_pnl),
53
+ )
54
+ return {
55
+ "action": normalized_action,
56
+ "symbol": normalized_symbol,
57
+ "quantity": quantity,
58
+ "price": price,
59
+ "currency": currency.upper(),
60
+ "realized_pnl": realized_pnl,
61
+ }
62
+
63
+ def list(self, limit: int = 50) -> list[dict[str, object]]:
64
+ rows = self.db.query(
65
+ """
66
+ SELECT id, action, symbol, quantity, price, currency, realized_pnl, created_at
67
+ FROM portfolio_transactions
68
+ ORDER BY id DESC
69
+ LIMIT ?
70
+ """,
71
+ (limit,),
72
+ )
73
+ return [dict(row) for row in rows]
74
+
75
+ def realized_pnl_total(self) -> float:
76
+ rows = self.db.query("SELECT COALESCE(SUM(realized_pnl), 0) AS total FROM portfolio_transactions")
77
+ return float(rows[0]["total"]) if rows else 0.0
78
+
79
+ def _position(self, symbol: str) -> dict[str, object] | None:
80
+ rows = self.db.query(
81
+ "SELECT symbol, quantity, average_price, currency FROM portfolio_positions WHERE symbol = ?",
82
+ (symbol,),
83
+ )
84
+ return dict(rows[0]) if rows else None
@@ -0,0 +1,84 @@
1
+ """Local user profile and gameplay rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.storage.database import FinCLIDatabase
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class UserProfile:
12
+ name: str
13
+ equity: float
14
+ currency: str
15
+ leverage: str
16
+ years_in_investment: float
17
+ gameplay: str
18
+
19
+
20
+ class UserProfileService:
21
+ """Persist one local profile used to tailor /analyze risk context."""
22
+
23
+ def __init__(self, db: FinCLIDatabase) -> None:
24
+ self.db = db
25
+
26
+ def save(self, name: str, equity: float, currency: str, leverage: str, years: float) -> UserProfile:
27
+ profile = UserProfile(
28
+ name=name.strip(),
29
+ equity=float(equity),
30
+ currency=currency.strip().upper(),
31
+ leverage=leverage.strip(),
32
+ years_in_investment=float(years),
33
+ gameplay=classify_gameplay(float(equity)),
34
+ )
35
+ self.db.execute(
36
+ """
37
+ INSERT INTO user_profile (id, name, equity, currency, leverage, years_in_investment, gameplay, updated_at)
38
+ VALUES (1, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
39
+ ON CONFLICT(id) DO UPDATE SET
40
+ name=excluded.name,
41
+ equity=excluded.equity,
42
+ currency=excluded.currency,
43
+ leverage=excluded.leverage,
44
+ years_in_investment=excluded.years_in_investment,
45
+ gameplay=excluded.gameplay,
46
+ updated_at=CURRENT_TIMESTAMP
47
+ """,
48
+ (
49
+ profile.name,
50
+ profile.equity,
51
+ profile.currency,
52
+ profile.leverage,
53
+ profile.years_in_investment,
54
+ profile.gameplay,
55
+ ),
56
+ )
57
+ return profile
58
+
59
+ def get(self) -> UserProfile | None:
60
+ rows = self.db.query("SELECT * FROM user_profile WHERE id = 1")
61
+ if not rows:
62
+ return None
63
+ row = rows[0]
64
+ return UserProfile(
65
+ name=str(row["name"]),
66
+ equity=float(row["equity"]),
67
+ currency=str(row["currency"]),
68
+ leverage=str(row["leverage"]),
69
+ years_in_investment=float(row["years_in_investment"]),
70
+ gameplay=str(row["gameplay"]),
71
+ )
72
+
73
+ def clear(self) -> None:
74
+ self.db.execute("DELETE FROM user_profile WHERE id = 1")
75
+
76
+
77
+ def classify_gameplay(equity: float) -> str:
78
+ if equity <= 400:
79
+ return "Scalper"
80
+ if equity <= 1000:
81
+ return "Intra day"
82
+ if equity <= 5000:
83
+ return "Day trade"
84
+ return "Swing/Investor"
@@ -0,0 +1,72 @@
1
+ """Local plugin discovery for FinCLI.
2
+
3
+ Plugins are intentionally manifest-first in v0.2.2: FinCLI reads metadata and
4
+ exposes status, but does not execute plugin code yet. This keeps the plugin
5
+ surface useful without creating a security footgun.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Iterable
14
+
15
+ from fincli.app.storage import config_paths
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class PluginManifest:
20
+ name: str
21
+ version: str
22
+ description: str
23
+ commands: tuple[str, ...]
24
+ capabilities: tuple[str, ...]
25
+ path: Path
26
+ status: str = "available"
27
+
28
+
29
+ class PluginLoader:
30
+ """Discover plugin manifests from local plugin directories."""
31
+
32
+ def __init__(self, search_paths: Iterable[Path] | None = None) -> None:
33
+ self.search_paths = tuple(search_paths) if search_paths is not None else (config_paths.APP_DIR / "plugins",)
34
+
35
+ def discover(self) -> list[PluginManifest]:
36
+ plugins: list[PluginManifest] = []
37
+ for root in self.search_paths:
38
+ if not root.exists():
39
+ continue
40
+ for manifest_path in sorted(root.glob("*/plugin.json")):
41
+ plugins.append(self._read_manifest(manifest_path))
42
+ return plugins
43
+
44
+ def _read_manifest(self, manifest_path: Path) -> PluginManifest:
45
+ try:
46
+ payload = json.loads(manifest_path.read_text(encoding="utf-8"))
47
+ name = str(payload["name"]).strip()
48
+ version = str(payload.get("version") or "0.0.0").strip()
49
+ description = str(payload.get("description") or "").strip()
50
+ commands = tuple(str(item) for item in payload.get("commands", []) if str(item).strip())
51
+ capabilities = tuple(str(item) for item in payload.get("capabilities", []) if str(item).strip())
52
+ if not name:
53
+ raise ValueError("name is empty")
54
+ return PluginManifest(
55
+ name=name,
56
+ version=version,
57
+ description=description,
58
+ commands=commands,
59
+ capabilities=capabilities,
60
+ path=manifest_path,
61
+ status="available",
62
+ )
63
+ except Exception as exc: # noqa: BLE001
64
+ return PluginManifest(
65
+ name=manifest_path.parent.name,
66
+ version="unknown",
67
+ description=f"Invalid plugin manifest: {exc}",
68
+ commands=(),
69
+ capabilities=(),
70
+ path=manifest_path,
71
+ status="invalid",
72
+ )
@@ -1,11 +1,12 @@
1
- """Anthropic provider placeholder for Phase 2."""
1
+ """Anthropic provider compatibility wrapper."""
2
2
 
3
- from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
- from fincli.app.utils.errors import ProviderError
3
+ from __future__ import annotations
5
4
 
5
+ import os
6
6
 
7
- class AnthropicProvider(BaseAIProvider):
8
- name = "anthropic"
7
+ from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP
9
8
 
10
- async def complete(self, request: AIRequest) -> AIResponse:
11
- raise ProviderError("Anthropic client belum diimplementasi di Phase 1.")
9
+
10
+ class AnthropicProvider(AnthropicProviderHTTP):
11
+ def __init__(self, api_key: str | None = None) -> None:
12
+ super().__init__(api_key or os.getenv("ANTHROPIC_API_KEY"))