cyan-eclipse-book 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.
- bazaar-manifest.json +66 -0
- book_engine.py +619 -0
- cyan_eclipse_book-0.1.0.dist-info/METADATA +50 -0
- cyan_eclipse_book-0.1.0.dist-info/RECORD +9 -0
- cyan_eclipse_book-0.1.0.dist-info/WHEEL +4 -0
- cyan_eclipse_book-0.1.0.dist-info/entry_points.txt +2 -0
- cyan_eclipse_book-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixtures.json +1 -0
- server.py +428 -0
bazaar-manifest.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bench": {
|
|
3
|
+
"bench_verdict": "CERTIFIED(local)",
|
|
4
|
+
"certified": true,
|
|
5
|
+
"checks": [
|
|
6
|
+
"criteria edit mid-position: one-word change -> CRITERIA_CHANGED event + receipt; position keeps as-of-entry, flag set",
|
|
7
|
+
"resolution_verify against a WRONG source refused by name; correct source verifies",
|
|
8
|
+
"DISPUTED blocks verification until cleared",
|
|
9
|
+
"fee math recomputed independently (all-in buy AND sell, per-unit + total); event-contract max payout attached",
|
|
10
|
+
"missing schedule AND stale schedule -> FEE_UNVERIFIED while bid/ask still serve",
|
|
11
|
+
"probability scan: planted keys in provenance, criteria-object field name, nested trading_window \u2014 never emitted; verbatim carriers refused by name",
|
|
12
|
+
"UNMAPPED native status preserved verbatim",
|
|
13
|
+
"order_draft: DRAFT only, NOT_EVALUATED mandate note, receipt ref; off-allowlist type refused",
|
|
14
|
+
"capability denylist by enumeration: exactly the 17 M0 tools; placement/withdraw/account/advice names refused",
|
|
15
|
+
"auth enforced",
|
|
16
|
+
"schema valid",
|
|
17
|
+
"no network surface (source-level)",
|
|
18
|
+
"no secret leak",
|
|
19
|
+
"sane errors",
|
|
20
|
+
"process health",
|
|
21
|
+
"audit completeness (receipt chain recomputed independently)",
|
|
22
|
+
"bench isolation"
|
|
23
|
+
],
|
|
24
|
+
"not_tested": [
|
|
25
|
+
"Live venue reads inside the shipped build \u2014 M0 contains no network code path; live Kalshi/Polymarket public payloads were verified through the transport seam from the Verity bench seat 2026-11-02 (BENCH-VERITY.md: PASS-WITH-CONDITIONS), but no in-build live adapter ships in v0.1.0.",
|
|
26
|
+
"Order placement, account reads, and the Wallet mandate gate \u2014 M2/M1 scope, owner-gated (spec 18, decision D3). M0 order_draft records an unsubmitted DRAFT with mandate_pre_eval NOT_EVALUATED; no submission path exists.",
|
|
27
|
+
"Fee schedules as live data \u2014 shipped schedules are fixture-shaped records with provenance labels saying so; the live fetch + account-specific tier provenance lands with the adapters' live step.",
|
|
28
|
+
"Kalshi / Polymarket US / Kraken / Binance.US / Alpaca chapters \u2014 specced (suite spec 10-12), not built in M0."
|
|
29
|
+
],
|
|
30
|
+
"scorecard_ref": "test-bench/results/eclipse-book-mcp-2026-10-25.json",
|
|
31
|
+
"status": "CERTIFIED(local) \u2014 Eclipse Bazaar Test Bench 2026-10-25 (scorecard eclipse-book-mcp-2026-10-25.json): 16 pass / 1 na / 0 fail over 17 checks incl. bench isolation. M0 data-only scope; live venue reads not_tested per the preserved not_tested list."
|
|
32
|
+
},
|
|
33
|
+
"category": "Markets / Exchange & Prediction-Market Infrastructure",
|
|
34
|
+
"created": "2026-10-25",
|
|
35
|
+
"description": "The Market Core of the Eclipse Markets suite (spec eclipse-markets-suite-spec-20261025): one canonical Instrument / Book / Quote / Order / Position / Resolution model over exchanges and prediction markets, with venue adapters that translate and never improvise. Resolution criteria are stored verbatim and hash-pinned \u2014 a venue criteria edit is a CRITERIA_CHANGED event and positions keep the criteria as of entry. Quotes carry all-in buy/sell cost for a stated size from versioned, provenance-stamped fee schedules; a stale or missing schedule degrades all-in fields to FEE_UNVERIFIED rather than quoting a wrong number. Resolutions move REPORTED -> VERIFIED only against the criteria's own named source (DISPUTED is first-class), and no core output ever carries a probability field \u2014 outputs carry price + liquidity flag + depth + verbatim criteria. M0 is data only: no accounts, no keys, no order submission at any tier; order_draft is an unsubmitted record whose mandate pre-evaluation is honestly NOT_EVALUATED until the owner-gated M2.",
|
|
36
|
+
"icon": "icon-512.png",
|
|
37
|
+
"name": "Eclipse Book MCP",
|
|
38
|
+
"powered_by": "Cyan Eclipse Technologies",
|
|
39
|
+
"price_model": "Free local v0.1; family pricing TBD per spec",
|
|
40
|
+
"repo": null,
|
|
41
|
+
"slug": "eclipse-book-mcp",
|
|
42
|
+
"status": "Live \u2014 v0.1.0 M0 local build (Market Core, data only; CERTIFIED(local) \u2014 Eclipse Bazaar Test Bench 2026-10-25; Verity bench 2026-11-02 PASS-WITH-CONDITIONS incl. live Kalshi/Polymarket public reads via the transport seam \u2014 see BENCH-VERITY.md)",
|
|
43
|
+
"tagline": "Market data + venue access layer \u2014 not an exchange: one book, one fee truth, one ledger of record.",
|
|
44
|
+
"tools": [
|
|
45
|
+
"instrument_ingest",
|
|
46
|
+
"instrument_get",
|
|
47
|
+
"instrument_search",
|
|
48
|
+
"book_ingest",
|
|
49
|
+
"quote_get",
|
|
50
|
+
"fee_schedule_register",
|
|
51
|
+
"fee_schedule_get",
|
|
52
|
+
"position_open",
|
|
53
|
+
"position_list",
|
|
54
|
+
"position_get",
|
|
55
|
+
"resolution_report",
|
|
56
|
+
"resolution_verify",
|
|
57
|
+
"resolution_get",
|
|
58
|
+
"order_draft",
|
|
59
|
+
"order_get",
|
|
60
|
+
"venue_status",
|
|
61
|
+
"receipt_list"
|
|
62
|
+
],
|
|
63
|
+
"transport": "MCP over stdio (HTTP transport on roadmap)",
|
|
64
|
+
"vendor": "Cyan Eclipse Technologies",
|
|
65
|
+
"x402_ready": false
|
|
66
|
+
}
|
book_engine.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
# Eclipse Book MCP
|
|
2
|
+
# Copyright © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
3
|
+
# Eclipse Book — shared Market Core engine (spec
|
|
4
|
+
|
|
5
|
+
"""Eclipse Book — shared Market Core engine (spec
|
|
6
|
+
eclipse-markets-suite-spec-20261025 sections 5-7).
|
|
7
|
+
|
|
8
|
+
This module is the suite law in code: canonical Instrument /
|
|
9
|
+
Book / Quote / Order / Position / Resolution objects, fee truth
|
|
10
|
+
(section 5.3), the resolution-verification discipline (5.4),
|
|
11
|
+
liquidity + probability honesty (5.5), and the append-only
|
|
12
|
+
hash-chained receipt ledger.
|
|
13
|
+
|
|
14
|
+
Adapters (Forecast, Strike first) import this module from the
|
|
15
|
+
sibling core directory and translate venue-native payloads into
|
|
16
|
+
engine calls against the SHARED suite state, loaded fresh at
|
|
17
|
+
every call. An adapter may never emit a core object it had to
|
|
18
|
+
guess at: missing native fields surface as explicit gaps, and a
|
|
19
|
+
venue status that does not map into the core vocabulary surfaces
|
|
20
|
+
as UNMAPPED with the native string preserved, never coerced
|
|
21
|
+
(section 5.2).
|
|
22
|
+
|
|
23
|
+
M0 SCOPE: data only. This engine contains no order-submission,
|
|
24
|
+
account, withdrawal, or transfer capability at any tier — the
|
|
25
|
+
Order object exists as a DRAFT record (section 5.6) whose mandate
|
|
26
|
+
pre-evaluation is honestly NOT_EVALUATED in M0. Placement is M2,
|
|
27
|
+
owner-gated (spec section 18, decision D3).
|
|
28
|
+
|
|
29
|
+
Pure functions over a state dict: load/save are the caller's
|
|
30
|
+
job. Stdlib only. Money math is Decimal end-to-end; prices are
|
|
31
|
+
decimal strings on every surface (section 5.2).
|
|
32
|
+
"""
|
|
33
|
+
import copy
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from decimal import Decimal
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
KINDS = {"SPOT", "EVENT_CONTRACT", "EQUITY", "PLAY_EVENT_CONTRACT"}
|
|
45
|
+
STATUSES = {"OPEN", "CLOSED", "RESOLVED", "VOIDED"}
|
|
46
|
+
MONEY_CLASSES = {"REAL", "PLAY"}
|
|
47
|
+
BOOK_TYPES = {"CLOB", "AMM", "INDICATIVE"}
|
|
48
|
+
RESOLUTION_STATES = {"NONE", "REPORTED", "VERIFIED", "DISPUTED"}
|
|
49
|
+
ORDER_TYPES = {"MARKET", "LIMIT"}
|
|
50
|
+
ORDER_STATUSES = {"DRAFT", "SUBMITTED", "OPEN", "PARTIAL", "FILLED",
|
|
51
|
+
"CANCELLED", "REJECTED", "EXPIRED"}
|
|
52
|
+
|
|
53
|
+
# Liquidity law (spec 5.5): core-owned thresholds, notional in the
|
|
54
|
+
# instrument's currency at the best ask. Adapters may tighten,
|
|
55
|
+
# never loosen. DEEP >= 10000, THIN < 1000, else FRAGILE.
|
|
56
|
+
LIQUIDITY_DEEP_MIN = Decimal("10000")
|
|
57
|
+
LIQUIDITY_THIN_MAX = Decimal("1000")
|
|
58
|
+
|
|
59
|
+
DEFAULT_FEE_FRESHNESS_HOURS = 24
|
|
60
|
+
DEFAULT_BOOK_STALE_SECONDS = 86400
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def now_iso() -> str:
|
|
64
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_time(value):
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
text = str(value).strip()
|
|
71
|
+
if len(text) == 10:
|
|
72
|
+
text += "T00:00:00+00:00"
|
|
73
|
+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sha(text: str) -> str:
|
|
77
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canon(obj) -> str:
|
|
81
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def D(value) -> Decimal:
|
|
85
|
+
return Decimal(str(value))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ds(value) -> str:
|
|
89
|
+
"""Decimal -> canonical decimal string (no exponent, no
|
|
90
|
+
trailing-zero noise)."""
|
|
91
|
+
d = D(value)
|
|
92
|
+
s = format(d, "f")
|
|
93
|
+
if "." in s:
|
|
94
|
+
s = s.rstrip("0").rstrip(".")
|
|
95
|
+
return s if s not in ("", "-0") else "0"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------- receipts
|
|
99
|
+
def emit_receipt(state: dict, rtype: str, detail: dict) -> dict:
|
|
100
|
+
prev = state["receipts"][-1]["hash"] if state["receipts"] else "GENESIS"
|
|
101
|
+
entry = {"seq": len(state["receipts"]) + 1, "ts": now_iso(),
|
|
102
|
+
"type": rtype, "prev_hash": prev, "detail": detail}
|
|
103
|
+
entry["hash"] = sha(prev + canon(
|
|
104
|
+
{k: v for k, v in entry.items() if k != "hash"}))
|
|
105
|
+
state["receipts"].append(entry)
|
|
106
|
+
return entry
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def verify_receipt_chain(receipts) -> bool:
|
|
110
|
+
prev = "GENESIS"
|
|
111
|
+
for entry in receipts:
|
|
112
|
+
expect = sha(prev + canon(
|
|
113
|
+
{k: v for k, v in entry.items() if k != "hash"}))
|
|
114
|
+
if entry.get("hash") != expect or entry.get("prev_hash") != prev:
|
|
115
|
+
return False
|
|
116
|
+
prev = entry["hash"]
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------- probability law
|
|
121
|
+
def probability_scan(obj, path="$"):
|
|
122
|
+
"""Spec 5.5: the core never emits a probability field.
|
|
123
|
+
Returns the list of offending key paths (keys only — a price
|
|
124
|
+
is a price, and no output key may name it anything else)."""
|
|
125
|
+
hits = []
|
|
126
|
+
if isinstance(obj, dict):
|
|
127
|
+
for key, value in obj.items():
|
|
128
|
+
if "probab" in str(key).lower():
|
|
129
|
+
hits.append(f"{path}.{key}")
|
|
130
|
+
hits.extend(probability_scan(value, f"{path}.{key}"))
|
|
131
|
+
elif isinstance(obj, list):
|
|
132
|
+
for i, value in enumerate(obj):
|
|
133
|
+
hits.extend(probability_scan(value, f"{path}[{i}]"))
|
|
134
|
+
return hits
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def guard_no_probability(obj):
|
|
138
|
+
hits = probability_scan(obj)
|
|
139
|
+
if hits:
|
|
140
|
+
raise ToolError(
|
|
141
|
+
"PROBABILITY_FIELD_FORBIDDEN: core outputs carry price + "
|
|
142
|
+
f"liquidity + criteria, never a probability field ({hits})")
|
|
143
|
+
return obj
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------- state
|
|
147
|
+
def empty_state() -> dict:
|
|
148
|
+
return {"config": {"fee_freshness_hours": DEFAULT_FEE_FRESHNESS_HOURS,
|
|
149
|
+
"book_stale_seconds": DEFAULT_BOOK_STALE_SECONDS},
|
|
150
|
+
"instruments": {}, "books": {}, "fee_schedules": {},
|
|
151
|
+
"positions": {}, "orders": {}, "receipts": []}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def ensure_seeded(state: dict, seed: dict) -> dict:
|
|
155
|
+
state["config"].update(seed.get("config", {}))
|
|
156
|
+
for sched in seed.get("fee_schedules", []):
|
|
157
|
+
if sched["schedule_ref"] not in state["fee_schedules"]:
|
|
158
|
+
state["fee_schedules"][sched["schedule_ref"]] = sched
|
|
159
|
+
return state
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------ fee schedules
|
|
163
|
+
def fee_schedule_register(state: dict, args: dict) -> dict:
|
|
164
|
+
"""Fee schedules are versioned data with provenance (5.3):
|
|
165
|
+
source + fetched_at are required; a schedule without them is
|
|
166
|
+
not a schedule."""
|
|
167
|
+
for field in ("venue", "version", "source", "fetched_at",
|
|
168
|
+
"taker_fee_bps"):
|
|
169
|
+
if args.get(field) in (None, ""):
|
|
170
|
+
raise ToolError(f"FEE_SCHEDULE_PROVENANCE_REQUIRED: {field}")
|
|
171
|
+
ref = f"{args['venue']}@{args['version']}"
|
|
172
|
+
sched = {"schedule_ref": ref, "venue": args["venue"],
|
|
173
|
+
"version": args["version"], "source": args["source"],
|
|
174
|
+
"fetched_at": args["fetched_at"],
|
|
175
|
+
"taker_fee_bps": ds(args["taker_fee_bps"]),
|
|
176
|
+
"maker_fee_bps": ds(args.get("maker_fee_bps", "0")),
|
|
177
|
+
"settlement_fee_model": args.get(
|
|
178
|
+
"settlement_fee_model", {"type": "none"}),
|
|
179
|
+
"freshness_hours": int(args.get(
|
|
180
|
+
"freshness_hours",
|
|
181
|
+
state["config"]["fee_freshness_hours"]))}
|
|
182
|
+
state["fee_schedules"][ref] = sched
|
|
183
|
+
emit_receipt(state, "fee_schedule_registered",
|
|
184
|
+
{"schedule_ref": ref, "venue": sched["venue"],
|
|
185
|
+
"fetched_at": sched["fetched_at"]})
|
|
186
|
+
return {"fee_schedule": sched}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def fee_schedule_for(state: dict, venue: str):
|
|
190
|
+
"""Latest registered schedule for a venue, or None."""
|
|
191
|
+
cands = [s for s in state["fee_schedules"].values()
|
|
192
|
+
if s["venue"] == venue]
|
|
193
|
+
if not cands:
|
|
194
|
+
return None
|
|
195
|
+
return sorted(cands, key=lambda s: s["fetched_at"])[-1]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def fee_freshness(state: dict, venue: str, as_of: str):
|
|
199
|
+
"""-> (schedule, status): FRESH | STALE | NO_SCHEDULE."""
|
|
200
|
+
sched = fee_schedule_for(state, venue)
|
|
201
|
+
if sched is None:
|
|
202
|
+
return None, "NO_SCHEDULE"
|
|
203
|
+
age_h = (parse_time(as_of) - parse_time(
|
|
204
|
+
sched["fetched_at"])).total_seconds() / 3600.0
|
|
205
|
+
if age_h > sched["freshness_hours"]:
|
|
206
|
+
return sched, "STALE"
|
|
207
|
+
return sched, "FRESH"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --------------------------------------------------------------- instruments
|
|
211
|
+
def instrument_id_for(venue: str, kind: str, native_id: str) -> str:
|
|
212
|
+
wing = "B" if kind in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT") else "A"
|
|
213
|
+
return f"{wing}:{venue}:{native_id}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def ingest_instrument(state: dict, spec_in: dict) -> dict:
|
|
217
|
+
"""Register or refresh a canonical Instrument. Resolution
|
|
218
|
+
criteria are verbatim + hash-pinned at first read (5.4): a
|
|
219
|
+
later ingest carrying different criteria records a
|
|
220
|
+
CRITERIA_CHANGED event; the instrument keeps every version,
|
|
221
|
+
and positions keep the criteria as of their entry."""
|
|
222
|
+
for field in ("venue", "native_id", "kind", "title"):
|
|
223
|
+
if not spec_in.get(field):
|
|
224
|
+
raise ToolError(f"INSTRUMENT_FIELD_REQUIRED: {field}")
|
|
225
|
+
kind = spec_in["kind"]
|
|
226
|
+
if kind not in KINDS:
|
|
227
|
+
raise ToolError(f"INSTRUMENT_KIND_UNKNOWN: {kind}")
|
|
228
|
+
money_class = spec_in.get("money_class", "REAL")
|
|
229
|
+
if money_class not in MONEY_CLASSES:
|
|
230
|
+
raise ToolError(f"MONEY_CLASS_UNKNOWN: {money_class}")
|
|
231
|
+
if kind == "PLAY_EVENT_CONTRACT" and money_class != "PLAY":
|
|
232
|
+
raise ToolError("PLAY_INSTRUMENT_REQUIRES_PLAY_MONEY_CLASS")
|
|
233
|
+
criteria = spec_in.get("resolution_criteria")
|
|
234
|
+
if kind in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
|
|
235
|
+
if not criteria or not criteria.get("text"):
|
|
236
|
+
raise ToolError(
|
|
237
|
+
"RESOLUTION_CRITERIA_REQUIRED: an event contract "
|
|
238
|
+
"without verbatim criteria is not an instrument")
|
|
239
|
+
if not spec_in.get("resolution_source"):
|
|
240
|
+
raise ToolError(
|
|
241
|
+
"RESOLUTION_SOURCE_REQUIRED: the named resolver is "
|
|
242
|
+
"part of the instrument (spec 5.4)")
|
|
243
|
+
native_status = spec_in.get("native_status")
|
|
244
|
+
mapped = spec_in.get("status_mapped")
|
|
245
|
+
status = mapped if mapped in STATUSES else "UNMAPPED"
|
|
246
|
+
iid = instrument_id_for(spec_in["venue"], kind, spec_in["native_id"])
|
|
247
|
+
existing = state["instruments"].get(iid)
|
|
248
|
+
event = None
|
|
249
|
+
if existing is None:
|
|
250
|
+
inst = {"instrument_id": iid, "venue": spec_in["venue"],
|
|
251
|
+
"native_id": spec_in["native_id"],
|
|
252
|
+
"native_symbol": spec_in.get("native_symbol"),
|
|
253
|
+
"kind": kind, "title": spec_in["title"],
|
|
254
|
+
"underlying": spec_in.get("underlying"),
|
|
255
|
+
"outcomes": spec_in.get("outcomes", []),
|
|
256
|
+
"resolution_source": spec_in.get("resolution_source"),
|
|
257
|
+
"trading_window": spec_in.get("trading_window", {}),
|
|
258
|
+
"tick": spec_in.get("tick"),
|
|
259
|
+
"min_size": spec_in.get("min_size"),
|
|
260
|
+
"currency": spec_in.get("currency", "USD"),
|
|
261
|
+
"money_class": money_class, "status": status,
|
|
262
|
+
"native_status": native_status,
|
|
263
|
+
"provenance": spec_in.get("provenance", {}),
|
|
264
|
+
"resolution": {"state": "NONE", "outcome": None,
|
|
265
|
+
"source": None, "evidence_ref": None,
|
|
266
|
+
"criteria_match": None, "history": []}}
|
|
267
|
+
if criteria:
|
|
268
|
+
inst["resolution_criteria"] = criteria["text"]
|
|
269
|
+
inst["resolution_criteria_source_ref"] = criteria.get(
|
|
270
|
+
"source_ref")
|
|
271
|
+
inst["criteria_versions"] = [{
|
|
272
|
+
"hash": sha(criteria["text"]), "text": criteria["text"],
|
|
273
|
+
"source_ref": criteria.get("source_ref"),
|
|
274
|
+
"first_seen": now_iso()}]
|
|
275
|
+
# Probability guard runs PRE-COMMIT (PP3-H3): a refused
|
|
276
|
+
# ingest writes nothing — no instrument, no receipt.
|
|
277
|
+
guard_no_probability(inst)
|
|
278
|
+
state["instruments"][iid] = inst
|
|
279
|
+
emit_receipt(state, "instrument_ingested",
|
|
280
|
+
{"instrument_id": iid, "venue": inst["venue"],
|
|
281
|
+
"kind": kind})
|
|
282
|
+
return {"instrument": inst, "criteria_changed": None}
|
|
283
|
+
# Refresh: status/native fields update; criteria are pinned.
|
|
284
|
+
# Mutations land on a copy and commit only after the
|
|
285
|
+
# probability guard passes (PP3-H3) — a refused refresh
|
|
286
|
+
# leaves the stored instrument and receipts untouched.
|
|
287
|
+
updated = copy.deepcopy(existing)
|
|
288
|
+
updated["status"] = status
|
|
289
|
+
updated["native_status"] = native_status
|
|
290
|
+
if spec_in.get("trading_window"):
|
|
291
|
+
updated["trading_window"] = spec_in["trading_window"]
|
|
292
|
+
if criteria:
|
|
293
|
+
new_hash = sha(criteria["text"])
|
|
294
|
+
cur_hash = updated["criteria_versions"][-1]["hash"]
|
|
295
|
+
if new_hash != cur_hash:
|
|
296
|
+
updated["criteria_versions"].append({
|
|
297
|
+
"hash": new_hash, "text": criteria["text"],
|
|
298
|
+
"source_ref": criteria.get("source_ref"),
|
|
299
|
+
"first_seen": now_iso()})
|
|
300
|
+
updated["resolution_criteria"] = criteria["text"]
|
|
301
|
+
updated["resolution_criteria_source_ref"] = criteria.get(
|
|
302
|
+
"source_ref")
|
|
303
|
+
event = {"type": "CRITERIA_CHANGED", "instrument_id": iid,
|
|
304
|
+
"old_hash": cur_hash, "new_hash": new_hash}
|
|
305
|
+
guard_no_probability(updated)
|
|
306
|
+
state["instruments"][iid] = updated
|
|
307
|
+
if event is not None:
|
|
308
|
+
emit_receipt(state, "criteria_changed", dict(event))
|
|
309
|
+
return {"instrument": updated, "criteria_changed": event}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def criteria_snapshot(inst: dict):
|
|
313
|
+
if "criteria_versions" not in inst:
|
|
314
|
+
return None
|
|
315
|
+
cur = inst["criteria_versions"][-1]
|
|
316
|
+
return {"text": cur["text"], "hash": cur["hash"],
|
|
317
|
+
"source_ref": cur.get("source_ref")}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------- books
|
|
321
|
+
def build_book(instrument_id: str, venue: str, book_type: str,
|
|
322
|
+
bids, asks, as_of: str, tick=None, native_ref=None):
|
|
323
|
+
"""Canonical Book from normalized levels. bids/asks: lists of
|
|
324
|
+
{price, size} with decimal-string values, any order — the
|
|
325
|
+
builder sorts (bids desc, asks asc) and preserves venue-native
|
|
326
|
+
precision by keeping the strings it was given."""
|
|
327
|
+
if book_type not in BOOK_TYPES:
|
|
328
|
+
raise ToolError(f"BOOK_TYPE_UNKNOWN: {book_type}")
|
|
329
|
+
bids_s = sorted(bids, key=lambda lv: D(lv["price"]), reverse=True)
|
|
330
|
+
asks_s = sorted(asks, key=lambda lv: D(lv["price"]))
|
|
331
|
+
book = {"instrument_id": instrument_id, "venue": venue,
|
|
332
|
+
"as_of": as_of, "book_type": book_type,
|
|
333
|
+
"bids": [{"price": ds(lv["price"]), "size": ds(lv["size"])}
|
|
334
|
+
for lv in bids_s],
|
|
335
|
+
"asks": [{"price": ds(lv["price"]), "size": ds(lv["size"])}
|
|
336
|
+
for lv in asks_s],
|
|
337
|
+
"native_ref": native_ref}
|
|
338
|
+
book["depth_summary"] = depth_summary(book, tick)
|
|
339
|
+
if bids_s and asks_s:
|
|
340
|
+
bid, ask = D(bids_s[0]["price"]), D(asks_s[0]["price"])
|
|
341
|
+
mid = (bid + ask) / 2
|
|
342
|
+
book["spread_bps"] = ds((ask - bid) / mid * 10000) if mid else None
|
|
343
|
+
else:
|
|
344
|
+
book["spread_bps"] = None
|
|
345
|
+
return book
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def depth_summary(book: dict, tick=None) -> dict:
|
|
349
|
+
bids, asks = book["bids"], book["asks"]
|
|
350
|
+
out = {"size_at_best_bid": bids[0]["size"] if bids else "0",
|
|
351
|
+
"size_at_best_ask": asks[0]["size"] if asks else "0",
|
|
352
|
+
"notional_at_best_ask": "0", "size_within_5_ticks_bid": "0",
|
|
353
|
+
"size_within_5_ticks_ask": "0"}
|
|
354
|
+
if asks:
|
|
355
|
+
out["notional_at_best_ask"] = ds(
|
|
356
|
+
D(asks[0]["size"]) * D(asks[0]["price"]))
|
|
357
|
+
if tick:
|
|
358
|
+
t = D(tick)
|
|
359
|
+
if bids:
|
|
360
|
+
floor = D(bids[0]["price"]) - 5 * t
|
|
361
|
+
out["size_within_5_ticks_bid"] = ds(sum(
|
|
362
|
+
D(lv["size"]) for lv in bids if D(lv["price"]) >= floor))
|
|
363
|
+
if asks:
|
|
364
|
+
cap = D(asks[0]["price"]) + 5 * t
|
|
365
|
+
out["size_within_5_ticks_ask"] = ds(sum(
|
|
366
|
+
D(lv["size"]) for lv in asks if D(lv["price"]) <= cap))
|
|
367
|
+
return out
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def liquidity_flag(book: dict) -> str:
|
|
371
|
+
notional = D(book["depth_summary"]["notional_at_best_ask"])
|
|
372
|
+
if notional >= LIQUIDITY_DEEP_MIN:
|
|
373
|
+
return "DEEP"
|
|
374
|
+
if notional < LIQUIDITY_THIN_MAX:
|
|
375
|
+
return "THIN"
|
|
376
|
+
return "FRAGILE"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def ingest_book(state: dict, book: dict) -> dict:
|
|
380
|
+
iid = book["instrument_id"]
|
|
381
|
+
if iid not in state["instruments"]:
|
|
382
|
+
raise ToolError(f"BOOK_FOR_UNKNOWN_INSTRUMENT: {iid}")
|
|
383
|
+
outcome = book.get("outcome")
|
|
384
|
+
key = f"{iid}|{outcome}" if outcome else iid
|
|
385
|
+
book = dict(book)
|
|
386
|
+
book["outcome"] = outcome
|
|
387
|
+
state["books"][key] = book
|
|
388
|
+
return {"book": book}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def get_book(state: dict, instrument_id: str, outcome=None):
|
|
392
|
+
key = f"{instrument_id}|{outcome}" if outcome else instrument_id
|
|
393
|
+
book = state["books"].get(key)
|
|
394
|
+
if book is None and outcome is None:
|
|
395
|
+
cands = [b for k, b in state["books"].items()
|
|
396
|
+
if k.startswith(instrument_id + "|")]
|
|
397
|
+
if len(cands) == 1:
|
|
398
|
+
book = cands[0]
|
|
399
|
+
if book is None:
|
|
400
|
+
raise ToolError(f"BOOK_NOT_FOUND: {key}")
|
|
401
|
+
return book
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# -------------------------------------------------------------------- quotes
|
|
405
|
+
def quote_get(state: dict, instrument_id: str, size,
|
|
406
|
+
outcome=None, as_of=None) -> dict:
|
|
407
|
+
"""The ONLY price-shaped object consumers may receive (5.1):
|
|
408
|
+
price + all-in cost for a stated size + fee-truth state +
|
|
409
|
+
liquidity + (for event contracts) the criteria attached.
|
|
410
|
+
Never a probability field (5.5)."""
|
|
411
|
+
inst = state["instruments"].get(instrument_id)
|
|
412
|
+
if inst is None:
|
|
413
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {instrument_id}")
|
|
414
|
+
book = get_book(state, instrument_id, outcome)
|
|
415
|
+
if not book["bids"] or not book["asks"]:
|
|
416
|
+
raise ToolError("BOOK_ONE_SIDED: no two-sided book, no quote")
|
|
417
|
+
as_of = as_of or now_iso()
|
|
418
|
+
bid = D(book["bids"][0]["price"])
|
|
419
|
+
ask = D(book["asks"][0]["price"])
|
|
420
|
+
mid = (bid + ask) / 2
|
|
421
|
+
n = D(size)
|
|
422
|
+
sched, fee_status = fee_freshness(state, inst["venue"], as_of)
|
|
423
|
+
quote = {"instrument_id": instrument_id, "venue": inst["venue"],
|
|
424
|
+
"outcome": outcome, "as_of": as_of,
|
|
425
|
+
"book_as_of": book["as_of"], "bid": ds(bid), "ask": ds(ask),
|
|
426
|
+
"mid": ds(mid), "mid_basis": "computed",
|
|
427
|
+
"spread_bps": book["spread_bps"],
|
|
428
|
+
"size": ds(n), "currency": inst["currency"],
|
|
429
|
+
"money_class": inst["money_class"],
|
|
430
|
+
"liquidity_flag": liquidity_flag(book),
|
|
431
|
+
"depth_summary": book["depth_summary"],
|
|
432
|
+
"fee_status": fee_status,
|
|
433
|
+
"fee_schedule_ref": sched["schedule_ref"] if sched else None}
|
|
434
|
+
age = (parse_time(as_of) - parse_time(
|
|
435
|
+
book["as_of"])).total_seconds()
|
|
436
|
+
quote["book_stale"] = age > state["config"]["book_stale_seconds"]
|
|
437
|
+
if fee_status == "FRESH":
|
|
438
|
+
bps = D(sched["taker_fee_bps"])
|
|
439
|
+
buy_gross = ask * n
|
|
440
|
+
buy_fee = buy_gross * bps / 10000
|
|
441
|
+
sell_gross = bid * n
|
|
442
|
+
sell_fee = sell_gross * bps / 10000
|
|
443
|
+
quote["all_in_buy_cost"] = {
|
|
444
|
+
"total": ds(buy_gross + buy_fee),
|
|
445
|
+
"per_unit": ds((buy_gross + buy_fee) / n),
|
|
446
|
+
"fee_amount": ds(buy_fee),
|
|
447
|
+
"fee_schedule_ref": sched["schedule_ref"]}
|
|
448
|
+
quote["all_in_sell_value"] = {
|
|
449
|
+
"total": ds(sell_gross - sell_fee),
|
|
450
|
+
"per_unit": ds((sell_gross - sell_fee) / n),
|
|
451
|
+
"fee_amount": ds(sell_fee),
|
|
452
|
+
"fee_schedule_ref": sched["schedule_ref"]}
|
|
453
|
+
quote["settlement_fee_model"] = sched["settlement_fee_model"]
|
|
454
|
+
if inst["kind"] in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
|
|
455
|
+
quote["max_payout_for_size"] = ds(n * D(1))
|
|
456
|
+
else:
|
|
457
|
+
# A wrong all-in number is worse than no number (5.3).
|
|
458
|
+
quote["all_in_buy_cost"] = "FEE_UNVERIFIED"
|
|
459
|
+
quote["all_in_sell_value"] = "FEE_UNVERIFIED"
|
|
460
|
+
if inst["kind"] in ("EVENT_CONTRACT", "PLAY_EVENT_CONTRACT"):
|
|
461
|
+
snap = criteria_snapshot(inst)
|
|
462
|
+
quote["resolution_criteria"] = snap["text"]
|
|
463
|
+
quote["resolution_criteria_hash"] = snap["hash"]
|
|
464
|
+
quote["resolution_source"] = inst["resolution_source"]
|
|
465
|
+
return guard_no_probability(quote)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ----------------------------------------------------------------- positions
|
|
469
|
+
def position_open(state: dict, args: dict) -> dict:
|
|
470
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
471
|
+
if inst is None:
|
|
472
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
473
|
+
pid = f"pos-{len(state['positions']) + 1}"
|
|
474
|
+
snap = criteria_snapshot(inst)
|
|
475
|
+
pos = {"position_id": pid, "instrument_id": inst["instrument_id"],
|
|
476
|
+
"venue": inst["venue"],
|
|
477
|
+
"account_ref": args.get("account_ref", "owner"),
|
|
478
|
+
"outcome": args.get("outcome"),
|
|
479
|
+
"size": ds(args["size"]),
|
|
480
|
+
"avg_cost": ds(args["entry_price"]),
|
|
481
|
+
"avg_cost_basis": "all-in per unit, as supplied at entry",
|
|
482
|
+
"settlement_state": "OPEN",
|
|
483
|
+
"criteria_as_of_entry": snap,
|
|
484
|
+
"opened_at": now_iso()}
|
|
485
|
+
state["positions"][pid] = pos
|
|
486
|
+
emit_receipt(state, "position_opened",
|
|
487
|
+
{"position_id": pid, "instrument_id": inst["instrument_id"],
|
|
488
|
+
"criteria_hash": snap["hash"] if snap else None})
|
|
489
|
+
return {"position": position_view(state, pos)}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def position_view(state: dict, pos: dict) -> dict:
|
|
493
|
+
inst = state["instruments"][pos["instrument_id"]]
|
|
494
|
+
view = dict(pos)
|
|
495
|
+
cur = criteria_snapshot(inst)
|
|
496
|
+
view["criteria_current"] = cur
|
|
497
|
+
entry = pos.get("criteria_as_of_entry")
|
|
498
|
+
view["criteria_changed_since_entry"] = bool(
|
|
499
|
+
entry and cur and entry["hash"] != cur["hash"])
|
|
500
|
+
view["resolution_state"] = inst["resolution"]["state"]
|
|
501
|
+
view["money_class"] = inst["money_class"]
|
|
502
|
+
return guard_no_probability(view)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# --------------------------------------------------------------- resolutions
|
|
506
|
+
def resolution_report(state: dict, args: dict) -> dict:
|
|
507
|
+
"""A resolution is a claim until verified (5.4): REPORTED."""
|
|
508
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
509
|
+
if inst is None:
|
|
510
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
511
|
+
res = inst["resolution"]
|
|
512
|
+
if args.get("disputed"):
|
|
513
|
+
res["state"] = "DISPUTED"
|
|
514
|
+
elif res["state"] in ("NONE", "DISPUTED"):
|
|
515
|
+
res["state"] = "REPORTED"
|
|
516
|
+
res["outcome"] = args["outcome"]
|
|
517
|
+
res["source"] = args.get("source")
|
|
518
|
+
res["evidence_ref"] = args.get("evidence_ref")
|
|
519
|
+
res["history"].append({"state": res["state"], "ts": now_iso(),
|
|
520
|
+
"outcome": args["outcome"],
|
|
521
|
+
"evidence_ref": args.get("evidence_ref")})
|
|
522
|
+
emit_receipt(state, "resolution_reported",
|
|
523
|
+
{"instrument_id": inst["instrument_id"],
|
|
524
|
+
"state": res["state"], "outcome": args["outcome"]})
|
|
525
|
+
return {"resolution": resolution_view(inst)}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def resolution_verify(state: dict, args: dict) -> dict:
|
|
529
|
+
"""VERIFIED only against the criteria's OWN named source
|
|
530
|
+
(5.4). A verification attempt naming any other source is
|
|
531
|
+
refused by name; a live dispute keeps the state DISPUTED."""
|
|
532
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
533
|
+
if inst is None:
|
|
534
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
535
|
+
res = inst["resolution"]
|
|
536
|
+
if res["state"] == "NONE":
|
|
537
|
+
raise ToolError("RESOLUTION_NOT_REPORTED: nothing to verify")
|
|
538
|
+
named = inst.get("resolution_source")
|
|
539
|
+
if args.get("verification_source") != named:
|
|
540
|
+
raise ToolError(
|
|
541
|
+
f"RESOLUTION_SOURCE_MISMATCH: criteria name '{named}' as "
|
|
542
|
+
f"the source of truth; '{args.get('verification_source')}' "
|
|
543
|
+
"cannot verify this resolution")
|
|
544
|
+
if res["state"] == "DISPUTED" and not args.get("dispute_cleared"):
|
|
545
|
+
raise ToolError(
|
|
546
|
+
"RESOLUTION_DISPUTED: dispute is open; verification waits "
|
|
547
|
+
"for finality (spec 5.4)")
|
|
548
|
+
res["state"] = "VERIFIED"
|
|
549
|
+
res["criteria_match"] = bool(args.get("criteria_match", True))
|
|
550
|
+
res["history"].append({"state": "VERIFIED", "ts": now_iso(),
|
|
551
|
+
"verification_source": named,
|
|
552
|
+
"criteria_match": res["criteria_match"]})
|
|
553
|
+
emit_receipt(state, "resolution_verified",
|
|
554
|
+
{"instrument_id": inst["instrument_id"],
|
|
555
|
+
"outcome": res["outcome"],
|
|
556
|
+
"criteria_match": res["criteria_match"]})
|
|
557
|
+
return {"resolution": resolution_view(inst)}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def resolution_view(inst: dict) -> dict:
|
|
561
|
+
res = inst["resolution"]
|
|
562
|
+
return guard_no_probability({
|
|
563
|
+
"instrument_id": inst["instrument_id"],
|
|
564
|
+
"state": res["state"], "verification": res["state"],
|
|
565
|
+
"outcome": res["outcome"], "source": res["source"],
|
|
566
|
+
"named_resolution_source": inst.get("resolution_source"),
|
|
567
|
+
"evidence_ref": res["evidence_ref"],
|
|
568
|
+
"criteria_match": res["criteria_match"],
|
|
569
|
+
"history": res["history"]})
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# -------------------------------------------------------------------- orders
|
|
573
|
+
def order_draft(state: dict, args: dict) -> dict:
|
|
574
|
+
"""M0: a DRAFT record only. No submission path exists in
|
|
575
|
+
this build at any tier; the mandate pre-evaluation is
|
|
576
|
+
honestly NOT_EVALUATED until M2 wires the Wallet gate
|
|
577
|
+
(spec 5.6 / 7.2)."""
|
|
578
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
579
|
+
if inst is None:
|
|
580
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
581
|
+
otype = args.get("type", "LIMIT")
|
|
582
|
+
if otype not in ORDER_TYPES:
|
|
583
|
+
raise ToolError(
|
|
584
|
+
f"ORDER_TYPE_NOT_IN_V1_ALLOWLIST: {otype} (MARKET|LIMIT only)")
|
|
585
|
+
oid = f"ord-{len(state['orders']) + 1}"
|
|
586
|
+
order = {"order_id": oid, "venue_order_id": None,
|
|
587
|
+
"instrument_id": inst["instrument_id"],
|
|
588
|
+
"venue": inst["venue"], "side": args["side"],
|
|
589
|
+
"type": otype, "size": ds(args["size"]),
|
|
590
|
+
"limit_price": ds(args["limit_price"])
|
|
591
|
+
if args.get("limit_price") is not None else None,
|
|
592
|
+
"status": "DRAFT", "fills": [],
|
|
593
|
+
"mandate_ref": None,
|
|
594
|
+
"mandate_pre_eval": "NOT_EVALUATED_M0_DATA_ONLY",
|
|
595
|
+
"created_at": now_iso()}
|
|
596
|
+
state["orders"][oid] = order
|
|
597
|
+
receipt = emit_receipt(state, "order_drafted",
|
|
598
|
+
{"order_id": oid,
|
|
599
|
+
"instrument_id": inst["instrument_id"]})
|
|
600
|
+
order["receipt_ref"] = receipt["hash"]
|
|
601
|
+
return {"order": order}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ---------------------------------------------------------------- venue status
|
|
605
|
+
def venue_status(state: dict, venue: str, as_of=None) -> dict:
|
|
606
|
+
as_of = as_of or now_iso()
|
|
607
|
+
sched, fee_status = fee_freshness(state, venue, as_of)
|
|
608
|
+
insts = [i for i in state["instruments"].values()
|
|
609
|
+
if i["venue"] == venue]
|
|
610
|
+
return {"venue": venue, "as_of": as_of,
|
|
611
|
+
"capability": "M0_DATA_ONLY",
|
|
612
|
+
"order_tools": "ABSENT",
|
|
613
|
+
"account_tools": "ABSENT",
|
|
614
|
+
"instruments": len(insts),
|
|
615
|
+
"fee_schedule_ref": sched["schedule_ref"] if sched else None,
|
|
616
|
+
"fee_status": fee_status,
|
|
617
|
+
"chain_valid": verify_receipt_chain(state["receipts"])}
|
|
618
|
+
|
|
619
|
+
# Eclipse Book MCP · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cyan-eclipse-book
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Market data + venue access layer — not an exchange: one book, one fee truth, one ledger of record.
|
|
5
|
+
Project-URL: Homepage, https://cetech.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/CYAN-ECLIPSE-TECHNOLOGIES-LLC/eclipse-book-mcp
|
|
7
|
+
Project-URL: Vendor, https://cetech.ai
|
|
8
|
+
Author: Cyan Eclipse Technologies LLC
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fees,market-data,mcp,model-context-protocol,venues
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# cyan-eclipse-book
|
|
21
|
+
|
|
22
|
+
mcp-name: io.github.CYAN-ECLIPSE-TECHNOLOGIES-LLC/book
|
|
23
|
+
|
|
24
|
+
**What this is:** the MCP server for **Eclipse Book MCP** - The Market Core of the Eclipse Markets suite (spec eclipse-markets-suite-spec-20261025): one canonical Instrument / Book / Quote / Order / Position / Resolution model over exchanges and prediction markets, with venue adapters that translate and never improvise. Resolution criteria are stored verbatim and hash-pinned — a venue criteria edit is a CRITERIA_CHANGED event and positions keep the criteria as of entry. Quotes carry all-in buy/sell cost for a stated size from versioned, provenance-stamped fee schedules; a stale or missing schedule degrades all-in fields to FEE_UNVERIFIED rather than quoting a wrong number. Resolutions move REPORTED -> VERIFIED only against the criteria's own named source (DISPUTED is first-class), and no core output ever carries a probability field — outputs carry price + liquidity flag + depth + verbatim criteria. M0 is data only: no accounts, no keys, no order submission at any tier; order_draft is an unsubmitted record whose mandate pre-evaluation is honestly NOT_EVALUATED until the owner-gated M2.
|
|
25
|
+
|
|
26
|
+
## Run it
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
$ pip install cyan-eclipse-book
|
|
30
|
+
$ eclipse-book # MCP over stdio, Python stdlib only
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
MCP client config:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "mcpServers": { "cyan-eclipse-book": { "command": "uvx", "args": ["--from", "cyan-eclipse-book", "eclipse-book"] } } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Tools (17)
|
|
40
|
+
|
|
41
|
+
`instrument_ingest` - `instrument_get` - `instrument_search` - `book_ingest` - `quote_get` - `fee_schedule_register` - `fee_schedule_get` - `position_open` - `position_list` - `position_get` - `resolution_report` - `resolution_verify` - `resolution_get` - `order_draft` - `order_get` - `venue_status` - `receipt_list`
|
|
42
|
+
|
|
43
|
+
## Honest scope
|
|
44
|
+
|
|
45
|
+
- In development — v0.1.0 M0 local build (Market Core, data only; fixtures behind transport seams; CERTIFIED(local) — Eclipse Bazaar Test Bench 2026-10-25)
|
|
46
|
+
- State and runtime data files ship in the wheel beside `server.py`,
|
|
47
|
+
at the same relative paths the server reads them from.
|
|
48
|
+
|
|
49
|
+
Powered by Cyan Eclipse Technologies LLC (cetech.ai) - a Veteran-Owned
|
|
50
|
+
Small Business. MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
bazaar-manifest.json,sha256=oPV-jqsBoreA4iCiYSNmy7uw9WVf6-RgxYe2Apg16Pw,4657
|
|
2
|
+
book_engine.py,sha256=fceXMnZLDbaeI_PV_JeSdN-yHfS3kVKPW-tQudWJdHE,26539
|
|
3
|
+
fixtures.json,sha256=t1d8_AuHUsGiK8cE3DjpwLp4JsJ9VPVZJrsE00Ot7ts,1130
|
|
4
|
+
server.py,sha256=KuPRo9uLtvThnjHIXpI0SQHjwFL3NA-6bYFzfnR-5co,16985
|
|
5
|
+
cyan_eclipse_book-0.1.0.dist-info/METADATA,sha256=ajV_lXtrcs9HS7tfNvVRo1DkjEm-pA4yYwW9F-8MqGI,2981
|
|
6
|
+
cyan_eclipse_book-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
cyan_eclipse_book-0.1.0.dist-info/entry_points.txt,sha256=xjkDyF8zjzh6lAKYcZuQGhZ4X1pDW0dFdpftfxHRltA,45
|
|
8
|
+
cyan_eclipse_book-0.1.0.dist-info/licenses/LICENSE,sha256=J64raxD33SRnnCn8p1F-lRl0rnesurM9Jlkd3auDZvA,1086
|
|
9
|
+
cyan_eclipse_book-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cyan Eclipse Technologies LLC
|
|
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.
|
fixtures.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"config":{"book_stale_seconds":86400,"fee_freshness_hours":24},"fee_schedules":[{"fetched_at":"2026-10-25T00:00:00Z","freshness_hours":24,"maker_fee_bps":"0","schedule_ref":"polymarket_global@2026-07-01","settlement_fee_model":{"type":"none"},"source":"fixture: Polymarket fee schedule shape (per-market taker curve, maker zero) — live schedule is a build-time fetch, not_tested in M0","taker_fee_bps":"75","venue":"polymarket_global","version":"2026-07-01"},{"fetched_at":"2026-10-25T12:00:00Z","freshness_hours":72,"maker_fee_bps":"40","schedule_ref":"coinbase@2026-10-25","settlement_fee_model":{"type":"none"},"source":"fixture: Coinbase Advanced Trade public fee schedule shape — account-specific tiers supersede at M1","taker_fee_bps":"60","venue":"coinbase","version":"2026-10-25"},{"fetched_at":"2026-08-01T00:00:00Z","freshness_hours":24,"maker_fee_bps":"40","schedule_ref":"coinbase@2026-08-01-stale","settlement_fee_model":{"type":"none"},"source":"fixture: deliberately stale schedule used to prove FEE_UNVERIFIED degradation","taker_fee_bps":"60","venue":"coinbase_legacy_fixture","version":"2026-08-01-stale"}]}
|
server.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Eclipse Book MCP
|
|
3
|
+
# Copyright © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
4
|
+
|
|
5
|
+
"""Eclipse Book MCP — v0.1.0, phase M0 (Market Core, data only).
|
|
6
|
+
|
|
7
|
+
Powered by Cyan Eclipse Technologies (cetech.ai).
|
|
8
|
+
|
|
9
|
+
Descriptor (mandatory with the name, spec section 3.1): Eclipse
|
|
10
|
+
Book — market data + venue access layer: exchanges and
|
|
11
|
+
prediction markets, normalized: one book, one fee truth, one
|
|
12
|
+
ledger of record. It is NOT an exchange, not a matching engine,
|
|
13
|
+
and never a bookmaker: it holds no funds, crosses no orders,
|
|
14
|
+
and takes no other side.
|
|
15
|
+
|
|
16
|
+
This server owns (spec eclipse-markets-suite-spec-20261025
|
|
17
|
+
section 5): the canonical Instrument / Book / Quote / Order /
|
|
18
|
+
Position / Resolution objects; resolution criteria stored
|
|
19
|
+
VERBATIM + hash-pinned (a criteria edit is a CRITERIA_CHANGED
|
|
20
|
+
event; positions keep the criteria as of entry); fee truth
|
|
21
|
+
(all-in cost per stated size from versioned, provenance-stamped
|
|
22
|
+
fee schedules — a stale schedule degrades the quote to
|
|
23
|
+
FEE_UNVERIFIED rather than quoting a wrong number); the
|
|
24
|
+
resolution lifecycle (REPORTED -> VERIFIED only against the
|
|
25
|
+
criteria's own named source; DISPUTED is first-class); and the
|
|
26
|
+
probability law (no core output carries a probability field —
|
|
27
|
+
outputs carry price + liquidity flag + depth + criteria ref).
|
|
28
|
+
|
|
29
|
+
ADAPTER SEAM (M0 fixture form, stated plainly): adapters import
|
|
30
|
+
book_engine from this directory and evaluate against the SHARED
|
|
31
|
+
suite state file ($ECLIPSE_BOOK_STATE or ./data/state.json
|
|
32
|
+
beside this file), loaded fresh at every call. Adapters own
|
|
33
|
+
translation from venue-native payloads; the core owns objects,
|
|
34
|
+
law, and the ledger.
|
|
35
|
+
|
|
36
|
+
M0 SCOPE: data only. No accounts, no keys, no order submission
|
|
37
|
+
exists in this build at any tier — order_draft produces an
|
|
38
|
+
unsubmitted DRAFT record whose mandate pre-evaluation is
|
|
39
|
+
honestly NOT_EVALUATED until M2 (owner-gated). No certification
|
|
40
|
+
claimed — bench pending.
|
|
41
|
+
|
|
42
|
+
Transport: MCP over stdio (newline-delimited JSON-RPC 2.0),
|
|
43
|
+
stdlib only. State: $ECLIPSE_BOOK_STATE or ./data/state.json,
|
|
44
|
+
seeded from fixtures.json.
|
|
45
|
+
"""
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
HERE = Path(__file__).parent
|
|
52
|
+
sys.path.insert(0, str(HERE))
|
|
53
|
+
|
|
54
|
+
import book_engine as eng # noqa: E402
|
|
55
|
+
from book_engine import ToolError # noqa: E402
|
|
56
|
+
|
|
57
|
+
STATE_PATH = Path(os.environ.get(
|
|
58
|
+
"ECLIPSE_BOOK_STATE", HERE / "data" / "state.json"))
|
|
59
|
+
FIXTURES_PATH = HERE / "fixtures.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_state() -> dict:
|
|
63
|
+
if STATE_PATH.exists():
|
|
64
|
+
return json.loads(STATE_PATH.read_text())
|
|
65
|
+
state = eng.empty_state()
|
|
66
|
+
seed = json.loads(FIXTURES_PATH.read_text())
|
|
67
|
+
eng.ensure_seeded(state, seed)
|
|
68
|
+
save_state(state)
|
|
69
|
+
return state
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_state(state: dict) -> None:
|
|
73
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
STATE_PATH.write_text(json.dumps(state, indent=1))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------- tools
|
|
78
|
+
def t_instrument_ingest(state, args):
|
|
79
|
+
return eng.ingest_instrument(state, args)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def t_instrument_get(state, args):
|
|
83
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
84
|
+
if inst is None:
|
|
85
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
86
|
+
return {"instrument": eng.guard_no_probability(inst)}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def t_instrument_search(state, args):
|
|
90
|
+
query = (args.get("query") or "").lower()
|
|
91
|
+
venue = args.get("venue")
|
|
92
|
+
out = []
|
|
93
|
+
for inst in state["instruments"].values():
|
|
94
|
+
if venue and inst["venue"] != venue:
|
|
95
|
+
continue
|
|
96
|
+
if query and query not in inst["title"].lower() \
|
|
97
|
+
and query not in inst["instrument_id"].lower():
|
|
98
|
+
continue
|
|
99
|
+
out.append({"instrument_id": inst["instrument_id"],
|
|
100
|
+
"venue": inst["venue"], "kind": inst["kind"],
|
|
101
|
+
"title": inst["title"], "status": inst["status"],
|
|
102
|
+
"native_status": inst["native_status"],
|
|
103
|
+
"money_class": inst["money_class"]})
|
|
104
|
+
return {"instruments": out, "count": len(out)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def t_book_ingest(state, args):
|
|
108
|
+
book = eng.build_book(
|
|
109
|
+
args["instrument_id"], args["venue"],
|
|
110
|
+
args.get("book_type", "CLOB"), args["bids"], args["asks"],
|
|
111
|
+
args["as_of"], tick=args.get("tick"),
|
|
112
|
+
native_ref=args.get("native_ref"))
|
|
113
|
+
book["outcome"] = args.get("outcome")
|
|
114
|
+
return eng.ingest_book(state, book)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def t_quote_get(state, args):
|
|
118
|
+
return {"quote": eng.quote_get(
|
|
119
|
+
state, args["instrument_id"], args["size"],
|
|
120
|
+
outcome=args.get("outcome"), as_of=args.get("as_of"))}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def t_fee_schedule_register(state, args):
|
|
124
|
+
return eng.fee_schedule_register(state, args)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def t_fee_schedule_get(state, args):
|
|
128
|
+
sched, status = eng.fee_freshness(
|
|
129
|
+
state, args["venue"], args.get("as_of") or eng.now_iso())
|
|
130
|
+
if sched is None:
|
|
131
|
+
raise ToolError(f"FEE_SCHEDULE_NOT_FOUND: {args['venue']}")
|
|
132
|
+
return {"fee_schedule": sched, "fee_status": status}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def t_position_open(state, args):
|
|
136
|
+
return eng.position_open(state, args)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def t_position_list(state, args):
|
|
140
|
+
return {"positions": [eng.position_view(state, p)
|
|
141
|
+
for p in state["positions"].values()],
|
|
142
|
+
"count": len(state["positions"])}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def t_position_get(state, args):
|
|
146
|
+
pos = state["positions"].get(args["position_id"])
|
|
147
|
+
if pos is None:
|
|
148
|
+
raise ToolError(f"POSITION_NOT_FOUND: {args['position_id']}")
|
|
149
|
+
return {"position": eng.position_view(state, pos)}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def t_resolution_report(state, args):
|
|
153
|
+
return eng.resolution_report(state, args)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def t_resolution_verify(state, args):
|
|
157
|
+
return eng.resolution_verify(state, args)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def t_resolution_get(state, args):
|
|
161
|
+
inst = state["instruments"].get(args["instrument_id"])
|
|
162
|
+
if inst is None:
|
|
163
|
+
raise ToolError(f"INSTRUMENT_NOT_FOUND: {args['instrument_id']}")
|
|
164
|
+
return {"resolution": eng.resolution_view(inst)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def t_order_draft(state, args):
|
|
168
|
+
return eng.order_draft(state, args)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def t_order_get(state, args):
|
|
172
|
+
order = state["orders"].get(args["order_id"])
|
|
173
|
+
if order is None:
|
|
174
|
+
raise ToolError(f"ORDER_NOT_FOUND: {args['order_id']}")
|
|
175
|
+
return {"order": order}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def t_venue_status(state, args):
|
|
179
|
+
return eng.venue_status(state, args["venue"],
|
|
180
|
+
as_of=args.get("as_of"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def t_receipt_list(state, args):
|
|
184
|
+
limit = args.get("limit")
|
|
185
|
+
receipts = state["receipts"]
|
|
186
|
+
if limit is not None:
|
|
187
|
+
receipts = receipts[-int(limit):]
|
|
188
|
+
return {"receipts": receipts, "count": len(state["receipts"]),
|
|
189
|
+
"chain_valid": eng.verify_receipt_chain(state["receipts"])}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
TOOLS = {
|
|
193
|
+
"instrument_ingest": (
|
|
194
|
+
t_instrument_ingest,
|
|
195
|
+
"T1 (core-local). Register or refresh a canonical "
|
|
196
|
+
"Instrument from an adapter-normalized spec. Event "
|
|
197
|
+
"contracts REQUIRE verbatim resolution criteria + a "
|
|
198
|
+
"named resolution source; criteria are hash-pinned and a "
|
|
199
|
+
"later change is a CRITERIA_CHANGED event (spec 5.4). "
|
|
200
|
+
"Unmapped venue statuses surface as UNMAPPED, never "
|
|
201
|
+
"coerced (5.2).",
|
|
202
|
+
{"venue": {"type": "string"}, "native_id": {"type": "string"},
|
|
203
|
+
"native_symbol": {"type": "string"},
|
|
204
|
+
"kind": {"type": "string"}, "title": {"type": "string"},
|
|
205
|
+
"underlying": {"type": "string"},
|
|
206
|
+
"outcomes": {"type": "array", "items": {"type": "string"}},
|
|
207
|
+
"resolution_criteria": {"type": "object"},
|
|
208
|
+
"resolution_source": {"type": "string"},
|
|
209
|
+
"trading_window": {"type": "object"},
|
|
210
|
+
"tick": {"type": "string"}, "min_size": {"type": "string"},
|
|
211
|
+
"currency": {"type": "string"},
|
|
212
|
+
"money_class": {"type": "string"},
|
|
213
|
+
"native_status": {"type": "string"},
|
|
214
|
+
"status_mapped": {"type": "string"},
|
|
215
|
+
"provenance": {"type": "object"}},
|
|
216
|
+
["venue", "native_id", "kind", "title"]),
|
|
217
|
+
"instrument_get": (
|
|
218
|
+
t_instrument_get,
|
|
219
|
+
"T0. One canonical Instrument by core id, criteria "
|
|
220
|
+
"versions and resolution state included.",
|
|
221
|
+
{"instrument_id": {"type": "string"}}, ["instrument_id"]),
|
|
222
|
+
"instrument_search": (
|
|
223
|
+
t_instrument_search,
|
|
224
|
+
"T0. Search instruments by title/id substring, optional "
|
|
225
|
+
"venue filter.",
|
|
226
|
+
{"query": {"type": "string"}, "venue": {"type": "string"}}, []),
|
|
227
|
+
"book_ingest": (
|
|
228
|
+
t_book_ingest,
|
|
229
|
+
"T1 (core-local). Store a canonical Book snapshot built "
|
|
230
|
+
"from normalized levels for an ingested instrument "
|
|
231
|
+
"(per outcome for event contracts).",
|
|
232
|
+
{"instrument_id": {"type": "string"},
|
|
233
|
+
"venue": {"type": "string"}, "book_type": {"type": "string"},
|
|
234
|
+
"bids": {"type": "array", "items": {"type": "object"}},
|
|
235
|
+
"asks": {"type": "array", "items": {"type": "object"}},
|
|
236
|
+
"as_of": {"type": "string"}, "tick": {"type": "string"},
|
|
237
|
+
"outcome": {"type": "string"},
|
|
238
|
+
"native_ref": {"type": "string"}},
|
|
239
|
+
["instrument_id", "venue", "bids", "asks", "as_of"]),
|
|
240
|
+
"quote_get": (
|
|
241
|
+
t_quote_get,
|
|
242
|
+
"T0. The only price-shaped object (spec 5.1): bid/ask/"
|
|
243
|
+
"computed mid, ALL-IN buy/sell cost for the stated size "
|
|
244
|
+
"from the venue's dated fee schedule, liquidity flag + "
|
|
245
|
+
"depth, and — for event contracts — the verbatim "
|
|
246
|
+
"resolution criteria. A stale or missing fee schedule "
|
|
247
|
+
"degrades all-in fields to FEE_UNVERIFIED (5.3). Never "
|
|
248
|
+
"a probability field (5.5).",
|
|
249
|
+
{"instrument_id": {"type": "string"},
|
|
250
|
+
"size": {"type": "string"}, "outcome": {"type": "string"},
|
|
251
|
+
"as_of": {"type": "string"}},
|
|
252
|
+
["instrument_id", "size"]),
|
|
253
|
+
"fee_schedule_register": (
|
|
254
|
+
t_fee_schedule_register,
|
|
255
|
+
"T1 (core-local). Register a versioned fee schedule. "
|
|
256
|
+
"Provenance (source + fetched_at) is REQUIRED — a "
|
|
257
|
+
"schedule without it is not a schedule (5.3).",
|
|
258
|
+
{"venue": {"type": "string"}, "version": {"type": "string"},
|
|
259
|
+
"source": {"type": "string"},
|
|
260
|
+
"fetched_at": {"type": "string"},
|
|
261
|
+
"taker_fee_bps": {"type": "string"},
|
|
262
|
+
"maker_fee_bps": {"type": "string"},
|
|
263
|
+
"settlement_fee_model": {"type": "object"},
|
|
264
|
+
"freshness_hours": {"type": "integer"}},
|
|
265
|
+
["venue", "version", "source", "fetched_at", "taker_fee_bps"]),
|
|
266
|
+
"fee_schedule_get": (
|
|
267
|
+
t_fee_schedule_get,
|
|
268
|
+
"T0. The venue's current fee schedule + its freshness "
|
|
269
|
+
"state (FRESH | STALE | NO_SCHEDULE).",
|
|
270
|
+
{"venue": {"type": "string"}, "as_of": {"type": "string"}},
|
|
271
|
+
["venue"]),
|
|
272
|
+
"position_open": (
|
|
273
|
+
t_position_open,
|
|
274
|
+
"T1 (core-local record). Record a position with the "
|
|
275
|
+
"resolution criteria snapshotted AS OF ENTRY (5.4). A "
|
|
276
|
+
"record, not an execution: M0 places nothing.",
|
|
277
|
+
{"instrument_id": {"type": "string"},
|
|
278
|
+
"size": {"type": "string"},
|
|
279
|
+
"entry_price": {"type": "string"},
|
|
280
|
+
"outcome": {"type": "string"},
|
|
281
|
+
"account_ref": {"type": "string"}},
|
|
282
|
+
["instrument_id", "size", "entry_price"]),
|
|
283
|
+
"position_list": (
|
|
284
|
+
t_position_list,
|
|
285
|
+
"T0/T1. All recorded positions, each carrying its "
|
|
286
|
+
"criteria-as-of-entry beside the current criteria and a "
|
|
287
|
+
"criteria_changed_since_entry flag.",
|
|
288
|
+
{}, []),
|
|
289
|
+
"position_get": (
|
|
290
|
+
t_position_get,
|
|
291
|
+
"T0/T1. One recorded position by id.",
|
|
292
|
+
{"position_id": {"type": "string"}}, ["position_id"]),
|
|
293
|
+
"resolution_report": (
|
|
294
|
+
t_resolution_report,
|
|
295
|
+
"T1 (core-local). Record a venue-reported resolution "
|
|
296
|
+
"(REPORTED) or mark a dispute (DISPUTED, first-class, "
|
|
297
|
+
"spec 5.4). A report is a claim, not a fact.",
|
|
298
|
+
{"instrument_id": {"type": "string"},
|
|
299
|
+
"outcome": {"type": "string"}, "source": {"type": "string"},
|
|
300
|
+
"evidence_ref": {"type": "string"},
|
|
301
|
+
"disputed": {"type": "boolean"}},
|
|
302
|
+
["instrument_id", "outcome"]),
|
|
303
|
+
"resolution_verify": (
|
|
304
|
+
t_resolution_verify,
|
|
305
|
+
"T1 (core-local). Promote REPORTED -> VERIFIED, only "
|
|
306
|
+
"against the criteria's OWN named source of truth; any "
|
|
307
|
+
"other source is refused by name, and an open dispute "
|
|
308
|
+
"blocks verification until cleared (5.4).",
|
|
309
|
+
{"instrument_id": {"type": "string"},
|
|
310
|
+
"verification_source": {"type": "string"},
|
|
311
|
+
"criteria_match": {"type": "boolean"},
|
|
312
|
+
"dispute_cleared": {"type": "boolean"}},
|
|
313
|
+
["instrument_id", "verification_source"]),
|
|
314
|
+
"resolution_get": (
|
|
315
|
+
t_resolution_get,
|
|
316
|
+
"T0. The Resolution record for an instrument: state "
|
|
317
|
+
"(NONE|REPORTED|VERIFIED|DISPUTED), outcome, sources, "
|
|
318
|
+
"history. Unverified is data, not fact (5.4).",
|
|
319
|
+
{"instrument_id": {"type": "string"}}, ["instrument_id"]),
|
|
320
|
+
"order_draft": (
|
|
321
|
+
t_order_draft,
|
|
322
|
+
"T1. Build an UNSUBMITTED Order in DRAFT (MARKET|LIMIT "
|
|
323
|
+
"only) with its receipt. M0 has no submission path at "
|
|
324
|
+
"any tier; mandate pre-evaluation reads "
|
|
325
|
+
"NOT_EVALUATED_M0_DATA_ONLY (spec 5.6, 7.2).",
|
|
326
|
+
{"instrument_id": {"type": "string"},
|
|
327
|
+
"side": {"type": "string"}, "type": {"type": "string"},
|
|
328
|
+
"size": {"type": "string"},
|
|
329
|
+
"limit_price": {"type": "string"}},
|
|
330
|
+
["instrument_id", "side", "size"]),
|
|
331
|
+
"order_get": (
|
|
332
|
+
t_order_get,
|
|
333
|
+
"T0/T1. One Order record with lifecycle + fills + fee "
|
|
334
|
+
"reconciliation fields.",
|
|
335
|
+
{"order_id": {"type": "string"}}, ["order_id"]),
|
|
336
|
+
"venue_status": (
|
|
337
|
+
t_venue_status,
|
|
338
|
+
"T0. Adapter health for a venue: capability record "
|
|
339
|
+
"(M0 = DATA ONLY), instrument count, fee-schedule "
|
|
340
|
+
"freshness, ledger chain state.",
|
|
341
|
+
{"venue": {"type": "string"}, "as_of": {"type": "string"}},
|
|
342
|
+
["venue"]),
|
|
343
|
+
"receipt_list": (
|
|
344
|
+
t_receipt_list,
|
|
345
|
+
"T0. The suite ledger: append-only, hash-chained "
|
|
346
|
+
"receipts; verifies the chain.",
|
|
347
|
+
{"limit": {"type": "integer"}}, []),
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# Capability denylist, stated in code (spec 7/8): M0 is data
|
|
351
|
+
# only — no placement, no accounts, no withdrawals, no advice.
|
|
352
|
+
FORBIDDEN_TOOL_NAMES = {
|
|
353
|
+
"order_place", "order_submit", "place_order", "submit_order",
|
|
354
|
+
"trade_execute", "buy", "sell", "withdraw", "withdraw_funds",
|
|
355
|
+
"transfer_funds", "account_get", "account_summary",
|
|
356
|
+
"create_api_key", "margin_open", "leverage_set",
|
|
357
|
+
"recommend", "signal_get", "probability_get",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def tool_list():
|
|
362
|
+
return [{"name": name, "description": description,
|
|
363
|
+
"inputSchema": {"type": "object", "properties": props,
|
|
364
|
+
"required": required}}
|
|
365
|
+
for name, (_, description, props, required) in TOOLS.items()]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def handle(req: dict):
|
|
369
|
+
method, rid = req.get("method", ""), req.get("id")
|
|
370
|
+
if method == "initialize":
|
|
371
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
372
|
+
"protocolVersion": req.get("params", {}).get(
|
|
373
|
+
"protocolVersion", "2025-03-26"),
|
|
374
|
+
"capabilities": {"tools": {}},
|
|
375
|
+
"serverInfo": {"name": "eclipse-book",
|
|
376
|
+
"version": "0.1.0"}}}
|
|
377
|
+
if method == "ping":
|
|
378
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
379
|
+
if method == "tools/list":
|
|
380
|
+
return {"jsonrpc": "2.0", "id": rid,
|
|
381
|
+
"result": {"tools": tool_list()}}
|
|
382
|
+
if method == "tools/call":
|
|
383
|
+
name = req["params"]["name"]
|
|
384
|
+
args = req["params"].get("arguments", {})
|
|
385
|
+
if name in FORBIDDEN_TOOL_NAMES or name not in TOOLS:
|
|
386
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {
|
|
387
|
+
"code": -32602,
|
|
388
|
+
"message": f"unknown or line-forbidden tool {name}"}}
|
|
389
|
+
state = load_state()
|
|
390
|
+
receipts_before = len(state["receipts"])
|
|
391
|
+
try:
|
|
392
|
+
out = TOOLS[name][0](state, args)
|
|
393
|
+
save_state(state)
|
|
394
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
395
|
+
"content": [{"type": "text",
|
|
396
|
+
"text": json.dumps(out, indent=1)}]}}
|
|
397
|
+
except ToolError as exc:
|
|
398
|
+
if len(state["receipts"]) > receipts_before:
|
|
399
|
+
save_state(state)
|
|
400
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {
|
|
401
|
+
"content": [{"type": "text",
|
|
402
|
+
"text": json.dumps({"error": str(exc)})}],
|
|
403
|
+
"isError": True}}
|
|
404
|
+
if rid is not None:
|
|
405
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {
|
|
406
|
+
"code": -32601, "message": f"method not found: {method}"}}
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def main():
|
|
411
|
+
for line in sys.stdin:
|
|
412
|
+
line = line.strip()
|
|
413
|
+
if not line:
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
response = handle(json.loads(line))
|
|
417
|
+
except Exception as exc: # never crash the transport
|
|
418
|
+
response = {"jsonrpc": "2.0", "id": None, "error": {
|
|
419
|
+
"code": -32603, "message": str(exc)}}
|
|
420
|
+
if response is not None:
|
|
421
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
422
|
+
sys.stdout.flush()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
main()
|
|
427
|
+
|
|
428
|
+
# Eclipse Book MCP · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|