seiche 0.7.0__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 (110) hide show
  1. seiche-0.7.0/PKG-INFO +48 -0
  2. seiche-0.7.0/README.md +25 -0
  3. seiche-0.7.0/pyproject.toml +41 -0
  4. seiche-0.7.0/seiche/__init__.py +0 -0
  5. seiche-0.7.0/seiche/accounts.py +163 -0
  6. seiche-0.7.0/seiche/ai.py +267 -0
  7. seiche-0.7.0/seiche/alerts.py +351 -0
  8. seiche-0.7.0/seiche/api.py +794 -0
  9. seiche-0.7.0/seiche/assemble.py +1172 -0
  10. seiche-0.7.0/seiche/badge.py +62 -0
  11. seiche-0.7.0/seiche/brief.py +197 -0
  12. seiche-0.7.0/seiche/cli.py +681 -0
  13. seiche-0.7.0/seiche/config.py +927 -0
  14. seiche-0.7.0/seiche/dispatch_daily.py +500 -0
  15. seiche-0.7.0/seiche/dispatch_pages.py +530 -0
  16. seiche-0.7.0/seiche/engines/__init__.py +0 -0
  17. seiche-0.7.0/seiche/engines/auctions.py +85 -0
  18. seiche-0.7.0/seiche/engines/backtest.py +362 -0
  19. seiche-0.7.0/seiche/engines/basins.py +231 -0
  20. seiche-0.7.0/seiche/engines/bathymetry.py +405 -0
  21. seiche-0.7.0/seiche/engines/book.py +463 -0
  22. seiche-0.7.0/seiche/engines/breakwater.py +134 -0
  23. seiche-0.7.0/seiche/engines/communique.py +108 -0
  24. seiche-0.7.0/seiche/engines/composite.py +73 -0
  25. seiche-0.7.0/seiche/engines/echo.py +76 -0
  26. seiche-0.7.0/seiche/engines/farbasin.py +95 -0
  27. seiche-0.7.0/seiche/engines/gyre.py +482 -0
  28. seiche-0.7.0/seiche/engines/history.py +146 -0
  29. seiche-0.7.0/seiche/engines/hydrophone.py +136 -0
  30. seiche-0.7.0/seiche/engines/kink.py +118 -0
  31. seiche-0.7.0/seiche/engines/leakaudit.py +239 -0
  32. seiche-0.7.0/seiche/engines/market.py +112 -0
  33. seiche-0.7.0/seiche/engines/markov.py +103 -0
  34. seiche-0.7.0/seiche/engines/merian.py +348 -0
  35. seiche-0.7.0/seiche/engines/microseism.py +389 -0
  36. seiche-0.7.0/seiche/engines/mlpred.py +375 -0
  37. seiche-0.7.0/seiche/engines/montecarlo.py +84 -0
  38. seiche-0.7.0/seiche/engines/moorings.py +147 -0
  39. seiche-0.7.0/seiche/engines/navigator.py +189 -0
  40. seiche-0.7.0/seiche/engines/oujump.py +113 -0
  41. seiche-0.7.0/seiche/engines/playbook.py +113 -0
  42. seiche-0.7.0/seiche/engines/regatta.py +195 -0
  43. seiche-0.7.0/seiche/engines/resonance.py +221 -0
  44. seiche-0.7.0/seiche/engines/riptide.py +255 -0
  45. seiche-0.7.0/seiche/engines/roguewave.py +370 -0
  46. seiche-0.7.0/seiche/engines/rvxray.py +157 -0
  47. seiche-0.7.0/seiche/engines/searoom.py +183 -0
  48. seiche-0.7.0/seiche/engines/seastate.py +263 -0
  49. seiche-0.7.0/seiche/engines/sonar.py +68 -0
  50. seiche-0.7.0/seiche/engines/stacker.py +301 -0
  51. seiche-0.7.0/seiche/engines/stationkeeping.py +167 -0
  52. seiche-0.7.0/seiche/engines/swell.py +388 -0
  53. seiche-0.7.0/seiche/engines/tails.py +88 -0
  54. seiche-0.7.0/seiche/engines/thermohaline.py +155 -0
  55. seiche-0.7.0/seiche/engines/tidetables.py +333 -0
  56. seiche-0.7.0/seiche/engines/turn.py +226 -0
  57. seiche-0.7.0/seiche/engines/undertow.py +271 -0
  58. seiche-0.7.0/seiche/engines/warehouse.py +79 -0
  59. seiche-0.7.0/seiche/engines/weather.py +209 -0
  60. seiche-0.7.0/seiche/engines/wrecks.py +160 -0
  61. seiche-0.7.0/seiche/mailer.py +59 -0
  62. seiche-0.7.0/seiche/mcp_server.py +702 -0
  63. seiche-0.7.0/seiche/notary.py +244 -0
  64. seiche-0.7.0/seiche/provisioning.py +183 -0
  65. seiche-0.7.0/seiche/public_view.py +62 -0
  66. seiche-0.7.0/seiche/publisher.py +65 -0
  67. seiche-0.7.0/seiche/sources/__init__.py +0 -0
  68. seiche-0.7.0/seiche/sources/base.py +79 -0
  69. seiche-0.7.0/seiche/sources/bis.py +96 -0
  70. seiche-0.7.0/seiche/sources/cftc.py +101 -0
  71. seiche-0.7.0/seiche/sources/crypto.py +167 -0
  72. seiche-0.7.0/seiche/sources/ecb.py +71 -0
  73. seiche-0.7.0/seiche/sources/fedtext.py +85 -0
  74. seiche-0.7.0/seiche/sources/fiscaldata.py +131 -0
  75. seiche-0.7.0/seiche/sources/fred.py +102 -0
  76. seiche-0.7.0/seiche/sources/nyfed.py +171 -0
  77. seiche-0.7.0/seiche/sources/ofr.py +65 -0
  78. seiche-0.7.0/seiche/sources/palimpsest.py +138 -0
  79. seiche-0.7.0/seiche/store.py +115 -0
  80. seiche-0.7.0/seiche/usage.py +104 -0
  81. seiche-0.7.0/seiche/x402.py +176 -0
  82. seiche-0.7.0/seiche.egg-info/PKG-INFO +48 -0
  83. seiche-0.7.0/seiche.egg-info/SOURCES.txt +108 -0
  84. seiche-0.7.0/seiche.egg-info/dependency_links.txt +1 -0
  85. seiche-0.7.0/seiche.egg-info/entry_points.txt +3 -0
  86. seiche-0.7.0/seiche.egg-info/requires.txt +21 -0
  87. seiche-0.7.0/seiche.egg-info/top_level.txt +1 -0
  88. seiche-0.7.0/setup.cfg +4 -0
  89. seiche-0.7.0/tests/test_accounts.py +141 -0
  90. seiche-0.7.0/tests/test_api_caching.py +149 -0
  91. seiche-0.7.0/tests/test_backtest_split.py +64 -0
  92. seiche-0.7.0/tests/test_badge.py +65 -0
  93. seiche-0.7.0/tests/test_book.py +256 -0
  94. seiche-0.7.0/tests/test_dispatch_daily.py +100 -0
  95. seiche-0.7.0/tests/test_dispatch_pages.py +132 -0
  96. seiche-0.7.0/tests/test_engines.py +1095 -0
  97. seiche-0.7.0/tests/test_gauge.py +40 -0
  98. seiche-0.7.0/tests/test_gyre.py +131 -0
  99. seiche-0.7.0/tests/test_leakaudit.py +67 -0
  100. seiche-0.7.0/tests/test_mcp_http.py +180 -0
  101. seiche-0.7.0/tests/test_mcp_server.py +188 -0
  102. seiche-0.7.0/tests/test_merian.py +126 -0
  103. seiche-0.7.0/tests/test_microseism.py +153 -0
  104. seiche-0.7.0/tests/test_notary.py +128 -0
  105. seiche-0.7.0/tests/test_provisioning.py +141 -0
  106. seiche-0.7.0/tests/test_roguewave.py +219 -0
  107. seiche-0.7.0/tests/test_scenarios.py +107 -0
  108. seiche-0.7.0/tests/test_tier1.py +221 -0
  109. seiche-0.7.0/tests/test_wrecks.py +91 -0
  110. seiche-0.7.0/tests/test_x402.py +186 -0
seiche-0.7.0/PKG-INFO ADDED
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: seiche
3
+ Version: 0.7.0
4
+ Summary: Funding-stress and leveraged-positioning early-warning terminal (money market + capital market), built on free keyless public APIs.
5
+ License-Expression: AGPL-3.0-or-later
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: uvicorn>=0.30
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: numpy>=1.26
12
+ Requires-Dist: pandas>=2.2
13
+ Requires-Dist: scikit-learn>=1.4
14
+ Requires-Dist: scipy>=1.11
15
+ Requires-Dist: arch>=7
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == "dev"
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
19
+ Requires-Dist: pytest-memray>=1.7; sys_platform != "win32" and extra == "dev"
20
+ Requires-Dist: pytest-pystack>=1.0; sys_platform == "linux" and extra == "dev"
21
+ Provides-Extra: notary
22
+ Requires-Dist: opentimestamps>=0.4; extra == "notary"
23
+
24
+ # Seiche
25
+
26
+ Funding-stress early warning for US money markets, built entirely from free,
27
+ keyless public data (Fed H.4.1, NY Fed operations, OFR repo, Treasury cash). It
28
+ reads the plumbing so you don't have to: one stress board, honest backtests,
29
+ published misses, updated twice a day.
30
+
31
+ Full project, the terminal UI, and deployment: https://github.com/beepboop2025/seiche
32
+ Live: https://seiche.info
33
+
34
+ ## As an agent tool (MCP)
35
+
36
+ Seiche is a Model Context Protocol server. Any MCP-capable agent can read the
37
+ live board as tools — the current stress regime, forward event odds, historical
38
+ analogs, and the honest backtest.
39
+
40
+ ```bash
41
+ pip install seiche
42
+ seiche-mcp # stdio MCP server
43
+ ```
44
+
45
+ Or connect to the hosted, metered endpoint at `https://api.seiche.info/mcp`.
46
+ See [docs/MCP.md](https://github.com/beepboop2025/seiche/blob/main/docs/MCP.md).
47
+
48
+ mcp-name: io.github.beepboop2025/seiche
seiche-0.7.0/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # Seiche
2
+
3
+ Funding-stress early warning for US money markets, built entirely from free,
4
+ keyless public data (Fed H.4.1, NY Fed operations, OFR repo, Treasury cash). It
5
+ reads the plumbing so you don't have to: one stress board, honest backtests,
6
+ published misses, updated twice a day.
7
+
8
+ Full project, the terminal UI, and deployment: https://github.com/beepboop2025/seiche
9
+ Live: https://seiche.info
10
+
11
+ ## As an agent tool (MCP)
12
+
13
+ Seiche is a Model Context Protocol server. Any MCP-capable agent can read the
14
+ live board as tools — the current stress regime, forward event odds, historical
15
+ analogs, and the honest backtest.
16
+
17
+ ```bash
18
+ pip install seiche
19
+ seiche-mcp # stdio MCP server
20
+ ```
21
+
22
+ Or connect to the hosted, metered endpoint at `https://api.seiche.info/mcp`.
23
+ See [docs/MCP.md](https://github.com/beepboop2025/seiche/blob/main/docs/MCP.md).
24
+
25
+ mcp-name: io.github.beepboop2025/seiche
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "seiche"
3
+ version = "0.7.0"
4
+ description = "Funding-stress and leveraged-positioning early-warning terminal (money market + capital market), built on free keyless public APIs."
5
+ readme = "README.md"
6
+ license = "AGPL-3.0-or-later"
7
+ # 3.12+ only: the MCP bridge calls the async assembler via asyncio.run() per
8
+ # call, and on 3.10/3.11 the module-level asyncio.Lock in assemble.py raises
9
+ # "bound to a different event loop" across loops. 3.12 relaxed that binding;
10
+ # prod runs 3.12. Keep this floor so a PyPI install can't land on 3.11.
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "fastapi>=0.115",
14
+ "uvicorn>=0.30",
15
+ "httpx>=0.27",
16
+ "numpy>=1.26",
17
+ "pandas>=2.2",
18
+ "scikit-learn>=1.4",
19
+ "scipy>=1.11", # microseism MLE (was transitive via sklearn; now direct)
20
+ "arch>=7", # regatta: Model Confidence Set bootstrap
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=8",
26
+ "pytest-asyncio>=0.23",
27
+ "pytest-memray>=1.7; sys_platform != 'win32'",
28
+ "pytest-pystack>=1.0; sys_platform == 'linux'",
29
+ ]
30
+ notary = ["opentimestamps>=0.4"] # Bitcoin anchoring for the record ledger
31
+
32
+ [project.scripts]
33
+ seiche = "seiche.cli:main"
34
+ seiche-mcp = "seiche.mcp_server:main"
35
+
36
+ [build-system]
37
+ requires = ["setuptools>=68"]
38
+ build-backend = "setuptools.build_meta"
39
+
40
+ [tool.setuptools.packages.find]
41
+ include = ["seiche*"]
File without changes
@@ -0,0 +1,163 @@
1
+ """Subscriber accounts — stdlib-only auth for the gated endpoints.
2
+
3
+ The public window (api.seiche.info) serves the live board to everyone; the
4
+ Time Machine replay is the subscriber feature. Design matches the project's
5
+ ethos: no new dependencies, fail loud, nothing clever.
6
+
7
+ * passwords: hashlib.scrypt (n=2^14, r=8, p=1), per-user 16-byte salt;
8
+ * tokens: HMAC-SHA256 over "username|tier|expiry" with a secret that lives
9
+ in DATA_DIR/auth_secret (created 0600 on first use) or SEICHE_AUTH_SECRET;
10
+ * the gate is OPT-IN: SEICHE_ASOF_AUTH=1 turns it on (the box); dev and
11
+ tests run open unless they say otherwise.
12
+
13
+ Accounts are provisioned by the operator (`seiche user add NAME`), not by
14
+ self-signup — payments come later; this is the lock, not the till.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import hmac
21
+ import os
22
+ import secrets
23
+ import sqlite3
24
+ import time
25
+
26
+ from seiche.config import DATA_DIR, DB_PATH
27
+
28
+ _SCRYPT = dict(n=2**14, r=8, p=1)
29
+ TOKEN_TTL_S = 30 * 24 * 3600 # 30 days
30
+
31
+
32
+ def _conn() -> sqlite3.Connection:
33
+ conn = sqlite3.connect(DB_PATH)
34
+ conn.execute(
35
+ """CREATE TABLE IF NOT EXISTS users (
36
+ username TEXT PRIMARY KEY,
37
+ salt_hex TEXT NOT NULL,
38
+ hash_hex TEXT NOT NULL,
39
+ tier TEXT NOT NULL DEFAULT 'pro',
40
+ created_utc REAL NOT NULL
41
+ )"""
42
+ )
43
+ # idempotent migration: subscriber alert prefs
44
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(users)")}
45
+ if "email" not in cols:
46
+ conn.execute("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''")
47
+ if "alerts_on" not in cols:
48
+ conn.execute("ALTER TABLE users ADD COLUMN alerts_on INTEGER DEFAULT 0")
49
+ return conn
50
+
51
+
52
+ def _secret() -> bytes:
53
+ env = os.getenv("SEICHE_AUTH_SECRET")
54
+ if env:
55
+ return env.encode()
56
+ path = DATA_DIR / "auth_secret"
57
+ if not path.exists():
58
+ path.write_text(secrets.token_hex(32))
59
+ os.chmod(path, 0o600)
60
+ return path.read_text().strip().encode()
61
+
62
+
63
+ def _hash(password: str, salt: bytes) -> str:
64
+ return hashlib.scrypt(password.encode(), salt=salt, **_SCRYPT).hex()
65
+
66
+
67
+ def add_user(username: str, password: str, tier: str = "pro") -> None:
68
+ if not username or not username.replace("_", "").replace("-", "").isalnum():
69
+ raise ValueError("username must be alphanumeric (plus - _)")
70
+ if len(password) < 10:
71
+ raise ValueError("password must be at least 10 characters")
72
+ salt = os.urandom(16)
73
+ with _conn() as conn:
74
+ conn.execute(
75
+ "INSERT OR REPLACE INTO users (username, salt_hex, hash_hex, tier, created_utc) "
76
+ "VALUES (?,?,?,?,?)",
77
+ (username, salt.hex(), _hash(password, salt), tier, time.time()),
78
+ )
79
+
80
+
81
+ def verify_user(username: str, password: str) -> dict | None:
82
+ with _conn() as conn:
83
+ row = conn.execute(
84
+ "SELECT salt_hex, hash_hex, tier FROM users WHERE username=?", (username,)
85
+ ).fetchone()
86
+ if row is None:
87
+ return None
88
+ salt_hex, hash_hex, tier = row
89
+ if hmac.compare_digest(_hash(password, bytes.fromhex(salt_hex)), hash_hex):
90
+ return {"username": username, "tier": tier}
91
+ return None
92
+
93
+
94
+ def user_exists(username: str) -> bool:
95
+ with _conn() as conn:
96
+ row = conn.execute(
97
+ "SELECT 1 FROM users WHERE username=?", (username,)
98
+ ).fetchone()
99
+ return row is not None
100
+
101
+
102
+ def list_users() -> list[dict]:
103
+ with _conn() as conn:
104
+ rows = conn.execute("SELECT username, tier, created_utc FROM users").fetchall()
105
+ return [{"username": u, "tier": t, "created_utc": c} for u, t, c in rows]
106
+
107
+
108
+ # ---- tokens -----------------------------------------------------------------
109
+
110
+ def issue_token(username: str, tier: str, now: float | None = None) -> dict:
111
+ exp = int((now or time.time()) + TOKEN_TTL_S)
112
+ body = f"{username}|{tier}|{exp}"
113
+ sig = hmac.new(_secret(), body.encode(), hashlib.sha256).hexdigest()
114
+ return {"token": f"{body}|{sig}", "expires_utc": exp, "tier": tier}
115
+
116
+
117
+ def verify_token(token: str, now: float | None = None) -> dict | None:
118
+ parts = token.split("|")
119
+ if len(parts) != 4:
120
+ return None
121
+ username, tier, exp_s, sig = parts
122
+ body = f"{username}|{tier}|{exp_s}"
123
+ want = hmac.new(_secret(), body.encode(), hashlib.sha256).hexdigest()
124
+ if not hmac.compare_digest(want, sig):
125
+ return None
126
+ if int(exp_s) < (now or time.time()):
127
+ return None
128
+ return {"username": username, "tier": tier}
129
+
130
+
131
+ def get_alert_prefs(username: str) -> dict:
132
+ with _conn() as conn:
133
+ row = conn.execute(
134
+ "SELECT email, alerts_on FROM users WHERE username=?", (username,)
135
+ ).fetchone()
136
+ if row is None:
137
+ return {"email": "", "alerts_on": False}
138
+ return {"email": row[0] or "", "alerts_on": bool(row[1])}
139
+
140
+
141
+ def set_alert_prefs(username: str, email: str, alerts_on: bool) -> dict:
142
+ email = (email or "").strip()
143
+ if email and ("@" not in email or len(email) > 254):
144
+ raise ValueError("invalid email")
145
+ if alerts_on and not email:
146
+ raise ValueError("an email is required to turn alerts on")
147
+ with _conn() as conn:
148
+ conn.execute("UPDATE users SET email=?, alerts_on=? WHERE username=?",
149
+ (email, 1 if alerts_on else 0, username))
150
+ return {"email": email, "alerts_on": alerts_on}
151
+
152
+
153
+ def alert_recipients() -> list[str]:
154
+ """Emails of subscribers who have alerts on — the notify fan-out list."""
155
+ with _conn() as conn:
156
+ rows = conn.execute(
157
+ "SELECT email FROM users WHERE alerts_on=1 AND email != ''"
158
+ ).fetchall()
159
+ return [r[0] for r in rows]
160
+
161
+
162
+ def asof_gate_enabled() -> bool:
163
+ return os.getenv("SEICHE_ASOF_AUTH", "0") == "1"
@@ -0,0 +1,267 @@
1
+ """The desk assistant — an LLM strictly moored to the board.
2
+
3
+ Architecture: a deterministic CONTEXT PACK (compact JSON of the live board —
4
+ composite decomposition, headline, Tell/Turn/ML summaries, calendar, movers,
5
+ faults, staleness) is the model's ONLY source of truth. The system prompt
6
+ forbids outside numbers, requires an engine + as-of citation for every figure,
7
+ and mandates "not in the pack" over improvisation. Temperature low. This is a
8
+ reading assistant for the instrument, not an oracle.
9
+
10
+ Routing: free-llm-router (Groq→Cerebras→Google→Mistral→OpenRouter free tiers)
11
+ when importable and keyed; otherwise any OpenAI-compatible endpoint via
12
+ SEICHE_LLM_BASE_URL / SEICHE_LLM_API_KEY / SEICHE_LLM_MODEL; otherwise the
13
+ call fails open and returns the context pack itself — still useful, paste it
14
+ into any chat you like.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+
23
+ import httpx
24
+
25
+
26
+ _META_OPENERS = re.compile(
27
+ r"^\s*(we need to|the user|let'?s|i need to|i should|we should|okay[, ]|first[, ])",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+
32
+ def _strip_reasoning(text: str) -> str:
33
+ """Free-tier reasoning models leak chain-of-thought. Three passes:
34
+ <think> blocks, 'Final answer:' markers, and the gpt-oss-style pattern
35
+ where plain-text deliberation precedes the real answer — there, the final
36
+ paragraph is the deliverable."""
37
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
38
+ m = re.search(r"(?:final answer|answer)\s*[:\-]\s*", text, flags=re.IGNORECASE)
39
+ if m and m.start() > 80: # only treat as a marker if real preamble precedes it
40
+ return text[m.end():].strip()
41
+ if _META_OPENERS.match(text):
42
+ paras = [p.strip() for p in text.split("\n\n") if p.strip()]
43
+ # walk from the end past any trailing meta paragraphs
44
+ for p in reversed(paras):
45
+ if not _META_OPENERS.match(p):
46
+ return p
47
+ return text
48
+
49
+ SYSTEM_PROMPT = """You are the desk assistant inside SEICHE, a funding-stress terminal.
50
+ You will receive a CONTEXT PACK (JSON) describing the live board, then a question.
51
+
52
+ Hard rules:
53
+ 1. The context pack is your ONLY source of numbers. Never use outside data or memory of markets.
54
+ 2. Cite the engine and as-of date for every figure you use, like: (composite, 2026-07-06).
55
+ 3. If the pack does not contain what is asked, say "not on the board" — do not improvise.
56
+ 4. Plain prose, tight, desk-note voice. No headers unless asked. Max ~180 words unless asked for more.
57
+ 5. You describe readings and mechanics; you do not give investment advice. If asked for a trade, restate what the Playbook table shows (with n) and say the decision is the operator's.
58
+ 6. Respect the tool's honesty: mention coverage %, DEAD inputs, staleness or backtest caveats when they materially qualify the answer.
59
+ 7. Output ONLY the final answer. No reasoning preamble, no "we need to", no meta-commentary about the task."""
60
+
61
+
62
+ def context_pack(snap: dict) -> dict:
63
+ """Compact, deterministic extract of the payload — the model's whole world."""
64
+ eng = snap.get("engines", {})
65
+ deep = snap.get("deep", {})
66
+ comp = eng.get("composite", {})
67
+ tell = deep.get("tell", {})
68
+ turn = (deep.get("turn") or {}).get("next_turn")
69
+ ml = deep.get("ml", {})
70
+ bt = (deep.get("backtest") or {}).get("event_capture", {})
71
+ sonar = eng.get("sonar", {})
72
+ basins = eng.get("basins", {})
73
+ moor = eng.get("moorings", {})
74
+ prov = snap.get("provenance", [])
75
+ stale_counts: dict[str, int] = {}
76
+ for p in prov:
77
+ stale_counts[p.get("staleness", "?")] = stale_counts.get(p.get("staleness", "?"), 0) + 1
78
+
79
+ return {
80
+ "generated_at": snap.get("generated_at"),
81
+ "version": snap.get("version"),
82
+ "composite": {
83
+ "value": comp.get("value"), "regime": comp.get("regime"),
84
+ "coverage_pct": comp.get("coverage_pct"), "dead_inputs": comp.get("dead_inputs"),
85
+ "decomposition": comp.get("decomposition"),
86
+ },
87
+ "headline": snap.get("headline"),
88
+ "tell": {k: tell.get(k) for k in ("tell", "plumbing_pctl", "market_pctl", "reading", "asof")} if tell.get("ok") else None,
89
+ "next_turn": turn,
90
+ "ml": {k: ml.get(k) for k in ("p_event_5bd", "verdict", "asof")} if ml.get("ok") else None,
91
+ "kink": {k: eng.get("kink", {}).get(k) for k in ("kink_reserves_b", "current_reserves_b", "distance_b", "days_to_kink", "r2", "asof")} if eng.get("kink", {}).get("ok") else None,
92
+ "weather_crunches": eng.get("weather", {}).get("crunch_windows", [])[:5],
93
+ "resonance": {
94
+ "score": eng.get("resonance", {}).get("score"),
95
+ "worst_mode": eng.get("resonance", {}).get("worst_mode"),
96
+ } if eng.get("resonance", {}).get("ok") else None,
97
+ "warehouse": {k: eng.get("warehouse", {}).get(k) for k in ("total_net_b", "total_pctl", "long_end_share_pct", "asof")} if eng.get("warehouse", {}).get("ok") else None,
98
+ "echo_top": eng.get("echo", {}).get("top"),
99
+ "book": {
100
+ "today": deep.get("book", {}).get("today"),
101
+ "verdict": (deep.get("book", {}).get("backtest") or {}).get("verdict"),
102
+ "live": deep.get("book", {}).get("live"),
103
+ } if (deep.get("book") or {}).get("ok") else None,
104
+ "stacker": {
105
+ "p_now": deep.get("stacker", {}).get("p_now"),
106
+ "published": deep.get("stacker", {}).get("published"),
107
+ "dispersion_now": deep.get("stacker", {}).get("dispersion_now"),
108
+ "verdict": deep.get("stacker", {}).get("verdict"),
109
+ } if (deep.get("stacker") or {}).get("ok") else None,
110
+ "farbasin": {
111
+ "channels": {k: {kk: vv for kk, vv in (v or {}).items() if kk != "series"}
112
+ for k, v in (eng.get("farbasin", {}).get("channels") or {}).items()},
113
+ "status": eng.get("farbasin", {}).get("status"),
114
+ } if eng.get("farbasin", {}).get("ok") else None,
115
+ "tidetables": {
116
+ "event_odds": deep.get("tidetables", {}).get("event_odds"),
117
+ "novelty": deep.get("tidetables", {}).get("novelty"),
118
+ "skill_verdict": (deep.get("tidetables", {}).get("skill") or {}).get("verdict"),
119
+ "asof": deep.get("tidetables", {}).get("asof"),
120
+ } if (deep.get("tidetables") or {}).get("ok") else None,
121
+ "undertow": {
122
+ "score": eng.get("undertow", {}).get("score"),
123
+ "per_series": {
124
+ k: {kk: v.get(kk) for kk in ("ac1_pctl", "tau_bd", "var_pctl")}
125
+ for k, v in (eng.get("undertow", {}).get("per_series") or {}).items()
126
+ },
127
+ "asof": eng.get("undertow", {}).get("asof"),
128
+ } if eng.get("undertow", {}).get("ok") else None,
129
+ "bathymetry": {
130
+ "p_event_5bd": deep.get("bathymetry", {}).get("p_event_5bd"),
131
+ "mfpt_bd": deep.get("bathymetry", {}).get("mfpt_bd"),
132
+ "floor": {
133
+ k: (deep.get("bathymetry", {}).get("floor") or {}).get(k)
134
+ for k in ("well_bp", "stiffness", "barrier_kt")
135
+ },
136
+ "tau_bd": (deep.get("bathymetry", {}).get("spectrum") or {}).get("tau_bd"),
137
+ "tau_pctl": (deep.get("bathymetry", {}).get("spectrum") or {}).get("tau_pctl"),
138
+ "entropy_pctl": (deep.get("bathymetry", {}).get("arrow") or {}).get("pctl"),
139
+ "validation_verdict": (deep.get("bathymetry", {}).get("validation") or {}).get("verdict"),
140
+ "asof": deep.get("bathymetry", {}).get("asof"),
141
+ } if (deep.get("bathymetry") or {}).get("ok") else None,
142
+ "swell": {
143
+ "p_event_5bd": deep.get("swell", {}).get("p_event_5bd"),
144
+ "event_by_horizon": deep.get("swell", {}).get("event_by_horizon"),
145
+ "peak": deep.get("swell", {}).get("peak"),
146
+ "validation_verdict": (deep.get("swell", {}).get("validation") or {}).get("verdict"),
147
+ "asof": deep.get("swell", {}).get("asof"),
148
+ } if (deep.get("swell") or {}).get("ok") else None,
149
+ "merian": {
150
+ "instability": eng.get("merian", {}).get("instability"),
151
+ "modes": (eng.get("merian", {}).get("modes") or [])[:3],
152
+ "asof": eng.get("merian", {}).get("asof"),
153
+ } if eng.get("merian", {}).get("ok") else None,
154
+ "gyre": {
155
+ "determinism_verdict": (deep.get("gyre", {}).get("determinism") or {}).get("verdict"),
156
+ "nonlinearity_verdict": (deep.get("gyre", {}).get("nonlinearity") or {}).get("verdict"),
157
+ "stability": deep.get("gyre", {}).get("stability"),
158
+ "forecast": deep.get("gyre", {}).get("forecast"),
159
+ "asof": deep.get("gyre", {}).get("asof"),
160
+ } if (deep.get("gyre") or {}).get("ok") else None,
161
+ "roguewave": {
162
+ "tail_verdict": eng.get("roguewave", {}).get("tail_verdict"),
163
+ "fit": eng.get("roguewave", {}).get("fit"),
164
+ "return_levels": eng.get("roguewave", {}).get("return_levels"),
165
+ "sample_max_bp": eng.get("roguewave", {}).get("sample_max_bp"),
166
+ "asof": eng.get("roguewave", {}).get("asof"),
167
+ } if eng.get("roguewave", {}).get("ok") else None,
168
+ "basins": basins.get("basins") if basins.get("ok") else None,
169
+ "swap_lines_30d_m": (basins.get("swap_lines") or {}).get("ops_30d_total_m") if basins.get("ok") else None,
170
+ "moorings": {
171
+ "usdt_dev_bp": (moor.get("usdt") or {}).get("dev_bp"),
172
+ "stable_total_b": (moor.get("demand") or {}).get("total_b"),
173
+ "stable_chg_30d_pct": (moor.get("demand") or {}).get("chg_30d_pct"),
174
+ } if moor.get("ok") else None,
175
+ "communique": {
176
+ "latest": eng.get("communique", {}).get("latest"),
177
+ "flags": eng.get("communique", {}).get("flags"),
178
+ "n_statements": eng.get("communique", {}).get("n_statements"),
179
+ } if eng.get("communique", {}).get("ok") else None,
180
+ "riptide": {
181
+ "live": deep.get("riptide", {}).get("live"),
182
+ "flat_water": deep.get("riptide", {}).get("flat_water"),
183
+ "asof": deep.get("riptide", {}).get("asof"),
184
+ } if (deep.get("riptide") or {}).get("ok") else None,
185
+ "breakwater": {
186
+ "rescue_proximity": eng.get("breakwater", {}).get("rescue_proximity"),
187
+ "revealed_threshold": eng.get("breakwater", {}).get("revealed_threshold"),
188
+ "reading": eng.get("breakwater", {}).get("reading"),
189
+ } if eng.get("breakwater", {}).get("ok") else None,
190
+ "sonar_flagged": [m for m in sonar.get("movers", []) if m.get("flag")][:6],
191
+ "calendar": snap.get("calendar", {}),
192
+ "playbook": deep.get("playbook", {}).get("tables") if (deep.get("playbook") or {}).get("ok") else None,
193
+ "playbook_state": (deep.get("playbook") or {}).get("state"),
194
+ "backtest_headline": {
195
+ "recall": bt.get("recall"), "precision": bt.get("precision"),
196
+ "base_rate": bt.get("base_rate"), "median_lead_d": bt.get("median_lead_d"),
197
+ },
198
+ "backtest_caveats": (deep.get("backtest") or {}).get("caveats"),
199
+ "faults": snap.get("faults"),
200
+ "provenance_staleness": stale_counts,
201
+ }
202
+
203
+
204
+ async def _via_router(messages: list[dict]) -> str | None:
205
+ try:
206
+ from free_llm_router import FreeLLMRouter
207
+ except ImportError:
208
+ return None
209
+ router = FreeLLMRouter()
210
+ try:
211
+ # router envelope: {"text", "model", "provider", "tokens", ...}.
212
+ # fast tier first (non-reasoning models: clean output for a read-the-
213
+ # pack task); smart as fallback, with _strip_reasoning as the net for
214
+ # chain-of-thought leakage.
215
+ last: Exception | None = None
216
+ for tier in ("fast", "smart"):
217
+ try:
218
+ resp = await router.chat_completion(messages, tier=tier, temperature=0.2, max_tokens=700)
219
+ return resp["text"]
220
+ except Exception as e: # noqa: BLE001 — try the other tier
221
+ last = e
222
+ if last:
223
+ raise last
224
+ return None
225
+ finally:
226
+ await router.close()
227
+
228
+
229
+ async def _via_env(messages: list[dict]) -> str | None:
230
+ base = os.environ.get("SEICHE_LLM_BASE_URL")
231
+ if not base:
232
+ return None
233
+ key = os.environ.get("SEICHE_LLM_API_KEY", "")
234
+ model = os.environ.get("SEICHE_LLM_MODEL", "gpt-4o-mini")
235
+ async with httpx.AsyncClient(timeout=60) as client:
236
+ r = await client.post(
237
+ f"{base.rstrip('/')}/chat/completions",
238
+ headers={"Authorization": f"Bearer {key}"} if key else {},
239
+ json={"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 700},
240
+ )
241
+ r.raise_for_status()
242
+ return r.json()["choices"][0]["message"]["content"]
243
+
244
+
245
+ async def ask(question: str, snap: dict) -> dict:
246
+ pack = context_pack(snap)
247
+ messages = [
248
+ {"role": "system", "content": SYSTEM_PROMPT},
249
+ {"role": "user", "content": "CONTEXT PACK:\n" + json.dumps(pack, default=str)
250
+ + f"\n\nQUESTION: {question}"},
251
+ ]
252
+ errors = []
253
+ for route, fn in (("free-llm-router", _via_router), ("env-endpoint", _via_env)):
254
+ try:
255
+ answer = await fn(messages)
256
+ if answer:
257
+ return {"ok": True, "route": route, "answer": _strip_reasoning(answer),
258
+ "grounding": "answers are restricted to the context pack; verify against the board"}
259
+ except Exception as e:
260
+ errors.append(f"{route}: {type(e).__name__}: {str(e)[:80]}")
261
+ return {
262
+ "ok": False,
263
+ "reason": "no LLM route available (" + ("; ".join(errors) if errors else
264
+ "free-llm-router unkeyed and SEICHE_LLM_BASE_URL unset") + ")",
265
+ "context_pack": pack,
266
+ "hint": "the context pack above is self-contained — paste it into any chat model",
267
+ }