kadeconsole 0.1.0__py3-none-any.whl
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.
- kadeconsole/__init__.py +10 -0
- kadeconsole/cache/__init__.py +0 -0
- kadeconsole/cache/store.py +155 -0
- kadeconsole/cli.py +193 -0
- kadeconsole/config/__init__.py +0 -0
- kadeconsole/config/loader.py +111 -0
- kadeconsole/core/__init__.py +0 -0
- kadeconsole/core/parser.py +241 -0
- kadeconsole/core/router.py +126 -0
- kadeconsole/core/session.py +131 -0
- kadeconsole/indicators/__init__.py +0 -0
- kadeconsole/indicators/momentum.py +166 -0
- kadeconsole/indicators/moving_averages.py +126 -0
- kadeconsole/indicators/volatility.py +114 -0
- kadeconsole/indicators/volume.py +79 -0
- kadeconsole/jev/__init__.py +0 -0
- kadeconsole/jev/client.py +140 -0
- kadeconsole/jev/narratives.py +142 -0
- kadeconsole/jev/ratings.py +253 -0
- kadeconsole/jev/verdicts.py +199 -0
- kadeconsole/pages/__init__.py +0 -0
- kadeconsole/pages/des.py +97 -0
- kadeconsole/pages/equity.py +248 -0
- kadeconsole/pages/fa.py +212 -0
- kadeconsole/pages/gp.py +88 -0
- kadeconsole/pages/help.py +144 -0
- kadeconsole/pages/hp.py +127 -0
- kadeconsole/pages/rate.py +157 -0
- kadeconsole/pages/verdict.py +167 -0
- kadeconsole/providers/__init__.py +0 -0
- kadeconsole/providers/base.py +139 -0
- kadeconsole/providers/registry.py +102 -0
- kadeconsole/providers/yfinance_provider.py +351 -0
- kadeconsole/render/__init__.py +0 -0
- kadeconsole/render/charts.py +144 -0
- kadeconsole/render/tables.py +227 -0
- kadeconsole/render/theme.py +105 -0
- kadeconsole/tests/__init__.py +0 -0
- kadeconsole-0.1.0.dist-info/METADATA +164 -0
- kadeconsole-0.1.0.dist-info/RECORD +43 -0
- kadeconsole-0.1.0.dist-info/WHEEL +5 -0
- kadeconsole-0.1.0.dist-info/entry_points.txt +2 -0
- kadeconsole-0.1.0.dist-info/top_level.txt +1 -0
kadeconsole/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cache/store.py — SQLite-backed response and Jev-verdict cache.
|
|
3
|
+
|
|
4
|
+
Two tables:
|
|
5
|
+
provider_cache — raw market data, keyed by (symbol, endpoint), TTL 15 min
|
|
6
|
+
jev_cache — Jev responses, keyed by sha256 of (ticker+timeframe+data_hash), TTL 24h
|
|
7
|
+
|
|
8
|
+
The cache lives at ~/.kadeconsole/cache.db and is created automatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import sqlite3
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
CACHE_DB = Path.home() / ".kadeconsole" / "cache.db"
|
|
21
|
+
|
|
22
|
+
_CREATE_PROVIDER = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS provider_cache (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
data_json TEXT NOT NULL,
|
|
26
|
+
expires_at REAL NOT NULL
|
|
27
|
+
);
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_CREATE_JEV = """
|
|
31
|
+
CREATE TABLE IF NOT EXISTS jev_cache (
|
|
32
|
+
key TEXT PRIMARY KEY,
|
|
33
|
+
response_json TEXT NOT NULL,
|
|
34
|
+
created_at REAL NOT NULL,
|
|
35
|
+
expires_at REAL NOT NULL
|
|
36
|
+
);
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CacheStore:
|
|
41
|
+
"""Thread-safe SQLite cache store."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
db_path: Path = CACHE_DB,
|
|
46
|
+
provider_ttl_minutes: int = 15,
|
|
47
|
+
jev_ttl_hours: int = 24,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.db_path = db_path
|
|
50
|
+
self.provider_ttl = provider_ttl_minutes * 60
|
|
51
|
+
self.jev_ttl = jev_ttl_hours * 3600
|
|
52
|
+
self._ensure_db()
|
|
53
|
+
|
|
54
|
+
# ── Public API ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def get_provider(self, symbol: str, endpoint: str) -> Optional[Any]:
|
|
57
|
+
"""Return cached provider data or None if missing/expired."""
|
|
58
|
+
key = self._provider_key(symbol, endpoint)
|
|
59
|
+
row = self._fetch("provider_cache", key)
|
|
60
|
+
return json.loads(row) if row else None
|
|
61
|
+
|
|
62
|
+
def set_provider(self, symbol: str, endpoint: str, data: Any) -> None:
|
|
63
|
+
"""Cache provider data with TTL."""
|
|
64
|
+
key = self._provider_key(symbol, endpoint)
|
|
65
|
+
self._upsert("provider_cache", key, json.dumps(data, default=str), self.provider_ttl)
|
|
66
|
+
|
|
67
|
+
def get_jev(self, ticker: str, timeframe: str, data_fingerprint: str) -> Optional[Any]:
|
|
68
|
+
"""Return cached Jev response or None if missing/expired."""
|
|
69
|
+
key = self._jev_key(ticker, timeframe, data_fingerprint)
|
|
70
|
+
row = self._fetch_jev(key)
|
|
71
|
+
return json.loads(row) if row else None
|
|
72
|
+
|
|
73
|
+
def set_jev(self, ticker: str, timeframe: str, data_fingerprint: str, response: Any) -> None:
|
|
74
|
+
"""Cache a Jev response with TTL."""
|
|
75
|
+
key = self._jev_key(ticker, timeframe, data_fingerprint)
|
|
76
|
+
self._upsert("jev_cache", key, json.dumps(response, default=str), self.jev_ttl)
|
|
77
|
+
|
|
78
|
+
def clear_expired(self) -> None:
|
|
79
|
+
"""Purge all expired rows from both tables."""
|
|
80
|
+
now = time.time()
|
|
81
|
+
with self._connect() as conn:
|
|
82
|
+
conn.execute("DELETE FROM provider_cache WHERE expires_at < ?", (now,))
|
|
83
|
+
conn.execute("DELETE FROM jev_cache WHERE expires_at < ?", (now,))
|
|
84
|
+
|
|
85
|
+
def clear_all(self) -> None:
|
|
86
|
+
"""Nuke entire cache (useful for debugging)."""
|
|
87
|
+
with self._connect() as conn:
|
|
88
|
+
conn.execute("DELETE FROM provider_cache")
|
|
89
|
+
conn.execute("DELETE FROM jev_cache")
|
|
90
|
+
|
|
91
|
+
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def make_data_fingerprint(data: Any) -> str:
|
|
95
|
+
"""Return a short sha256 fingerprint of any JSON-serialisable data."""
|
|
96
|
+
raw = json.dumps(data, sort_keys=True, default=str).encode()
|
|
97
|
+
return hashlib.sha256(raw).hexdigest()[:16]
|
|
98
|
+
|
|
99
|
+
# ── Internal ───────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _ensure_db(self) -> None:
|
|
102
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with self._connect() as conn:
|
|
104
|
+
conn.execute(_CREATE_PROVIDER)
|
|
105
|
+
conn.execute(_CREATE_JEV)
|
|
106
|
+
|
|
107
|
+
def _connect(self) -> sqlite3.Connection:
|
|
108
|
+
return sqlite3.connect(str(self.db_path))
|
|
109
|
+
|
|
110
|
+
def _fetch(self, table: str, key: str) -> Optional[str]:
|
|
111
|
+
"""Fetch from provider_cache (data_json column)."""
|
|
112
|
+
now = time.time()
|
|
113
|
+
with self._connect() as conn:
|
|
114
|
+
row = conn.execute(
|
|
115
|
+
f"SELECT data_json FROM {table} WHERE key=? AND expires_at>?",
|
|
116
|
+
(key, now),
|
|
117
|
+
).fetchone()
|
|
118
|
+
return row[0] if row else None
|
|
119
|
+
|
|
120
|
+
def _fetch_jev(self, key: str) -> Optional[str]:
|
|
121
|
+
"""Fetch from jev_cache (response_json column)."""
|
|
122
|
+
now = time.time()
|
|
123
|
+
with self._connect() as conn:
|
|
124
|
+
row = conn.execute(
|
|
125
|
+
"SELECT response_json FROM jev_cache WHERE key=? AND expires_at>?",
|
|
126
|
+
(key, now),
|
|
127
|
+
).fetchone()
|
|
128
|
+
return row[0] if row else None
|
|
129
|
+
|
|
130
|
+
def _upsert(self, table: str, key: str, data_json: str, ttl: float) -> None:
|
|
131
|
+
now = time.time()
|
|
132
|
+
expires = now + ttl
|
|
133
|
+
if table == "provider_cache":
|
|
134
|
+
sql = (
|
|
135
|
+
"INSERT OR REPLACE INTO provider_cache (key, data_json, expires_at) "
|
|
136
|
+
"VALUES (?, ?, ?)"
|
|
137
|
+
)
|
|
138
|
+
with self._connect() as conn:
|
|
139
|
+
conn.execute(sql, (key, data_json, expires))
|
|
140
|
+
else:
|
|
141
|
+
sql = (
|
|
142
|
+
"INSERT OR REPLACE INTO jev_cache (key, response_json, created_at, expires_at) "
|
|
143
|
+
"VALUES (?, ?, ?, ?)"
|
|
144
|
+
)
|
|
145
|
+
with self._connect() as conn:
|
|
146
|
+
conn.execute(sql, (key, data_json, now, expires))
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _provider_key(symbol: str, endpoint: str) -> str:
|
|
150
|
+
return f"{symbol.upper()}:{endpoint}"
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _jev_key(ticker: str, timeframe: str, fingerprint: str) -> str:
|
|
154
|
+
raw = f"{ticker.upper()}:{timeframe}:{fingerprint}"
|
|
155
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
kadeconsole/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — kadeConsole REPL entrypoint.
|
|
3
|
+
|
|
4
|
+
Entry point: `kadeconsole` (via pyproject.toml console_scripts)
|
|
5
|
+
|
|
6
|
+
Startup sequence:
|
|
7
|
+
1. Load config from ~/.kadeconsole/config.yaml
|
|
8
|
+
2. Initialise provider registry + Jev client + SQLite cache
|
|
9
|
+
3. Print startup banner
|
|
10
|
+
4. Drop into prompt_toolkit REPL loop
|
|
11
|
+
5. Parse each input line → Router.dispatch() → continue or exit
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
from kadeconsole.render.theme import RICH_THEME, t
|
|
22
|
+
from kadeconsole import __version__
|
|
23
|
+
|
|
24
|
+
console = Console(theme=RICH_THEME)
|
|
25
|
+
|
|
26
|
+
# ── Banner ─────────────────────────────────────────────────────────────────────
|
|
27
|
+
_BANNER = r"""
|
|
28
|
+
██╗ ██╗ █████╗ ██████╗ ███████╗
|
|
29
|
+
██║ ██╔╝██╔══██╗██╔══██╗██╔════╝
|
|
30
|
+
█████╔╝ ███████║██║ ██║█████╗
|
|
31
|
+
██╔═██╗ ██╔══██║██║ ██║██╔══╝
|
|
32
|
+
██║ ██╗██║ ██║██████╔╝███████╗
|
|
33
|
+
╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_banner() -> None:
|
|
38
|
+
console.print(f"[accent]{_BANNER}[/accent]", highlight=False)
|
|
39
|
+
console.print(
|
|
40
|
+
f" [bold {t.accent}]CONSOLE[/bold {t.accent}] "
|
|
41
|
+
f"[{t.muted}]v{__version__}[/] "
|
|
42
|
+
f"[{t.dim_text}]· Bloomberg-style equity research terminal[/]\n"
|
|
43
|
+
)
|
|
44
|
+
console.print(
|
|
45
|
+
f" [{t.muted}]Type [accent]HELP[/accent] or [accent]?[/accent] for the command reference. "
|
|
46
|
+
f"[accent]EXIT[/accent] or [accent]Q[/accent] to quit.[/]\n"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Autocomplete word list ─────────────────────────────────────────────────────
|
|
51
|
+
_AUTOCOMPLETE_WORDS = [
|
|
52
|
+
"EQUITY", "DES", "FA", "FA Q", "GP", "HP", "VERDICT", "RATE",
|
|
53
|
+
"TA", "NI", "RV", "COMPARE", "MACRO", "SCREEN", "WATCH", "EXPORT",
|
|
54
|
+
"SOURCE", "HELP", "EXIT", "QUIT",
|
|
55
|
+
"1D", "5D", "1M", "3M", "6M", "1Y", "2Y", "5Y",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_completer():
|
|
60
|
+
"""Build a prompt_toolkit WordCompleter for the REPL."""
|
|
61
|
+
try:
|
|
62
|
+
from prompt_toolkit.completion import WordCompleter
|
|
63
|
+
return WordCompleter(_AUTOCOMPLETE_WORDS, ignore_case=True, sentence=True)
|
|
64
|
+
except ImportError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_prompt_session():
|
|
69
|
+
"""Build a prompt_toolkit PromptSession (with history)."""
|
|
70
|
+
try:
|
|
71
|
+
from prompt_toolkit import PromptSession
|
|
72
|
+
from prompt_toolkit.history import FileHistory
|
|
73
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
|
|
76
|
+
hist_file = Path.home() / ".kadeconsole" / "history"
|
|
77
|
+
hist_file.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
|
|
79
|
+
return PromptSession(
|
|
80
|
+
history=AutoSuggestFromHistory() and FileHistory(str(hist_file)),
|
|
81
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
82
|
+
completer=_build_completer(),
|
|
83
|
+
complete_while_typing=False,
|
|
84
|
+
)
|
|
85
|
+
except ImportError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _simple_input(prompt: str) -> str:
|
|
90
|
+
"""Fallback to built-in input() when prompt_toolkit is unavailable."""
|
|
91
|
+
try:
|
|
92
|
+
return input(prompt)
|
|
93
|
+
except EOFError:
|
|
94
|
+
return "EXIT"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main() -> None:
|
|
98
|
+
"""Main entrypoint — called by the `kadeconsole` console script."""
|
|
99
|
+
|
|
100
|
+
# ── 1. Load config ─────────────────────────────────────────────────────────
|
|
101
|
+
from kadeconsole.config.loader import load_config
|
|
102
|
+
config = load_config()
|
|
103
|
+
|
|
104
|
+
# ── 2. Initialise cache ────────────────────────────────────────────────────
|
|
105
|
+
from kadeconsole.cache.store import CacheStore
|
|
106
|
+
cache = CacheStore(
|
|
107
|
+
provider_ttl_minutes = config.get("cache_ttl_provider_minutes", 15),
|
|
108
|
+
jev_ttl_hours = config.get("cache_ttl_jev_hours", 24),
|
|
109
|
+
)
|
|
110
|
+
cache.clear_expired() # housekeeping on startup
|
|
111
|
+
|
|
112
|
+
# ── 3. Initialise providers ────────────────────────────────────────────────
|
|
113
|
+
from kadeconsole.providers.registry import ProviderRegistry
|
|
114
|
+
registry = ProviderRegistry(config)
|
|
115
|
+
provider = registry.get_primary()
|
|
116
|
+
|
|
117
|
+
# ── 4. Initialise Jev client ───────────────────────────────────────────────
|
|
118
|
+
from kadeconsole.jev.client import JevClient
|
|
119
|
+
jev = JevClient(
|
|
120
|
+
api_key = config.get("typesafe_api_key", ""),
|
|
121
|
+
cache = cache,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ── 5. Session + Router ───────────────────────────────────────────────────
|
|
125
|
+
from kadeconsole.core.session import Session
|
|
126
|
+
from kadeconsole.core.router import Router
|
|
127
|
+
session = Session(timeframe=config.get("default_timeframe", "1Y"))
|
|
128
|
+
router = Router(session=session, provider=provider, jev=jev)
|
|
129
|
+
|
|
130
|
+
# ── 6. Banner ──────────────────────────────────────────────────────────────
|
|
131
|
+
_print_banner()
|
|
132
|
+
|
|
133
|
+
# Provider status line
|
|
134
|
+
provider_line = f"[{t.muted}] Data: [accent]{provider.name}[/accent]"
|
|
135
|
+
if registry.provider_names:
|
|
136
|
+
provider_line += f" [{t.muted}]({', '.join(registry.provider_names)})[/]"
|
|
137
|
+
jev_status = (
|
|
138
|
+
f"[positive]◆ Jev active[/positive]"
|
|
139
|
+
if jev.available
|
|
140
|
+
else f"[{t.muted}]◆ Jev inactive — set TYPESAFE_API_KEY to enable AI analysis[/]"
|
|
141
|
+
)
|
|
142
|
+
console.print(f"{provider_line} {jev_status}\n")
|
|
143
|
+
|
|
144
|
+
# ── 7. REPL loop ───────────────────────────────────────────────────────────
|
|
145
|
+
prompt_session = _build_prompt_session()
|
|
146
|
+
|
|
147
|
+
# Prompt style — amber "kadeConsole >" prefix
|
|
148
|
+
prompt_str = f"kadeConsole > "
|
|
149
|
+
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
if prompt_session is not None:
|
|
153
|
+
try:
|
|
154
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
155
|
+
pt_style = PTStyle.from_dict({
|
|
156
|
+
"": f"#{t.text[1:]}",
|
|
157
|
+
"prompt": f"bold #{t.accent[1:]}",
|
|
158
|
+
})
|
|
159
|
+
raw = prompt_session.prompt(
|
|
160
|
+
[("class:prompt", prompt_str)],
|
|
161
|
+
style=pt_style,
|
|
162
|
+
)
|
|
163
|
+
except Exception:
|
|
164
|
+
raw = prompt_session.prompt(prompt_str)
|
|
165
|
+
else:
|
|
166
|
+
raw = _simple_input(prompt_str)
|
|
167
|
+
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
console.print(f"\n[{t.muted}] Use EXIT or Q to quit.[/]\n")
|
|
170
|
+
continue
|
|
171
|
+
except EOFError:
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
raw = raw.strip()
|
|
175
|
+
if not raw:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Parse and dispatch
|
|
179
|
+
from kadeconsole.core.parser import parse, ParseError
|
|
180
|
+
try:
|
|
181
|
+
parsed = parse(raw)
|
|
182
|
+
except ParseError as exc:
|
|
183
|
+
console.print(f"[negative] Parse error: {exc}[/negative]")
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
keep_going = router.dispatch(parsed)
|
|
187
|
+
if not keep_going:
|
|
188
|
+
console.print(f"\n[{t.muted}] Goodbye.[/]\n")
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
config/loader.py — ~/.kadeconsole/config.yaml reader.
|
|
3
|
+
|
|
4
|
+
Creates a default config file on first run. Returns a validated config dict.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import yaml
|
|
15
|
+
_YAML_AVAILABLE = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
_YAML_AVAILABLE = False
|
|
18
|
+
|
|
19
|
+
CONFIG_DIR = Path.home() / ".kadeconsole"
|
|
20
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
23
|
+
"provider_priority": ["yfinance"],
|
|
24
|
+
"typesafe_api_key": "",
|
|
25
|
+
"alpaca_api_key": "",
|
|
26
|
+
"alphavantage_api_key": "",
|
|
27
|
+
"polygon_api_key": "",
|
|
28
|
+
"default_timeframe": "1Y",
|
|
29
|
+
"theme": "default",
|
|
30
|
+
"cache_ttl_provider_minutes": 15,
|
|
31
|
+
"cache_ttl_jev_hours": 24,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config() -> dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Load configuration from ~/.kadeconsole/config.yaml.
|
|
38
|
+
|
|
39
|
+
Creates the directory and a default config file if they don't exist.
|
|
40
|
+
Merges loaded values over the defaults (new keys in defaults are preserved
|
|
41
|
+
across upgrades without blowing away user customisations).
|
|
42
|
+
"""
|
|
43
|
+
_ensure_dir()
|
|
44
|
+
|
|
45
|
+
if not CONFIG_FILE.exists():
|
|
46
|
+
_write_default()
|
|
47
|
+
|
|
48
|
+
if not _YAML_AVAILABLE:
|
|
49
|
+
# Fallback: return defaults, don't crash if PyYAML not installed yet
|
|
50
|
+
return DEFAULT_CONFIG.copy()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with open(CONFIG_FILE, "r") as f:
|
|
54
|
+
user_config: dict[str, Any] = yaml.safe_load(f) or {}
|
|
55
|
+
except Exception: # noqa: BLE001
|
|
56
|
+
user_config = {}
|
|
57
|
+
|
|
58
|
+
# Merge: defaults first, user values override
|
|
59
|
+
merged = {**DEFAULT_CONFIG, **user_config}
|
|
60
|
+
|
|
61
|
+
# Also honour environment variable overrides (CI-friendly)
|
|
62
|
+
_apply_env_overrides(merged)
|
|
63
|
+
|
|
64
|
+
return merged
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_dir() -> None:
|
|
68
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_default() -> None:
|
|
72
|
+
if not _YAML_AVAILABLE:
|
|
73
|
+
return
|
|
74
|
+
comment_block = """\
|
|
75
|
+
# kadeConsole configuration
|
|
76
|
+
# ─────────────────────────────────────────────────────────────
|
|
77
|
+
# Set your API keys below to unlock all data providers.
|
|
78
|
+
# kadeConsole works without any keys using yfinance (free).
|
|
79
|
+
#
|
|
80
|
+
# Get a TypeSafe AI key at: https://typesafe.ai
|
|
81
|
+
# ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
with open(CONFIG_FILE, "w") as f:
|
|
85
|
+
f.write(comment_block)
|
|
86
|
+
yaml.dump(DEFAULT_CONFIG, f, default_flow_style=False, allow_unicode=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _apply_env_overrides(config: dict[str, Any]) -> None:
|
|
90
|
+
"""Override config values from environment variables."""
|
|
91
|
+
env_map = {
|
|
92
|
+
"TYPESAFE_API_KEY": "typesafe_api_key",
|
|
93
|
+
"ALPACA_API_KEY": "alpaca_api_key",
|
|
94
|
+
"ALPHAVANTAGE_API_KEY": "alphavantage_api_key",
|
|
95
|
+
"POLYGON_API_KEY": "polygon_api_key",
|
|
96
|
+
}
|
|
97
|
+
for env_key, config_key in env_map.items():
|
|
98
|
+
val = os.environ.get(env_key)
|
|
99
|
+
if val:
|
|
100
|
+
config[config_key] = val
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_api_key(config: dict[str, Any], provider: str) -> str:
|
|
104
|
+
"""Convenience helper to retrieve a provider's API key from config."""
|
|
105
|
+
key_map = {
|
|
106
|
+
"typesafe": "typesafe_api_key",
|
|
107
|
+
"alpaca": "alpaca_api_key",
|
|
108
|
+
"alphavantage": "alphavantage_api_key",
|
|
109
|
+
"polygon": "polygon_api_key",
|
|
110
|
+
}
|
|
111
|
+
return config.get(key_map.get(provider, ""), "") or ""
|
|
File without changes
|