@thetanuts-finance/odette-cli 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/NOTICE.md +65 -0
- package/README.md +156 -2
- package/dist/bookCheck.js +305 -0
- package/dist/bookEligibility.js +69 -0
- package/dist/bookMatch.js +131 -0
- package/dist/bookOrderIdentity.js +43 -0
- package/dist/bookPreview.js +35 -0
- package/dist/client.js +119 -0
- package/dist/commands/book.js +918 -0
- package/dist/commands/config.js +299 -0
- package/dist/commands/registry.js +14 -0
- package/dist/commands/setup.js +221 -0
- package/dist/commands/wallet.js +570 -0
- package/dist/config.js +146 -0
- package/dist/confirm.js +39 -0
- package/dist/defaults.js +17 -0
- package/dist/format.js +130 -0
- package/dist/index.js +84 -0
- package/dist/options.js +50 -0
- package/dist/output.js +417 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thetanuts Finance
|
|
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.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# NOTICE
|
|
2
|
+
|
|
3
|
+
This repository adapts code from **thetanuts-sdk**
|
|
4
|
+
(<https://github.com/Thetanuts-Finance/thetanuts-sdk>), MIT License,
|
|
5
|
+
Copyright (c) 2026 Thetanuts Finance. Source commit:
|
|
6
|
+
`9e72aef1ab5c008ccc92352fa5048bd08c452ede`.
|
|
7
|
+
|
|
8
|
+
Each file below keeps the Thetanuts Finance MIT notice in its header, and the
|
|
9
|
+
header lists what changed.
|
|
10
|
+
|
|
11
|
+
## SDK (`packages/sdk/src`)
|
|
12
|
+
|
|
13
|
+
| File | Source in thetanuts-sdk | How |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `abi/optionBook.ts` | `src/abis/optionBook.ts` | Copied as-is |
|
|
16
|
+
| `abi/erc20.ts` | `src/abis/erc20.ts` | Copied as-is |
|
|
17
|
+
| `chain.ts` | `src/chains/index.ts` (Base block) | Addresses only |
|
|
18
|
+
| `client.ts` | `src/client/ThetanutsClient.ts` | Trimmed; fixed referrer; Base only |
|
|
19
|
+
| `errors.ts` | `src/types/errors.ts`, `src/utils/errors.ts` | Trimmed; renamed to `OdetteError` |
|
|
20
|
+
| `logger.ts` | `src/utils/logger.ts`, `src/types/logger.ts` | Renamed to `OdetteLogger` |
|
|
21
|
+
| `types.ts` | `src/types/optionBook.ts`, `src/types/api.ts` | Trimmed to the feed order shape |
|
|
22
|
+
| `feed.ts` | `src/modules/api.ts` (`fetchOrders`) | Rewritten on `fetch`, strict field checks |
|
|
23
|
+
| `normalize.ts` | `src/modules/api.ts` (`normalizeOdetteOrder`) | Typed input; no 0 / "" fallbacks |
|
|
24
|
+
| `modules/optionBook.ts` | `src/modules/optionBook.ts` | Trimmed; fixed referrer; Odette sizing and checks |
|
|
25
|
+
| `modules/erc20.ts` | `src/modules/erc20.ts` | Trimmed (no transfer, no callStatic) |
|
|
26
|
+
| `modules/utils.ts` | `src/modules/utils.ts` | `toBigInt`, `fromBigInt` only |
|
|
27
|
+
| `utils/decimals.ts` | `src/utils/decimals.ts` | `toBigInt`, `fromBigInt` only |
|
|
28
|
+
| `utils/validation.ts` | `src/utils/validation.ts` | `validateAddress` only |
|
|
29
|
+
|
|
30
|
+
## CLI (`packages/cli/src`)
|
|
31
|
+
|
|
32
|
+
| File | Source in thetanuts-sdk | How |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `index.ts` | `cli/src/index.ts` | Name and description |
|
|
35
|
+
| `options.ts` | `cli/src/options.ts` | No `--chain`, no `--referrer` |
|
|
36
|
+
| `client.ts` | `cli/src/client.ts` | `OdetteClient`; no referrer resolution; no RFQ keystore |
|
|
37
|
+
| `config.ts` | `cli/src/config.ts` | `~/.config/odette`; a `referrer` key is an error |
|
|
38
|
+
| `confirm.ts` | `cli/src/confirm.ts` | Unchanged |
|
|
39
|
+
| `defaults.ts` | `cli/src/defaults.ts` | Comments |
|
|
40
|
+
| `output.ts` | `cli/src/output.ts` | Comments |
|
|
41
|
+
| `format.ts` | `cli/src/format.ts` | Unchanged |
|
|
42
|
+
| `bookEligibility.ts` | `cli/src/bookEligibility.ts` | Import only |
|
|
43
|
+
| `bookMatch.ts` | `cli/src/bookMatch.ts` | Import only |
|
|
44
|
+
| `bookOrderIdentity.ts` | `cli/src/bookOrderIdentity.ts` | Adds `findSameInstrument` (Odette re-signs orders) |
|
|
45
|
+
| `bookPreview.ts` | `cli/src/bookPreview.ts` | Unchanged |
|
|
46
|
+
| `bookCheck.ts` | `cli/src/bookCheck.ts` | No RFQ recommendation |
|
|
47
|
+
| `commands/book.ts` | `cli/src/commands/book.ts` | Top-level commands; no payout block; pre-send checks |
|
|
48
|
+
| `commands/wallet.ts` | `cli/src/commands/wallet.ts` | No `transfer`; approve is USDC to the OptionBook only |
|
|
49
|
+
| `commands/setup.ts` | `cli/src/commands/setup.ts` | No referrer prompt |
|
|
50
|
+
| `commands/config.ts` | `cli/src/commands/config.ts` | `referrer` refused |
|
|
51
|
+
| `commands/registry.ts` | `cli/src/commands/registry.ts` | Odette commands only |
|
|
52
|
+
|
|
53
|
+
Test patterns (not copied code): `cli/tests/exitCodes.test.ts`.
|
|
54
|
+
|
|
55
|
+
## Ported from the Odette web app
|
|
56
|
+
|
|
57
|
+
These files port rules from `thetaverse/odette` (Thetanuts' Odette web app) at
|
|
58
|
+
`origin/dev` `5c3f2b0639ce07a134a0f82b7df8113dea1e744b`, checked against the
|
|
59
|
+
OptionBook and option contracts in `thetanuts_rfq` at
|
|
60
|
+
`2c830620861eefcca895fb8e2a60b8f3fa915e7f`. They carry the same MIT notice.
|
|
61
|
+
|
|
62
|
+
| File | Source |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `packages/sdk/src/whitelist.ts` | `odette/config.js`: implementation constants, `isSpreadOrder`, `isDirectionalOrder`, `isRangerOrder`, `CANONICAL_ASSETS`, `COLLATERAL_TOKENS`, `filterOrdersForDailyBook`, `EXPIRY_FILTER_LIMITS`, `isAssetAvailableOnDay`; `ui_interactions.js` `isOrderExpiringSoon` |
|
|
65
|
+
| `packages/sdk/src/sizing.ts` | `odette/config.js`: `getContractQuantumStrike`, `getMaxRawContracts`, `validateNumContractsForContract`, `calculateMaxContracts`, `getMinimumPercentage`, `MIN_PREMIUM_USD` |
|
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Odette SDK and CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Buy Odette options on Base from your terminal or your own code.
|
|
4
|
+
|
|
5
|
+
- **`odette`**, the command-line tool: `@thetanuts-finance/odette-cli`
|
|
6
|
+
- **The SDK**, a TypeScript library: `@thetanuts-finance/odette-sdk`
|
|
7
|
+
|
|
8
|
+
Odette runs on Thetanuts Finance contracts on Base. You sign every trade with your
|
|
9
|
+
own wallet. No trade goes through a server: `odette` only reads the public order
|
|
10
|
+
list and sends your signed transaction straight to Base.
|
|
11
|
+
|
|
12
|
+
## What you can do
|
|
13
|
+
|
|
14
|
+
- See the options Odette lists right now: PUT, CALL (LINEAR_CALL) and RANGER.
|
|
15
|
+
- Check a trade before you make it.
|
|
16
|
+
- Buy an option with USDC.
|
|
17
|
+
|
|
18
|
+
The tools only **buy**. They do not sell or close positions. Odette options are
|
|
19
|
+
cash-settled in USDC at expiry, so there is nothing to close.
|
|
20
|
+
|
|
21
|
+
## Before you start
|
|
22
|
+
|
|
23
|
+
- [Node.js](https://nodejs.org) 20 or newer
|
|
24
|
+
- A wallet on **Base** with:
|
|
25
|
+
- **USDC**, to pay for the option (the minimum trade is 0.50 USDC)
|
|
26
|
+
- a little **ETH**, for gas (a trade costs a few cents)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install -g @thetanuts-finance/odette-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Set up your wallet (once)
|
|
35
|
+
|
|
36
|
+
Use the wallet you already have:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
odette wallet import
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Paste your private key when asked. You won't see it as you paste, and that's expected.
|
|
43
|
+
|
|
44
|
+
Don't have one? `odette wallet create` makes a new wallet. Then send it USDC and some ETH on Base.
|
|
45
|
+
|
|
46
|
+
Your key is saved only on your computer, in `~/.config/odette/config.json`, and only you
|
|
47
|
+
can read that file. **Never share your private key or paste it into a chat.**
|
|
48
|
+
|
|
49
|
+
Check it worked:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
odette wallet show # your address
|
|
53
|
+
odette wallet balance # your ETH and USDC
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Make a trade
|
|
57
|
+
|
|
58
|
+
**1. See what's on offer.**
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
odette orders --underlying ETH --type PUT
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each row is one option: its strike price, the expiry date and time, and the price of one contract.
|
|
65
|
+
`--type` can be `PUT`, `CALL` or `RANGER`.
|
|
66
|
+
|
|
67
|
+
**2. Check the trade.** Pick a strike and expiry from the list. `--collateral` is how
|
|
68
|
+
much USDC you want to spend. (The strike and expiry below are examples: copy real
|
|
69
|
+
ones from `odette orders`, because options expire every day.)
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
odette preview --underlying ETH --type PUT --strike 2200 --expiry 1793174400 --collateral 5
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This shows how many contracts you get, and runs safety checks:
|
|
76
|
+
does the seller have the funds, do you have enough USDC, is the quote still live.
|
|
77
|
+
Nothing is sent.
|
|
78
|
+
|
|
79
|
+
**3. Buy.**
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
odette fill --underlying ETH --type PUT --strike 2200 --expiry 1793174400 --collateral 5
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The first time, `odette` asks you to let the Odette contract take your USDC. That
|
|
86
|
+
permission is called an approval. Then it asks you to confirm the trade. Answer `y`
|
|
87
|
+
to both. When it's done, it prints the transaction link.
|
|
88
|
+
|
|
89
|
+
Prices update about once a minute. If the price moves while you're deciding, `odette`
|
|
90
|
+
shows the new price and asks you again.
|
|
91
|
+
|
|
92
|
+
Want to see the transaction without sending it? Add `--dry-run`. With `--yes` (no
|
|
93
|
+
questions), `--collateral` is required, so `odette` never buys a maximum size by itself.
|
|
94
|
+
|
|
95
|
+
**Buying a RANGER.** A RANGER pays out if the price ends inside a range. It has
|
|
96
|
+
four strikes, so use `--type RANGER` with `--strikes`:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
odette orders --underlying BTC --type RANGER
|
|
100
|
+
odette preview --underlying BTC --type RANGER --strikes 78500,79000,80000,80500 --expiry 1793174400 --collateral 5
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`preview` shows what you pay (`premium`) and the most you can be paid at expiry (`maxPayout`). In `-o json`
|
|
104
|
+
output the premium is the `totalCollateral` field (the name upstream uses).
|
|
105
|
+
|
|
106
|
+
## Useful flags
|
|
107
|
+
|
|
108
|
+
| Flag | What it does |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `--dry-run` | Show what would happen. Send nothing. |
|
|
111
|
+
| `--yes` | Skip the questions (the safety checks still run). |
|
|
112
|
+
| `-o json` | Print JSON, for scripts. |
|
|
113
|
+
| `--rpc-url <url>` | Use your own Base RPC instead of the public one. |
|
|
114
|
+
|
|
115
|
+
Exit codes for scripts: `0` ok, `1` the trade or a check failed, `2` a wrong command or flag,
|
|
116
|
+
`3` you said no, `4` a wallet or config problem.
|
|
117
|
+
|
|
118
|
+
## For developers: the SDK
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
npm install @thetanuts-finance/odette-sdk ethers
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { JsonRpcProvider, Wallet } from 'ethers';
|
|
126
|
+
import { OdetteClient } from '@thetanuts-finance/odette-sdk';
|
|
127
|
+
|
|
128
|
+
const provider = new JsonRpcProvider('https://mainnet.base.org');
|
|
129
|
+
const signer = new Wallet(process.env.ODETTE_PRIVATE_KEY!, provider);
|
|
130
|
+
const client = new OdetteClient({ provider, signer });
|
|
131
|
+
|
|
132
|
+
const orders = await client.api.fetchOrders();
|
|
133
|
+
const order = orders.find((o) => o.product === 'PUT' && o.underlying === 'ETH')!;
|
|
134
|
+
|
|
135
|
+
const usdc = 5_000000n; // 5 USDC (USDC has 6 decimals)
|
|
136
|
+
const preview = client.optionBook.previewFillOrder(order, usdc);
|
|
137
|
+
const checks = await client.optionBook.checkFill(order, usdc); // reads only, never sends
|
|
138
|
+
|
|
139
|
+
await client.erc20.ensureAllowance(preview.collateralToken, preview.optionBook, preview.totalCollateral);
|
|
140
|
+
const receipt = await client.optionBook.fillOrder(order, usdc); // refuses if a check fails
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Amounts: USDC and contract sizes have 6 decimals. Prices and strikes are in USD with 8 decimals.
|
|
144
|
+
The amount you pass is the USDC you want to spend, not a number of contracts.
|
|
145
|
+
|
|
146
|
+
## Good to know
|
|
147
|
+
|
|
148
|
+
- **Base only** (chain 8453).
|
|
149
|
+
- **Found a security problem?** Don't open a public issue. See `SECURITY.md`.
|
|
150
|
+
- **Every trade is tagged with Odette's referrer**, `0x94D784e81A5c8cA6E19629C73217b61a256Ea1c7`.
|
|
151
|
+
You can't change it in these tools. The contract itself does not enforce the tag.
|
|
152
|
+
- Orders and contracts come from Thetanuts Finance. Odette is the app on top.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT, Copyright (c) 2026 Thetanuts Finance. Parts are adapted from
|
|
157
|
+
[thetanuts-sdk](https://github.com/Thetanuts-Finance/thetanuts-sdk); see `NOTICE.md`.
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { ORDER_PRICE_DECIMALS } from './bookEligibility.js';
|
|
2
|
+
import { findMatchingOrders, orderStrikesUsd, ordersWithStrikeAsLeg, structureInfo, usdToStrikeRaw, } from './bookMatch.js';
|
|
3
|
+
const NEARBY_TOLERANCE = 0.05;
|
|
4
|
+
const DAPP_URL = 'https://odette.fi';
|
|
5
|
+
function isoOf(expiry) {
|
|
6
|
+
return new Date(expiry * 1000).toISOString();
|
|
7
|
+
}
|
|
8
|
+
function utcDate(expiry) {
|
|
9
|
+
return isoOf(expiry).slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
function priceOf(order) {
|
|
12
|
+
return order.order?.price ? Number(order.order.price) / 10 ** ORDER_PRICE_DECIMALS : 0;
|
|
13
|
+
}
|
|
14
|
+
function strikesCsv(strikesUsd) {
|
|
15
|
+
return strikesUsd.join(',');
|
|
16
|
+
}
|
|
17
|
+
export function computeCheckResult(orders, params, ctx) {
|
|
18
|
+
const indexOf = new Map();
|
|
19
|
+
orders.forEach((o, i) => indexOf.set(o, i));
|
|
20
|
+
const strikeRaw = usdToStrikeRaw(params.strike);
|
|
21
|
+
const side = params.direction === 'buy' ? 'ASK' : 'BID';
|
|
22
|
+
const contracts = (o) => Number(ctx.maxContracts(o)) / ctx.contractScale;
|
|
23
|
+
const describe = (o) => structureInfo(o, params.underlying, ctx.implementations);
|
|
24
|
+
// Everything on this feed/type, regardless of expiry — used for the
|
|
25
|
+
// "your expiry has no book at all" branch.
|
|
26
|
+
const sameInstrument = orders.filter((o) => {
|
|
27
|
+
const raw = o.rawApiData;
|
|
28
|
+
if (!raw)
|
|
29
|
+
return false;
|
|
30
|
+
const orderType = raw.isCall ? 'CALL' : 'PUT';
|
|
31
|
+
return orderType === params.type && raw.priceFeed?.toLowerCase() === ctx.priceFeed;
|
|
32
|
+
});
|
|
33
|
+
const atExpiry = sameInstrument.filter((o) => Number(o.order.expiry) === params.expiry);
|
|
34
|
+
const liveExpiryMap = new Map();
|
|
35
|
+
for (const o of sameInstrument) {
|
|
36
|
+
const e = Number(o.order.expiry);
|
|
37
|
+
let entry = liveExpiryMap.get(e);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
entry = { expiry: e, iso: isoOf(e), vanillaCount: 0, structureCount: 0 };
|
|
40
|
+
liveExpiryMap.set(e, entry);
|
|
41
|
+
}
|
|
42
|
+
if (orderStrikesUsd(o).length > 1)
|
|
43
|
+
entry.structureCount += 1;
|
|
44
|
+
else
|
|
45
|
+
entry.vanillaCount += 1;
|
|
46
|
+
}
|
|
47
|
+
const liveExpiries = [...liveExpiryMap.values()].sort((a, b) => a.expiry - b.expiry);
|
|
48
|
+
// Same UTC date as the requested expiry. Never auto-snap: the book carries
|
|
49
|
+
// both an 03:00Z and an 08:00Z expiry on some dates, so picking one for the
|
|
50
|
+
// caller would be a guess with money attached.
|
|
51
|
+
const didYouMean = liveExpiries
|
|
52
|
+
.filter((e) => e.expiry !== params.expiry && utcDate(e.expiry) === utcDate(params.expiry))
|
|
53
|
+
.map((e) => e.expiry);
|
|
54
|
+
// The Odette referrer is fixed, so emitted commands need no referrer flag.
|
|
55
|
+
const previewBase = `odette preview --underlying ${params.underlying} --type ${params.type}`;
|
|
56
|
+
// Odette has no RFQ. When the book has nothing here, say so in prose.
|
|
57
|
+
const noOrderStep = `No Odette order matches. Run \`odette orders --underlying ${params.underlying} --type ${params.type}\` ` +
|
|
58
|
+
'and pick a listed strike and expiry.';
|
|
59
|
+
const dappStep = `Fill this ${params.direction === 'buy' ? 'ask' : 'bid'} in the Odette app (${DAPP_URL}) — ` +
|
|
60
|
+
'the CLI executes buys only.';
|
|
61
|
+
// --- 1. Exact single-strike instrument -----------------------------------
|
|
62
|
+
const exact = findMatchingOrders(atExpiry, {
|
|
63
|
+
priceFeed: ctx.priceFeed,
|
|
64
|
+
type: params.type,
|
|
65
|
+
strikesRaw: [strikeRaw],
|
|
66
|
+
expiry: params.expiry,
|
|
67
|
+
});
|
|
68
|
+
// Best price first, matching how `resolveOrderBySelector` picks (strictly
|
|
69
|
+
// lowest price for a buy, first-encountered on a tie — Array#sort is stable,
|
|
70
|
+
// so `orderbookOrders[0]` IS the order the emitted command resolves to).
|
|
71
|
+
const orderbookOrders = exact
|
|
72
|
+
.map((o) => {
|
|
73
|
+
const info = describe(o);
|
|
74
|
+
return {
|
|
75
|
+
index: indexOf.get(o) ?? -1,
|
|
76
|
+
ticker: info.ticker,
|
|
77
|
+
type: params.type,
|
|
78
|
+
strike: params.strike,
|
|
79
|
+
expiry: Number(o.order.expiry),
|
|
80
|
+
expiryDate: isoOf(Number(o.order.expiry)),
|
|
81
|
+
side,
|
|
82
|
+
price: priceOf(o),
|
|
83
|
+
availableContracts: contracts(o),
|
|
84
|
+
maker: o.makerAddress ?? o.order?.maker ?? '',
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
.sort((a, b) => (params.direction === 'buy' ? a.price - b.price : b.price - a.price));
|
|
88
|
+
const levelMap = new Map();
|
|
89
|
+
for (const o of orderbookOrders) {
|
|
90
|
+
let level = levelMap.get(o.price);
|
|
91
|
+
if (!level) {
|
|
92
|
+
level = { price: o.price, availableContracts: 0, orders: 0 };
|
|
93
|
+
levelMap.set(o.price, level);
|
|
94
|
+
}
|
|
95
|
+
level.availableContracts += o.availableContracts;
|
|
96
|
+
level.orders += 1;
|
|
97
|
+
}
|
|
98
|
+
const priceLevels = [...levelMap.values()].sort((a, b) => params.direction === 'buy' ? a.price - b.price : b.price - a.price);
|
|
99
|
+
// --- 2. Structures carrying the strike on any leg ------------------------
|
|
100
|
+
//
|
|
101
|
+
// Deliberately NOT ranked. A spread, a fly and a ranger that happen to share
|
|
102
|
+
// one strike are different products: their whole-structure premiums are not
|
|
103
|
+
// comparable, and the requested strike can be a long, short or middle leg —
|
|
104
|
+
// so "cheapest" could hand the caller exposure opposite to the vanilla they
|
|
105
|
+
// asked about. Sorted by price only for a stable, inspectable listing.
|
|
106
|
+
const legMatches = ordersWithStrikeAsLeg(atExpiry, ctx.priceFeed, params.type, params.expiry, strikeRaw);
|
|
107
|
+
const structureMatches = legMatches
|
|
108
|
+
.map(({ order, legIndex }) => {
|
|
109
|
+
const info = describe(order);
|
|
110
|
+
const expiry = Number(order.order.expiry);
|
|
111
|
+
const availableContracts = contracts(order);
|
|
112
|
+
return {
|
|
113
|
+
index: indexOf.get(order) ?? -1,
|
|
114
|
+
structure: info.name,
|
|
115
|
+
structureKind: info.kind,
|
|
116
|
+
ticker: info.ticker,
|
|
117
|
+
strikes: info.strikesUsd,
|
|
118
|
+
legIndex,
|
|
119
|
+
legCount: info.strikesUsd.length,
|
|
120
|
+
structurePrice: priceOf(order),
|
|
121
|
+
availableContracts,
|
|
122
|
+
meetsRequestedSize: params.size === undefined ? null : availableContracts >= params.size,
|
|
123
|
+
expiry,
|
|
124
|
+
expiryDate: isoOf(expiry),
|
|
125
|
+
maker: order.makerAddress ?? order.order?.maker ?? '',
|
|
126
|
+
// `book preview` filters to asks, so its command form is meaningless
|
|
127
|
+
// for a sell-side match — emit the manual action instead.
|
|
128
|
+
nextStep: ctx.cliExecutable
|
|
129
|
+
? `${previewBase} --strikes ${strikesCsv(info.strikesUsd)} ` +
|
|
130
|
+
`--expiry ${expiry} --collateral <amount>`
|
|
131
|
+
: dappStep,
|
|
132
|
+
nextStepIsCommand: ctx.cliExecutable,
|
|
133
|
+
};
|
|
134
|
+
})
|
|
135
|
+
.sort((a, b) => a.structurePrice - b.structurePrice);
|
|
136
|
+
// --- 3. Nearby vanilla strikes (same expiry, within tolerance) -----------
|
|
137
|
+
const tolerance = params.strike * NEARBY_TOLERANCE;
|
|
138
|
+
const nearbyByStrike = new Map();
|
|
139
|
+
for (const o of atExpiry) {
|
|
140
|
+
const strikes = orderStrikesUsd(o);
|
|
141
|
+
if (strikes.length !== 1)
|
|
142
|
+
continue;
|
|
143
|
+
const s = strikes[0];
|
|
144
|
+
if (s === params.strike)
|
|
145
|
+
continue;
|
|
146
|
+
if (Math.abs(s - params.strike) > tolerance)
|
|
147
|
+
continue;
|
|
148
|
+
if (!nearbyByStrike.has(s))
|
|
149
|
+
nearbyByStrike.set(s, []);
|
|
150
|
+
nearbyByStrike.get(s).push(o);
|
|
151
|
+
}
|
|
152
|
+
const nearbyStrikes = [...nearbyByStrike.entries()]
|
|
153
|
+
.map(([strike, group]) => {
|
|
154
|
+
const best = group.reduce((acc, curr) => params.direction === 'buy'
|
|
155
|
+
? priceOf(curr) < priceOf(acc)
|
|
156
|
+
? curr
|
|
157
|
+
: acc
|
|
158
|
+
: priceOf(curr) > priceOf(acc)
|
|
159
|
+
? curr
|
|
160
|
+
: acc);
|
|
161
|
+
const diff = (((strike - params.strike) / params.strike) * 100).toFixed(1);
|
|
162
|
+
return {
|
|
163
|
+
strike,
|
|
164
|
+
priceDiff: `${parseFloat(diff) >= 0 ? '+' : ''}${diff}%`,
|
|
165
|
+
bestPrice: priceOf(best),
|
|
166
|
+
availableContracts: group.reduce((sum, o) => sum + contracts(o), 0),
|
|
167
|
+
orderIndex: indexOf.get(best) ?? -1,
|
|
168
|
+
};
|
|
169
|
+
})
|
|
170
|
+
.sort((a, b) => Math.abs(a.strike - params.strike) - Math.abs(b.strike - params.strike))
|
|
171
|
+
.slice(0, 5);
|
|
172
|
+
// --- Aggregate vanilla availability --------------------------------------
|
|
173
|
+
const totalAvailable = orderbookOrders.reduce((sum, o) => sum + o.availableContracts, 0);
|
|
174
|
+
const bestPrice = orderbookOrders.length > 0 ? orderbookOrders[0].price : null;
|
|
175
|
+
// What ONE `book fill` against the recommended command can actually take.
|
|
176
|
+
const topOrderSize = orderbookOrders.length > 0 ? orderbookOrders[0].availableContracts : null;
|
|
177
|
+
const sideWord = params.direction === 'buy' ? 'ask' : 'bid';
|
|
178
|
+
const cliCaveat = ctx.cliExecutable
|
|
179
|
+
? ''
|
|
180
|
+
: ` The CLI cannot execute sells — fill via the Odette app (${DAPP_URL}).`;
|
|
181
|
+
let recommendation;
|
|
182
|
+
let reason;
|
|
183
|
+
let nextStep;
|
|
184
|
+
let nextStepIsCommand;
|
|
185
|
+
let nextStepMaxSize = null;
|
|
186
|
+
let partialFillAvailable = false;
|
|
187
|
+
let partialSize = null;
|
|
188
|
+
if (orderbookOrders.length > 0) {
|
|
189
|
+
recommendation = 'orderbook';
|
|
190
|
+
const previewStep = `${previewBase} --strike ${params.strike} --expiry ${params.expiry} --collateral <amount>`;
|
|
191
|
+
const spread = priceLevels.length > 1
|
|
192
|
+
? ` Liquidity spans ${priceLevels.length} price levels across ` +
|
|
193
|
+
`${orderbookOrders.length} makers — one fill takes one order, so sweeping ` +
|
|
194
|
+
`the rest means repeating the command against the next level.`
|
|
195
|
+
: '';
|
|
196
|
+
if (params.size !== undefined && topOrderSize !== null && params.size > topOrderSize) {
|
|
197
|
+
partialFillAvailable = true;
|
|
198
|
+
partialSize = topOrderSize;
|
|
199
|
+
reason =
|
|
200
|
+
params.size > totalAvailable
|
|
201
|
+
? `Found ${totalAvailable.toFixed(4)} contracts total at strike $${params.strike} ` +
|
|
202
|
+
`(you requested ${params.size}); the best ${sideWord} at ` +
|
|
203
|
+
`$${bestPrice?.toFixed(2)} holds ${topOrderSize.toFixed(4)}. Partial fill ` +
|
|
204
|
+
`available.${spread}${cliCaveat}`
|
|
205
|
+
: `The book holds ${totalAvailable.toFixed(4)} contracts at strike $${params.strike} ` +
|
|
206
|
+
`(you requested ${params.size}), but the best ${sideWord} at ` +
|
|
207
|
+
`$${bestPrice?.toFixed(2)} holds only ${topOrderSize.toFixed(4)} — one fill takes ` +
|
|
208
|
+
`${topOrderSize.toFixed(4)}, the remainder sits at worse prices. See ` +
|
|
209
|
+
`priceLevels for the ladder.${cliCaveat}`;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
reason =
|
|
213
|
+
`Found orderbook liquidity at strike $${params.strike}. ` +
|
|
214
|
+
`Best ${sideWord} price: $${bestPrice?.toFixed(2)} ` +
|
|
215
|
+
`(${topOrderSize?.toFixed(4)} contracts). ` +
|
|
216
|
+
`Available across all makers: ${totalAvailable.toFixed(4)} contracts.${spread}${cliCaveat}`;
|
|
217
|
+
}
|
|
218
|
+
// When the CLI cannot execute the side, say so as prose.
|
|
219
|
+
nextStep = ctx.cliExecutable ? previewStep : dappStep;
|
|
220
|
+
nextStepIsCommand = ctx.cliExecutable;
|
|
221
|
+
nextStepMaxSize = ctx.cliExecutable ? topOrderSize : null;
|
|
222
|
+
}
|
|
223
|
+
else if (structureMatches.length > 0) {
|
|
224
|
+
// The strike is on the book, but only inside a structure. Staying on the
|
|
225
|
+
// book preserves orderbook credit; the caller has to choose WHICH
|
|
226
|
+
// structure, because these payoffs cannot be ranked against each other.
|
|
227
|
+
recommendation = 'orderbook';
|
|
228
|
+
const listed = structureMatches
|
|
229
|
+
.slice(0, 3)
|
|
230
|
+
.map((m) => `${m.ticker} (strikes ${m.strikes.join('/')}, your strike is leg ${m.legIndex + 1} ` +
|
|
231
|
+
`of ${m.legCount}) at $${m.structurePrice.toFixed(2)} per contract, ` +
|
|
232
|
+
`${m.availableContracts.toFixed(4)} available`)
|
|
233
|
+
.join('; ');
|
|
234
|
+
const more = structureMatches.length > 3 ? ` (+${structureMatches.length - 3} more)` : '';
|
|
235
|
+
const sizeNote = params.size === undefined
|
|
236
|
+
? ''
|
|
237
|
+
: ` ${structureMatches.filter((m) => m.meetsRequestedSize).length} of ` +
|
|
238
|
+
`${structureMatches.length} can cover your ${params.size}-contract size on their own ` +
|
|
239
|
+
`— see meetsRequestedSize per match.`;
|
|
240
|
+
reason =
|
|
241
|
+
`No standalone $${params.strike} ${params.type} on the book, but $${params.strike} sits ` +
|
|
242
|
+
`on a leg of ${structureMatches.length} live ` +
|
|
243
|
+
`${structureMatches.length === 1 ? 'structure' : 'structures'} at this expiry: ` +
|
|
244
|
+
`${listed}${more}. These are alternatives, not substitutes — a spread, fly, condor and ` +
|
|
245
|
+
`ranger sharing one strike have different payoffs, and your strike may be a short or ` +
|
|
246
|
+
`middle leg, so the whole-structure premiums are not comparable and none is "cheapest". ` +
|
|
247
|
+
`Pick the structure whose payoff you want, then fill it in full to stay on the ` +
|
|
248
|
+
`orderbook.${sizeNote}${cliCaveat}`;
|
|
249
|
+
nextStep = ctx.cliExecutable
|
|
250
|
+
? 'Choose a structure from structureMatches after inspecting its payoff, then run that ' +
|
|
251
|
+
"match's nextStep. No top-level command is emitted: ranking by premium is only valid " +
|
|
252
|
+
'among orders for the same complete structure.'
|
|
253
|
+
: dappStep;
|
|
254
|
+
nextStepIsCommand = false;
|
|
255
|
+
}
|
|
256
|
+
else if (atExpiry.length > 0) {
|
|
257
|
+
recommendation = 'none';
|
|
258
|
+
reason =
|
|
259
|
+
nearbyStrikes.length > 0
|
|
260
|
+
? `No Odette order at strike $${params.strike} for this expiry. ` +
|
|
261
|
+
`Nearby strikes available: ${nearbyStrikes
|
|
262
|
+
.slice(0, 3)
|
|
263
|
+
.map((s) => `$${s.strike} (${s.priceDiff})`)
|
|
264
|
+
.join(', ')}. Odette has no RFQ: trade a nearby strike.`
|
|
265
|
+
: `The book has ${atExpiry.length} live ${params.type} order(s) at this expiry but ` +
|
|
266
|
+
`nothing at or near $${params.strike}. Odette has no RFQ: pick a listed strike.`;
|
|
267
|
+
nextStep = noOrderStep;
|
|
268
|
+
nextStepIsCommand = false;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
recommendation = 'none';
|
|
272
|
+
const expiryHint = liveExpiries.length === 0
|
|
273
|
+
? `The book has no live ${params.underlying} ${params.type} orders at all right now.`
|
|
274
|
+
: didYouMean.length > 0
|
|
275
|
+
? `No orders at expiry ${params.expiry}. The book has ${didYouMean
|
|
276
|
+
.map((e) => `${e} (${isoOf(e).slice(0, 16)}Z)`)
|
|
277
|
+
.join(' and ')} on the same UTC date — did you mean one of those?`
|
|
278
|
+
: `No orders at expiry ${params.expiry}. Live expiries: ${liveExpiries
|
|
279
|
+
.map((e) => `${e.expiry} (${e.iso.slice(0, 16)}Z)`)
|
|
280
|
+
.join(', ')}.`;
|
|
281
|
+
reason = `${expiryHint} Re-check against a listed expiry.`;
|
|
282
|
+
nextStep = noOrderStep;
|
|
283
|
+
nextStepIsCommand = false;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
recommendation,
|
|
287
|
+
reason,
|
|
288
|
+
params,
|
|
289
|
+
orderbookOrders: orderbookOrders.slice(0, 10),
|
|
290
|
+
priceLevels,
|
|
291
|
+
structureMatches: structureMatches.slice(0, 10),
|
|
292
|
+
bestPrice,
|
|
293
|
+
availableSize: totalAvailable > 0 ? totalAvailable : null,
|
|
294
|
+
partialFillAvailable,
|
|
295
|
+
partialSize,
|
|
296
|
+
nearbyStrikes,
|
|
297
|
+
liveExpiries,
|
|
298
|
+
didYouMean,
|
|
299
|
+
cliExecutable: ctx.cliExecutable,
|
|
300
|
+
nextStep,
|
|
301
|
+
nextStepIsCommand,
|
|
302
|
+
nextStepMaxSize,
|
|
303
|
+
referrer: ctx.referrer ?? null,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI currently supports cash-settled, USDC-collateralized OptionBook
|
|
3
|
+
* purchases only. Keep the accepted implementation names explicit: physical
|
|
4
|
+
* PUTs may also use USDC, so collateral filtering alone is not sufficient.
|
|
5
|
+
*/
|
|
6
|
+
const CASH_SETTLED_IMPLEMENTATION_KEYS = [
|
|
7
|
+
'PUT',
|
|
8
|
+
'LINEAR_CALL',
|
|
9
|
+
'CALL_SPREAD',
|
|
10
|
+
'PUT_SPREAD',
|
|
11
|
+
'CALL_FLY',
|
|
12
|
+
'PUT_FLY',
|
|
13
|
+
'CALL_CONDOR',
|
|
14
|
+
'PUT_CONDOR',
|
|
15
|
+
'IRON_CONDOR',
|
|
16
|
+
'RANGER',
|
|
17
|
+
];
|
|
18
|
+
export const ORDER_PRICE_DECIMALS = 8;
|
|
19
|
+
/** Build the current-chain allowlist while dropping undeployed zero addresses. */
|
|
20
|
+
export function cashSettledImplementationSet(implementations) {
|
|
21
|
+
const values = implementations;
|
|
22
|
+
const accepted = new Set();
|
|
23
|
+
for (const key of CASH_SETTLED_IMPLEMENTATION_KEYS) {
|
|
24
|
+
const address = values[key];
|
|
25
|
+
if (address &&
|
|
26
|
+
address.toLowerCase() !== '0x0000000000000000000000000000000000000000') {
|
|
27
|
+
accepted.add(address.toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return accepted;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Return the first reason an order is unsafe for the requested taker side.
|
|
34
|
+
*
|
|
35
|
+
* Odette's OptionBook semantics are:
|
|
36
|
+
* isLong=false -> maker is the seller -> taker can BUY (an ask)
|
|
37
|
+
* isLong=true -> maker is the buyer -> taker can SELL (a bid)
|
|
38
|
+
*
|
|
39
|
+
* This is deliberately based on rawApiData.isLong rather than the SDK's legacy
|
|
40
|
+
* `order.isBuyer` compatibility field, whose Odette mapping was historically
|
|
41
|
+
* reversed in `normalizeOdetteOrder`.
|
|
42
|
+
*/
|
|
43
|
+
export function bookOrderIneligibility(order, policy) {
|
|
44
|
+
const raw = order.rawApiData;
|
|
45
|
+
if (!raw)
|
|
46
|
+
return 'missing-order-data';
|
|
47
|
+
if (raw.collateral.toLowerCase() !== policy.usdcAddress.toLowerCase()) {
|
|
48
|
+
return 'wrong-collateral';
|
|
49
|
+
}
|
|
50
|
+
if (!policy.cashSettledImplementations.has(raw.implementation.toLowerCase())) {
|
|
51
|
+
return 'unsupported-implementation';
|
|
52
|
+
}
|
|
53
|
+
const makerIsLong = raw.isLong;
|
|
54
|
+
const requiredMakerIsLong = policy.direction === 'sell';
|
|
55
|
+
if (makerIsLong !== requiredMakerIsLong)
|
|
56
|
+
return 'wrong-side';
|
|
57
|
+
if (Number(order.order.expiry) <= policy.now)
|
|
58
|
+
return 'expired-option';
|
|
59
|
+
if (raw.orderExpiryTimestamp <= policy.now)
|
|
60
|
+
return 'expired-order';
|
|
61
|
+
if (order.order.price <= 0n)
|
|
62
|
+
return 'invalid-price';
|
|
63
|
+
if (order.availableAmount <= 0n)
|
|
64
|
+
return 'no-liquidity';
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export function isEligibleBookOrder(order, policy) {
|
|
68
|
+
return bookOrderIneligibility(order, policy) === null;
|
|
69
|
+
}
|