@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.
- package/LICENSE +21 -0
- package/README.md +909 -718
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +23 -16
- package/fincli/app/analysis/analyzer.py +107 -100
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +105 -83
- package/fincli/app/cli/router.py +2123 -1294
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/research/__init__.py +7 -0
- package/fincli/app/research/engine.py +75 -0
- package/fincli/app/research/formatter.py +22 -0
- package/fincli/app/research/models.py +18 -0
- package/fincli/app/research/prompt_builder.py +47 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +203 -203
- package/fincli/app/services/news_aggregator.py +90 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +200 -101
- package/fincli/app/storage/secrets.py +8 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +23 -23
- 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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if expr.startswith("
|
|
85
|
-
|
|
86
|
-
return summary.
|
|
87
|
-
|
|
88
|
-
if expr.startswith("rsi
|
|
89
|
-
threshold = _parse_threshold(expr, "rsi
|
|
90
|
-
return summary.rsi is not None and summary.rsi
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
return "
|
|
105
|
-
|
|
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}"
|
|
@@ -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,60 +1,60 @@
|
|
|
1
|
-
"""AI provider catalog and selection state."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
import os
|
|
7
|
-
|
|
8
|
-
from fincli.app.providers.ai.base import BaseAIProvider
|
|
9
|
-
from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP, GeminiProviderHTTP, OpenAICompatibleProvider
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@dataclass(frozen=True, slots=True)
|
|
13
|
-
class AIProviderInfo:
|
|
14
|
-
name: str
|
|
15
|
-
env_key: str
|
|
16
|
-
default_model: str
|
|
17
|
-
status: str = "configured"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
AI_PROVIDERS: dict[str, AIProviderInfo] = {
|
|
21
|
-
"openrouter": AIProviderInfo("openrouter", "OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
|
|
22
|
-
"gemini": AIProviderInfo("gemini", "GEMINI_API_KEY", "gemini-1.5-flash"),
|
|
23
|
-
"anthropic": AIProviderInfo("anthropic", "ANTHROPIC_API_KEY", "claude-3-5-sonnet-latest"),
|
|
24
|
-
"openai": AIProviderInfo("openai", "OPENAI_API_KEY", "gpt-4o-mini"),
|
|
25
|
-
"together": AIProviderInfo("together", "TOGETHER_API_KEY", "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"),
|
|
26
|
-
"huggingface": AIProviderInfo("huggingface", "HUGGINGFACE_API_KEY", "meta-llama/Llama-3.1-8B-Instruct"),
|
|
27
|
-
"groq": AIProviderInfo("groq", "GROQ_API_KEY", "llama-3.1-70b-versatile"),
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class AIProviderManager:
|
|
32
|
-
"""AI provider catalog and factory."""
|
|
33
|
-
|
|
34
|
-
def list_providers(self) -> list[AIProviderInfo]:
|
|
35
|
-
return list(AI_PROVIDERS.values())
|
|
36
|
-
|
|
37
|
-
def get(self, name: str) -> AIProviderInfo | None:
|
|
38
|
-
return AI_PROVIDERS.get(name.lower())
|
|
39
|
-
|
|
40
|
-
def create(self, name: str) -> BaseAIProvider:
|
|
41
|
-
provider = self.get(name)
|
|
42
|
-
if provider is None:
|
|
43
|
-
raise ValueError(f"AI provider tidak dikenal: {name}")
|
|
44
|
-
|
|
45
|
-
api_key = os.getenv(provider.env_key)
|
|
46
|
-
if provider.name == "openrouter":
|
|
47
|
-
return OpenAICompatibleProvider(provider.name, "https://openrouter.ai/api/v1", api_key)
|
|
48
|
-
if provider.name == "openai":
|
|
49
|
-
return OpenAICompatibleProvider(provider.name, "https://api.openai.com/v1", api_key)
|
|
50
|
-
if provider.name == "together":
|
|
51
|
-
return OpenAICompatibleProvider(provider.name, "https://api.together.xyz/v1", api_key)
|
|
52
|
-
if provider.name == "groq":
|
|
53
|
-
return OpenAICompatibleProvider(provider.name, "https://api.groq.com/openai/v1", api_key)
|
|
54
|
-
if provider.name == "huggingface":
|
|
55
|
-
return OpenAICompatibleProvider(provider.name, "https://router.huggingface.co/v1", api_key)
|
|
56
|
-
if provider.name == "gemini":
|
|
57
|
-
return GeminiProviderHTTP(api_key)
|
|
58
|
-
if provider.name == "anthropic":
|
|
59
|
-
return AnthropicProviderHTTP(api_key)
|
|
60
|
-
raise ValueError(f"AI provider tidak didukung: {name}")
|
|
1
|
+
"""AI provider catalog and selection state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from fincli.app.providers.ai.base import BaseAIProvider
|
|
9
|
+
from fincli.app.providers.ai.http_provider import AnthropicProviderHTTP, GeminiProviderHTTP, OpenAICompatibleProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class AIProviderInfo:
|
|
14
|
+
name: str
|
|
15
|
+
env_key: str
|
|
16
|
+
default_model: str
|
|
17
|
+
status: str = "configured"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AI_PROVIDERS: dict[str, AIProviderInfo] = {
|
|
21
|
+
"openrouter": AIProviderInfo("openrouter", "OPENROUTER_API_KEY", "openai/gpt-4o-mini"),
|
|
22
|
+
"gemini": AIProviderInfo("gemini", "GEMINI_API_KEY", "gemini-1.5-flash"),
|
|
23
|
+
"anthropic": AIProviderInfo("anthropic", "ANTHROPIC_API_KEY", "claude-3-5-sonnet-latest"),
|
|
24
|
+
"openai": AIProviderInfo("openai", "OPENAI_API_KEY", "gpt-4o-mini"),
|
|
25
|
+
"together": AIProviderInfo("together", "TOGETHER_API_KEY", "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"),
|
|
26
|
+
"huggingface": AIProviderInfo("huggingface", "HUGGINGFACE_API_KEY", "meta-llama/Llama-3.1-8B-Instruct"),
|
|
27
|
+
"groq": AIProviderInfo("groq", "GROQ_API_KEY", "llama-3.1-70b-versatile"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AIProviderManager:
|
|
32
|
+
"""AI provider catalog and factory."""
|
|
33
|
+
|
|
34
|
+
def list_providers(self) -> list[AIProviderInfo]:
|
|
35
|
+
return list(AI_PROVIDERS.values())
|
|
36
|
+
|
|
37
|
+
def get(self, name: str) -> AIProviderInfo | None:
|
|
38
|
+
return AI_PROVIDERS.get(name.lower())
|
|
39
|
+
|
|
40
|
+
def create(self, name: str) -> BaseAIProvider:
|
|
41
|
+
provider = self.get(name)
|
|
42
|
+
if provider is None:
|
|
43
|
+
raise ValueError(f"AI provider tidak dikenal: {name}")
|
|
44
|
+
|
|
45
|
+
api_key = os.getenv(provider.env_key)
|
|
46
|
+
if provider.name == "openrouter":
|
|
47
|
+
return OpenAICompatibleProvider(provider.name, "https://openrouter.ai/api/v1", api_key)
|
|
48
|
+
if provider.name == "openai":
|
|
49
|
+
return OpenAICompatibleProvider(provider.name, "https://api.openai.com/v1", api_key)
|
|
50
|
+
if provider.name == "together":
|
|
51
|
+
return OpenAICompatibleProvider(provider.name, "https://api.together.xyz/v1", api_key)
|
|
52
|
+
if provider.name == "groq":
|
|
53
|
+
return OpenAICompatibleProvider(provider.name, "https://api.groq.com/openai/v1", api_key)
|
|
54
|
+
if provider.name == "huggingface":
|
|
55
|
+
return OpenAICompatibleProvider(provider.name, "https://router.huggingface.co/v1", api_key)
|
|
56
|
+
if provider.name == "gemini":
|
|
57
|
+
return GeminiProviderHTTP(api_key)
|
|
58
|
+
if provider.name == "anthropic":
|
|
59
|
+
return AnthropicProviderHTTP(api_key)
|
|
60
|
+
raise ValueError(f"AI provider tidak didukung: {name}")
|