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.
- optiondesk_agent-0.3.0/LICENSE +76 -0
- optiondesk_agent-0.3.0/PKG-INFO +15 -0
- optiondesk_agent-0.3.0/README.md +99 -0
- optiondesk_agent-0.3.0/pyproject.toml +22 -0
- optiondesk_agent-0.3.0/src/optiondesk_agent/__init__.py +36 -0
- optiondesk_agent-0.3.0/src/optiondesk_agent/artifacts.py +263 -0
- optiondesk_agent-0.3.0/src/optiondesk_agent/graph.py +341 -0
- optiondesk_agent-0.3.0/src/optiondesk_agent/prompts.py +81 -0
- optiondesk_agent-0.3.0/src/optiondesk_agent/tools.py +287 -0
- optiondesk_agent-0.3.0/tests/conftest.py +47 -0
- optiondesk_agent-0.3.0/tests/test_artifacts.py +761 -0
- optiondesk_agent-0.3.0/tests/test_graph.py +898 -0
- optiondesk_agent-0.3.0/tests/test_package_import.py +148 -0
- optiondesk_agent-0.3.0/tests/test_prompts.py +195 -0
- optiondesk_agent-0.3.0/tests/test_tools.py +259 -0
|
@@ -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)
|