@drico2008/fincli 0.3.1 → 0.4.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 +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -65
- package/package.json +7 -7
- package/pyproject.toml +1 -1
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
"""Local secret storage for globally installed FinCLI."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from fincli.app.storage.config_paths import APP_DIR
|
|
9
|
-
from fincli.app.utils.errors import ConfigError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
SECRETS_FILE = APP_DIR / "secrets.env"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def load_local_secrets(
|
|
16
|
-
path: Path | None = None,
|
|
17
|
-
*,
|
|
18
|
-
override: bool = False,
|
|
19
|
-
override_keys: set[str] | None = None,
|
|
20
|
-
) -> None:
|
|
21
|
-
"""Load persisted secrets into process environment."""
|
|
22
|
-
path = path or SECRETS_FILE
|
|
23
|
-
override_keys = override_keys or set()
|
|
24
|
-
if not path.exists():
|
|
25
|
-
return
|
|
26
|
-
for line in path.read_text(encoding="utf-8").splitlines():
|
|
27
|
-
stripped = line.strip()
|
|
28
|
-
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
29
|
-
continue
|
|
30
|
-
key, value = stripped.split("=", 1)
|
|
31
|
-
key = key.strip()
|
|
32
|
-
value = _unquote(value.strip())
|
|
33
|
-
if key and (override or key in override_keys or key not in os.environ or os.environ.get(key, "") == ""):
|
|
34
|
-
os.environ[key] = value
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def save_secret(env_key: str, value: str, path: Path | None = None) -> None:
|
|
38
|
-
"""Persist a secret locally and expose it to the current process."""
|
|
39
|
-
path = path or SECRETS_FILE
|
|
40
|
-
key = _validate_env_key(env_key)
|
|
41
|
-
secret = _sanitize_value(value)
|
|
42
|
-
if not secret:
|
|
43
|
-
raise ConfigError(f"Nilai {key} kosong.")
|
|
44
|
-
|
|
45
|
-
secrets = read_secrets(path)
|
|
46
|
-
secrets[key] = secret
|
|
47
|
-
|
|
48
|
-
try:
|
|
49
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
lines = ["# FinCLI local secrets. Do not commit or share this file."]
|
|
51
|
-
lines.extend(f"{item_key}={_quote(item_value)}" for item_key, item_value in sorted(secrets.items()))
|
|
52
|
-
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
53
|
-
try:
|
|
54
|
-
os.chmod(path, 0o600)
|
|
55
|
-
except OSError:
|
|
56
|
-
pass
|
|
57
|
-
except OSError as exc:
|
|
58
|
-
raise ConfigError("Secret lokal gagal disimpan.", f"Path: {path}") from exc
|
|
59
|
-
|
|
60
|
-
os.environ[key] = secret
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def clear_secrets(path: Path | None = None) -> int:
|
|
64
|
-
"""Clear persisted local secrets and remove them from the current process."""
|
|
65
|
-
path = path or SECRETS_FILE
|
|
66
|
-
secrets = read_secrets(path)
|
|
67
|
-
for key in secrets:
|
|
68
|
-
os.environ.pop(key, None)
|
|
69
|
-
try:
|
|
70
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
-
path.write_text("# FinCLI local secrets. Do not commit or share this file.\n", encoding="utf-8")
|
|
72
|
-
try:
|
|
73
|
-
os.chmod(path, 0o600)
|
|
74
|
-
except OSError:
|
|
75
|
-
pass
|
|
76
|
-
except OSError as exc:
|
|
77
|
-
raise ConfigError("Secret lokal gagal dibersihkan.", f"Path: {path}") from exc
|
|
78
|
-
return len(secrets)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def read_secrets(path: Path | None = None) -> dict[str, str]:
|
|
82
|
-
"""Read local secrets without printing or masking them."""
|
|
83
|
-
path = path or SECRETS_FILE
|
|
84
|
-
if not path.exists():
|
|
85
|
-
return {}
|
|
86
|
-
result: dict[str, str] = {}
|
|
87
|
-
for line in path.read_text(encoding="utf-8").splitlines():
|
|
88
|
-
stripped = line.strip()
|
|
89
|
-
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
90
|
-
continue
|
|
91
|
-
key, value = stripped.split("=", 1)
|
|
92
|
-
result[key.strip()] = _unquote(value.strip())
|
|
93
|
-
return result
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def secret_source(env_key: str, path: Path | None = None) -> str:
|
|
97
|
-
"""Return a display-safe source for a secret."""
|
|
98
|
-
path = path or SECRETS_FILE
|
|
99
|
-
current = os.getenv(env_key)
|
|
100
|
-
if not current:
|
|
101
|
-
return "-"
|
|
102
|
-
if env_key in os.environ:
|
|
103
|
-
if read_secrets(path).get(env_key) == current:
|
|
104
|
-
return "~/.fincli/secrets.env"
|
|
105
|
-
return "environment/.env"
|
|
106
|
-
return "-"
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _validate_env_key(env_key: str) -> str:
|
|
110
|
-
key = env_key.strip().upper()
|
|
111
|
-
if not key or not all(char.isalnum() or char == "_" for char in key):
|
|
112
|
-
raise ConfigError(f"Nama environment key tidak valid: {env_key}")
|
|
113
|
-
return key
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _sanitize_value(value: str) -> str:
|
|
117
|
-
return value.strip().replace("\r", "").replace("\n", "")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _quote(value: str) -> str:
|
|
121
|
-
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
122
|
-
return f'"{escaped}"'
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _unquote(value: str) -> str:
|
|
126
|
-
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
127
|
-
return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
|
128
|
-
return value
|
|
1
|
+
"""Local secret storage for globally installed FinCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fincli.app.storage.config_paths import APP_DIR
|
|
9
|
+
from fincli.app.utils.errors import ConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SECRETS_FILE = APP_DIR / "secrets.env"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_local_secrets(
|
|
16
|
+
path: Path | None = None,
|
|
17
|
+
*,
|
|
18
|
+
override: bool = False,
|
|
19
|
+
override_keys: set[str] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Load persisted secrets into process environment."""
|
|
22
|
+
path = path or SECRETS_FILE
|
|
23
|
+
override_keys = override_keys or set()
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return
|
|
26
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
27
|
+
stripped = line.strip()
|
|
28
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
29
|
+
continue
|
|
30
|
+
key, value = stripped.split("=", 1)
|
|
31
|
+
key = key.strip()
|
|
32
|
+
value = _unquote(value.strip())
|
|
33
|
+
if key and (override or key in override_keys or key not in os.environ or os.environ.get(key, "") == ""):
|
|
34
|
+
os.environ[key] = value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_secret(env_key: str, value: str, path: Path | None = None) -> None:
|
|
38
|
+
"""Persist a secret locally and expose it to the current process."""
|
|
39
|
+
path = path or SECRETS_FILE
|
|
40
|
+
key = _validate_env_key(env_key)
|
|
41
|
+
secret = _sanitize_value(value)
|
|
42
|
+
if not secret:
|
|
43
|
+
raise ConfigError(f"Nilai {key} kosong.")
|
|
44
|
+
|
|
45
|
+
secrets = read_secrets(path)
|
|
46
|
+
secrets[key] = secret
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
lines = ["# FinCLI local secrets. Do not commit or share this file."]
|
|
51
|
+
lines.extend(f"{item_key}={_quote(item_value)}" for item_key, item_value in sorted(secrets.items()))
|
|
52
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
53
|
+
try:
|
|
54
|
+
os.chmod(path, 0o600)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise ConfigError("Secret lokal gagal disimpan.", f"Path: {path}") from exc
|
|
59
|
+
|
|
60
|
+
os.environ[key] = secret
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def clear_secrets(path: Path | None = None) -> int:
|
|
64
|
+
"""Clear persisted local secrets and remove them from the current process."""
|
|
65
|
+
path = path or SECRETS_FILE
|
|
66
|
+
secrets = read_secrets(path)
|
|
67
|
+
for key in secrets:
|
|
68
|
+
os.environ.pop(key, None)
|
|
69
|
+
try:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text("# FinCLI local secrets. Do not commit or share this file.\n", encoding="utf-8")
|
|
72
|
+
try:
|
|
73
|
+
os.chmod(path, 0o600)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise ConfigError("Secret lokal gagal dibersihkan.", f"Path: {path}") from exc
|
|
78
|
+
return len(secrets)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_secrets(path: Path | None = None) -> dict[str, str]:
|
|
82
|
+
"""Read local secrets without printing or masking them."""
|
|
83
|
+
path = path or SECRETS_FILE
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return {}
|
|
86
|
+
result: dict[str, str] = {}
|
|
87
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
88
|
+
stripped = line.strip()
|
|
89
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
90
|
+
continue
|
|
91
|
+
key, value = stripped.split("=", 1)
|
|
92
|
+
result[key.strip()] = _unquote(value.strip())
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def secret_source(env_key: str, path: Path | None = None) -> str:
|
|
97
|
+
"""Return a display-safe source for a secret."""
|
|
98
|
+
path = path or SECRETS_FILE
|
|
99
|
+
current = os.getenv(env_key)
|
|
100
|
+
if not current:
|
|
101
|
+
return "-"
|
|
102
|
+
if env_key in os.environ:
|
|
103
|
+
if read_secrets(path).get(env_key) == current:
|
|
104
|
+
return "~/.fincli/secrets.env"
|
|
105
|
+
return "environment/.env"
|
|
106
|
+
return "-"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_env_key(env_key: str) -> str:
|
|
110
|
+
key = env_key.strip().upper()
|
|
111
|
+
if not key or not all(char.isalnum() or char == "_" for char in key):
|
|
112
|
+
raise ConfigError(f"Nama environment key tidak valid: {env_key}")
|
|
113
|
+
return key
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sanitize_value(value: str) -> str:
|
|
117
|
+
return value.strip().replace("\r", "").replace("\n", "")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _quote(value: str) -> str:
|
|
121
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
122
|
+
return f'"{escaped}"'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _unquote(value: str) -> str:
|
|
126
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
127
|
+
return value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
|
128
|
+
return value
|
package/npm/bin/fincli.js
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const { spawn, spawnSync } = require("child_process");
|
|
6
|
-
|
|
7
|
-
const packageRoot = path.resolve(__dirname, "..", "..");
|
|
8
|
-
const packageJson = require(path.join(packageRoot, "package.json"));
|
|
9
|
-
const venvDir = path.join(packageRoot, ".npm-python");
|
|
10
|
-
const pythonBin = process.platform === "win32"
|
|
11
|
-
? path.join(venvDir, "Scripts", "python.exe")
|
|
12
|
-
: path.join(venvDir, "bin", "python");
|
|
13
|
-
|
|
14
|
-
function run() {
|
|
15
|
-
const args = process.argv.slice(2);
|
|
16
|
-
if (args.includes("--version") || args.includes("-v")) {
|
|
17
|
-
console.log(packageJson.version);
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (!fs.existsSync(pythonBin)) {
|
|
22
|
-
console.error("FinCLI Python runtime is missing.");
|
|
23
|
-
console.error("Try reinstalling with: npm install -g @drico2008/fincli");
|
|
24
|
-
console.error("Python 3.11+ must be available during npm install.");
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
ensurePythonRuntime();
|
|
29
|
-
|
|
30
|
-
const child = spawn(pythonBin, ["-m", "fincli.app.main", ...args], {
|
|
31
|
-
cwd: packageRoot,
|
|
32
|
-
stdio: "inherit"
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
child.on("exit", (code, signal) => {
|
|
36
|
-
if (signal) {
|
|
37
|
-
process.kill(process.pid, signal);
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
process.exit(code ?? 0);
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function ensurePythonRuntime() {
|
|
45
|
-
const probe = spawnSync(pythonBin, ["-c", "import textual, rich, httpx, pydantic, yfinance, pandas, numpy"], {
|
|
46
|
-
cwd: packageRoot,
|
|
47
|
-
stdio: "ignore"
|
|
48
|
-
});
|
|
49
|
-
if (probe.status === 0) {
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
console.error("FinCLI Python dependencies are incomplete. Repairing local npm runtime...");
|
|
54
|
-
const repair = spawnSync(pythonBin, ["-m", "pip", "install", "."], {
|
|
55
|
-
cwd: packageRoot,
|
|
56
|
-
stdio: "inherit"
|
|
57
|
-
});
|
|
58
|
-
if (repair.status !== 0) {
|
|
59
|
-
console.error("FinCLI runtime repair failed.");
|
|
60
|
-
console.error("Try reinstalling with: npm install -g @drico2008/fincli");
|
|
61
|
-
process.exit(repair.status ?? 1);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
run();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { spawn, spawnSync } = require("child_process");
|
|
6
|
+
|
|
7
|
+
const packageRoot = path.resolve(__dirname, "..", "..");
|
|
8
|
+
const packageJson = require(path.join(packageRoot, "package.json"));
|
|
9
|
+
const venvDir = path.join(packageRoot, ".npm-python");
|
|
10
|
+
const pythonBin = process.platform === "win32"
|
|
11
|
+
? path.join(venvDir, "Scripts", "python.exe")
|
|
12
|
+
: path.join(venvDir, "bin", "python");
|
|
13
|
+
|
|
14
|
+
function run() {
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
17
|
+
console.log(packageJson.version);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(pythonBin)) {
|
|
22
|
+
console.error("FinCLI Python runtime is missing.");
|
|
23
|
+
console.error("Try reinstalling with: npm install -g @drico2008/fincli");
|
|
24
|
+
console.error("Python 3.11+ must be available during npm install.");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ensurePythonRuntime();
|
|
29
|
+
|
|
30
|
+
const child = spawn(pythonBin, ["-m", "fincli.app.main", ...args], {
|
|
31
|
+
cwd: packageRoot,
|
|
32
|
+
stdio: "inherit"
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
child.on("exit", (code, signal) => {
|
|
36
|
+
if (signal) {
|
|
37
|
+
process.kill(process.pid, signal);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
process.exit(code ?? 0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensurePythonRuntime() {
|
|
45
|
+
const probe = spawnSync(pythonBin, ["-c", "import textual, rich, httpx, pydantic, yfinance, pandas, numpy"], {
|
|
46
|
+
cwd: packageRoot,
|
|
47
|
+
stdio: "ignore"
|
|
48
|
+
});
|
|
49
|
+
if (probe.status === 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.error("FinCLI Python dependencies are incomplete. Repairing local npm runtime...");
|
|
54
|
+
const repair = spawnSync(pythonBin, ["-m", "pip", "install", "."], {
|
|
55
|
+
cwd: packageRoot,
|
|
56
|
+
stdio: "inherit"
|
|
57
|
+
});
|
|
58
|
+
if (repair.status !== 0) {
|
|
59
|
+
console.error("FinCLI runtime repair failed.");
|
|
60
|
+
console.error("Try reinstalling with: npm install -g @drico2008/fincli");
|
|
61
|
+
process.exit(repair.status ?? 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
run();
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drico2008/fincli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Modern financial CLI/TUI terminal for market monitoring and analysis.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
7
|
"fincli": "npm/bin/fincli.js"
|
|
8
8
|
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"postinstall": "node npm/postinstall.js",
|
|
11
|
-
"check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js",
|
|
12
|
-
"prepublish:safety": "python scripts/prepublish_check.py",
|
|
13
|
-
"prepublishOnly": "python scripts/prepublish_check.py"
|
|
14
|
-
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"postinstall": "node npm/postinstall.js",
|
|
11
|
+
"check": "node --check npm/bin/fincli.js && node --check npm/postinstall.js",
|
|
12
|
+
"prepublish:safety": "python scripts/prepublish_check.py",
|
|
13
|
+
"prepublishOnly": "python scripts/prepublish_check.py"
|
|
14
|
+
},
|
|
15
15
|
"files": [
|
|
16
16
|
"fincli/**/*.py",
|
|
17
17
|
"npm",
|
package/pyproject.toml
CHANGED