@drico2008/fincli 0.1.9 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -718
  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 +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  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 +105 -83
  14. package/fincli/app/cli/router.py +2123 -1294
  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/reports.py +151 -0
  21. package/fincli/app/modules/scanner.py +111 -93
  22. package/fincli/app/modules/transactions.py +84 -84
  23. package/fincli/app/modules/user_profile.py +84 -0
  24. package/fincli/app/plugins/loader.py +72 -0
  25. package/fincli/app/providers/ai/manager.py +60 -60
  26. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  27. package/fincli/app/providers/market/base.py +98 -77
  28. package/fincli/app/providers/market/custom_provider.py +186 -169
  29. package/fincli/app/providers/market/manager.py +84 -1
  30. package/fincli/app/providers/market/symbols.py +143 -0
  31. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  32. package/fincli/app/research/__init__.py +7 -0
  33. package/fincli/app/research/engine.py +75 -0
  34. package/fincli/app/research/formatter.py +22 -0
  35. package/fincli/app/research/models.py +18 -0
  36. package/fincli/app/research/prompt_builder.py +47 -0
  37. package/fincli/app/services/macro_data.py +50 -0
  38. package/fincli/app/services/market_data.py +203 -203
  39. package/fincli/app/services/news_aggregator.py +90 -0
  40. package/fincli/app/services/web_research.py +267 -267
  41. package/fincli/app/storage/config.py +122 -88
  42. package/fincli/app/storage/database.py +200 -101
  43. package/fincli/app/storage/secrets.py +8 -2
  44. package/fincli/app/tui/components.py +68 -50
  45. package/fincli/app/tui/layout.py +269 -258
  46. package/fincli/app/tui/market_provider_selector.py +3 -1
  47. package/fincli/app/tui/theme.py +134 -74
  48. package/fincli/app/utils/formatting.py +123 -60
  49. package/package.json +23 -23
  50. package/pyproject.toml +35 -35
@@ -1,112 +1,146 @@
1
- """Configuration loading and persistence.
2
-
3
- Secrets are read from environment variables. Non-secret preferences are stored
4
- in a local JSON config file under ~/.fincli by default.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from dataclasses import asdict, dataclass, field
10
- import json
11
- import os
12
- from pathlib import Path
13
- from typing import Any
14
-
1
+ """Configuration loading and persistence.
2
+
3
+ Secrets are read from environment variables. Non-secret preferences are stored
4
+ in a local JSON config file under ~/.fincli by default.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
15
  try:
16
- from dotenv import load_dotenv
16
+ from dotenv import dotenv_values, load_dotenv
17
17
  except ImportError: # pragma: no cover - dependency exists in normal install
18
18
  load_dotenv = None # type: ignore[assignment]
19
-
20
- from fincli.app.utils.errors import ConfigError
21
- from fincli.app.utils.formatting import mask_secret
22
- from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
23
- from fincli.app.storage.secrets import load_local_secrets
24
-
25
-
26
- @dataclass(slots=True)
27
- class FinCLISettings:
28
- ai_provider: str = "openrouter"
29
- ai_model: str = "openai/gpt-4o-mini"
19
+ dotenv_values = None # type: ignore[assignment]
20
+
21
+ from fincli.app.utils.errors import ConfigError
22
+ from fincli.app.utils.formatting import mask_secret
23
+ from fincli.app.storage.config_paths import APP_DIR, CONFIG_FILE
24
+ from fincli.app.storage.secrets import load_local_secrets
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class FinCLISettings:
29
+ ai_provider: str = "openrouter"
30
+ ai_model: str = "openai/gpt-4o-mini"
30
31
  market_provider: str = "yfinance"
31
32
  news_provider: str = "yfinance"
32
33
  market_provider_priority: list[str] = field(default_factory=lambda: ["yfinance"])
34
+ news_provider_priority: list[str] = field(
35
+ default_factory=lambda: ["yfinance", "google_news_rss", "yahoo_finance_rss", "marketaux", "newsapi", "gnews"]
36
+ )
33
37
  timezone: str = "Asia/Jakarta"
34
- default_currency: str = "USD"
35
- cache_ttl_seconds: int = 300
36
- theme: str = "fincli-dark"
37
-
38
- def safe_dict(self) -> dict[str, Any]:
39
- """Return display-safe config, including masked secret status."""
40
- data = asdict(self)
41
- data["api_keys"] = {
42
- "openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
43
- "gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
44
- "anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
45
- "openai": mask_secret(os.getenv("OPENAI_API_KEY")),
46
- "together": mask_secret(os.getenv("TOGETHER_API_KEY")),
47
- "huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
48
- "groq": mask_secret(os.getenv("GROQ_API_KEY")),
49
- "market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
38
+ default_currency: str = "USD"
39
+ cache_ttl_seconds: int = 300
40
+ theme: str = "fincli-dark"
41
+
42
+ def safe_dict(self) -> dict[str, Any]:
43
+ """Return display-safe config, including masked secret status."""
44
+ data = asdict(self)
45
+ data["api_keys"] = {
46
+ "openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
47
+ "gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
48
+ "anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
49
+ "openai": mask_secret(os.getenv("OPENAI_API_KEY")),
50
+ "together": mask_secret(os.getenv("TOGETHER_API_KEY")),
51
+ "huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
52
+ "groq": mask_secret(os.getenv("GROQ_API_KEY")),
53
+ "market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
50
54
  "news_data": mask_secret(os.getenv("NEWS_DATA_API_KEY")),
51
55
  "finnhub": mask_secret(os.getenv("FINNHUB_API_KEY")),
52
56
  "twelvedata": mask_secret(os.getenv("TWELVE_DATA_API_KEY")),
57
+ "alphavantage": mask_secret(os.getenv("ALPHA_VANTAGE_API_KEY")),
58
+ "marketaux": mask_secret(os.getenv("MARKETAUX_API_KEY")),
59
+ "newsapi": mask_secret(os.getenv("NEWSAPI_API_KEY")),
60
+ "gnews": mask_secret(os.getenv("GNEWS_API_KEY")),
61
+ "stocknewsapi": mask_secret(os.getenv("STOCKNEWSAPI_API_KEY")),
62
+ "apitube": mask_secret(os.getenv("APITUBE_API_KEY")),
63
+ "benzinga": mask_secret(os.getenv("BENZINGA_API_KEY")),
64
+ "polygon": mask_secret(os.getenv("POLYGON_API_KEY")),
65
+ "tiingo": mask_secret(os.getenv("TIINGO_API_KEY")),
66
+ "fmp": mask_secret(os.getenv("FMP_API_KEY")),
67
+ "eodhd": mask_secret(os.getenv("EODHD_API_KEY")),
68
+ "custom_news": mask_secret(os.getenv("CUSTOM_NEWS_API_KEY") or os.getenv("NEWS_DATA_API_KEY")),
53
69
  }
54
70
  return data
55
-
56
-
57
- class ConfigManager:
58
- """Load, update, and persist non-secret FinCLI settings."""
59
-
60
- def __init__(self, config_file: Path = CONFIG_FILE) -> None:
61
- self.config_file = config_file
62
- self.settings = self.load()
63
-
71
+
72
+
73
+ class ConfigManager:
74
+ """Load, update, and persist non-secret FinCLI settings."""
75
+
76
+ def __init__(self, config_file: Path = CONFIG_FILE) -> None:
77
+ self.config_file = config_file
78
+ self.settings = self.load()
79
+
64
80
  def load(self) -> FinCLISettings:
81
+ dotenv_loaded_keys: set[str] = set()
65
82
  if load_dotenv is not None:
83
+ before_env = dict(os.environ)
66
84
  load_dotenv()
67
- load_local_secrets()
68
-
69
- if not self.config_file.exists():
70
- return FinCLISettings()
71
-
72
- try:
73
- raw = json.loads(self.config_file.read_text(encoding="utf-8"))
74
- allowed = FinCLISettings.__dataclass_fields__.keys()
75
- filtered = {key: value for key, value in raw.items() if key in allowed}
76
- return FinCLISettings(**filtered)
77
- except Exception as exc: # noqa: BLE001
78
- raise ConfigError(
79
- "Config lokal gagal dibaca.",
80
- "Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
81
- ) from exc
82
-
83
- def save(self) -> None:
84
- try:
85
- self.config_file.parent.mkdir(parents=True, exist_ok=True)
86
- self.config_file.write_text(
87
- json.dumps(asdict(self.settings), indent=2),
88
- encoding="utf-8",
89
- )
90
- except Exception as exc: # noqa: BLE001
91
- raise ConfigError("Config lokal gagal disimpan.") from exc
92
-
93
- def set_ai_model(self, provider: str, model: str) -> None:
94
- self.settings.ai_provider = provider.strip().lower()
95
- self.settings.ai_model = model.strip()
96
- self.save()
97
-
98
- def set_market_provider(self, provider: str) -> None:
99
- self.settings.market_provider = provider.strip().lower()
100
- self.save()
101
-
85
+ if dotenv_values is not None:
86
+ dotenv_loaded_keys = {
87
+ key
88
+ for key, value in dotenv_values().items()
89
+ if key and value is not None and (key not in before_env or before_env.get(key, "") == "")
90
+ }
91
+ # API keys saved from FinCLI commands should override stale project .env values,
92
+ # while explicit OS/process environment variables remain respected.
93
+ load_local_secrets(override_keys=dotenv_loaded_keys)
94
+
95
+ if not self.config_file.exists():
96
+ return FinCLISettings()
97
+
98
+ try:
99
+ raw = json.loads(self.config_file.read_text(encoding="utf-8"))
100
+ allowed = FinCLISettings.__dataclass_fields__.keys()
101
+ filtered = {key: value for key, value in raw.items() if key in allowed}
102
+ return FinCLISettings(**filtered)
103
+ except Exception as exc: # noqa: BLE001
104
+ raise ConfigError(
105
+ "Config lokal gagal dibaca.",
106
+ "Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
107
+ ) from exc
108
+
109
+ def save(self) -> None:
110
+ try:
111
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
112
+ self.config_file.write_text(
113
+ json.dumps(asdict(self.settings), indent=2),
114
+ encoding="utf-8",
115
+ )
116
+ except Exception as exc: # noqa: BLE001
117
+ raise ConfigError("Config lokal gagal disimpan.") from exc
118
+
119
+ def set_ai_model(self, provider: str, model: str) -> None:
120
+ self.settings.ai_provider = provider.strip().lower()
121
+ self.settings.ai_model = model.strip()
122
+ self.save()
123
+
124
+ def set_market_provider(self, provider: str) -> None:
125
+ self.settings.market_provider = provider.strip().lower()
126
+ self.save()
127
+
102
128
  def set_news_provider(self, provider: str) -> None:
103
129
  self.settings.news_provider = provider.strip().lower()
104
130
  self.save()
105
131
 
106
- def set_market_provider_priority(self, providers: list[str]) -> None:
132
+ def set_news_provider_priority(self, providers: list[str]) -> None:
107
133
  normalized = [provider.strip().lower() for provider in providers if provider.strip()]
108
134
  if not normalized:
109
- normalized = ["yfinance"]
135
+ normalized = ["yfinance", "google_news_rss", "yahoo_finance_rss"]
136
+ self.settings.news_provider_priority = normalized
137
+ self.settings.news_provider = normalized[0]
138
+ self.save()
139
+
140
+ def set_market_provider_priority(self, providers: list[str]) -> None:
141
+ normalized = [provider.strip().lower() for provider in providers if provider.strip()]
142
+ if not normalized:
143
+ normalized = ["yfinance"]
110
144
  self.settings.market_provider_priority = normalized
111
145
  self.settings.market_provider = normalized[0]
112
146
  self.settings.news_provider = normalized[0]
@@ -1,118 +1,217 @@
1
- """SQLite storage for FinCLI local data."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from contextlib import closing
7
- import sqlite3
8
- from typing import Iterable
9
-
10
- from fincli.app.storage.config import APP_DIR
11
- from fincli.app.utils.errors import StorageError
12
-
13
-
14
- DB_FILE = APP_DIR / "fincli.db"
15
-
16
-
17
- class FinCLIDatabase:
18
- """Small SQLite wrapper for watchlist, portfolio, and journal data."""
19
-
20
- def __init__(self, db_file: Path = DB_FILE) -> None:
21
- self.db_file = db_file
22
- self.db_file.parent.mkdir(parents=True, exist_ok=True)
23
- self.initialize()
24
-
25
- def connect(self) -> sqlite3.Connection:
26
- connection = sqlite3.connect(self.db_file)
27
- connection.row_factory = sqlite3.Row
28
- return connection
29
-
30
- def initialize(self) -> None:
31
- try:
32
- with closing(self.connect()) as db:
33
- with db:
34
- db.executescript(
35
- """
36
- CREATE TABLE IF NOT EXISTS watchlist (
37
- symbol TEXT PRIMARY KEY,
38
- group_name TEXT DEFAULT 'default',
39
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
40
- );
41
-
42
- CREATE TABLE IF NOT EXISTS portfolio_positions (
43
- symbol TEXT PRIMARY KEY,
44
- quantity REAL NOT NULL,
45
- average_price REAL NOT NULL,
46
- currency TEXT DEFAULT 'USD',
47
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
48
- updated_at TEXT DEFAULT CURRENT_TIMESTAMP
49
- );
50
-
51
- CREATE TABLE IF NOT EXISTS journal_entries (
52
- id INTEGER PRIMARY KEY AUTOINCREMENT,
53
- instrument TEXT NOT NULL,
54
- bias TEXT DEFAULT '',
55
- entry_reason TEXT DEFAULT '',
56
- exit_reason TEXT DEFAULT '',
57
- result TEXT DEFAULT '',
58
- emotion TEXT DEFAULT '',
59
- lesson TEXT DEFAULT '',
60
- tags TEXT DEFAULT '',
61
- created_at TEXT DEFAULT CURRENT_TIMESTAMP
62
- );
63
-
64
- CREATE TABLE IF NOT EXISTS portfolio_transactions (
1
+ """SQLite storage for FinCLI local data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from contextlib import closing
7
+ import sqlite3
8
+ from typing import Iterable
9
+
10
+ from fincli.app.storage.config import APP_DIR
11
+ from fincli.app.utils.errors import StorageError
12
+
13
+
14
+ DB_FILE = APP_DIR / "fincli.db"
15
+
16
+
17
+ class FinCLIDatabase:
18
+ """Small SQLite wrapper for watchlist, portfolio, and journal data."""
19
+
20
+ def __init__(self, db_file: Path = DB_FILE) -> None:
21
+ self.db_file = db_file
22
+ self.db_file.parent.mkdir(parents=True, exist_ok=True)
23
+ self.initialize()
24
+
25
+ def connect(self) -> sqlite3.Connection:
26
+ connection = sqlite3.connect(self.db_file)
27
+ connection.row_factory = sqlite3.Row
28
+ return connection
29
+
30
+ def initialize(self) -> None:
31
+ try:
32
+ with closing(self.connect()) as db:
33
+ with db:
34
+ db.executescript(
35
+ """
36
+ CREATE TABLE IF NOT EXISTS watchlist (
37
+ symbol TEXT PRIMARY KEY,
38
+ group_name TEXT DEFAULT 'default',
39
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS portfolio_positions (
43
+ symbol TEXT PRIMARY KEY,
44
+ quantity REAL NOT NULL,
45
+ average_price REAL NOT NULL,
46
+ currency TEXT DEFAULT 'USD',
47
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
48
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS journal_entries (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ instrument TEXT NOT NULL,
54
+ bias TEXT DEFAULT '',
55
+ entry_reason TEXT DEFAULT '',
56
+ exit_reason TEXT DEFAULT '',
57
+ result TEXT DEFAULT '',
58
+ emotion TEXT DEFAULT '',
59
+ lesson TEXT DEFAULT '',
60
+ tags TEXT DEFAULT '',
61
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS portfolio_transactions (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ action TEXT NOT NULL,
67
+ symbol TEXT NOT NULL,
68
+ quantity REAL NOT NULL,
69
+ price REAL NOT NULL,
70
+ currency TEXT DEFAULT 'USD',
71
+ realized_pnl REAL DEFAULT 0,
72
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS market_cache (
76
+ namespace TEXT NOT NULL,
77
+ cache_key TEXT NOT NULL,
78
+ payload TEXT NOT NULL,
79
+ expires_at REAL NOT NULL,
80
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
81
+ PRIMARY KEY (namespace, cache_key)
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS sessions (
85
+ id TEXT PRIMARY KEY,
86
+ title TEXT NOT NULL,
87
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
88
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS session_events (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ session_id TEXT NOT NULL,
94
+ command TEXT NOT NULL,
95
+ status TEXT NOT NULL,
96
+ output_preview TEXT DEFAULT '',
97
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
98
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS alerts (
65
102
  id INTEGER PRIMARY KEY AUTOINCREMENT,
66
- action TEXT NOT NULL,
67
103
  symbol TEXT NOT NULL,
68
- quantity REAL NOT NULL,
69
- price REAL NOT NULL,
70
- currency TEXT DEFAULT 'USD',
71
- realized_pnl REAL DEFAULT 0,
104
+ condition TEXT NOT NULL,
105
+ target REAL NOT NULL,
106
+ note TEXT DEFAULT '',
107
+ active INTEGER DEFAULT 1,
108
+ triggered_at TEXT DEFAULT '',
72
109
  created_at TEXT DEFAULT CURRENT_TIMESTAMP
73
110
  );
74
111
 
75
- CREATE TABLE IF NOT EXISTS market_cache (
76
- namespace TEXT NOT NULL,
77
- cache_key TEXT NOT NULL,
78
- payload TEXT NOT NULL,
79
- expires_at REAL NOT NULL,
80
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
81
- PRIMARY KEY (namespace, cache_key)
82
- );
83
-
84
- CREATE TABLE IF NOT EXISTS sessions (
85
- id TEXT PRIMARY KEY,
86
- title TEXT NOT NULL,
87
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
112
+ CREATE TABLE IF NOT EXISTS user_profile (
113
+ id INTEGER PRIMARY KEY CHECK (id = 1),
114
+ name TEXT NOT NULL,
115
+ equity REAL NOT NULL,
116
+ currency TEXT NOT NULL,
117
+ leverage TEXT NOT NULL,
118
+ years_in_investment REAL NOT NULL,
119
+ gameplay TEXT NOT NULL,
88
120
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
89
121
  );
90
-
91
- CREATE TABLE IF NOT EXISTS session_events (
92
- id INTEGER PRIMARY KEY AUTOINCREMENT,
93
- session_id TEXT NOT NULL,
94
- command TEXT NOT NULL,
95
- status TEXT NOT NULL,
96
- output_preview TEXT DEFAULT '',
97
- created_at TEXT DEFAULT CURRENT_TIMESTAMP,
98
- FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
99
- );
100
122
  """
101
123
  )
124
+ _migrate_user_profile_schema(db)
102
125
  except sqlite3.Error as exc:
103
126
  raise StorageError("Database lokal gagal diinisialisasi.") from exc
104
-
105
- def execute(self, sql: str, params: Iterable[object] = ()) -> None:
106
- try:
107
- with closing(self.connect()) as db:
108
- with db:
109
- db.execute(sql, tuple(params))
110
- except sqlite3.Error as exc:
111
- raise StorageError("Operasi database gagal.") from exc
112
-
127
+
128
+ def execute(self, sql: str, params: Iterable[object] = ()) -> None:
129
+ try:
130
+ with closing(self.connect()) as db:
131
+ with db:
132
+ db.execute(sql, tuple(params))
133
+ except sqlite3.Error as exc:
134
+ raise StorageError("Operasi database gagal.") from exc
135
+
113
136
  def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
114
137
  try:
115
138
  with closing(self.connect()) as db:
116
139
  return list(db.execute(sql, tuple(params)).fetchall())
117
140
  except sqlite3.Error as exc:
118
141
  raise StorageError("Query database gagal.") from exc
142
+
143
+
144
+ def _migrate_user_profile_schema(db: sqlite3.Connection) -> None:
145
+ """Normalize older user_profile schemas to the v0.2.2 canonical shape."""
146
+
147
+ columns = {str(row["name"]) for row in db.execute("PRAGMA table_info(user_profile)").fetchall()}
148
+ canonical = {"id", "name", "equity", "currency", "leverage", "years_in_investment", "gameplay", "updated_at"}
149
+ if canonical.issubset(columns):
150
+ return
151
+
152
+ rows = list(db.execute("SELECT * FROM user_profile").fetchall())
153
+ legacy_profile = _legacy_profile_payload(rows[0]) if rows else None
154
+ db.execute("DROP TABLE user_profile")
155
+ db.execute(
156
+ """
157
+ CREATE TABLE user_profile (
158
+ id INTEGER PRIMARY KEY CHECK (id = 1),
159
+ name TEXT NOT NULL,
160
+ equity REAL NOT NULL,
161
+ currency TEXT NOT NULL,
162
+ leverage TEXT NOT NULL,
163
+ years_in_investment REAL NOT NULL,
164
+ gameplay TEXT NOT NULL,
165
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
166
+ )
167
+ """
168
+ )
169
+ if legacy_profile is None:
170
+ return
171
+ db.execute(
172
+ """
173
+ INSERT INTO user_profile (id, name, equity, currency, leverage, years_in_investment, gameplay, updated_at)
174
+ VALUES (1, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
175
+ """,
176
+ legacy_profile,
177
+ )
178
+
179
+
180
+ def _legacy_profile_payload(row: sqlite3.Row) -> tuple[object, ...]:
181
+ keys = set(row.keys())
182
+ name = row["name"] if "name" in keys else "User"
183
+ equity = row["equity"] if "equity" in keys else row["equity_amount"] if "equity_amount" in keys else 0
184
+ currency = row["currency"] if "currency" in keys else row["equity_currency"] if "equity_currency" in keys else "USD"
185
+ leverage = row["leverage"] if "leverage" in keys else "1:1"
186
+ years = (
187
+ row["years_in_investment"]
188
+ if "years_in_investment" in keys
189
+ else row["experience_years"]
190
+ if "experience_years" in keys
191
+ else 0
192
+ )
193
+ gameplay = _normalize_legacy_gameplay(str(row["gameplay"])) if "gameplay" in keys else _classify_legacy_gameplay(float(equity))
194
+ return (name, equity, currency, leverage, years, gameplay)
195
+
196
+
197
+ def _normalize_legacy_gameplay(value: str) -> str:
198
+ normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
199
+ return {
200
+ "scalper": "Scalper",
201
+ "intra_day": "Intra day",
202
+ "intraday": "Intra day",
203
+ "day_trade": "Day trade",
204
+ "day_trader": "Day trade",
205
+ "swing": "Swing/Investor",
206
+ "investor": "Swing/Investor",
207
+ }.get(normalized, value.strip() or "Scalper")
208
+
209
+
210
+ def _classify_legacy_gameplay(equity: float) -> str:
211
+ if equity <= 400:
212
+ return "Scalper"
213
+ if equity <= 1000:
214
+ return "Intra day"
215
+ if equity <= 5000:
216
+ return "Day trade"
217
+ return "Swing/Investor"
@@ -12,9 +12,15 @@ from fincli.app.utils.errors import ConfigError
12
12
  SECRETS_FILE = APP_DIR / "secrets.env"
13
13
 
14
14
 
15
- def load_local_secrets(path: Path | None = None, *, override: bool = False) -> None:
15
+ def load_local_secrets(
16
+ path: Path | None = None,
17
+ *,
18
+ override: bool = False,
19
+ override_keys: set[str] | None = None,
20
+ ) -> None:
16
21
  """Load persisted secrets into process environment."""
17
22
  path = path or SECRETS_FILE
23
+ override_keys = override_keys or set()
18
24
  if not path.exists():
19
25
  return
20
26
  for line in path.read_text(encoding="utf-8").splitlines():
@@ -24,7 +30,7 @@ def load_local_secrets(path: Path | None = None, *, override: bool = False) -> N
24
30
  key, value = stripped.split("=", 1)
25
31
  key = key.strip()
26
32
  value = _unquote(value.strip())
27
- if key and (override or key not in os.environ or os.environ.get(key, "") == ""):
33
+ if key and (override or key in override_keys or key not in os.environ or os.environ.get(key, "") == ""):
28
34
  os.environ[key] = value
29
35
 
30
36