@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.
- package/README.md +644 -0
- package/fincli/__init__.py +3 -0
- package/fincli/app/__init__.py +1 -0
- package/fincli/app/analysis/__init__.py +1 -0
- package/fincli/app/analysis/ai_prompts.py +33 -0
- package/fincli/app/analysis/analyzer.py +119 -0
- package/fincli/app/analysis/assistant_context.py +161 -0
- package/fincli/app/analysis/indicators.py +143 -0
- package/fincli/app/analysis/market_structure.py +106 -0
- package/fincli/app/analysis/technical_debate.py +251 -0
- package/fincli/app/analysis/technical_signal.py +203 -0
- package/fincli/app/cli/__init__.py +1 -0
- package/fincli/app/cli/autocomplete.py +17 -0
- package/fincli/app/cli/commands.py +82 -0
- package/fincli/app/cli/router.py +1257 -0
- package/fincli/app/main.py +16 -0
- package/fincli/app/modules/__init__.py +1 -0
- package/fincli/app/modules/economic_calendar.py +139 -0
- package/fincli/app/modules/exporter.py +51 -0
- package/fincli/app/modules/journal.py +65 -0
- package/fincli/app/modules/journal_analytics.py +70 -0
- package/fincli/app/modules/portfolio.py +34 -0
- package/fincli/app/modules/scanner.py +105 -0
- package/fincli/app/modules/transactions.py +84 -0
- package/fincli/app/modules/watchlist.py +25 -0
- package/fincli/app/providers/__init__.py +1 -0
- package/fincli/app/providers/ai/__init__.py +1 -0
- package/fincli/app/providers/ai/anthropic_provider.py +11 -0
- package/fincli/app/providers/ai/base.py +29 -0
- package/fincli/app/providers/ai/gemini_provider.py +11 -0
- package/fincli/app/providers/ai/groq_provider.py +11 -0
- package/fincli/app/providers/ai/http_provider.py +145 -0
- package/fincli/app/providers/ai/huggingface_provider.py +11 -0
- package/fincli/app/providers/ai/manager.py +60 -0
- package/fincli/app/providers/ai/openai_provider.py +11 -0
- package/fincli/app/providers/ai/openrouter_provider.py +11 -0
- package/fincli/app/providers/ai/together_provider.py +11 -0
- package/fincli/app/providers/market/__init__.py +1 -0
- package/fincli/app/providers/market/base.py +77 -0
- package/fincli/app/providers/market/custom_provider.py +169 -0
- package/fincli/app/providers/market/finnhub_provider.py +187 -0
- package/fincli/app/providers/market/manager.py +123 -0
- package/fincli/app/providers/market/news_provider.py +28 -0
- package/fincli/app/providers/market/symbols.py +182 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -0
- package/fincli/app/providers/market/yfinance_provider.py +447 -0
- package/fincli/app/services/__init__.py +1 -0
- package/fincli/app/services/market_data.py +203 -0
- package/fincli/app/services/market_overview.py +111 -0
- package/fincli/app/storage/__init__.py +1 -0
- package/fincli/app/storage/cache.py +38 -0
- package/fincli/app/storage/config.py +114 -0
- package/fincli/app/storage/database.py +101 -0
- package/fincli/app/storage/market_cache.py +92 -0
- package/fincli/app/tui/__init__.py +1 -0
- package/fincli/app/tui/components.py +55 -0
- package/fincli/app/tui/layout.py +261 -0
- package/fincli/app/tui/market_provider_selector.py +267 -0
- package/fincli/app/tui/model_selector.py +412 -0
- package/fincli/app/tui/theme.py +157 -0
- package/fincli/app/utils/__init__.py +1 -0
- package/fincli/app/utils/errors.py +33 -0
- package/fincli/app/utils/formatting.py +17 -0
- package/fincli/app/utils/logger.py +19 -0
- package/npm/bin/fincli.js +35 -0
- package/npm/postinstall.js +72 -0
- package/package.json +23 -0
- package/pyproject.toml +31 -0
- 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.")
|