optiondesk-agent 0.3.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.
@@ -0,0 +1,76 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Required Notice: Copyright 2026 Iman Samizadeh
6
+ (https://github.com/Iman/agent-driven-options-desk-and-skills)
7
+
8
+ ## Acceptance
9
+
10
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
11
+
12
+ ## Copyright License
13
+
14
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
15
+
16
+ ## Distribution License
17
+
18
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
19
+
20
+ ## Notices
21
+
22
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
23
+
24
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
25
+
26
+ ## Changes and New Works License
27
+
28
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
29
+
30
+ ## Patent License
31
+
32
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
33
+
34
+ ## Noncommercial Purposes
35
+
36
+ Any noncommercial purpose is a permitted purpose.
37
+
38
+ ## Personal Uses
39
+
40
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
41
+
42
+ ## Noncommercial Organizations
43
+
44
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
45
+
46
+ ## Fair Use
47
+
48
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
49
+
50
+ ## No Other Rights
51
+
52
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
53
+
54
+ ## Patent Defense
55
+
56
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
57
+
58
+ ## Violations
59
+
60
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
61
+
62
+ ## No Liability
63
+
64
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
65
+
66
+ ## Definitions
67
+
68
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
69
+
70
+ **You** refers to the individual or entity agreeing to these terms.
71
+
72
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
73
+
74
+ **Your licenses** are all the licenses granted to you for the software under these terms.
75
+
76
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: optiondesk-agent
3
+ Version: 0.3.0
4
+ Summary: LangChain bindings for the option desk: tools, artifact retrieval, and a grounded answer chain
5
+ Author: Iman Samizadeh
6
+ License: PolyForm-Noncommercial-1.0.0
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: langchain-core>=0.3
10
+ Requires-Dist: optiondesk
11
+ Provides-Extra: dev
12
+ Requires-Dist: langgraph>=0.2; extra == 'dev'
13
+ Requires-Dist: pytest; extra == 'dev'
14
+ Provides-Extra: graph
15
+ Requires-Dist: langgraph>=0.2; extra == 'graph'
@@ -0,0 +1,99 @@
1
+ # optiondesk-agent
2
+
3
+ LangChain bindings for the desk. PolyForm Noncommercial 1.0.0, like the
4
+ rest of the project.
5
+
6
+ ## What this is, and what it deliberately is not
7
+
8
+ It exposes the desk's capabilities as LangChain tools, turns the artifacts
9
+ on disk into retrievable documents, and assembles a prompt that answers a
10
+ question strictly from those artifacts.
11
+
12
+ It is not in the compute path and must never be. Pricing, Greeks,
13
+ positioning, simulation and backtests all happen in the engine, which has
14
+ no dependency on this package and no knowledge that it exists. If LangChain
15
+ were in the compute path, a dependency bump could change a Greek, and the
16
+ numbers would stop being reproducible from the engine alone.
17
+
18
+ The separation is the point:
19
+
20
+ engine numbers, no dependencies, no network
21
+ shell data, contracts, artifacts, MCP, dashboard
22
+ agent language: tools, retrieval, and prompt assembly
23
+
24
+ ## Why it exists next to MCP
25
+
26
+ MCP serves an agent runtime that already exists, such as Claude Code,
27
+ Codex or Gemini CLI. This serves an application someone builds themselves,
28
+ where the desk is one capability among several and the orchestration is
29
+ theirs. Both call the same commands and read the same artifacts.
30
+
31
+ ## Use
32
+
33
+ ```python
34
+ from optiondesk_agent import desk_tools, ArtifactStore, build_answer_prompt
35
+
36
+ tools = desk_tools() # LangChain StructuredTools
37
+ store = ArtifactStore() # documents from the artifact dir
38
+ context = store.context_for("SPY")
39
+ prompt = build_answer_prompt() # carries the reporting rules
40
+ ```
41
+
42
+ Bring your own model. This package deliberately declares no provider
43
+ dependency, so it adds no key requirement and no vendor to the tree.
44
+
45
+ ## The graph
46
+
47
+ A desk routine as a bounded state machine, in `graph.py`. It needs the
48
+ optional extra:
49
+
50
+ ```
51
+ pip install -e "agent[graph]"
52
+ ```
53
+
54
+ ```python
55
+ from optiondesk_agent import open_desk
56
+
57
+ state = open_desk("SPY", budget=8)
58
+ state["outcome"] # complete, exhausted, or failed
59
+ state["summary"]
60
+ state["log"] # every step it took, in order
61
+ ```
62
+
63
+ Opening a desk is a dependency chain rather than one call: a chain
64
+ snapshot, then a Greek ladder from it, then positioning, then structures,
65
+ then a comparison. Three nodes handle it. `plan` reads which stages already
66
+ have an artifact. `gather` runs exactly one missing stage per visit and
67
+ returns to the decision, which keeps the loop visible in the trace and
68
+ stops a failure at the stage that failed rather than somewhere downstream.
69
+ `report` assembles the summary.
70
+
71
+ The three exits are kept distinct on purpose. `complete` means every stage
72
+ has an artifact, `exhausted` means the step budget ran out, `failed` means
73
+ a stage raised. Collapsing them into a boolean is how an agent ends up
74
+ reporting success on an empty directory.
75
+
76
+ There is no model in the graph. Every node runs a deterministic command, so
77
+ the same inputs give the same outputs and a failure reproduces. Pass
78
+ `model=` only if you want the closing summary written rather than
79
+ assembled, and the reporting rules below still apply to it.
80
+
81
+ Failures end the loop rather than being retried, because every failure this
82
+ pipeline produces is structural: a missing snapshot, an unbuildable
83
+ structure, a provider that is not answering. Running the same command again
84
+ produces the same failure while spending budget a different underlying
85
+ could have used.
86
+
87
+ `langgraph` stays optional at import time. `import optiondesk_agent` works
88
+ without it, and only touching `open_desk` or `build_desk_graph` pulls it in.
89
+
90
+ For loops outside an application, in Claude Code itself, see `LOOPS.md` at
91
+ the repository root.
92
+
93
+ ## The reporting rules travel with the prompt
94
+
95
+ The prompt template carries the same rules the skills state: report the
96
+ degraded flag before any number, never substitute a default volatility,
97
+ never present modelled premiums as tradable, and never turn an analysis
98
+ into a recommendation. A language layer is exactly where those rules get
99
+ lost, so they are compiled into it rather than left to the caller.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "optiondesk-agent"
3
+ version = "0.3.0"
4
+ description = "LangChain bindings for the option desk: tools, artifact retrieval, and a grounded answer chain"
5
+ requires-python = ">=3.11"
6
+ license = { text = "PolyForm-Noncommercial-1.0.0" }
7
+ authors = [{ name = "Iman Samizadeh" }]
8
+ dependencies = ["langchain-core>=0.3", "optiondesk"]
9
+
10
+ [project.optional-dependencies]
11
+ graph = ["langgraph>=0.2"]
12
+ dev = ["pytest", "langgraph>=0.2"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/optiondesk_agent"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,36 @@
1
+ """LangChain bindings for the option desk.
2
+
3
+ Licensed under the PolyForm Noncommercial License 1.0.0; see LICENSE.
4
+ This package is a language layer over the shell: it turns
5
+ commands into tools, artifacts into documents, and questions into prompts
6
+ that can only be answered from what is on disk.
7
+
8
+ It never computes a number. Everything quantitative comes from the engine,
9
+ which does not depend on this package.
10
+ """
11
+
12
+ from optiondesk_agent.artifacts import ArtifactStore
13
+ from optiondesk_agent.prompts import (
14
+ REPORTING_RULES,
15
+ build_answer_prompt,
16
+ build_router_prompt,
17
+ )
18
+ from optiondesk_agent.tools import desk_tools, tool_specs
19
+
20
+ # Imported lazily by name: the graph needs langgraph, which is an optional
21
+ # extra, and importing it here would make the whole package require it.
22
+ __lazy__ = ("build_desk_graph", "open_desk")
23
+
24
+
25
+ def __getattr__(name):
26
+ if name in __lazy__:
27
+ from optiondesk_agent import graph
28
+
29
+ return getattr(graph, name)
30
+ raise AttributeError(name)
31
+
32
+ __version__ = "0.3.0"
33
+
34
+ __all__ = ["ArtifactStore", "desk_tools", "tool_specs",
35
+ "build_answer_prompt", "build_router_prompt", "REPORTING_RULES",
36
+ "build_desk_graph", "open_desk", "__version__"]
@@ -0,0 +1,263 @@
1
+ """The artifact directory as retrievable documents.
2
+
3
+ Artifacts are JSON, often large, and a language model reading one whole is
4
+ mostly reading strike ladders. This turns each into a short document that
5
+ carries what a question is usually about: what it is, when it was made,
6
+ whether it is degraded, and the handful of numbers a person would quote.
7
+
8
+ The full artifact is always one path away, and the path is in the document,
9
+ so nothing here prevents a caller from reading the detail when the detail
10
+ is the point.
11
+ """
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ KINDS = ("chain", "greeks", "exposure", "strategy", "comparison",
17
+ "simulation", "backtest")
18
+
19
+
20
+ def _load(path):
21
+ try:
22
+ with open(path, encoding="utf-8") as handle:
23
+ payload = json.load(handle)
24
+ except (ValueError, OSError):
25
+ return None
26
+ return payload if isinstance(payload, dict) else None
27
+
28
+
29
+ def _percent(value, digits=2):
30
+ return "n/a" if value is None else "{:.{d}f}%".format(value * 100,
31
+ d=digits)
32
+
33
+
34
+ def _meta(payload):
35
+ """The artifact's envelope, or an empty one.
36
+
37
+ The envelope carries the degraded flag, so it is read on every path
38
+ that describes an artifact and is the one part that cannot be allowed
39
+ to fail. `payload.get("meta", {})` is not enough: it returns the value
40
+ when the key is present and null, so the default never fires, and a
41
+ meta that is present but is not an object fails the same way.
42
+ """
43
+ meta = payload.get("meta")
44
+ return meta if isinstance(meta, dict) else {}
45
+
46
+
47
+ def _summarise(kind, payload, path):
48
+ """One short paragraph per artifact, in the terms a person would use.
49
+
50
+ Never raises for any JSON object, which is all _load lets through. What
51
+ it cannot read it says it cannot read, because this runs over every
52
+ file in the directory in one pass and an exception here costs the
53
+ caller every other artifact as well.
54
+ """
55
+ meta = _meta(payload)
56
+ header = "{} for {}{}, generated {}".format(
57
+ kind, payload.get("underlying", "unknown"),
58
+ " expiry {}".format(payload["expiry"]) if payload.get("expiry")
59
+ else "", meta.get("generated_utc", "unknown"))
60
+ lines = [header]
61
+
62
+ if meta.get("degraded"):
63
+ lines.append("DEGRADED: {}".format(meta.get("degraded_reason")))
64
+ for note in (meta.get("notes") or [])[:3]:
65
+ lines.append("note: {}".format(note))
66
+
67
+ # The lines above are the ones the reporting rules turn on: what this
68
+ # is, when it was made, and whether it is degraded. They are built
69
+ # before this point and kept whatever happens next.
70
+ #
71
+ # Nothing validates an artifact on the way in. records() accepts any
72
+ # JSON object whose filename prefix is a known kind, so a file written
73
+ # by an older schema, or by something that is not the desk at all,
74
+ # reaches here intact. Reading a field it does not have used to raise
75
+ # through context_for, which summarises the whole directory in one
76
+ # pass: one bad file and the model was told nothing about any
77
+ # underlying. Losing this artifact's numbers is the smaller failure,
78
+ # and saying so is what keeps it from reading as a clean artifact.
79
+ try:
80
+ if kind == "chain":
81
+ counts = payload.get("counts", {})
82
+ lines.append(
83
+ "spot {} as of {}. {} contracts, {} with a usable implied "
84
+ "volatility, {} without.".format(
85
+ payload.get("spot"), payload.get("spot_asof"),
86
+ len(payload.get("contracts", [])), counts.get("with_iv"),
87
+ counts.get("without_iv")))
88
+ elif kind == "greeks":
89
+ rows = payload.get("rows", [])
90
+ skipped = payload.get("skipped") or {}
91
+ lines.append(
92
+ "{} contracts graded, {} skipped for having no implied "
93
+ "volatility.".format(len(rows), skipped.get("no_iv")))
94
+ if rows:
95
+ spot = payload.get("spot")
96
+ atm = min(rows, key=lambda r: abs(r["strike"] - spot))
97
+ lines.append(
98
+ "at the money {} strike {}: iv {}, delta {:.4f}, gamma "
99
+ "{:.5f}, vega {:.2f}, theta {:.3f} per day.".format(
100
+ atm["type"], atm["strike"], _percent(atm.get("iv")),
101
+ atm.get("delta", 0), atm.get("gamma", 0),
102
+ atm.get("vega", 0), atm.get("theta", 0)))
103
+ elif kind == "exposure":
104
+ exposure = payload.get("exposure", {})
105
+ smile = payload.get("smile") or {}
106
+ pain = payload.get("max_pain") or {}
107
+ lines.append(
108
+ "net gamma exposure {:.0f} per one percent move, regime {}. "
109
+ "call wall {}, put wall {}, flip {}. max pain {}. put to call "
110
+ "open interest {}.".format(
111
+ exposure.get("net_gex", 0), exposure.get("regime"),
112
+ (exposure.get("call_wall") or {}).get("strike"),
113
+ (exposure.get("put_wall") or {}).get("strike"),
114
+ exposure.get("gamma_flip"), pain.get("strike"),
115
+ exposure.get("put_call_oi_ratio")))
116
+ lines.append(
117
+ "at the money volatility {}, 25 delta risk reversal {}, "
118
+ "expected move {}.".format(
119
+ _percent(smile.get("atm_iv")),
120
+ _percent(smile.get("risk_reversal")),
121
+ smile.get("expected_move")))
122
+ lines.append("assumption: {}".format(exposure.get("assumption")))
123
+ elif kind == "strategy":
124
+ analysis = payload.get("analysis", {})
125
+ lines.append(
126
+ "{} {}: net {}, breakevens {}, max gain {}, max "
127
+ "loss {}.".format(
128
+ payload.get("strategy"), analysis.get("trade_type"),
129
+ analysis.get("net_cash"), analysis.get("breakevens"),
130
+ analysis.get("max_gain"), analysis.get("max_loss")))
131
+ probability = payload.get("probability") or {}
132
+ if probability.get("profit") is not None:
133
+ lines.append("model probability of profit {}.".format(
134
+ _percent(probability["profit"], 1)))
135
+ friction = payload.get("friction") or {}
136
+ if friction.get("verdict"):
137
+ lines.append("friction {}: {}".format(friction["verdict"],
138
+ friction.get("reason")))
139
+ elif kind == "comparison":
140
+ leader = payload.get("leader") or {}
141
+ lines.append("{} structures ranked, {} excluded. Leader {} at {} "
142
+ "expected return on capital at risk.".format(
143
+ payload.get("rankable_count"),
144
+ payload.get("excluded_count"),
145
+ leader.get("strategy"),
146
+ _percent(
147
+ leader.get("expected_return_on_risk"), 1)))
148
+ lines.append("criterion: {}".format(payload.get("criterion")))
149
+ lines.append("caveat: {}".format(payload.get("caveat")))
150
+ elif kind == "simulation":
151
+ posterior = payload.get("posterior", {})
152
+ risk = payload.get("risk") or {}
153
+ simulation = payload.get("simulation", {})
154
+ lines.append(
155
+ "horizon {} days, {} paths, converged {}. value at risk "
156
+ "95 {}, expected shortfall 95 {}.".format(
157
+ simulation.get("horizon_days"), simulation.get("paths"),
158
+ posterior.get("converged"), _percent(risk.get("var_95")),
159
+ _percent(risk.get("es_95"))))
160
+ for structure in (payload.get("structures") or [])[:5]:
161
+ lines.append(
162
+ "{}: probability of profit {} under realised volatility "
163
+ "against {} under implied.".format(
164
+ structure.get("strategy"),
165
+ _percent(structure.get(
166
+ "realised_vol_probability_of_profit"), 1),
167
+ _percent(structure.get(
168
+ "implied_vol_probability_of_profit"), 1)))
169
+ elif kind == "backtest":
170
+ statistics = payload.get("statistics") or {}
171
+ significance = payload.get("significance") or {}
172
+ lines.append(
173
+ "{} over {} trades: win rate {}, mean return on risk {}, "
174
+ "p value {}.".format(
175
+ payload.get("strategy"), statistics.get("trades"),
176
+ _percent(statistics.get("win_rate"), 1),
177
+ _percent(statistics.get("mean_return")),
178
+ significance.get("p_value")))
179
+ lines.append("honesty: {}".format(payload.get("honesty")))
180
+ except Exception as exc:
181
+ lines.append(
182
+ "detail unavailable: this {} artifact does not match the "
183
+ "current schema, so no number is quoted from it ({}: {}). "
184
+ "Re-run the command that writes it."
185
+ .format(kind, type(exc).__name__, exc))
186
+
187
+ lines.append("full artifact: {}".format(path))
188
+ return "\n".join(str(line) for line in lines)
189
+
190
+
191
+ class ArtifactStore:
192
+ """Read only view of the artifact directory, as documents."""
193
+
194
+ def __init__(self, directory=None):
195
+ if directory is None:
196
+ from optiondesk.artifacts import artifact_dir
197
+
198
+ directory = artifact_dir()
199
+ self.directory = Path(directory)
200
+
201
+ def records(self, underlying=None, kinds=None):
202
+ """Every artifact as (kind, payload, path), newest first.
203
+
204
+ The sort key stats each file, and the directory is written to by
205
+ the commands while this reads it, so a file can vanish between the
206
+ glob and the stat. That raised FileNotFoundError out of here,
207
+ which is a failure of the whole read rather than of one artifact,
208
+ and it would land on whoever happened to be summarising while a
209
+ refresh ran. A file that is gone sorts last and is then dropped by
210
+ _load, which is what should happen to it.
211
+ """
212
+ if not self.directory.exists():
213
+ return []
214
+
215
+ def _age(path):
216
+ try:
217
+ return path.stat().st_mtime
218
+ except OSError:
219
+ return float("-inf")
220
+
221
+ out = []
222
+ for path in sorted(self.directory.glob("*.json"), key=_age,
223
+ reverse=True):
224
+ kind = path.name.split("_")[0]
225
+ if kind not in KINDS or (kinds and kind not in kinds):
226
+ continue
227
+ payload = _load(path)
228
+ if payload is None:
229
+ continue
230
+ if underlying and str(payload.get("underlying", "")).upper() \
231
+ != underlying.upper():
232
+ continue
233
+ out.append((kind, payload, str(path)))
234
+ return out
235
+
236
+ def documents(self, underlying=None, kinds=None):
237
+ """LangChain documents, one per artifact."""
238
+ from langchain_core.documents import Document
239
+
240
+ documents = []
241
+ for kind, payload, path in self.records(underlying, kinds):
242
+ documents.append(Document(
243
+ page_content=_summarise(kind, payload, path),
244
+ metadata={
245
+ "kind": kind,
246
+ "underlying": payload.get("underlying"),
247
+ "expiry": payload.get("expiry"),
248
+ "generated_utc": _meta(payload).get("generated_utc"),
249
+ "degraded": bool(_meta(payload).get("degraded")),
250
+ "path": path,
251
+ },
252
+ ))
253
+ return documents
254
+
255
+ def context_for(self, underlying=None, kinds=None, limit=12):
256
+ """A single context string, newest first, ready for a prompt."""
257
+ blocks = []
258
+ for kind, payload, path in self.records(underlying, kinds)[:limit]:
259
+ blocks.append(_summarise(kind, payload, path))
260
+ if not blocks:
261
+ return ("No artifacts are on disk for this request. Nothing can "
262
+ "be answered from data that has not been pulled.")
263
+ return "\n\n".join(blocks)