@drico2008/fincli 0.1.9 → 0.3.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -625
  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 +26 -14
  7. package/fincli/app/analysis/analyzer.py +107 -96
  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 +108 -81
  14. package/fincli/app/cli/router.py +2327 -1237
  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/portfolio_risk.py +305 -0
  21. package/fincli/app/modules/reports.py +151 -0
  22. package/fincli/app/modules/scanner.py +111 -93
  23. package/fincli/app/modules/transactions.py +84 -84
  24. package/fincli/app/modules/user_profile.py +84 -0
  25. package/fincli/app/plugins/loader.py +72 -0
  26. package/fincli/app/providers/ai/manager.py +60 -60
  27. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  28. package/fincli/app/providers/market/base.py +98 -77
  29. package/fincli/app/providers/market/custom_provider.py +186 -169
  30. package/fincli/app/providers/market/manager.py +84 -1
  31. package/fincli/app/providers/market/symbols.py +143 -0
  32. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  33. package/fincli/app/providers/reliability.py +86 -0
  34. package/fincli/app/research/__init__.py +8 -0
  35. package/fincli/app/research/engine.py +137 -0
  36. package/fincli/app/research/exporter.py +91 -0
  37. package/fincli/app/research/formatter.py +27 -0
  38. package/fincli/app/research/models.py +24 -0
  39. package/fincli/app/research/prompt_builder.py +54 -0
  40. package/fincli/app/services/macro_data.py +50 -0
  41. package/fincli/app/services/market_data.py +274 -169
  42. package/fincli/app/services/market_overview.py +42 -1
  43. package/fincli/app/services/news_aggregator.py +95 -0
  44. package/fincli/app/services/web_research.py +267 -267
  45. package/fincli/app/storage/config.py +122 -88
  46. package/fincli/app/storage/database.py +209 -99
  47. package/fincli/app/storage/provider_metrics.py +61 -0
  48. package/fincli/app/storage/secrets.py +26 -2
  49. package/fincli/app/tui/components.py +68 -50
  50. package/fincli/app/tui/layout.py +269 -258
  51. package/fincli/app/tui/market_provider_selector.py +3 -1
  52. package/fincli/app/tui/theme.py +134 -74
  53. package/fincli/app/utils/formatting.py +123 -60
  54. package/package.json +22 -20
  55. package/pyproject.toml +35 -35
@@ -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.3.0: 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}")
@@ -0,0 +1,194 @@
1
+ """Alpha Vantage market provider adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
11
+ from fincli.app.providers.market.symbols import resolve_provider_symbol
12
+ from fincli.app.utils.errors import ProviderError, RateLimitError
13
+
14
+
15
+ class AlphaVantageProvider:
16
+ name = "alphavantage"
17
+
18
+ def __init__(
19
+ self,
20
+ api_key: str | None,
21
+ base_url: str = "https://www.alphavantage.co/query",
22
+ client: httpx.AsyncClient | None = None,
23
+ ) -> None:
24
+ self.api_key = api_key or ""
25
+ self.base_url = base_url
26
+ self._client = client
27
+
28
+ async def quote(self, symbol: str) -> Quote:
29
+ resolved = resolve_provider_symbol(self.name, symbol)
30
+ if resolved.asset_class == "forex":
31
+ data = await self._get(
32
+ {
33
+ "function": "CURRENCY_EXCHANGE_RATE",
34
+ "from_currency": resolved.symbol[:3],
35
+ "to_currency": resolved.symbol[3:],
36
+ }
37
+ )
38
+ rate = data.get("Realtime Currency Exchange Rate", {})
39
+ price = _safe_float(rate.get("5. Exchange Rate"))
40
+ if price is None:
41
+ raise ProviderError(f"Alpha Vantage tidak mengembalikan FX quote valid untuk {symbol}.")
42
+ return Quote(
43
+ symbol=resolved.symbol,
44
+ price=price,
45
+ currency=resolved.symbol[3:],
46
+ provider=self.name,
47
+ timestamp=_parse_datetime(rate.get("6. Last Refreshed")) or datetime.now(),
48
+ status="plan-dependent",
49
+ )
50
+
51
+ data = await self._get({"function": "GLOBAL_QUOTE", "symbol": resolved.symbol})
52
+ quote = data.get("Global Quote", {})
53
+ price = _safe_float(quote.get("05. price"))
54
+ if price is None:
55
+ raise ProviderError(f"Alpha Vantage tidak mengembalikan quote valid untuk {symbol}.")
56
+ return Quote(
57
+ symbol=str(quote.get("01. symbol") or resolved.symbol).upper(),
58
+ price=price,
59
+ currency="USD",
60
+ provider=self.name,
61
+ timestamp=_parse_datetime(quote.get("07. latest trading day")) or datetime.now(),
62
+ status="plan-dependent",
63
+ )
64
+
65
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
66
+ resolved = resolve_provider_symbol(self.name, symbol)
67
+ if resolved.asset_class == "forex":
68
+ data = await self._get(
69
+ {
70
+ "function": "FX_DAILY",
71
+ "from_symbol": resolved.symbol[:3],
72
+ "to_symbol": resolved.symbol[3:],
73
+ "outputsize": "compact",
74
+ }
75
+ )
76
+ series = data.get("Time Series FX (Daily)", {})
77
+ else:
78
+ data = await self._get(
79
+ {
80
+ "function": "TIME_SERIES_DAILY_ADJUSTED",
81
+ "symbol": resolved.symbol,
82
+ "outputsize": "compact",
83
+ }
84
+ )
85
+ series = data.get("Time Series (Daily)", {})
86
+
87
+ if not isinstance(series, dict) or not series:
88
+ raise ProviderError(f"Alpha Vantage OHLCV kosong untuk {symbol}.")
89
+ candles = [_parse_daily_candle(day, payload) for day, payload in series.items() if isinstance(payload, dict)]
90
+ candles.sort(key=lambda candle: candle.timestamp)
91
+ return candles
92
+
93
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
94
+ resolved = resolve_provider_symbol(self.name, symbol)
95
+ data = await self._get({"function": "NEWS_SENTIMENT", "tickers": resolved.symbol, "limit": limit})
96
+ feed = data.get("feed", [])
97
+ if not isinstance(feed, list):
98
+ return []
99
+ return [
100
+ NewsItem(
101
+ title=str(item.get("title") or "Untitled"),
102
+ source=str(item.get("source") or self.name),
103
+ url=item.get("url"),
104
+ published_at=_parse_datetime(item.get("time_published")),
105
+ summary=str(item.get("summary") or ""),
106
+ )
107
+ for item in feed[:limit]
108
+ if isinstance(item, dict)
109
+ ]
110
+
111
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
112
+ resolved = resolve_provider_symbol(self.name, symbol)
113
+ data = await self._get({"function": "OVERVIEW", "symbol": resolved.symbol})
114
+ return FundamentalSnapshot(
115
+ symbol=str(data.get("Symbol") or resolved.symbol).upper(),
116
+ provider=self.name,
117
+ currency=str(data.get("Currency") or "USD"),
118
+ market_cap=_safe_float(data.get("MarketCapitalization")),
119
+ pe_ratio=_safe_float(data.get("PERatio")),
120
+ eps=_safe_float(data.get("EPS")),
121
+ revenue=_safe_float(data.get("RevenueTTM")),
122
+ beta=_safe_float(data.get("Beta")),
123
+ sector=data.get("Sector"),
124
+ industry=data.get("Industry"),
125
+ )
126
+
127
+ async def status(self) -> ProviderStatus:
128
+ status = "configured" if self.api_key else "unavailable"
129
+ message = "Alpha Vantage provider configured." if self.api_key else "Requires ALPHA_VANTAGE_API_KEY."
130
+ return ProviderStatus(name=self.name, realtime=False, status=status, message=message)
131
+
132
+ async def _get(self, params: dict[str, object]) -> dict[str, Any]:
133
+ if not self.api_key:
134
+ raise ProviderError("API key Alpha Vantage belum diatur.", "Gunakan /news_model key alphavantage <api_key>.")
135
+ close_client = self._client is None
136
+ client = self._client or httpx.AsyncClient(timeout=30)
137
+ try:
138
+ response = await client.get(self.base_url, params={**params, "apikey": self.api_key})
139
+ if response.status_code == 429:
140
+ raise RateLimitError("Alpha Vantage terkena rate limit.")
141
+ response.raise_for_status()
142
+ data = response.json()
143
+ if not isinstance(data, dict):
144
+ raise ProviderError("Response Alpha Vantage bukan JSON object.")
145
+ message = data.get("Note") or data.get("Information")
146
+ if message:
147
+ raise RateLimitError(f"Alpha Vantage membatasi request: {message}")
148
+ if "Error Message" in data:
149
+ raise ProviderError(f"Alpha Vantage gagal: {data['Error Message']}")
150
+ return data
151
+ except httpx.TimeoutException as exc:
152
+ raise ProviderError("Alpha Vantage timeout.") from exc
153
+ except httpx.HTTPStatusError as exc:
154
+ raise ProviderError(f"Alpha Vantage gagal: HTTP {exc.response.status_code}.") from exc
155
+ except ValueError as exc:
156
+ raise ProviderError("Response Alpha Vantage bukan JSON valid.") from exc
157
+ finally:
158
+ if close_client:
159
+ await client.aclose()
160
+
161
+
162
+ def _parse_daily_candle(day: str, payload: dict[str, Any]) -> Candle:
163
+ return Candle(
164
+ timestamp=_parse_datetime(day) or datetime.now(),
165
+ open=float(payload.get("1. open")),
166
+ high=float(payload.get("2. high")),
167
+ low=float(payload.get("3. low")),
168
+ close=float(payload.get("4. close")),
169
+ volume=float(payload.get("6. volume") or payload.get("5. volume") or 0),
170
+ )
171
+
172
+
173
+ def _safe_float(value: Any) -> float | None:
174
+ try:
175
+ if value in {None, "None", "-", ""}:
176
+ return None
177
+ return float(value)
178
+ except (TypeError, ValueError):
179
+ return None
180
+
181
+
182
+ def _parse_datetime(value: Any) -> datetime | None:
183
+ if not value:
184
+ return None
185
+ text = str(value)
186
+ for fmt in ("%Y%m%dT%H%M%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
187
+ try:
188
+ return datetime.strptime(text, fmt)
189
+ except ValueError:
190
+ continue
191
+ try:
192
+ return datetime.fromisoformat(text.replace("Z", "+00:00"))
193
+ except ValueError:
194
+ return None