sciqnt 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.
- sciqnt-0.1.0.dist-info/METADATA +133 -0
- sciqnt-0.1.0.dist-info/RECORD +15 -0
- sciqnt-0.1.0.dist-info/WHEEL +5 -0
- sciqnt-0.1.0.dist-info/entry_points.txt +2 -0
- sciqnt-0.1.0.dist-info/licenses/LICENSE +21 -0
- sciqnt-0.1.0.dist-info/top_level.txt +4 -0
- sciqnt_cli.py +107 -0
- sq_config_ui/__init__.py +252 -0
- sq_platform/__init__.py +431 -0
- sq_platform/_cache.py +244 -0
- sq_platform/aggregated.py +2927 -0
- sq_platform/home.py +1154 -0
- sq_platform/insights.py +136 -0
- sq_platform/modules_cmd.py +272 -0
- sq_tui/__init__.py +1251 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sciqnt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: sciqnt — local-first, agent-native cross-asset portfolio tracker & data layer (CLI/TUI)
|
|
5
|
+
Author: sciqnt
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sciqnt/sciqnt
|
|
8
|
+
Project-URL: Source, https://github.com/sciqnt/sciqnt
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: sciqnt-schema<0.2,>=0.1
|
|
13
|
+
Requires-Dist: sciqnt-fmt<0.2,>=0.1
|
|
14
|
+
Requires-Dist: sciqnt-config<0.2,>=0.1
|
|
15
|
+
Requires-Dist: sciqnt-price-store<0.2,>=0.1
|
|
16
|
+
Requires-Dist: sciqnt-compute<0.2,>=0.1
|
|
17
|
+
Requires-Dist: sciqnt-performance<0.2,>=0.1
|
|
18
|
+
Requires-Dist: sciqnt-market-data<0.2,>=0.1
|
|
19
|
+
Requires-Dist: sciqnt-fx<0.2,>=0.1
|
|
20
|
+
Requires-Dist: sciqnt-secrets<0.2,>=0.1
|
|
21
|
+
Requires-Dist: sciqnt-analytics<0.2,>=0.1
|
|
22
|
+
Requires-Dist: sciqnt-aggregator<0.2,>=0.1
|
|
23
|
+
Requires-Dist: sciqnt-skills<0.2,>=0.1
|
|
24
|
+
Requires-Dist: sciqnt-agents<0.2,>=0.1
|
|
25
|
+
Requires-Dist: prompt-toolkit
|
|
26
|
+
Requires-Dist: questionary
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# sciqnt
|
|
30
|
+
|
|
31
|
+
**A local-first, agent-native portfolio tracker & cross-asset financial data
|
|
32
|
+
layer.** One canonical, point-in-time-correct schema for your positions,
|
|
33
|
+
transactions, cash and prices — fed by community connectors for each broker
|
|
34
|
+
/ exchange / data source, rendered in a fast TUI, and consumable by any AI
|
|
35
|
+
agent (Claude Code, Codex, …) through plain CLI + versioned JSON surfaces.
|
|
36
|
+
|
|
37
|
+
Deterministic code computes the numbers. Agents reason and explain. You own
|
|
38
|
+
every byte: your credentials live in your OS keyring, your data on your
|
|
39
|
+
disk — there is no server.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
net worth (EUR) │ daily
|
|
43
|
+
|
|
44
|
+
24,570 ┤ ⢀⡤⠖⠋⠹
|
|
45
|
+
│ ⣀⡤⣄⣤⣀⣀⣀⣀⣀ ⡤⠤⠤⠤⣄ ⣀⣀⣀⣀⡤⠞
|
|
46
|
+
22,019 ┤ ⢀⣠⢤⣰⠒⠃ ⠉ ⠈⠉⠛⠁ ⠈⠉⠁⠉⠈⠁
|
|
47
|
+
│ ⢀⣰⠒⠚⠉⠉⠓⠒⠋
|
|
48
|
+
│⣀ ⣀⢀⣀⣠⠤⠖⠋⠛⠉⠉
|
|
49
|
+
19,468 ┤⠈⠙⠒⢦⣀⡤⠤⣤⣄⣠⠴⠋⠉
|
|
50
|
+
╰────────────────────────────────────────────────────────────
|
|
51
|
+
2026-01-02 2026-06-12
|
|
52
|
+
|
|
53
|
+
total value 24,121.05 EUR TWR 13.70 %/yr
|
|
54
|
+
total P/L (lifetime) +3,340.95 EUR XIRR 9.79 %/yr
|
|
55
|
+
dividends (lifetime) +395.10 EUR max DD −14.5 %
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
*That's the built-in demo portfolio — synthetic and deterministic. sciqnt
|
|
59
|
+
runs in demo mode until you connect an account; no real finances appear in
|
|
60
|
+
any screenshot or doc, ever.*
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/sciqnt/sciqnt && cd sciqnt
|
|
66
|
+
python3 -m venv .venv && .venv/bin/pip install pydantic prompt-toolkit keyring
|
|
67
|
+
./bin/sciqnt install # adds `sciqnt` to your PATH
|
|
68
|
+
sciqnt # the interactive home (demo portfolio until you connect)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
PyPI packages (`uv tool install sciqnt`, `pip install sciqnt-degiro`, …) are
|
|
72
|
+
coming with the first tagged release. macOS note: use a Python built
|
|
73
|
+
against modern OpenSSL (e.g. Homebrew `python@3.13`) — the system Python's
|
|
74
|
+
LibreSSL is fragile against financial-API TLS.
|
|
75
|
+
|
|
76
|
+
## What you get
|
|
77
|
+
|
|
78
|
+
- **The TUI**: portfolio home with net-worth chart (1D…All ranges,
|
|
79
|
+
5-minute intraday), positions / exposure / income / news / flows /
|
|
80
|
+
history tabs, account drill-downs — everything keyboard-driven.
|
|
81
|
+
- **Honest money math**: `Decimal` end-to-end, FIFO/LIFO/AVG lots,
|
|
82
|
+
fees-inclusive cost basis, TWR (GIPS-style breaks), XIRR, max drawdown,
|
|
83
|
+
benchmark comparison — computed from your raw transaction history,
|
|
84
|
+
point-in-time-correct (`sciqnt --asof 2024-12-31`).
|
|
85
|
+
- **Connectors as self-contained bundles** (`modules/sq-*`): manifest +
|
|
86
|
+
agent-facing SKILL + a living quirks log (FINDINGS) + conformance tests.
|
|
87
|
+
Degiro (CSV + live), Robinhood, Kalshi, Polymarket, Yahoo, Tiingo, ECB
|
|
88
|
+
FX, SEC EDGAR, FIRDS, OpenFIGI, RSS news — and a scaffold + harness for
|
|
89
|
+
building your own.
|
|
90
|
+
- **Agent-native, both directions**: every view is reproducible from the
|
|
91
|
+
CLI (`sciqnt --help` maps it; `--json` gives versioned, Decimal-as-string
|
|
92
|
+
data — `sciqnt.portfolio/v1`, `sciqnt.history/v1`, …). Summon your coding
|
|
93
|
+
agent from any screen and it receives where you are, what's on your
|
|
94
|
+
screen, and the command that reproduces it; agents can leave findings on
|
|
95
|
+
your home screen (`sciqnt insight add`).
|
|
96
|
+
- **A point-in-time price archive**: append-only, bitemporal, yours.
|
|
97
|
+
|
|
98
|
+
## For agents
|
|
99
|
+
|
|
100
|
+
Run `sciqnt --help`. Every screen of the app has a CLI form; add `--json`
|
|
101
|
+
for structured data. Skills ship in-repo (`sq-portfolio`, `sq-connectors`)
|
|
102
|
+
and install into Claude Code / Codex automatically when summoned from the
|
|
103
|
+
app. **[`AGENTS.md`](AGENT_GUIDE.md) is the codebase map — start there if
|
|
104
|
+
you're an agent.**
|
|
105
|
+
|
|
106
|
+
## Build a connector for your broker
|
|
107
|
+
|
|
108
|
+
The platform ships the contract + conformance harness + a scaffold — the
|
|
109
|
+
long tail of connectors belongs to the community (and to your coding
|
|
110
|
+
agent). See [CONTRIBUTING.md](CONTRIBUTING.md); the short version:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
"build a sciqnt connector for <my broker>" # tell your coding agent
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Independent connector repos install with `sciqnt modules add owner/repo`
|
|
117
|
+
(conformance runs locally before first use — trust is earned by the
|
|
118
|
+
harness, not claimed).
|
|
119
|
+
|
|
120
|
+
## Going deeper
|
|
121
|
+
|
|
122
|
+
- [`FOUNDATION.md`](FOUNDATION.md) — the worldview + the 13 Founding Articles.
|
|
123
|
+
- [`PRINCIPLES.md`](PRINCIPLES.md) — the 18 operating principles.
|
|
124
|
+
- [`research/`](research/) — the grounded reasoning behind every decision.
|
|
125
|
+
|
|
126
|
+
Principles, the short list: local-first and sovereign — fire us and keep
|
|
127
|
+
everything. Deterministic core, probabilistic edge — LLMs never touch the
|
|
128
|
+
money math. Data first — rendered text is for humans, versioned JSON is the
|
|
129
|
+
contract. Read wide, execute gated. Synthetic fixtures only.
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
sciqnt_cli.py,sha256=NPRwf1ZXn7OY8KXShJ_ZBTGGNl0RzzvN5TUGFuR8nQo,4801
|
|
2
|
+
sciqnt-0.1.0.dist-info/licenses/LICENSE,sha256=rozsqhc3L9xo499FRoHjuzJHojz77OlAQzl_T7VKJPE,1069
|
|
3
|
+
sq_config_ui/__init__.py,sha256=kvd_d3J-UjqSTq3vL8BG-c1tTPsilDexfvG3GY0P1Ls,10515
|
|
4
|
+
sq_platform/__init__.py,sha256=e_PtFqX1K0kytqwaPNHwGJuDo9uN_ucWzTGbW3zwj_4,19188
|
|
5
|
+
sq_platform/_cache.py,sha256=6HLXzkYE-pgBzvnW7TwTt21xSu3Plb0_6kZsA8jWBvU,9041
|
|
6
|
+
sq_platform/aggregated.py,sha256=4xEk-Nfi-NHu04gXOEAYvLVaHL1HmFOL2L5QtR7Cf-c,130608
|
|
7
|
+
sq_platform/home.py,sha256=_x8923kIPyTIXtttQfJrZo9gNquX1fqBmN6Cen40ANM,55851
|
|
8
|
+
sq_platform/insights.py,sha256=stu65YZWhUCUw7Miy-eRECymYOtCCrNkgcvynqRZqe4,4798
|
|
9
|
+
sq_platform/modules_cmd.py,sha256=8XvFXoyDA2inpXsJzp46tpwWt90rJ4a5QyM5slfWXvY,11499
|
|
10
|
+
sq_tui/__init__.py,sha256=rNi1qmz-_55MjzAuZiaYTmwKpPl7xPFo-VB1uA_y7Bs,51015
|
|
11
|
+
sciqnt-0.1.0.dist-info/METADATA,sha256=xDvX2g-7yCGPca0zC_03gGjouCruOfIWeLzLNKgLIU8,5968
|
|
12
|
+
sciqnt-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
sciqnt-0.1.0.dist-info/entry_points.txt,sha256=uwhH-DEVGXRVCvSb-XlU3MZ_sjny7WzD2EQeSp_qpqY,43
|
|
14
|
+
sciqnt-0.1.0.dist-info/top_level.txt,sha256=ghRsqQpgT-FsNH8n2XxZanwpZVvZs7f_kvI4ft6hHeE,43
|
|
15
|
+
sciqnt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DavideGCosta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sciqnt_cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""sciqnt — the installed CLI front door.
|
|
2
|
+
|
|
3
|
+
The pip-installable entry point for the component-world structure: `pip install
|
|
4
|
+
sciqnt` brings the app (sq_platform + sq_tui + config-UI) plus its library deps
|
|
5
|
+
(pinned to the `sciqnt/sq-*` repos), and `sciqnt` launches it.
|
|
6
|
+
|
|
7
|
+
Unlike the in-repo `bin/sciqnt` launcher (which assumed the monorepo layout), this
|
|
8
|
+
entry is layout-free: the app's library deps are installed packages, and connectors
|
|
9
|
+
are discovered from the **user dir** (`~/.local/share/sciqnt/modules/`, populated by
|
|
10
|
+
`sciqnt modules add owner/repo`) — sovereign, no repo `modules/` needed.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, time, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _root() -> Path:
|
|
22
|
+
"""The app's data root. For an installed app there's no repo `modules/`, so the
|
|
23
|
+
root is the user data dir — `bundle_dirs(root)` then resolves to the same user
|
|
24
|
+
modules dir connectors are added into. Overridable via $SQ_ROOT."""
|
|
25
|
+
return Path(os.environ.get("SQ_ROOT", Path.home() / ".local" / "share" / "sciqnt"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _setup_bundles(root: Path) -> None:
|
|
29
|
+
"""Put every installed connector bundle's src/ on sys.path so `import sq_<broker>`
|
|
30
|
+
resolves inside the aggregation registry (mirrors bin/sciqnt-aggregated.py)."""
|
|
31
|
+
import sq_platform
|
|
32
|
+
for src in sq_platform.bundle_src_paths(root):
|
|
33
|
+
if src not in sys.path:
|
|
34
|
+
sys.path.insert(0, src)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_asof(s: str) -> datetime:
|
|
38
|
+
try:
|
|
39
|
+
return (datetime.fromisoformat(s).replace(tzinfo=timezone.utc) if "T" in s
|
|
40
|
+
else datetime.combine(datetime.fromisoformat(s).date(),
|
|
41
|
+
time(23, 59, 59), tzinfo=timezone.utc))
|
|
42
|
+
except ValueError as e:
|
|
43
|
+
raise argparse.ArgumentTypeError(
|
|
44
|
+
f"--asof must be YYYY-MM-DD or ISO timestamp; got {s!r}") from e
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv: list[str] | None = None) -> int:
|
|
48
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
49
|
+
root = _root()
|
|
50
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
# Sub-commands that route straight into the platform (parsed before the
|
|
53
|
+
# aggregated-view flags, exactly like the original bin/sciqnt dispatcher).
|
|
54
|
+
if argv and argv[0] == "modules":
|
|
55
|
+
import sq_platform # noqa: F401
|
|
56
|
+
from sq_platform import modules_cmd
|
|
57
|
+
_setup_bundles(root)
|
|
58
|
+
return modules_cmd.cli(root, argv[1:])
|
|
59
|
+
if argv and argv[0] == "--list":
|
|
60
|
+
import sq_platform
|
|
61
|
+
_setup_bundles(root)
|
|
62
|
+
return sq_platform.find_modules(root, " ".join(argv[1:]))
|
|
63
|
+
|
|
64
|
+
# Otherwise: the aggregated / interactive portfolio view.
|
|
65
|
+
_setup_bundles(root)
|
|
66
|
+
import sq_config
|
|
67
|
+
sq_config.materialise()
|
|
68
|
+
from sq_platform.aggregated import run_aggregated
|
|
69
|
+
from sq_platform.home import run_home
|
|
70
|
+
|
|
71
|
+
p = argparse.ArgumentParser(prog="sciqnt", description="sciqnt — portfolio view")
|
|
72
|
+
p.add_argument("--asof", type=_parse_asof, default=None,
|
|
73
|
+
help="as-of date (YYYY-MM-DD) for a PIT historical view")
|
|
74
|
+
p.add_argument("--fresh", action="store_true",
|
|
75
|
+
help="bypass the snapshot cache and force a live fetch")
|
|
76
|
+
p.add_argument("--once", action="store_true",
|
|
77
|
+
help="print the aggregated view once and exit (non-interactive dump)")
|
|
78
|
+
p.add_argument("--history", nargs="?", const="30", default=None, metavar="RANGE",
|
|
79
|
+
help="print portfolio state history and exit")
|
|
80
|
+
p.add_argument("--account", default=None, metavar="LABEL",
|
|
81
|
+
help="one account only, in its own base currency")
|
|
82
|
+
p.add_argument("--tab", default=None, metavar="NAME",
|
|
83
|
+
help="dump just one tab (summary/positions/exposure/news/flows/detailed)")
|
|
84
|
+
p.add_argument("--json", action="store_true", dest="as_json",
|
|
85
|
+
help="emit structured data instead of the rendered view")
|
|
86
|
+
args = p.parse_args(argv)
|
|
87
|
+
|
|
88
|
+
interactive = (sys.stdin.isatty() and sys.stdout.isatty()
|
|
89
|
+
and args.asof is None and not args.once
|
|
90
|
+
and args.history is None and args.account is None
|
|
91
|
+
and args.tab is None and not args.as_json)
|
|
92
|
+
try:
|
|
93
|
+
if interactive:
|
|
94
|
+
return run_home(root, use_snapshot_cache=not args.fresh)
|
|
95
|
+
if args.history is not None:
|
|
96
|
+
from sq_platform.aggregated import run_history
|
|
97
|
+
return run_history(root, args.history, account=args.account,
|
|
98
|
+
as_json=args.as_json, use_snapshot_cache=not args.fresh)
|
|
99
|
+
return run_aggregated(root, asof=args.asof, account=args.account, tab=args.tab,
|
|
100
|
+
as_json=args.as_json, use_snapshot_cache=not args.fresh)
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
print()
|
|
103
|
+
return 130
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
sq_config_ui/__init__.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""sq_config_ui — THE interactive settings screen for sciqnt.
|
|
2
|
+
|
|
3
|
+
One full-screen settings experience (sq_tui.select_screen on the alternate
|
|
4
|
+
screen), consistent with the rest of the app — NOT questionary (questionary
|
|
5
|
+
choices don't render ANSI, which is how the old picker leaked raw `^[[2m`
|
|
6
|
+
escapes into labels). Every entry point shares this loop:
|
|
7
|
+
|
|
8
|
+
* `sciqnt config` (bare) and `sciqnt config set` → set.py → run_settings()
|
|
9
|
+
* the home's Settings action → sq_platform.home calls
|
|
10
|
+
run_settings() in-process with the home chrome, so the experience is
|
|
11
|
+
identical from home and CLI.
|
|
12
|
+
|
|
13
|
+
The screen lists every setting from `sq_config.schema()`: key left, CURRENT
|
|
14
|
+
value right (accent bold); not-yet-wired (`mvp=False`) settings render dim
|
|
15
|
+
with a "(soon)" suffix. Per-setting help lives in the `?` overlay, not in the
|
|
16
|
+
rows. Selecting a row edits it:
|
|
17
|
+
enum → a second select_screen (current preselected, per-option descriptions),
|
|
18
|
+
bool → toggles immediately (no second screen),
|
|
19
|
+
str → text_input_screen prefilled with the current value (empty → cancel).
|
|
20
|
+
All writes go through `sq_config.set()` (schema-validated, atomic) — an
|
|
21
|
+
invalid value is never written. After an edit the screen re-renders with the
|
|
22
|
+
cursor kept on the same row. Esc/q returns.
|
|
23
|
+
|
|
24
|
+
Non-interactive callers must NOT enter this loop — `set.py` checks
|
|
25
|
+
`sq_tui._streams_interactive()` and prints the plain dump (`show.py`, the
|
|
26
|
+
script/agent-facing surface) instead.
|
|
27
|
+
"""
|
|
28
|
+
import textwrap
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import sq_config
|
|
32
|
+
import sq_tui
|
|
33
|
+
from sq_tui import BOLD, DIM, RST
|
|
34
|
+
|
|
35
|
+
# repo root: modules/sq-config/src/sq_config_ui/__init__.py → up 4 levels
|
|
36
|
+
_ROOT = Path(__file__).resolve().parents[4]
|
|
37
|
+
|
|
38
|
+
# Style for the value cell on a non-hovered, wired row (accent bold — matches
|
|
39
|
+
# the hover/selection accent everywhere else). Dim rows pass "" so the value
|
|
40
|
+
# inherits the row's dim base.
|
|
41
|
+
_VALUE_STYLE = f"fg:{sq_tui.ACCENT_HEX} bold"
|
|
42
|
+
|
|
43
|
+
# Per-option descriptions for enum settings whose schema help implies them.
|
|
44
|
+
# preferred_agent is handled dynamically (live install detection).
|
|
45
|
+
_ENUM_DESC = {
|
|
46
|
+
"cost_basis_method": {
|
|
47
|
+
"FIFO": "first in, first out",
|
|
48
|
+
"LIFO": "last in, first out",
|
|
49
|
+
"AVG": "average cost · ACB / Section-104 pool / Degiro BEP",
|
|
50
|
+
},
|
|
51
|
+
"performance_return_method": {
|
|
52
|
+
"TWR": "time-weighted — manager skill (GIPS default)",
|
|
53
|
+
"MWR": "money-weighted / XIRR — your personal cash-flow experience",
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_bool(v) -> bool:
|
|
59
|
+
"""Tolerant bool read for the toggle (true/false/yes/no/1/0/on/off…).
|
|
60
|
+
Anything unrecognised reads as False — the toggle then writes True, a
|
|
61
|
+
safe self-heal for a hand-mangled value. Writes still go through
|
|
62
|
+
`sq_config.set`, which is strict."""
|
|
63
|
+
if isinstance(v, bool):
|
|
64
|
+
return v
|
|
65
|
+
return str(v).strip().lower() in ("true", "1", "yes", "on")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def display_value(setting, value) -> str:
|
|
69
|
+
"""The row's value cell: bools as lowercase true/false (matching the JSON
|
|
70
|
+
on disk), None as an em-dash, everything else str()."""
|
|
71
|
+
if value is None:
|
|
72
|
+
return "—"
|
|
73
|
+
if setting.type == "bool":
|
|
74
|
+
return "true" if parse_bool(value) else "false"
|
|
75
|
+
return str(value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_settings_items(schema, data):
|
|
79
|
+
"""The settings rows for select_screen: (items, item_styles).
|
|
80
|
+
|
|
81
|
+
Each item is (fragments, key) — rich labels so the value cell can carry
|
|
82
|
+
its own style: key left, current value right in accent bold; mvp=False
|
|
83
|
+
rows get base style 'dim' plus a "(soon)" suffix (their value inherits
|
|
84
|
+
the dim base instead of the accent). Pure — unit-testable without a
|
|
85
|
+
terminal. Help text deliberately NOT in the row (it's in the ? overlay)."""
|
|
86
|
+
keyw = max(len(s.key) for s in schema)
|
|
87
|
+
vals = {s.key: display_value(s, data.get(s.key, s.default)) for s in schema}
|
|
88
|
+
valw = max(len(v) for v in vals.values())
|
|
89
|
+
items, styles = [], []
|
|
90
|
+
for s in schema:
|
|
91
|
+
frags = [("", f"{s.key:<{keyw}} "),
|
|
92
|
+
(_VALUE_STYLE if s.mvp else "", f"{vals[s.key]:>{valw}}")]
|
|
93
|
+
if not s.mvp:
|
|
94
|
+
frags.append(("", " (soon)"))
|
|
95
|
+
items.append((frags, s.key))
|
|
96
|
+
styles.append(None if s.mvp else "dim")
|
|
97
|
+
return items, styles
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def help_text(schema) -> str:
|
|
101
|
+
"""The `?` overlay: the keymap plus every setting's full help (the rows
|
|
102
|
+
stay data-only; the prose lives one keystroke away)."""
|
|
103
|
+
lines = [
|
|
104
|
+
f" {BOLD}Keys{RST}",
|
|
105
|
+
"",
|
|
106
|
+
" ↑ ↓ · j k move",
|
|
107
|
+
" enter edit (a true/false setting toggles immediately)",
|
|
108
|
+
" ? toggle this help",
|
|
109
|
+
" esc · q back",
|
|
110
|
+
"",
|
|
111
|
+
f" {BOLD}Settings{RST}",
|
|
112
|
+
"",
|
|
113
|
+
]
|
|
114
|
+
for s in schema:
|
|
115
|
+
soon = "" if s.mvp else f" {DIM}(soon — declared, not yet wired){RST}"
|
|
116
|
+
lines.append(f" {BOLD}{s.key}{RST}{soon}")
|
|
117
|
+
for ln in textwrap.wrap(s.help or "—", 72):
|
|
118
|
+
lines.append(f" {DIM}{ln}{RST}")
|
|
119
|
+
lines += ["", f" {DIM}config file: {sq_config.path()}{RST}"]
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _default_header(*crumbs) -> str:
|
|
124
|
+
"""Standalone-CLI chrome: the banner + a bold 'Menu › Settings[ › …]'
|
|
125
|
+
breadcrumb — the same level layout as the home (which passes its own
|
|
126
|
+
richer chrome via `make_header`)."""
|
|
127
|
+
menu = " Menu › Settings" + "".join(f" › {c}" for c in crumbs)
|
|
128
|
+
try: # banner needs sq_platform (core)
|
|
129
|
+
from sq_platform import banner_text
|
|
130
|
+
top = banner_text(_ROOT) + "\n\n"
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
top = ""
|
|
133
|
+
return f"{top}{BOLD}{menu}{RST}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _intro(s, cur) -> str:
|
|
137
|
+
"""Dim context block for an edit screen: the setting's help (wrapped) +
|
|
138
|
+
current/default, appended to the header (header is ANSI-rendered, unlike
|
|
139
|
+
row labels — this is where styled prose belongs)."""
|
|
140
|
+
lines = textwrap.wrap(s.help or "", 72)
|
|
141
|
+
lines.append(f"current: {display_value(s, cur)} · default: {s.default}")
|
|
142
|
+
return "\n\n" + "".join(f" {DIM}{ln}{RST}\n" for ln in lines)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _agent_descs(s):
|
|
146
|
+
"""Live per-option tags for preferred_agent — like picking a default
|
|
147
|
+
browser: what 'auto' resolves to, and which agents are actually
|
|
148
|
+
installed. Degrades to no descriptions if detection fails."""
|
|
149
|
+
try:
|
|
150
|
+
import sq_agents
|
|
151
|
+
installed = set(sq_agents.detect())
|
|
152
|
+
auto = sq_agents.resolve("auto")
|
|
153
|
+
hint = sq_agents.label(auto) if auto else "none installed"
|
|
154
|
+
return {v: (f"auto → {hint}" if v == "auto"
|
|
155
|
+
else "installed" if v in installed else "not installed")
|
|
156
|
+
for v in s.allowed}
|
|
157
|
+
except Exception: # noqa: BLE001
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def enum_options(s, cur):
|
|
162
|
+
"""The second-screen rows for an enum setting: value left, dim
|
|
163
|
+
description right (rich fragments — select_screen strips raw ANSI from
|
|
164
|
+
labels, so styling MUST be fragments, never escape codes), '(current)'
|
|
165
|
+
on the active value."""
|
|
166
|
+
descs = _agent_descs(s) if s.key == "preferred_agent" else \
|
|
167
|
+
_ENUM_DESC.get(s.key, {})
|
|
168
|
+
w = max(len(v) for v in s.allowed)
|
|
169
|
+
out = []
|
|
170
|
+
for v in s.allowed:
|
|
171
|
+
frags = [("", f"{v:<{w}}")]
|
|
172
|
+
d = descs.get(v, "")
|
|
173
|
+
if d:
|
|
174
|
+
frags.append(("class:dim", f" {d}"))
|
|
175
|
+
if v == cur:
|
|
176
|
+
frags.append(("class:dim", " (current)"))
|
|
177
|
+
out.append((frags, v))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _pick_enum(s, cur, header_for):
|
|
182
|
+
"""Enum edit: a second select_screen over the allowed values, the current
|
|
183
|
+
one preselected. Returns the picked value, or None on Esc."""
|
|
184
|
+
sel = sq_tui.select_screen(
|
|
185
|
+
enum_options(s, cur),
|
|
186
|
+
header=header_for(s.key) + _intro(s, cur),
|
|
187
|
+
selected=(s.allowed.index(cur) if cur in s.allowed else 0),
|
|
188
|
+
footer_hint="↑↓ move · enter set · esc cancel",
|
|
189
|
+
esc_result=sq_tui.BACK)
|
|
190
|
+
if sel in (sq_tui.BACK, sq_tui.QUIT):
|
|
191
|
+
return None
|
|
192
|
+
return sel
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _enter_text(s, cur, header_for):
|
|
196
|
+
"""Free-form edit: full-screen text input prefilled with the current
|
|
197
|
+
value. Empty (or Esc) → None = cancel, nothing written."""
|
|
198
|
+
raw = sq_tui.text_input_screen(
|
|
199
|
+
f"{s.key}:", header=header_for(s.key) + _intro(s, cur),
|
|
200
|
+
default="" if cur is None else str(cur),
|
|
201
|
+
footer_hint="enter save · esc cancel")
|
|
202
|
+
if raw == sq_tui.BACK or not str(raw).strip():
|
|
203
|
+
return None
|
|
204
|
+
return str(raw).strip()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _edit(s, header_for):
|
|
208
|
+
"""Dispatch one edit by setting type. All writes go through
|
|
209
|
+
`sq_config.set` (schema-validated); a ValueError is swallowed — the loop
|
|
210
|
+
re-renders unchanged rather than ever writing an invalid value."""
|
|
211
|
+
cur = sq_config.get(s.key)
|
|
212
|
+
if s.type == "bool":
|
|
213
|
+
new = not parse_bool(cur) # toggle immediately — no 2nd screen
|
|
214
|
+
elif s.type == "enum":
|
|
215
|
+
new = _pick_enum(s, cur, header_for)
|
|
216
|
+
else: # str / int → free-form text
|
|
217
|
+
new = _enter_text(s, cur, header_for)
|
|
218
|
+
if new is None:
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
sq_config.set(s.key, new)
|
|
222
|
+
except ValueError:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_settings(make_header=None):
|
|
227
|
+
"""The settings loop. `make_header(*crumbs)` builds the chrome above the
|
|
228
|
+
list (the home passes its `_static_chrome`; standalone CLI defaults to
|
|
229
|
+
banner + breadcrumb). Re-renders after every edit with the cursor kept on
|
|
230
|
+
the edited row (select_screen.last_index); Esc/q returns.
|
|
231
|
+
|
|
232
|
+
Off-TTY, select_screen itself degrades to the numbered fallback — but
|
|
233
|
+
entry points should route pipes to the plain dump instead (see set.py)."""
|
|
234
|
+
header_for = make_header or _default_header
|
|
235
|
+
sq_config.materialise() # file exists + carries every key
|
|
236
|
+
cursor = 0
|
|
237
|
+
while True:
|
|
238
|
+
schema = sq_config.schema()
|
|
239
|
+
items, styles = build_settings_items(schema, sq_config.all())
|
|
240
|
+
sel = sq_tui.select_screen(
|
|
241
|
+
items, header=header_for() + "\n",
|
|
242
|
+
item_styles=styles, selected=cursor,
|
|
243
|
+
footer_hint="↑↓ move · enter edit · ? help · esc back",
|
|
244
|
+
help_lines=help_text(schema), esc_result=sq_tui.BACK)
|
|
245
|
+
if sel in (sq_tui.BACK, sq_tui.QUIT):
|
|
246
|
+
return
|
|
247
|
+
li = getattr(sq_tui.select_screen, "last_index", 0)
|
|
248
|
+
if isinstance(li, int): # mocked select stubs may omit it
|
|
249
|
+
cursor = li
|
|
250
|
+
s = next((x for x in schema if x.key == sel), None)
|
|
251
|
+
if s is not None:
|
|
252
|
+
_edit(s, header_for)
|