@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.
Files changed (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. package/requirements.txt +9 -0
@@ -0,0 +1,16 @@
1
+ """FinCLI application entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.tui.layout import FinCLIApp
6
+ from fincli.app.utils.logger import configure_logging
7
+
8
+
9
+ def main() -> None:
10
+ """Run the FinCLI TUI application."""
11
+ configure_logging()
12
+ FinCLIApp().run()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ main()
@@ -0,0 +1 @@
1
+ """Domain modules."""
@@ -0,0 +1,139 @@
1
+ """Economic calendar fetching and fallback formatting data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, timedelta
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from fincli.app.utils.errors import ProviderError, RateLimitError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class EconomicEvent:
16
+ event: str
17
+ country: str
18
+ impact: str
19
+ time: datetime | None
20
+ actual: str | None = None
21
+ estimate: str | None = None
22
+ previous: str | None = None
23
+ unit: str | None = None
24
+
25
+
26
+ class EconomicCalendarService:
27
+ """Fetch economic calendar events with Finnhub support."""
28
+
29
+ def __init__(
30
+ self,
31
+ api_key: str | None,
32
+ base_url: str = "https://finnhub.io/api/v1",
33
+ client: httpx.AsyncClient | None = None,
34
+ ) -> None:
35
+ self.api_key = api_key or ""
36
+ self.base_url = base_url.rstrip("/")
37
+ self._client = client
38
+
39
+ async def events(self, start: date, end: date) -> list[EconomicEvent]:
40
+ if not self.api_key:
41
+ raise ProviderError(
42
+ "Economic calendar provider belum dikonfigurasi.",
43
+ "Isi FINNHUB_API_KEY di .env untuk mengambil economic calendar aktual.",
44
+ )
45
+
46
+ close_client = self._client is None
47
+ client = self._client or httpx.AsyncClient(timeout=30)
48
+ try:
49
+ response = await client.get(
50
+ f"{self.base_url}/calendar/economic",
51
+ params={"from": start.isoformat(), "to": end.isoformat(), "token": self.api_key},
52
+ )
53
+ if response.status_code == 429:
54
+ raise RateLimitError("Finnhub economic calendar terkena rate limit.")
55
+ response.raise_for_status()
56
+ payload = response.json()
57
+ except httpx.TimeoutException as exc:
58
+ raise ProviderError("Finnhub economic calendar timeout.") from exc
59
+ except httpx.HTTPStatusError as exc:
60
+ raise ProviderError(f"Finnhub economic calendar gagal: HTTP {exc.response.status_code}.") from exc
61
+ except ValueError as exc:
62
+ raise ProviderError("Response economic calendar bukan JSON valid.") from exc
63
+ finally:
64
+ if close_client:
65
+ await client.aclose()
66
+
67
+ raw_events = payload.get("economicCalendar") if isinstance(payload, dict) else None
68
+ if not isinstance(raw_events, list):
69
+ raise ProviderError("Response Finnhub economic calendar tidak valid.")
70
+ return [_parse_event(item) for item in raw_events if isinstance(item, dict)]
71
+
72
+
73
+ def default_calendar_window(mode: str | None = None) -> tuple[date, date]:
74
+ today = date.today()
75
+ if mode == "today":
76
+ return today, today
77
+ if mode == "week":
78
+ return today, today + timedelta(days=7)
79
+ return today, today + timedelta(days=7)
80
+
81
+
82
+ def fallback_events(start: date, end: date) -> list[EconomicEvent]:
83
+ """Return non-date-specific event categories when no provider is configured."""
84
+
85
+ return [
86
+ EconomicEvent("Central bank rate decisions", "Global", "high", None, unit="event group"),
87
+ EconomicEvent("Inflation releases: CPI/PCE", "Global", "high", None, unit="event group"),
88
+ EconomicEvent("Labor market data: payrolls/unemployment", "Global", "high", None, unit="event group"),
89
+ EconomicEvent("GDP, PMI, retail sales, consumer sentiment", "Global", "medium", None, unit="event group"),
90
+ EconomicEvent(
91
+ f"Provider window requested: {start.isoformat()} to {end.isoformat()}",
92
+ "FinCLI",
93
+ "info",
94
+ None,
95
+ unit="fallback",
96
+ ),
97
+ ]
98
+
99
+
100
+ def filter_events(events: list[EconomicEvent], country: str | None = None, impact: str | None = None) -> list[EconomicEvent]:
101
+ filtered = events
102
+ if country:
103
+ normalized_country = country.lower()
104
+ filtered = [event for event in filtered if event.country.lower() == normalized_country]
105
+ if impact:
106
+ normalized_impact = impact.lower()
107
+ filtered = [event for event in filtered if event.impact.lower() == normalized_impact]
108
+ return filtered
109
+
110
+
111
+ def _parse_event(item: dict[str, Any]) -> EconomicEvent:
112
+ return EconomicEvent(
113
+ event=str(item.get("event") or item.get("name") or "Untitled event"),
114
+ country=str(item.get("country") or "N/A"),
115
+ impact=str(item.get("impact") or "N/A").lower(),
116
+ time=_parse_time(item.get("time")),
117
+ actual=_optional_text(item.get("actual")),
118
+ estimate=_optional_text(item.get("estimate")),
119
+ previous=_optional_text(item.get("prev") if "prev" in item else item.get("previous")),
120
+ unit=_optional_text(item.get("unit")),
121
+ )
122
+
123
+
124
+ def _parse_time(value: object) -> datetime | None:
125
+ if not value:
126
+ return None
127
+ if isinstance(value, (int, float)):
128
+ return datetime.fromtimestamp(value)
129
+ text = str(value).replace("Z", "+00:00")
130
+ try:
131
+ return datetime.fromisoformat(text)
132
+ except ValueError:
133
+ return None
134
+
135
+
136
+ def _optional_text(value: object) -> str | None:
137
+ if value is None or value == "":
138
+ return None
139
+ return str(value)
@@ -0,0 +1,51 @@
1
+ """CSV and JSON export helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fincli.app.utils.errors import CommandError
11
+
12
+
13
+ def export_rows(rows: list[dict[str, Any]], fmt: str, target: str | Path) -> Path:
14
+ """Export rows to CSV or JSON and return the written path."""
15
+ export_format = fmt.lower()
16
+ path = _safe_export_path(target)
17
+ path.parent.mkdir(parents=True, exist_ok=True)
18
+
19
+ if export_format == "json":
20
+ path.write_text(json.dumps(rows, indent=2, default=str), encoding="utf-8")
21
+ return path
22
+
23
+ if export_format == "csv":
24
+ fieldnames = _fieldnames(rows)
25
+ with path.open("w", newline="", encoding="utf-8") as handle:
26
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
27
+ writer.writeheader()
28
+ writer.writerows(rows)
29
+ return path
30
+
31
+ raise CommandError("Format export harus csv atau json.")
32
+
33
+
34
+ def _fieldnames(rows: list[dict[str, Any]]) -> list[str]:
35
+ if not rows:
36
+ return []
37
+ fields: list[str] = []
38
+ for row in rows:
39
+ for key in row:
40
+ if key not in fields:
41
+ fields.append(key)
42
+ return fields
43
+
44
+
45
+ def _safe_export_path(target: str | Path) -> Path:
46
+ path = Path(target).expanduser()
47
+ if any(part == ".." for part in path.parts):
48
+ raise CommandError("Path export tidak boleh mengandung '..'.")
49
+ if path.suffix.lower() not in {".csv", ".json"}:
50
+ raise CommandError("Path export harus berakhiran .csv atau .json.")
51
+ return path
@@ -0,0 +1,65 @@
1
+ """Trading journal service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.storage.database import FinCLIDatabase
6
+ from fincli.app.utils.formatting import normalize_symbol
7
+
8
+
9
+ class JournalService:
10
+ def __init__(self, db: FinCLIDatabase) -> None:
11
+ self.db = db
12
+
13
+ def add(
14
+ self,
15
+ instrument: str,
16
+ bias: str = "",
17
+ entry_reason: str = "",
18
+ exit_reason: str = "",
19
+ result: str = "",
20
+ emotion: str = "",
21
+ lesson: str = "",
22
+ tags: str = "",
23
+ ) -> None:
24
+ self.db.execute(
25
+ """
26
+ INSERT INTO journal_entries(
27
+ instrument, bias, entry_reason, exit_reason, result, emotion, lesson, tags
28
+ )
29
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
30
+ """,
31
+ (
32
+ normalize_symbol(instrument),
33
+ bias,
34
+ entry_reason,
35
+ exit_reason,
36
+ result,
37
+ emotion,
38
+ lesson,
39
+ tags,
40
+ ),
41
+ )
42
+
43
+ def list(self, instrument: str | None = None, limit: int = 20) -> list[dict[str, object]]:
44
+ if instrument:
45
+ rows = self.db.query(
46
+ """
47
+ SELECT id, instrument, bias, entry_reason, result, emotion, lesson, tags, created_at
48
+ FROM journal_entries
49
+ WHERE instrument = ?
50
+ ORDER BY created_at DESC
51
+ LIMIT ?
52
+ """,
53
+ (normalize_symbol(instrument), limit),
54
+ )
55
+ else:
56
+ rows = self.db.query(
57
+ """
58
+ SELECT id, instrument, bias, entry_reason, result, emotion, lesson, tags, created_at
59
+ FROM journal_entries
60
+ ORDER BY created_at DESC
61
+ LIMIT ?
62
+ """,
63
+ (limit,),
64
+ )
65
+ return [dict(row) for row in rows]
@@ -0,0 +1,70 @@
1
+ """Journal statistics and AI review prompt helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class JournalStats:
11
+ total_entries: int
12
+ wins: int
13
+ losses: int
14
+ win_rate: float
15
+ top_instrument: str
16
+ top_emotion: str
17
+ top_tags: list[str]
18
+
19
+
20
+ def calculate_journal_stats(entries: list[dict[str, object]]) -> JournalStats:
21
+ total = len(entries)
22
+ wins = sum(1 for entry in entries if str(entry.get("result", "")).lower() == "win")
23
+ losses = sum(1 for entry in entries if str(entry.get("result", "")).lower() == "loss")
24
+ win_rate = (wins / total * 100) if total else 0.0
25
+
26
+ instruments = Counter(str(entry.get("instrument", "")) for entry in entries if entry.get("instrument"))
27
+ emotions = Counter(str(entry.get("emotion", "")) for entry in entries if entry.get("emotion"))
28
+ tags = Counter()
29
+ for entry in entries:
30
+ for tag in str(entry.get("tags", "")).split(","):
31
+ cleaned = tag.strip()
32
+ if cleaned:
33
+ tags[cleaned] += 1
34
+
35
+ return JournalStats(
36
+ total_entries=total,
37
+ wins=wins,
38
+ losses=losses,
39
+ win_rate=win_rate,
40
+ top_instrument=instruments.most_common(1)[0][0] if instruments else "N/A",
41
+ top_emotion=emotions.most_common(1)[0][0] if emotions else "N/A",
42
+ top_tags=[tag for tag, _ in tags.most_common(5)],
43
+ )
44
+
45
+
46
+ def build_journal_review_prompt(entries: list[dict[str, object]], stats: JournalStats) -> str:
47
+ recent_lines = []
48
+ for entry in entries[:20]:
49
+ recent_lines.append(
50
+ (
51
+ f"- {entry.get('instrument')} | bias={entry.get('bias')} | result={entry.get('result')} | "
52
+ f"emotion={entry.get('emotion')} | reason={entry.get('entry_reason')} | lesson={entry.get('lesson')}"
53
+ )
54
+ )
55
+ return (
56
+ "You are FinCLI's trading journal review assistant.\n"
57
+ "Review the user's journal based only on the provided entries. Do not invent trades.\n"
58
+ "Focus on process quality, recurring mistakes, emotional patterns, risk control, and concrete improvements.\n"
59
+ "Do not provide financial advice or guaranteed trading signals.\n\n"
60
+ f"Total Entries: {stats.total_entries}\n"
61
+ f"Wins: {stats.wins}\n"
62
+ f"Losses: {stats.losses}\n"
63
+ f"Win Rate: {stats.win_rate:.4f}\n"
64
+ f"Top Instrument: {stats.top_instrument}\n"
65
+ f"Top Emotion: {stats.top_emotion}\n"
66
+ f"Top Tags: {', '.join(stats.top_tags) if stats.top_tags else 'N/A'}\n\n"
67
+ "Recent Entries:\n"
68
+ f"{chr(10).join(recent_lines) if recent_lines else 'No entries.'}\n\n"
69
+ "Return sections: Summary, Strengths, Repeated Mistakes, Risk Notes, Process Improvements, Disclaimer."
70
+ )
@@ -0,0 +1,34 @@
1
+ """Portfolio management service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.storage.database import FinCLIDatabase
6
+ from fincli.app.utils.formatting import normalize_symbol
7
+
8
+
9
+ class PortfolioService:
10
+ def __init__(self, db: FinCLIDatabase) -> None:
11
+ self.db = db
12
+
13
+ def add(self, symbol: str, quantity: float, average_price: float, currency: str = "USD") -> None:
14
+ self.db.execute(
15
+ """
16
+ INSERT INTO portfolio_positions(symbol, quantity, average_price, currency)
17
+ VALUES (?, ?, ?, ?)
18
+ ON CONFLICT(symbol) DO UPDATE SET
19
+ quantity = excluded.quantity,
20
+ average_price = excluded.average_price,
21
+ currency = excluded.currency,
22
+ updated_at = CURRENT_TIMESTAMP
23
+ """,
24
+ (normalize_symbol(symbol), quantity, average_price, currency.upper()),
25
+ )
26
+
27
+ def remove(self, symbol: str) -> None:
28
+ self.db.execute("DELETE FROM portfolio_positions WHERE symbol = ?", (normalize_symbol(symbol),))
29
+
30
+ def list(self) -> list[dict[str, object]]:
31
+ rows = self.db.query(
32
+ "SELECT symbol, quantity, average_price, currency, updated_at FROM portfolio_positions ORDER BY symbol"
33
+ )
34
+ return [dict(row) for row in rows]
@@ -0,0 +1,105 @@
1
+ """Watchlist scanner with simple technical filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+
8
+ from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
9
+ from fincli.app.providers.market.base import BaseMarketProvider
10
+
11
+
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
+
65
+
66
+ def _matches_filter(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
67
+ expr = expression.strip().lower()
68
+ if not expr:
69
+ return True, "all"
70
+
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}"
@@ -0,0 +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
@@ -0,0 +1,25 @@
1
+ """Watchlist commands backed by local SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.storage.database import FinCLIDatabase
6
+ from fincli.app.utils.formatting import normalize_symbol
7
+
8
+
9
+ class WatchlistService:
10
+ def __init__(self, db: FinCLIDatabase) -> None:
11
+ self.db = db
12
+
13
+ def add(self, symbol: str, group_name: str = "default") -> None:
14
+ normalized = normalize_symbol(symbol)
15
+ self.db.execute(
16
+ "INSERT OR REPLACE INTO watchlist(symbol, group_name) VALUES (?, ?)",
17
+ (normalized, group_name),
18
+ )
19
+
20
+ def remove(self, symbol: str) -> None:
21
+ self.db.execute("DELETE FROM watchlist WHERE symbol = ?", (normalize_symbol(symbol),))
22
+
23
+ def list(self) -> list[dict[str, str]]:
24
+ rows = self.db.query("SELECT symbol, group_name, created_at FROM watchlist ORDER BY symbol")
25
+ return [dict(row) for row in rows]
@@ -0,0 +1 @@
1
+ """Provider abstractions."""
@@ -0,0 +1 @@
1
+ """AI provider modules."""
@@ -0,0 +1,11 @@
1
+ """Anthropic provider placeholder for Phase 2."""
2
+
3
+ from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
+ from fincli.app.utils.errors import ProviderError
5
+
6
+
7
+ class AnthropicProvider(BaseAIProvider):
8
+ name = "anthropic"
9
+
10
+ async def complete(self, request: AIRequest) -> AIResponse:
11
+ raise ProviderError("Anthropic client belum diimplementasi di Phase 1.")
@@ -0,0 +1,29 @@
1
+ """Base AI provider contract for future provider implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class AIRequest:
11
+ prompt: str
12
+ model: str
13
+ temperature: float = 0.2
14
+ timeout_seconds: int = 45
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class AIResponse:
19
+ provider: str
20
+ model: str
21
+ content: str
22
+
23
+
24
+ class BaseAIProvider(ABC):
25
+ name: str
26
+
27
+ @abstractmethod
28
+ async def complete(self, request: AIRequest) -> AIResponse:
29
+ """Return a completion response from the provider."""
@@ -0,0 +1,11 @@
1
+ """Gemini provider placeholder for Phase 2."""
2
+
3
+ from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
+ from fincli.app.utils.errors import ProviderError
5
+
6
+
7
+ class GeminiProvider(BaseAIProvider):
8
+ name = "gemini"
9
+
10
+ async def complete(self, request: AIRequest) -> AIResponse:
11
+ raise ProviderError("Gemini client belum diimplementasi di Phase 1.")
@@ -0,0 +1,11 @@
1
+ """Groq provider placeholder for Phase 2."""
2
+
3
+ from fincli.app.providers.ai.base import AIRequest, AIResponse, BaseAIProvider
4
+ from fincli.app.utils.errors import ProviderError
5
+
6
+
7
+ class GroqProvider(BaseAIProvider):
8
+ name = "groq"
9
+
10
+ async def complete(self, request: AIRequest) -> AIResponse:
11
+ raise ProviderError("Groq client belum diimplementasi di Phase 1.")