alphaengine 0.9.0__tar.gz → 0.9.1__tar.gz

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 (93) hide show
  1. {alphaengine-0.9.0 → alphaengine-0.9.1}/PKG-INFO +1 -1
  2. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/_version.py +5 -1
  3. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/risk.py +6 -1
  4. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/model.py +6 -7
  5. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/tui/app.py +181 -17
  6. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/tui/dispatch.py +49 -10
  7. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_tui.py +92 -0
  8. {alphaengine-0.9.0 → alphaengine-0.9.1}/.github/workflows/ci.yml +0 -0
  9. {alphaengine-0.9.0 → alphaengine-0.9.1}/.github/workflows/publish.yml +0 -0
  10. {alphaengine-0.9.0 → alphaengine-0.9.1}/.gitignore +0 -0
  11. {alphaengine-0.9.0 → alphaengine-0.9.1}/AGENTS.md +0 -0
  12. {alphaengine-0.9.0 → alphaengine-0.9.1}/LICENSE +0 -0
  13. {alphaengine-0.9.0 → alphaengine-0.9.1}/README.md +0 -0
  14. {alphaengine-0.9.0 → alphaengine-0.9.1}/SECURITY.md +0 -0
  15. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/assets/banner.png +0 -0
  16. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/assets/data_boundary.png +0 -0
  17. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/assets/session.png +0 -0
  18. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/figure_contract.md +0 -0
  19. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/workflows/evaluate_process.json +0 -0
  20. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/workflows/research_week.json +0 -0
  21. {alphaengine-0.9.0 → alphaengine-0.9.1}/docs/workflows/stress_study.json +0 -0
  22. {alphaengine-0.9.0 → alphaengine-0.9.1}/pyproject.toml +0 -0
  23. {alphaengine-0.9.0 → alphaengine-0.9.1}/scripts/gen_docs.py +0 -0
  24. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/__init__.py +0 -0
  25. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/__main__.py +0 -0
  26. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/agent/__init__.py +0 -0
  27. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/agent/answer.py +0 -0
  28. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/agent/driver.py +0 -0
  29. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/auth.py +0 -0
  30. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/book.py +0 -0
  31. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/charts.py +0 -0
  32. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/cli.py +0 -0
  33. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/client/__init__.py +0 -0
  34. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/client/agent.py +0 -0
  35. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/client/executor.py +0 -0
  36. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/client/session.py +0 -0
  37. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/commands.py +0 -0
  38. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/complete.py +0 -0
  39. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/connectors/__init__.py +0 -0
  40. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/__init__.py +0 -0
  41. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/active.py +0 -0
  42. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/allocate.py +0 -0
  43. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/backtest.py +0 -0
  44. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/covariance.py +0 -0
  45. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/cross_section.py +0 -0
  46. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/factors.py +0 -0
  47. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/pairs.py +0 -0
  48. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/panel.py +0 -0
  49. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/performance.py +0 -0
  50. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/process.py +0 -0
  51. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/profile.py +0 -0
  52. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/screen.py +0 -0
  53. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/series_shapes.py +0 -0
  54. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/signals.py +0 -0
  55. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/stress.py +0 -0
  56. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/technical.py +0 -0
  57. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/trace.py +0 -0
  58. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/validation.py +0 -0
  59. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/core/walkforward.py +0 -0
  60. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/demo.py +0 -0
  61. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/demo_book.py +0 -0
  62. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/demo_returns.py +0 -0
  63. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/demo_signal.py +0 -0
  64. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/demo_universe.py +0 -0
  65. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/events.py +0 -0
  66. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/loaders.py +0 -0
  67. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/py.typed +0 -0
  68. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/repl.py +0 -0
  69. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/study/__init__.py +0 -0
  70. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/study/report.py +0 -0
  71. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/study/schema.py +0 -0
  72. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/sweep/__init__.py +0 -0
  73. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/sweep/runner.py +0 -0
  74. {alphaengine-0.9.0 → alphaengine-0.9.1}/src/alphaengine/tui/__init__.py +0 -0
  75. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_agent.py +0 -0
  76. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_answer.py +0 -0
  77. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_book.py +0 -0
  78. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_cli.py +0 -0
  79. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_client.py +0 -0
  80. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_commands.py +0 -0
  81. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_events.py +0 -0
  82. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_executor_ops.py +0 -0
  83. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_goldens.py +0 -0
  84. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_loaders.py +0 -0
  85. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_math_audit.py +0 -0
  86. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_model.py +0 -0
  87. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_process.py +0 -0
  88. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_quant_maths.py +0 -0
  89. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_screen.py +0 -0
  90. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_signals.py +0 -0
  91. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_smoke.py +0 -0
  92. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_sweep.py +0 -0
  93. {alphaengine-0.9.0 → alphaengine-0.9.1}/tests/test_trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: alphaengine
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Validated research tooling for investment strategies: deflation, overfitting detection, and honest trial counts.
5
5
  Project-URL: Homepage, https://github.com/quantOSC/alphaengine
6
6
  Project-URL: Documentation, https://github.com/quantOSC/alphaengine#readme
@@ -76,4 +76,8 @@ is what a changed figure costs. The API may still move underneath it.
76
76
  # Close fills are unchanged. No golden in tests/test_goldens.py moves. The
77
77
  # same release makes the full-screen terminal the only session, and posts each
78
78
  # math line's series on `/traces` without putting those series inside figures.
79
- __version__ = "0.9.0"
79
+ #
80
+ # 0.9.1 IS THE SESSION, NOT THE MATHS. No computed value moved. A signed-in
81
+ # account loads its one stored universe from the portal. `enter model key`
82
+ # takes whichever provider key is pasted. The left pane lists the commands.
83
+ __version__ = "0.9.1"
@@ -129,7 +129,12 @@ def compute_var_cvar(
129
129
  record(
130
130
  "parametric_var",
131
131
  "z * std(r, ddof=1) * sqrt(horizon), z = normsinv(confidence), as a positive percent",
132
- inputs={"n_obs": int(n_obs), "confidence": confidence, "horizon_days": int(horizon_days), "z": round(z, 6)},
132
+ inputs={
133
+ "n_obs": int(n_obs),
134
+ "confidence": confidence,
135
+ "horizon_days": int(horizon_days),
136
+ "z": round(z, 6),
137
+ },
133
138
  series={"returns": arr},
134
139
  result=result["parametric"]["var_pct"],
135
140
  )
@@ -328,13 +328,12 @@ def build_think(
328
328
  )
329
329
 
330
330
  raise NoModelConfigured(
331
- "No model key found. The agentic path runs under YOUR account, so it "
332
- "needs your key in this shell:\n"
333
- " ANTHROPIC_API_KEY=... or OPENAI_API_KEY=...\n"
334
- " or GEMINI_API_KEY / GROQ_API_KEY / OPENROUTER_API_KEY /\n"
335
- " AZURE_OPENAI_API_KEY / ALPHAENGINE_API_KEY+ALPHAENGINE_BASE_URL\n"
336
- "Nothing here stores it, and the scripted path (`run <workflow>`) needs "
337
- "no model at all."
331
+ "No model key found. In the session, type `enter model key` and paste it.\n"
332
+ "Or set the key in this shell: ANTHROPIC_API_KEY, OPENAI_API_KEY, "
333
+ "GEMINI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, "
334
+ "AZURE_OPENAI_API_KEY, or ALPHAENGINE_API_KEY with ALPHAENGINE_BASE_URL.\n"
335
+ "Nothing here sends that key to QuantOS, and the scripted path "
336
+ "(`run <workflow>`) needs no model at all."
338
337
  )
339
338
 
340
339
 
@@ -16,6 +16,28 @@ from .dispatch import Desk, submit
16
16
 
17
17
  _ANSI = re.compile(r"\x1b\[[0-9;]*m")
18
18
 
19
+ _COMMANDS = (
20
+ "login",
21
+ "enter model key",
22
+ "load <name>",
23
+ "screen",
24
+ "diagnose",
25
+ "signal",
26
+ "validate",
27
+ "stress",
28
+ "overlap",
29
+ "size",
30
+ "monitor",
31
+ "run <name>",
32
+ "workflows",
33
+ "help",
34
+ "quit",
35
+ )
36
+ _URL_AFTER = {
37
+ "AZURE_OPENAI_API_KEY": "AZURE_OPENAI_ENDPOINT",
38
+ "ALPHAENGINE_API_KEY": "ALPHAENGINE_BASE_URL",
39
+ }
40
+
19
41
  _CSS = """
20
42
  Screen { background: #0b0d10; }
21
43
  #status {
@@ -27,12 +49,12 @@ Screen { background: #0b0d10; }
27
49
  }
28
50
  #body { height: 1fr; }
29
51
  #rail {
30
- width: 26;
31
- min-width: 26;
52
+ width: 28;
53
+ min-width: 28;
32
54
  background: #101318;
33
55
  color: #c5cdd6;
34
56
  padding: 1 1;
35
- overflow: hidden;
57
+ overflow: auto;
36
58
  }
37
59
  #transcript {
38
60
  width: 1fr;
@@ -106,6 +128,7 @@ class QuantOSApp(App[None]):
106
128
  keyed=keyed,
107
129
  )
108
130
  self._universes: list[str] = []
131
+ self._stored: set[str] = set()
109
132
  self._lines: list[dict[str, Any]] = []
110
133
  self.busy = False
111
134
  self.rail_text = ""
@@ -133,18 +156,11 @@ class QuantOSApp(App[None]):
133
156
  log = self.query_one("#transcript", RichLog)
134
157
  log.write("Ask in plain English.")
135
158
  log.write("A sentence picks a workflow. run <name> follows it exactly.")
159
+ log.write("login signs you in. enter model key pastes whichever key you use.")
136
160
  log.write("")
137
161
  log.write("help lists what you can type. quit leaves.")
138
162
  self.query_one(Input).focus()
139
- try:
140
- rows = self.desk.session.universes()
141
- except Exception: # noqa: BLE001 — the ladder still stands without the list
142
- rows = []
143
- self._universes = [
144
- str(row.get("name") or row.get("id") or "") for row in rows if isinstance(row, dict)
145
- ]
146
- self._universes = [name for name in self._universes if name]
147
- self._refresh_rail()
163
+ self._sync_account()
148
164
 
149
165
  def _refresh_rail(self) -> None:
150
166
  from ..model import available_models, keys_without_sdk
@@ -158,7 +174,7 @@ class QuantOSApp(App[None]):
158
174
  elif keyed and stranded:
159
175
  ask_note = "sdk missing"
160
176
  elif keyed:
161
- ask_note = "add a model key"
177
+ ask_note = "enter model key"
162
178
  else:
163
179
  ask_note = "sign in first"
164
180
 
@@ -170,7 +186,8 @@ class QuantOSApp(App[None]):
170
186
  else:
171
187
  loaded = "nothing"
172
188
 
173
- lines = [
189
+ lines = ["commands", *[f" {name}" for name in _COMMANDS], ""]
190
+ lines += [
174
191
  f"{on} the maths",
175
192
  " offline, always",
176
193
  "",
@@ -186,11 +203,13 @@ class QuantOSApp(App[None]):
186
203
  "universes",
187
204
  ]
188
205
  if self._universes:
189
- lines.extend(f" {_fit(name, 20)}" for name in self._universes[:12])
206
+ for name in self._universes[:12]:
207
+ mark = " stored" if name in self._stored else ""
208
+ lines.append(f" {_fit(name, 20 - len(mark))}{mark}")
190
209
  else:
191
210
  lines.append(" none in reach")
192
211
  if not keyed:
193
- lines += ["", "key quantos", "signs you in"]
212
+ lines += ["", "login", "signs you in"]
194
213
  self.rail_text = "\n".join(lines)
195
214
  self.query_one("#rail", Static).update(self.rail_text)
196
215
 
@@ -213,6 +232,12 @@ class QuantOSApp(App[None]):
213
232
  if self.desk.pending_secret:
214
233
  self._accept_secret(text)
215
234
  return
235
+ if self.desk.pending_provider:
236
+ self._accept_provider(text)
237
+ return
238
+ if self.desk.pending_url:
239
+ self._accept_url(text)
240
+ return
216
241
  self.busy = True
217
242
  self.query_one("#transcript", RichLog).write(f"> {text}")
218
243
 
@@ -231,6 +256,43 @@ class QuantOSApp(App[None]):
231
256
  if env is None or not value:
232
257
  log.write("nothing entered.")
233
258
  return
259
+ if env == "MODEL_KEY":
260
+ self._take_model_key(value, log, prompt)
261
+ return
262
+ self._store_credential(env, value, log, prompt)
263
+
264
+ def _take_model_key(self, value: str, log: RichLog, prompt: Input) -> None:
265
+ from .dispatch import env_for_model_key
266
+
267
+ found = env_for_model_key(value)
268
+ if found is None:
269
+ self.desk.held_key = value
270
+ self.desk.pending_provider = True
271
+ prompt.password = False
272
+ prompt.placeholder = "anthropic, openai, gemini, groq, openrouter, azure, gateway"
273
+ log.write("Which provider is this key for?")
274
+ return
275
+ self._store_credential(found, value, log, prompt)
276
+
277
+ def _accept_provider(self, text: str) -> None:
278
+ from ..cli import _KEY_PROMPTS
279
+
280
+ which = text.strip().lower()
281
+ prompt = self.query_one(Input)
282
+ log = self.query_one("#transcript", RichLog)
283
+ if which not in _KEY_PROMPTS or which == "quantos":
284
+ log.write("Try anthropic, openai, gemini, groq, openrouter, azure, or gateway.")
285
+ return
286
+ value = self.desk.held_key or ""
287
+ self.desk.held_key = None
288
+ self.desk.pending_provider = False
289
+ prompt.placeholder = "Ask in plain English, or run <workflow>"
290
+ if not value:
291
+ log.write("nothing entered.")
292
+ return
293
+ self._store_credential(_KEY_PROMPTS[which][0], value, log, prompt)
294
+
295
+ def _store_credential(self, env: str, value: str, log: RichLog, prompt: Input) -> None:
234
296
  os.environ[env] = value
235
297
  if env == "QUANTOS_API_KEY":
236
298
  from ..cli import _session
@@ -247,6 +309,96 @@ class QuantOSApp(App[None]):
247
309
  log.write(f"{env} accepted. stored at {path}")
248
310
  except (OSError, ValueError) as exc:
249
311
  log.write(f"{env} accepted for this process. ({exc})")
312
+ follow = _URL_AFTER.get(env)
313
+ if follow and not os.environ.get(follow):
314
+ self.desk.pending_url = follow
315
+ prompt.placeholder = f"paste {follow}"
316
+ log.write(f"Now paste {follow}. It is not hidden.")
317
+ elif env != "QUANTOS_API_KEY":
318
+ self._say_model_ready(log)
319
+ if env == "QUANTOS_API_KEY":
320
+ self._sync_account()
321
+ else:
322
+ self._refresh_rail()
323
+
324
+ def _accept_url(self, value: str) -> None:
325
+ env = self.desk.pending_url
326
+ self.desk.pending_url = None
327
+ prompt = self.query_one(Input)
328
+ prompt.placeholder = "Ask in plain English, or run <workflow>"
329
+ log = self.query_one("#transcript", RichLog)
330
+ if env is None or not value:
331
+ log.write("nothing entered.")
332
+ return
333
+ os.environ[env] = value
334
+ try:
335
+ from ..auth import save_key
336
+
337
+ save_key(env, value)
338
+ except (OSError, ValueError):
339
+ pass
340
+ log.write(f"{env} set.")
341
+ self._say_model_ready(log)
342
+ self._refresh_rail()
343
+
344
+ def _say_model_ready(self, log: RichLog) -> None:
345
+ from ..model import _SDK, available_models, keys_without_sdk
346
+
347
+ stranded = keys_without_sdk()
348
+ ready = available_models()
349
+ if stranded:
350
+ installs = " or ".join(f"pip install {_SDK[label]}" for label in stranded)
351
+ log.write(f"Key stored. The SDK is not installed yet: {installs}")
352
+ return
353
+ if ready:
354
+ label, model = ready[0]
355
+ log.write(f"Model ready: {label}/{model}. Ask in plain English.")
356
+
357
+ def _sync_account(self) -> None:
358
+ """Refresh portal universes. One stored book loads itself."""
359
+ from ..cli import _narrow_to_universe, _stored_universes, narration_to
360
+
361
+ notes: list[str] = []
362
+
363
+ def sink(chunk: str) -> None:
364
+ notes.append(chunk)
365
+
366
+ try:
367
+ rows = self.desk.session.universes()
368
+ except Exception: # noqa: BLE001 — offline or unsigned still boots
369
+ rows = []
370
+ self._stored = set()
371
+ names: list[str] = []
372
+ for row in rows:
373
+ if not isinstance(row, dict):
374
+ continue
375
+ name = str(row.get("name") or row.get("id") or "")
376
+ if not name:
377
+ continue
378
+ names.append(name)
379
+ if (row.get("definition") or {}).get("cache_id"):
380
+ self._stored.add(name)
381
+ self._universes = names
382
+
383
+ if self.desk.data is None and self.desk.keyed:
384
+ with narration_to(sink):
385
+ wanted, ready = _stored_universes(self.desk.session)
386
+ if wanted:
387
+ try:
388
+ self.desk.data = _narrow_to_universe(self.desk.session, wanted, None)
389
+ self.desk.loaded = f"universe:{wanted}"
390
+ except Exception as exc: # noqa: BLE001
391
+ notes.append(str(exc))
392
+ elif len(ready) > 1:
393
+ notes.append(
394
+ "Stored on your account: " + ", ".join(ready) + ". Type load <name> to use one."
395
+ )
396
+ log = self.query_one("#transcript", RichLog)
397
+ for note in notes:
398
+ plain = _plain(note)
399
+ if plain.strip():
400
+ self.transcript.append(plain)
401
+ log.write(plain)
250
402
  self._refresh_rail()
251
403
 
252
404
  def _run_line(self, text: str) -> None:
@@ -274,7 +426,19 @@ class QuantOSApp(App[None]):
274
426
  prompt = self.query_one(Input)
275
427
  prompt.password = True
276
428
  prompt.placeholder = "paste the key (hidden)"
277
- self._refresh_rail()
429
+ elif self.desk.pending_provider:
430
+ prompt = self.query_one(Input)
431
+ prompt.password = False
432
+ prompt.placeholder = "anthropic, openai, gemini, groq, openrouter, azure, gateway"
433
+ elif self.desk.pending_url:
434
+ prompt = self.query_one(Input)
435
+ prompt.password = False
436
+ prompt.placeholder = f"paste {self.desk.pending_url}"
437
+ if self.desk.account_dirty:
438
+ self.desk.account_dirty = False
439
+ self._sync_account()
440
+ else:
441
+ self._refresh_rail()
278
442
  self._load_lines()
279
443
  self.busy = False
280
444
  if quitting:
@@ -51,6 +51,10 @@ class Desk:
51
51
  think: Callable[[str], str] | None = None
52
52
  keyed: bool = False
53
53
  pending_secret: str | None = None
54
+ pending_url: str | None = None
55
+ pending_provider: bool = False
56
+ held_key: str | None = None
57
+ account_dirty: bool = False
54
58
  pinned_model: str | None = None
55
59
  book: Any = None
56
60
 
@@ -84,6 +88,11 @@ def submit(desk: Desk, line: str) -> bool:
84
88
  if line.startswith("/"):
85
89
  line = line[1:].lstrip()
86
90
 
91
+ if line.lower() == "enter model key":
92
+ desk.pending_secret = "MODEL_KEY"
93
+ say(dim("Paste the model key. It stays hidden. The provider is read from the key."))
94
+ return False
95
+
87
96
  verb, _, rest = line.partition(" ")
88
97
  if verb in QUESTION_VERBS and not rest:
89
98
  verb, rest = "run", QUESTION_VERBS[verb]
@@ -210,28 +219,58 @@ def _ns(desk: Desk, **extra: Any) -> Any:
210
219
  def _logout(desk: Desk) -> None:
211
220
  import os
212
221
 
222
+ from ..auth import MANAGED
213
223
  from ..cli import _session, cmd_logout
214
224
 
215
225
  cmd_logout(_ns(desk))
216
- for name in ("QUANTOS_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"):
226
+ for name in MANAGED:
217
227
  os.environ.pop(name, None)
218
228
  desk.session, desk.url = _session(getattr(desk.args, "url", None), getattr(desk.args, "key", None))
219
229
  desk.keyed = False
220
230
  desk.pending_secret = None
231
+ desk.pending_url = None
232
+ desk.pending_provider = False
233
+ desk.held_key = None
234
+ desk.pinned_model = None
235
+ if str(desk.loaded or "").startswith("universe:"):
236
+ desk.data = None
237
+ desk.backtest_fn = None
238
+ desk.loaded = None
239
+ desk.account_dirty = True
221
240
 
222
241
 
223
242
  def _key(desk: Desk, rest: str) -> None:
243
+ from ..cli import _KEY_PROMPTS
244
+
224
245
  which = (rest.split() or [""])[0].lower()
225
- names = {
226
- "quantos": "QUANTOS_API_KEY",
227
- "anthropic": "ANTHROPIC_API_KEY",
228
- "openai": "OPENAI_API_KEY",
229
- }
230
- if which not in names:
231
- say(dim("key quantos, key anthropic, or key openai. The next line is the key, and it stays hidden."))
246
+ if not which:
247
+ if not desk.keyed:
248
+ which = "quantos"
249
+ else:
250
+ say(dim("Already signed in. Type `enter model key` and paste it."))
251
+ return
252
+ if which not in _KEY_PROMPTS:
253
+ say(dim(f"Unknown {which!r}. Try: {', '.join(_KEY_PROMPTS)}"))
232
254
  return
233
- desk.pending_secret = names[which]
234
- say(dim(f"Paste the {which} key. It is hidden, then stored for this machine."))
255
+ env, what = _KEY_PROMPTS[which]
256
+ desk.pending_secret = env
257
+ say(dim(f"Paste the {which} key. {what} It stays hidden, then this machine keeps it."))
258
+
259
+
260
+ def env_for_model_key(value: str) -> str | None:
261
+ """Which env var this key belongs in, when the prefix says so."""
262
+ text = value.strip()
263
+ if text.startswith("sk-ant-"):
264
+ return "ANTHROPIC_API_KEY"
265
+ if text.startswith("sk-or-"):
266
+ return "OPENROUTER_API_KEY"
267
+ if text.startswith("gsk_"):
268
+ return "GROQ_API_KEY"
269
+ if text.startswith("AIza"):
270
+ return "GEMINI_API_KEY"
271
+ if text.startswith("sk-"):
272
+ return "OPENAI_API_KEY"
273
+ return None
235
274
 
236
275
 
237
276
  def _pin_model(desk: Desk, rest: str) -> None:
@@ -85,6 +85,8 @@ def test_boot_shows_the_ladder_and_a_stored_universe() -> None:
85
85
  app = QuantOSApp(session=_DeskSession(), url="fake://", data=_returns(), keyed=True, think=_think)
86
86
  async with app.run_test(size=(140, 42)) as pilot:
87
87
  await pilot.pause()
88
+ assert "enter model key" in app.rail_text
89
+ assert "load <name>" in app.rail_text
88
90
  assert "the maths" in app.rail_text
89
91
  assert "sp500" in app.rail_text
90
92
  prompt = app.query_one(Input)
@@ -112,6 +114,96 @@ def test_a_scripted_run_opens_the_sharpe_line_at_full_length() -> None:
112
114
  asyncio.run(body())
113
115
 
114
116
 
117
+ class _StoredBook(Session):
118
+ """One portal universe whose closes were stored with the upload."""
119
+
120
+ def __init__(self, names: list[str]) -> None:
121
+ super().__init__(base_url="fake://", api_key="ae_live_test")
122
+ self._names = names
123
+
124
+ def _get(self, path: str):
125
+ if path.endswith("/series"):
126
+ return {"prices": {"AAPL": [10.0, 11.0, 12.0, 13.0]}}
127
+ if path.endswith("/universes"):
128
+ return {
129
+ "universes": [
130
+ {
131
+ "id": f"u{i}",
132
+ "name": name,
133
+ "definition": {"cache_id": f"c{i}", "symbols": ["AAPL"]},
134
+ }
135
+ for i, name in enumerate(self._names)
136
+ ]
137
+ }
138
+ if path.endswith("/workflows"):
139
+ return {"workflows": []}
140
+ return {}
141
+
142
+ def _post(self, path, body):
143
+ return {}
144
+
145
+
146
+ def test_a_signed_in_account_loads_its_one_stored_universe() -> None:
147
+ async def body() -> None:
148
+ app = QuantOSApp(session=_StoredBook(["sp500"]), url="fake://", keyed=True, think=_think)
149
+ async with app.run_test(size=(140, 42)) as pilot:
150
+ await pilot.pause()
151
+ assert app.desk.loaded == "universe:sp500"
152
+ assert "AAPL" in app.desk.data
153
+ assert "stored" in app.rail_text
154
+ assert any("portal" in line for line in app.transcript)
155
+
156
+ asyncio.run(body())
157
+
158
+
159
+ def test_several_stored_universes_are_named_and_not_guessed() -> None:
160
+ async def body() -> None:
161
+ app = QuantOSApp(session=_StoredBook(["sp500", "ndx"]), url="fake://", keyed=True, think=_think)
162
+ async with app.run_test(size=(140, 42)) as pilot:
163
+ await pilot.pause()
164
+ assert app.desk.data is None
165
+ text = "\n".join(app.transcript)
166
+ assert "sp500" in text and "ndx" in text
167
+
168
+ asyncio.run(body())
169
+
170
+
171
+ def test_login_pastes_a_quantos_key_and_login_gemini_pastes_that_provider() -> None:
172
+ async def body() -> None:
173
+ app = QuantOSApp(session=_DeskSession(), url="fake://", keyed=False, think=_think)
174
+ async with app.run_test(size=(140, 42)) as pilot:
175
+ await pilot.pause()
176
+ field = app.query_one(Input)
177
+ field.focus()
178
+ field.value = "login"
179
+ await pilot.press("enter")
180
+ await _wait(pilot, app)
181
+ assert app.desk.pending_secret == "QUANTOS_API_KEY"
182
+ assert app.query_one(Input).password is True
183
+
184
+ app.desk.pending_secret = None
185
+ app.desk.keyed = True
186
+ app.query_one(Input).password = False
187
+ field.value = "enter model key"
188
+ await pilot.press("enter")
189
+ await _wait(pilot, app)
190
+ assert app.desk.pending_secret == "MODEL_KEY"
191
+ assert app.query_one(Input).password is True
192
+
193
+ asyncio.run(body())
194
+
195
+
196
+ def test_a_model_key_prefix_names_the_provider() -> None:
197
+ from alphaengine.tui.dispatch import env_for_model_key
198
+
199
+ assert env_for_model_key("sk-ant-abc") == "ANTHROPIC_API_KEY"
200
+ assert env_for_model_key("sk-or-abc") == "OPENROUTER_API_KEY"
201
+ assert env_for_model_key("gsk_abc") == "GROQ_API_KEY"
202
+ assert env_for_model_key("AIzaabc") == "GEMINI_API_KEY"
203
+ assert env_for_model_key("sk-abc") == "OPENAI_API_KEY"
204
+ assert env_for_model_key("not-a-known-prefix") is None
205
+
206
+
115
207
  def test_a_sentence_uses_the_fake_model_to_choose_a_workflow() -> None:
116
208
  async def body() -> None:
117
209
  app = QuantOSApp(session=_DeskSession(), url="fake://", data=_returns(), keyed=True, think=_think)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes