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.
- seiche-0.7.0/PKG-INFO +48 -0
- seiche-0.7.0/README.md +25 -0
- seiche-0.7.0/pyproject.toml +41 -0
- seiche-0.7.0/seiche/__init__.py +0 -0
- seiche-0.7.0/seiche/accounts.py +163 -0
- seiche-0.7.0/seiche/ai.py +267 -0
- seiche-0.7.0/seiche/alerts.py +351 -0
- seiche-0.7.0/seiche/api.py +794 -0
- seiche-0.7.0/seiche/assemble.py +1172 -0
- seiche-0.7.0/seiche/badge.py +62 -0
- seiche-0.7.0/seiche/brief.py +197 -0
- seiche-0.7.0/seiche/cli.py +681 -0
- seiche-0.7.0/seiche/config.py +927 -0
- seiche-0.7.0/seiche/dispatch_daily.py +500 -0
- seiche-0.7.0/seiche/dispatch_pages.py +530 -0
- seiche-0.7.0/seiche/engines/__init__.py +0 -0
- seiche-0.7.0/seiche/engines/auctions.py +85 -0
- seiche-0.7.0/seiche/engines/backtest.py +362 -0
- seiche-0.7.0/seiche/engines/basins.py +231 -0
- seiche-0.7.0/seiche/engines/bathymetry.py +405 -0
- seiche-0.7.0/seiche/engines/book.py +463 -0
- seiche-0.7.0/seiche/engines/breakwater.py +134 -0
- seiche-0.7.0/seiche/engines/communique.py +108 -0
- seiche-0.7.0/seiche/engines/composite.py +73 -0
- seiche-0.7.0/seiche/engines/echo.py +76 -0
- seiche-0.7.0/seiche/engines/farbasin.py +95 -0
- seiche-0.7.0/seiche/engines/gyre.py +482 -0
- seiche-0.7.0/seiche/engines/history.py +146 -0
- seiche-0.7.0/seiche/engines/hydrophone.py +136 -0
- seiche-0.7.0/seiche/engines/kink.py +118 -0
- seiche-0.7.0/seiche/engines/leakaudit.py +239 -0
- seiche-0.7.0/seiche/engines/market.py +112 -0
- seiche-0.7.0/seiche/engines/markov.py +103 -0
- seiche-0.7.0/seiche/engines/merian.py +348 -0
- seiche-0.7.0/seiche/engines/microseism.py +389 -0
- seiche-0.7.0/seiche/engines/mlpred.py +375 -0
- seiche-0.7.0/seiche/engines/montecarlo.py +84 -0
- seiche-0.7.0/seiche/engines/moorings.py +147 -0
- seiche-0.7.0/seiche/engines/navigator.py +189 -0
- seiche-0.7.0/seiche/engines/oujump.py +113 -0
- seiche-0.7.0/seiche/engines/playbook.py +113 -0
- seiche-0.7.0/seiche/engines/regatta.py +195 -0
- seiche-0.7.0/seiche/engines/resonance.py +221 -0
- seiche-0.7.0/seiche/engines/riptide.py +255 -0
- seiche-0.7.0/seiche/engines/roguewave.py +370 -0
- seiche-0.7.0/seiche/engines/rvxray.py +157 -0
- seiche-0.7.0/seiche/engines/searoom.py +183 -0
- seiche-0.7.0/seiche/engines/seastate.py +263 -0
- seiche-0.7.0/seiche/engines/sonar.py +68 -0
- seiche-0.7.0/seiche/engines/stacker.py +301 -0
- seiche-0.7.0/seiche/engines/stationkeeping.py +167 -0
- seiche-0.7.0/seiche/engines/swell.py +388 -0
- seiche-0.7.0/seiche/engines/tails.py +88 -0
- seiche-0.7.0/seiche/engines/thermohaline.py +155 -0
- seiche-0.7.0/seiche/engines/tidetables.py +333 -0
- seiche-0.7.0/seiche/engines/turn.py +226 -0
- seiche-0.7.0/seiche/engines/undertow.py +271 -0
- seiche-0.7.0/seiche/engines/warehouse.py +79 -0
- seiche-0.7.0/seiche/engines/weather.py +209 -0
- seiche-0.7.0/seiche/engines/wrecks.py +160 -0
- seiche-0.7.0/seiche/mailer.py +59 -0
- seiche-0.7.0/seiche/mcp_server.py +702 -0
- seiche-0.7.0/seiche/notary.py +244 -0
- seiche-0.7.0/seiche/provisioning.py +183 -0
- seiche-0.7.0/seiche/public_view.py +62 -0
- seiche-0.7.0/seiche/publisher.py +65 -0
- seiche-0.7.0/seiche/sources/__init__.py +0 -0
- seiche-0.7.0/seiche/sources/base.py +79 -0
- seiche-0.7.0/seiche/sources/bis.py +96 -0
- seiche-0.7.0/seiche/sources/cftc.py +101 -0
- seiche-0.7.0/seiche/sources/crypto.py +167 -0
- seiche-0.7.0/seiche/sources/ecb.py +71 -0
- seiche-0.7.0/seiche/sources/fedtext.py +85 -0
- seiche-0.7.0/seiche/sources/fiscaldata.py +131 -0
- seiche-0.7.0/seiche/sources/fred.py +102 -0
- seiche-0.7.0/seiche/sources/nyfed.py +171 -0
- seiche-0.7.0/seiche/sources/ofr.py +65 -0
- seiche-0.7.0/seiche/sources/palimpsest.py +138 -0
- seiche-0.7.0/seiche/store.py +115 -0
- seiche-0.7.0/seiche/usage.py +104 -0
- seiche-0.7.0/seiche/x402.py +176 -0
- seiche-0.7.0/seiche.egg-info/PKG-INFO +48 -0
- seiche-0.7.0/seiche.egg-info/SOURCES.txt +108 -0
- seiche-0.7.0/seiche.egg-info/dependency_links.txt +1 -0
- seiche-0.7.0/seiche.egg-info/entry_points.txt +3 -0
- seiche-0.7.0/seiche.egg-info/requires.txt +21 -0
- seiche-0.7.0/seiche.egg-info/top_level.txt +1 -0
- seiche-0.7.0/setup.cfg +4 -0
- seiche-0.7.0/tests/test_accounts.py +141 -0
- seiche-0.7.0/tests/test_api_caching.py +149 -0
- seiche-0.7.0/tests/test_backtest_split.py +64 -0
- seiche-0.7.0/tests/test_badge.py +65 -0
- seiche-0.7.0/tests/test_book.py +256 -0
- seiche-0.7.0/tests/test_dispatch_daily.py +100 -0
- seiche-0.7.0/tests/test_dispatch_pages.py +132 -0
- seiche-0.7.0/tests/test_engines.py +1095 -0
- seiche-0.7.0/tests/test_gauge.py +40 -0
- seiche-0.7.0/tests/test_gyre.py +131 -0
- seiche-0.7.0/tests/test_leakaudit.py +67 -0
- seiche-0.7.0/tests/test_mcp_http.py +180 -0
- seiche-0.7.0/tests/test_mcp_server.py +188 -0
- seiche-0.7.0/tests/test_merian.py +126 -0
- seiche-0.7.0/tests/test_microseism.py +153 -0
- seiche-0.7.0/tests/test_notary.py +128 -0
- seiche-0.7.0/tests/test_provisioning.py +141 -0
- seiche-0.7.0/tests/test_roguewave.py +219 -0
- seiche-0.7.0/tests/test_scenarios.py +107 -0
- seiche-0.7.0/tests/test_tier1.py +221 -0
- seiche-0.7.0/tests/test_wrecks.py +91 -0
- 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
|
+
}
|