Drive Curve Desk from your own code
Everything the web page does is available over HTTP. Send the curve sheet the browser computes for one currency's par swap curve (optionally with government yields and inflation breakevens at the same tenors), and get the same review back: the shape and the belly call copied from the sheet, a stance (one of the curve trades the sheet sized, or no trade), a read of the levels and forwards, a read of swap spreads and real yields, how the trade is built and sized, what it earns or pays over the carry horizon, the alternatives weighed, the risks, one response per flag and the checks to make before acting. The natural use is a daily curve monitor: a script rebuilds the sheet from end-of-day swap marks, asks for the review, and files it next to the sheet.
One thing to be clear about before the first call: the model never does the arithmetic.
The bootstrap of discount factors, zero rates, 1y forwards, forward swap rates, DV01 per tenor, swap
spreads, real yields, slopes, flies, the shape and belly calls, the DV01-neutral trade sizing, carry
and roll-down and the flags are all computed by curve.js, the same file the web page
loads, and the result is sent as facts, a JSON string. The model's job is
judgement over those figures. See building the facts below.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
There is no slug header. The token is minted for this app (the guest endpoint takes
{"slug":"curve-desk"} in its body), and every later call knows the app from the token.
Send it as Authorization: Bearer … on every call.
The input object IS the request body. There is no {"input": …} wrapper.
A wrapped body returns a 200 with an unknown field 'input' warning, and the model never
sees your facts.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run on an app whose publisher does not sponsor guest runs. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. facts must be a string, not an object. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.
To mint a guest token yourself, POST /guest with
{"slug":"curve-desk"} in the body and no Authorization header. It answers
201 with {token, guest_id, expires_at}. A guest token can call
/me and /estimate. A review is metered, so it needs a
personal token from signing in: a guest run is refused with 403 unless the
publisher sponsors guest runs (/estimate reports this as sponsor_enabled).
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://curve-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. No Authorization header,
# the slug goes in the body. A guest token is enough for /me and /estimate;
# running a review needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"curve-desk"}'
# HTTP 201
# {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered review.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "curve-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r: # 201 Created
guest = json.load(r)["data"]
TOKEN = guest["token"]
print(guest["guest_id"], guest["expires_at"])
// Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "curve-desk" }),
});
const guest = (await res.json()).data; // res.status === 201
const TOKEN = guest.token;
console.log(guest.guest_id, guest.expires_at);
// Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"curve-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close() // guestRes.StatusCode == 201
var guest struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
ExpiresAt string `json:"expires_at"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token, guest.Data.ExpiresAt)
// Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"curve-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.statusCode()); // 201
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","guest_id":"…","expires_at":"…"}}
# Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered review.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "curve-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) } # 201
guest = JSON.parse(res.body)["data"]
TOKEN = guest["token"]
puts guest["guest_id"], guest["expires_at"]
<?php
// Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "curve-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true); // HTTP 201
curl_close($ch);
echo $guest["data"]["token"], " ", $guest["data"]["expires_at"];
// Open https://curve-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered review.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"curve-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq); // 201 Created
var guest = (await guestRes.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Console.WriteLine($"{guest.GetProperty("token").GetString()} {guest.GetProperty("expires_at")}");
2. A tiny client
One helper that adds the headers, unwraps data and raises on error. No other header is needed.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN" # from https://curve-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://curve-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://curve-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://curve-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public class CurveDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
static String sha256Hex(String s) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(d);
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://curve-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class CurveDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
public static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me answers {subject_type, subject_id, credits}. Branch on
subject_type: it is guest or user, and a guest
can price a run but, unless the publisher sponsors guest runs, cannot start one. credits
is the wallet balance. Compare it against min_credits from the next step before you run,
so a shortfall surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
me = call("me")
if me["subject_type"] != "user":
print("guest token: /estimate works, a review needs a signed-in token")
print(me["subject_type"], me["subject_id"], me.get("credits"))
const me = await call("me");
if (me.subject_type !== "user") console.warn("guest token: /estimate works, a review needs a signed-in token");
console.log(me.subject_type, me.subject_id, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
if me.SubjectType != "user" {
fmt.Println("guest token: /estimate works, a review needs a signed-in token")
}
fmt.Println(me.SubjectType, me.Credits)
String me = CurveDesk.call("me", null);
System.out.println(me);
// {"ok":true,"data":{"subject_type":"user","subject_id":"…","credits":51234}}
if (!me.contains("\"subject_type\":\"user\"")) System.out.println("guest token: a review needs a signed-in token");
me = call("me")
warn "guest token: a review needs a signed-in token" unless me["subject_type"] == "user"
puts "#{me['subject_type']} #{me['subject_id']} #{me['credits']}"
<?php
$me = call("me");
if ($me["subject_type"] !== "user") fwrite(STDERR, "guest token: a review needs a signed-in token\n");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await CurveDesk.Call("me");
var subject = me.GetProperty("subject_type").GetString();
if (subject != "user") Console.Error.WriteLine("guest token: a review needs a signed-in token");
Console.WriteLine($"{subject} {me.GetProperty("credits")}");
4. Price the review (free)
The input object is exactly what the app's form submits. The first field is
task. This app has one lane, so it is always review. A missing or
unknown task is still answered as review, and the reply's lane
says so.
| task | what it does |
|---|---|
review | Reads the curve sheet like a senior rates strategist: shape (steep, normal, flat, inverted) and belly (cheap, fair, rich, n/a) copied from the sheet, a stance (a trade id from facts.trades or no_trade), headline, curve read, spreads read, the trade (construction, sizing, carry, exit), alternatives, risks, flag responses, checks and summary. |
| field | type | meaning |
|---|---|---|
task | string, required | "review" |
facts | string, required | The JSON-encoded output of Curve.buildFacts: the curve settings, every pillar's figures, the 1y forward curve, forward swaps, slopes, flies, the shape and belly calls, every sized trade with its legs and carry, the best carry trade, the signal trust, flags and rules. Fields below. |
question | string, optional | What you want to know, up to 2,000 characters. May be empty. Longer text is cut on a word boundary with [...] and facts.note_clipped_chars says how much was cut. |
retry_note | string, optional (app-set only) | Only when resubmitting after an unparseable reply: a plain instruction about the reply's shape. The web app sends it once, as attempt 2; you normally never set it on a first run. |
The app declares an input schema with task and facts required, so an
estimate of an empty object comes back with missing required field warnings. A warning is
not a rejection: check the warnings array yourself before you run. And
/estimate does very little body validation — a bare string or an array
prices as happily as the real input. Make sure you send a JSON object with
task and facts as strings; the page's own guard,
Curve.mustBeObject, throws on anything else before it calls the API.
Building the facts
A direct API caller builds facts itself; the server does no curve arithmetic. The
engine is curve.js, plain JavaScript with no dependencies that exports itself
to Node through module.exports. Download it next to your script, put the curve in a
JSON file with the form field ids below (the page's Save curve .json button writes
exactly this file, as {"form": {...}, "question": "..."}), and let it build the body:
// make-body.js - node make-body.js curve.json "your question" > body.json
const fs = require("fs");
const Curve = require("./curve.js"); // https://curve-desk.skillsafe.ai/curve.js
const file = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const res = Curve.compute(file.form || file); // {ok, errors, warnings, inputs, model}
if (!res.ok) throw new Error(res.errors.join(" "));
res.warnings.forEach((w) => console.error("warning:", w)); // unreadable lines, skipped tenors
const body = Curve.mustBeObject(Curve.buildInput(res, process.argv[3] || file.question || ""));
process.stdout.write(JSON.stringify(body));
Or build it inline. This is the page's USD, normal example: a SOFR curve with an inverted front, government yields and a few breakevens (illustrative rates, not market data):
const Curve = require("./curve.js");
const form = {
curve_label: "USD SOFR OIS",
fixed_freq: "1", // annual fixed leg
horizon_months: "3", // carry and roll over 3 months
dv01_target: "10000", // $10,000 per bp on each leg
curve: [
"tenor swap govt breakeven",
"1Y 3.550 3.620 -",
"2Y 3.420 3.500 2.45",
"3Y 3.400 3.480 -",
"5Y 3.480 3.600 2.35",
"7Y 3.600 3.790 -",
"10Y 3.780 4.150 2.30",
"12Y 3.860 - -",
"15Y 3.950 - -",
"20Y 4.020 4.700 -",
"30Y 3.960 4.720 2.25",
].join("\n"),
};
const res = Curve.compute(form);
console.log(Curve.verdictLine(res.model));
// Normal curve: 2s10s +36.0 bp · belly rich (2s5s10s -7.5 bp vs the line) ·
// best 3m carry and roll: flattener 2s10s +5.77 bp (+$57,747).
const body = Curve.buildInput(res, "Is a 2s10s steepener worth putting on here, " +
"or does the carry argue for something else?");
// body = {task: "review", facts: "<JSON string, about 8.4 KB>", question: "..."}
The form fields
| field id | required | accepted input |
|---|---|---|
curve_label | no | A name for the sheet, for example USD SOFR OIS. Up to 80 characters; empty becomes "unnamed curve" in the facts. |
fixed_freq | yes | The fixed-leg frequency and bootstrap grid: "1" annual (SOFR, €STR, SONIA OIS), "2" semiannual or "4" quarterly. Any other value is read as annual. |
horizon_months | yes | The carry and roll-down horizon: 1, 3, 6 or 12. Anything else is replaced by 3 with a warning. |
dv01_target | yes | Risk per leg in dollars per bp, for example 10000 (a leading $ and thousands commas are accepted). Not a positive number: $10,000 is used with a warning. Capped at $10,000,000. |
curve | yes | One pillar per line, see below. At least three usable tenors, up to 30. |
The curve. Each line is a tenor and a par swap rate in percent, then optionally the
government yield and the inflation breakeven at the same tenor: 10Y 3.780 4.150 2.30.
Cells may be separated by spaces, tabs, commas, semicolons or pipes. Tenors are a number with an
optional unit (10, 10Y, 10yr, 18M,
6 months; a bare number is years), up to 50 years. Rates may carry a % or a
leading +; a dash, n/a, na or none leaves a column
empty, and a lone % or bp after a number is ignored. A line with no decimal
point whose cells are split by tabs, semicolons or spaces reads a decimal comma (3,42) as
a point. A swap rate larger than 60 in absolute value is taken as a unit mistake and the line is
skipped; if every swap rate is under 0.25 a warning says they may be decimals. An unreadable
government yield or breakeven is left empty. A header row with no digits in it (for
example tenor, swap, gilt, breakeven) maps the columns in any order; it needs a tenor
column (tenor, term, maturity, years,
pillar) and a swap column (swap, par, rate,
ois, sofr, estr, sonia, tona,
saron, irs), and reads a government column (govt,
government, tsy, treasury, ust, bond,
gilt, bund, jgb, yield, sovereign) and a
breakeven column (breakeven, be, bei, inflation,
linker). Anything after # is a comment. A tenor that appears twice keeps the
later line. Pillars are sorted by tenor; a tenor shorter than one fixed period, or not a whole number
of fixed periods (an 18M pillar on an annual leg), is skipped. At most 30 usable tenors
are kept; the longest beyond that are dropped and named in a PARTIAL warning, and
res.inputs.dropped lists them. A space-separated header such as
Tenor Swap rate Govt yield reads as three columns. Every skipped or repaired
line comes back in res.warnings. Fewer than three usable tenors, an empty curve, or a
curve that implies a non-positive discount factor is an error in res.errors and no facts
are built.
What is in facts
Numbers in facts are pre-formatted strings with their units ("3.780%", "+36.0 bp",
"-5.77 bp", "$827", "$52.6m", "+$57,747"), because the model is told to copy figures exactly as
written and the page re-reads every number in the reply against them.
| key | contents |
|---|---|
units | The conventions in words: rates in percent with three decimals; slopes, flies, spreads and carry in bp; DV01 and carry in dollars per bp of the stated risk; notionals in millions of dollars. |
curve | {label, fixed_leg, carry_horizon, risk_per_leg}, for example {"fixed_leg": "annual", "carry_horizon": "3m", "risk_per_leg": "$10,000 per bp"}. |
pillars[] | One object per pasted tenor: tenor, swap, zero, dv01_per_1m (DV01 of $1m notional), government and swap_spread (swap minus government, or "not supplied"), and, only where a breakeven was given, breakeven and real_yield (government minus breakeven). |
forward_1y[] | The 1y forward curve, one per year to the last tenor: {period: "0y1y", rate}, {period: "1y1y", rate}, … |
forward_swaps[] | {id, rate} for whichever of 1y1y, 2y1y, 1y2y, 2y3y, 5y5y, 1y10y, 10y10y, 10y20y, 20y10y end inside the curve. |
slopes[] | {id, value} for 2s5s, 2s10s, 5s30s, 10s30s where both tenors lie inside the pasted range (a missing tenor is interpolated and flagged). |
flies[] | {id, fifty_fifty, belly_vs_line, wing_weights} for 2s5s10s and 5s10s30s: the 50/50 fly, the belly against the maturity-weighted line through the wings (above zero means the belly is high, cheap to receive) and the wing weights. |
shape, shape_measure | "inverted" when 2s10s is below -10 bp, "flat" up to 30 bp, "normal" up to 120 bp, "steep" above. A curve that does not span 2Y to 10Y uses its first-to-last slope; the measure says which ("2s10s +36.0 bp"). |
belly, belly_measure | From the 2s5s10s fly (or 5s10s30s when 2s5s10s is not available): "cheap" when the belly is more than 5 bp above the line, "rich" more than 5 bp below, otherwise "fair"; "n/a" and "no fly available" when there is no fly. |
trades[] | Every trade the page sized: {id, label, legs, carry_roll_bp, carry_roll_usd}. Each leg is {side, tenor, rate, dv01, notional, carry, roll, carry_roll, contribution}; side is receive or pay. A leg's carry, roll and carry_roll are for its own side per unit of its own DV01 (a paid leg's are the receiver's with the sign flipped); contribution is carry_roll times the leg's DV01 weight, and the contributions add up to the trade's carry_roll_bp to rounding. carry_roll_bp is per unit of leg DV01 over the horizon, curve unchanged; carry_roll_usd is for the stated risk per leg. See the trade ids. |
best_carry_trade | The id with the highest carry_roll_bp, or "none". Not automatically the stance. |
signal_trust | "trusted", or "untrusted" when a kinked_curve flag was raised; the stance must then be no_trade. |
flags[] | {code, severity, detail, tenors}; see the flag codes. |
rules | The thresholds behind the calls, as text: {shape, belly, carry_roll, trust}. |
note_clipped_chars | Only when the question was longer than 2,000 characters: how many were cut. |
An excerpt of the real facts for the USD, normal example (10 pillars; long arrays shortened):
{
"units": "Rates in percent with three decimals; slopes, flies, spreads and carry in basis points; ...",
"curve": {"label": "USD SOFR OIS", "fixed_leg": "annual", "carry_horizon": "3m", "risk_per_leg": "$10,000 per bp"},
"pillars": [
{"tenor": "1Y", "swap": "3.550%", "zero": "3.550%", "dv01_per_1m": "$97", "government": "3.620%", "swap_spread": "-7.0 bp"},
{"tenor": "2Y", "swap": "3.420%", "zero": "3.418%", "dv01_per_1m": "$190", "government": "3.500%", "swap_spread": "-8.0 bp",
"breakeven": "2.45%", "real_yield": "1.050%"},
...,
{"tenor": "10Y", "swap": "3.780%", "zero": "3.818%", "dv01_per_1m": "$827", "government": "4.150%", "swap_spread": "-37.0 bp",
"breakeven": "2.30%", "real_yield": "1.850%"},
{"tenor": "12Y", "swap": "3.860%", "zero": "3.910%", "dv01_per_1m": "$956", "government": "not supplied", "swap_spread": "not supplied"},
... ],
"forward_1y": [{"period": "0y1y", "rate": "3.550%"}, {"period": "1y1y", "rate": "3.286%"}, {"period": "2y1y", "rate": "3.358%"}, ...],
"forward_swaps": [{"id": "1y1y", "rate": "3.286%"}, ..., {"id": "5y5y", "rate": "4.142%"}, {"id": "10y10y", "rate": "4.383%"}, ...],
"slopes": [{"id": "2s5s", "value": "+6.0 bp"}, {"id": "2s10s", "value": "+36.0 bp"}, {"id": "5s30s", "value": "+48.0 bp"}, {"id": "10s30s", "value": "+18.0 bp"}],
"flies": [
{"id": "2s5s10s", "fifty_fifty": "-24.0 bp", "belly_vs_line": "-7.5 bp", "wing_weights": "0.625 / 0.375"},
{"id": "5s10s30s", "fifty_fifty": "+12.0 bp", "belly_vs_line": "+20.4 bp", "wing_weights": "0.800 / 0.200"}
],
"shape": "normal",
"shape_measure": "2s10s +36.0 bp",
"belly": "rich",
"belly_measure": "2s5s10s belly vs line -7.5 bp",
"trades": [
{"id": "steepener_2s10s", "label": "Steepener 2s10s",
"legs": [
{"side": "receive", "tenor": "2Y", "rate": "3.420%", "dv01": "$10,000", "notional": "$52.6m",
"carry": "-2.57 bp", "roll": "-1.15 bp", "carry_roll": "-3.72 bp", "contribution": "-3.72 bp"},
{"side": "pay", "tenor": "10Y", "rate": "3.780%", "dv01": "$10,000", "notional": "$12.1m",
"carry": "-0.55 bp", "roll": "-1.50 bp", "carry_roll": "-2.05 bp", "contribution": "-2.05 bp"}],
"carry_roll_bp": "-5.77 bp", "carry_roll_usd": "-$57,747"},
{"id": "flattener_2s10s", ..., "carry_roll_bp": "+5.77 bp", "carry_roll_usd": "+$57,747"},
{"id": "steepener_5s30s", ..., "carry_roll_bp": "0.00 bp", "carry_roll_usd": "+$40"},
{"id": "flattener_5s30s", ..., "carry_roll_bp": "0.00 bp", "carry_roll_usd": "-$40"},
{"id": "receive_belly_2s5s10s", "label": "Receive the belly 2s5s10s",
"legs": [
{"side": "pay", "tenor": "2Y", "rate": "3.420%", "dv01": "$6,250", "notional": "$32.9m", ...},
{"side": "receive", "tenor": "5Y", "rate": "3.480%", "dv01": "$10,000", "notional": "$22.1m", ...},
{"side": "pay", "tenor": "10Y", "rate": "3.780%", "dv01": "$3,750", "notional": "$4.5m", ...}],
"carry_roll_bp": "+1.99 bp", "carry_roll_usd": "+$19,918"},
{"id": "pay_belly_2s5s10s", ..., "carry_roll_bp": "-1.99 bp", "carry_roll_usd": "-$19,918"},
{"id": "receive_belly_5s10s30s", ..., "carry_roll_bp": "+1.62 bp", "carry_roll_usd": "+$16,182"},
{"id": "pay_belly_5s10s30s", ..., "carry_roll_bp": "-1.62 bp", "carry_roll_usd": "-$16,182"}
],
"best_carry_trade": "flattener_2s10s",
"signal_trust": "trusted",
"flags": [
{"code": "inverted_segment", "severity": "low", "detail": "The curve falls from 1Y to 2Y (-13.0 bp), from 20Y to 30Y (-6.0 bp).", "tenors": ["2Y", "30Y"]},
{"code": "humped_curve", "severity": "low", "detail": "The curve peaks at 20Y (4.020%) and is lower at both ends.", "tenors": ["20Y"]},
{"code": "negative_swap_spread", "severity": "low", "detail": "Swaps yield less than the government bond at 1Y (-7.0 bp), 2Y (-8.0 bp), ...", "tenors": ["1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "20Y", "30Y"]}
],
"rules": {"shape": "2s10s below -10 bp inverted, up to 30 bp flat, up to 120 bp normal, above that steep", "belly": "...", "carry_roll": "...", "trust": "a kinked_curve flag makes the curve signals untrusted"}
}
The request body wraps that object as a string (abbreviated here):
{
"task": "review",
"facts": "{\"units\":\"Rates in percent with three decimals; slopes, flies, spreads and carry in basis points; ...\",\"curve\":{\"label\":\"USD SOFR OIS\",\"fixed_leg\":\"annual\",\"carry_horizon\":\"3m\",...",
"question": "Is a 2s10s steepener worth putting on here, or does the carry argue for something else?"
}
/estimate is free: it creates no job and charges nothing. It answers
model, model_alias, markup_bps, hold_credits and
min_credits (plus sponsor_enabled, input_checked and
warnings). Read hold_credits as a reservation against the
full output cap, not the price; the real cost is charged_credits on the finished job,
which is normally much lower. A balance under min_credits is refused with 402.
# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above.
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
# "hold_credits":…,"min_credits":…,"sponsor_enabled":false,
# "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is the real cost.
INPUT = json.load(open("body.json")) # task, facts, question
assert isinstance(INPUT, dict) and isinstance(INPUT.get("facts"), str) # /estimate will not check this for you
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserve", est["hold_credits"], "minimum", est["min_credits"], est.get("warnings"))
# Free: no job, no charge. The hold is a reservation, not the price of the run.
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8"));
if (typeof INPUT !== "object" || Array.isArray(INPUT) || typeof INPUT.facts !== "string") throw new Error("send an object with facts as a string");
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits, est.min_credits, est.warnings);
raw, _ := os.ReadFile("body.json")
var input map[string]any
if err := json.Unmarshal(raw, &input); err != nil { // an object, not a string or an array
panic(err)
}
if _, ok := input["facts"].(string); !ok {
panic("facts must be a JSON string")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits, warnings
String input = java.nio.file.Files.readString(java.nio.file.Path.of("body.json"));
if (!input.trim().startsWith("{")) throw new IllegalArgumentException("the body must be a JSON object");
System.out.println(CurveDesk.call("estimate", input));
// {"ok":true,"data":{"model":"…","model_alias":"…","markup_bps":…,
// "hold_credits":…,"min_credits":…,"input_checked":true,"warnings":[]}}
INPUT = JSON.parse(File.read("body.json"))
raise "facts must be a string" unless INPUT.is_a?(Hash) && INPUT["facts"].is_a?(String)
est = call("estimate", INPUT)
puts est.values_at("model", "model_alias", "markup_bps", "hold_credits", "min_credits").inspect
<?php
$input = json_decode(file_get_contents("body.json"), true);
if (!is_array($input) || !is_string($input["facts"] ?? null)) throw new RuntimeException("facts must be a string");
$est = call("estimate", $input);
echo $est["model"], " hold ", $est["hold_credits"], " min ", $est["min_credits"], PHP_EOL;
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("body.json"));
if (input.ValueKind != JsonValueKind.Object || input.GetProperty("facts").ValueKind != JsonValueKind.String)
throw new Exception("send an object with facts as a string");
var est = await CurveDesk.Call("estimate", input);
Console.WriteLine($"{est.GetProperty("model")} hold {est.GetProperty("hold_credits")} min {est.GetProperty("min_credits")}");
5. Run it, then poll
POST /run needs a signed-in (user) token and returns a
job_id; poll GET jobs/{job_id} until status is
succeeded or failed. The reply is the string at
data.output.output. The terminal job also carries charged_credits (the real
price) and the truncated flag.
Always send an Idempotency-Key on /run and
/run-stream. The web app derives it from the input with the lane and an attempt counter,
curve-desk:review:<hash>:a<attempt>, where the hash is a short digest of the
JSON body (the page's own is a 32-bit djb2 hash of the body and its length, both in hex; for the
USD, normal example it is curve-desk:review:1f586b9c-2777:a1; any stable digest
works). A retried request with the same key returns the same job instead of billing a second
run. Replaying a key with a different body is a 409, so bump the attempt suffix when the body
changes — for example when you add retry_note after an unparseable reply, as the page
does with :a2.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="curve-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"lane\":\"review\",\"shape\":\"normal\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > review.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"curve-desk:review:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"]
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `curve-desk:review:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output;
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("curve-desk:review:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println("charged", job.Charged, "truncated", job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "curve-desk:review:" + CurveDesk.sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(CurveDesk.BASE + "/run"))
.header("Authorization", "Bearer " + CurveDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = CurveDesk.HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
String job;
while (true) {
job = CurveDesk.call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) break;
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// data.output.output is a string holding the reply JSON; read it with your JSON library
// (Jackson below), along with data.charged_credits and data.truncated.
var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(job).get("data");
String jobOutput = data.get("output").get("output").asText();
System.out.println("charged " + data.get("charged_credits") + " truncated " + data.get("truncated"));
require "digest"
key = "curve-desk:review:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
<?php
$key = "curve-desk:review:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") throw new RuntimeException(json_encode($job));
echo "charged ", $job["charged_credits"], " truncated ", var_export($job["truncated"], true), PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(input);
var key = "curve-desk:review:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {CurveDesk.Token}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await CurveDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
Console.WriteLine($"charged {job.GetProperty("charged_credits")} truncated {job.GetProperty("truncated")}");
6. Or stream it
POST /run-stream is the same call over server-sent events, with the same token rules and
the same Idempotency-Key header. From a server or script, each delta event
carries {"text": "..."}, a chunk of the reply, and the final done event
carries status, charged_credits and truncated (and, when
present, the whole reply at output.output; the web app prefers it and falls back to the
concatenated deltas). In a browser, /run-stream sends progress ticks, not text
deltas, so do not build a live typing view on it there; the done event and the finished job
from step 5 always have the whole reply.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"lane\":\"review\",\"shape\":\"normal\","}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = (done && done.output && done.output.output) || raw;
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(CurveDesk.BASE + "/run-stream"))
.header("Authorization", "Bearer " + CurveDesk.TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
CurveDesk.HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {CurveDesk.Token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
data.output.output is a string holding one JSON object. The web app
(recon.js, also a Node module) strips any code fence, takes everything from
the first { to the last }, parses it and normalizes it: lane is
forced to review; shape, belly and stance are
lower-cased with spaces and hyphens turned into underscores; an unknown shape or
belly becomes empty (and is then reported as missing); an empty stance
becomes no_trade (an unknown one is kept and reported by the reconciler); an unknown risk
severity becomes medium; risk tenors are written as 10Y
("10", "10yr" and "10 years" all become 10Y) and flag
codes lower-cased; missing arrays become empty; alternatives without both an id and a
why, risks with neither text nor watch, and flag responses without a code or response are
dropped. A reply with none of headline, curve_read and
summary, or with neither a stance nor a risks array, is treated
as unparseable — that is when the page retries once with retry_note. Then it checks the
reply against the facts it sent. You should do the same.
# review.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("review.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["shape"], r["belly"], r["stance"], "-", r["headline"])
print("sizing:", r["trade"]["sizing"])
for x in r["risks"]:
print(x["severity"], x["risk"], x.get("tenors"))
EOF
import re
SHAPES = ("steep", "normal", "flat", "inverted")
BELLIES = ("cheap", "fair", "rich", "n/a")
def norm(v):
return re.sub(r"[\s-]+", "_", str(v or "").strip().lower())
def parse_review(text):
t = text.strip()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
r["lane"] = "review"
shape, belly = norm(r.get("shape")), str(r.get("belly") or "").strip().lower()
r["shape"] = shape if shape in SHAPES else "" # reported as missing
r["belly"] = belly if belly in BELLIES else ""
r["stance"] = norm(r.get("stance")) or "no_trade" # the page's fallback
for k in ("alternatives", "risks", "flag_responses", "checks"):
r[k] = r.get(k) or []
r["trade"] = r.get("trade") or {}
return r
r = parse_review(text)
print(r["shape"], r["belly"], r["stance"], [x["severity"] for x in r["risks"]])
// Or reuse the page's own parser: const Recon = require("./recon.js");
// const r = Recon.normalize(Recon.parseResult(text));
const norm = (v) => String(v || "").trim().toLowerCase().replace(/[\s-]+/g, "_");
function parseReview(text) {
const t = String(text).trim();
const r = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
r.lane = "review";
r.shape = ["steep", "normal", "flat", "inverted"].includes(norm(r.shape)) ? norm(r.shape) : "";
r.belly = ["cheap", "fair", "rich", "n/a"].includes(String(r.belly || "").trim().toLowerCase()) ? String(r.belly).trim().toLowerCase() : "";
r.stance = norm(r.stance) || "no_trade";
for (const k of ["alternatives", "risks", "flag_responses", "checks"]) r[k] = r[k] || [];
r.trade = r.trade || {};
return r;
}
const r = parseReview(text);
console.log(r.shape, r.belly, r.stance, r.risks.map((x) => x.severity));
type Review struct {
Lane string `json:"lane"`
Shape string `json:"shape"`
Belly string `json:"belly"`
Stance string `json:"stance"`
Headline string `json:"headline"`
CurveRead string `json:"curve_read"`
SpreadsRead string `json:"spreads_read"`
Trade struct {
Construction string `json:"construction"`
Sizing string `json:"sizing"`
Carry string `json:"carry"`
Exit string `json:"exit"`
} `json:"trade"`
Alternatives []struct {
ID string `json:"id"`
Why string `json:"why"`
} `json:"alternatives"`
Risks []struct {
Risk string `json:"risk"`
Severity string `json:"severity"`
Tenors []string `json:"tenors"`
Watch string `json:"watch"`
} `json:"risks"`
FlagResponses []struct {
Code string `json:"code"`
Response string `json:"response"`
} `json:"flag_responses"`
Checks []string `json:"checks"`
Summary string `json:"summary"`
}
text := jobOutput // data.output.output from step 5
var r Review
_ = json.Unmarshal([]byte(text[strings.Index(text, "{"):strings.LastIndex(text, "}")+1]), &r)
r.Stance = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(r.Stance)), "-", "_")
if r.Stance == "" {
r.Stance = "no_trade"
}
fmt.Println(r.Shape, r.Belly, r.Stance, len(r.Risks))
// With Jackson: strip to the outermost object, then read it.
String t = jobOutput.trim();
String obj = t.substring(t.indexOf('{'), t.lastIndexOf('}') + 1);
var r = new com.fasterxml.jackson.databind.ObjectMapper().readTree(obj);
String stance = r.path("stance").asText("").trim().toLowerCase().replaceAll("[\\s-]+", "_");
if (stance.isEmpty()) stance = "no_trade";
String shape = r.path("shape").asText();
if (!java.util.List.of("steep", "normal", "flat", "inverted").contains(shape)) shape = "";
System.out.println(shape + " " + r.path("belly").asText() + " " + stance);
t = job["output"]["output"].strip
r = JSON.parse(t[t.index("{")..t.rindex("}")])
r["shape"] = "" unless %w[steep normal flat inverted].include?(r["shape"])
r["belly"] = "" unless %w[cheap fair rich n/a].include?(r["belly"])
r["stance"] = r["stance"].to_s.strip.downcase.gsub(/[\s-]+/, "_")
r["stance"] = "no_trade" if r["stance"].empty?
%w[alternatives risks flag_responses checks].each { |k| r[k] ||= [] }
puts r["shape"], r["belly"], r["stance"]
<?php
$t = trim($job["output"]["output"]);
$r = json_decode(substr($t, strpos($t, "{"), strrpos($t, "}") - strpos($t, "{") + 1), true);
if (!in_array($r["shape"] ?? "", ["steep", "normal", "flat", "inverted"], true)) $r["shape"] = "";
if (!in_array($r["belly"] ?? "", ["cheap", "fair", "rich", "n/a"], true)) $r["belly"] = "";
$r["stance"] = preg_replace('/[\s-]+/', "_", strtolower(trim($r["stance"] ?? ""))) ?: "no_trade";
foreach (["alternatives", "risks", "flag_responses", "checks"] as $k) $r[$k] = $r[$k] ?? [];
echo $r["shape"], " ", $r["belly"], " ", $r["stance"], PHP_EOL;
var t = job.GetProperty("output").GetProperty("output").GetString()!.Trim();
var obj = t[t.IndexOf('{')..(t.LastIndexOf('}') + 1)];
var r = JsonSerializer.Deserialize<JsonElement>(obj);
var stance = r.TryGetProperty("stance", out var s) ? (s.GetString() ?? "").Trim().ToLowerInvariant().Replace('-', '_').Replace(' ', '_') : "";
if (stance == "") stance = "no_trade";
Console.WriteLine($"{r.GetProperty("shape")} {r.GetProperty("belly")} {stance}");
Invariants worth asserting
The page runs Recon.reconcile(result, facts) on every reply and shows each disagreement
next to the review. These are the checks, so a script can hold the reply to the same standard:
- Numbers. Every number written in the prose (headline, curve read, spreads read, the four trade fields, each alternative's why, risks and their watch, flag responses, checks, summary) must equal a figure in
factsexactly — re-rounding is a disagreement, so "3.78%" for "3.780%" fails. A number with a unit (%,bp,m,x) must match a figure with the same unit, and an explicit sign must match the figure's sign; an unsigned figure may stand for either sign ("37.0 bp below the government yield"). Bare integers of 10 or less and calendar years are not counted as claims. - Curve names. Curve names are cut out of the number scan and checked as names instead: every slope or fly (
2s10s,2s5s10s) must be infacts.slopesorfacts.flies, every forward (5y5y,1y1y) infacts.forward_swapsorfacts.forward_1y, and every tenor (10Y,10-year), including those inrisks[].tenors, infacts.pillarsor a trade leg. - Shape and belly.
shapeequalsfacts.shapeandbellyequalsfacts.belly; the model copies them and explains them, never decides them. A missing one is a disagreement too. - Stance.
no_tradeor exactly oneidinfacts.trades; it must beno_tradewhenfacts.signal_trustisuntrusted. - Alternatives. Each
alternatives[].idis a trade id infacts.tradesand is not the stance itself. - Flags.
flag_responsesanswers every code infacts.flagsand invents none. - Lane. A reply that names a lane other than
reviewis reported.
// Node: the page's own reconciliation, on your reply and the facts you sent.
const Recon = require("./recon.js"); // https://curve-desk.skillsafe.ai/recon.js
const result = Recon.normalize(Recon.parseResult(text));
const check = Recon.reconcile(result, JSON.parse(body.facts));
console.log(check.numbers_checked, "numbers and curve names checked,", check.disagreements, "disagreements");
console.log("flags answered:", check.coverage.flags_answered, "of", check.coverage.flags_total);
check.items.filter((i) => !i.ok).forEach((i) => console.log(i.kind, "-", i.text));
The output contract
{
"lane": "review",
"shape": "steep" | "normal" | "flat" | "inverted",
"belly": "cheap" | "fair" | "rich" | "n/a",
"stance": "a trade id from facts.trades, or no_trade",
"headline": "one sentence: the shape with its measure, the belly call, and the stance",
"curve_read": "3 to 5 sentences: levels and shape, what the forwards imply about where the curve is priced to go, where it is steepest or inverted",
"spreads_read": "2 to 3 sentences on swap spreads and real yields, or one sentence saying they were not supplied",
"trade": {
"construction": "the position: which legs are received and paid, or why there is no trade",
"sizing": "the DV01 per leg and the notionals from the trade's legs in facts",
"carry": "what it earns or pays over the horizon, quoting carry_roll_bp and carry_roll_usd, and which leg drives it",
"exit": "what move makes it pay and what would make you take it off"
},
"alternatives": [{"id": "another trade id from facts.trades", "why": "why it was not preferred"}],
"risks": [
{"risk": "what could go wrong", "severity": "high" | "medium" | "low", "tenors": ["tenors it concerns, as in facts, may be empty"], "watch": "the figure or event to watch"}
],
"flag_responses": [{"code": "a flag code from facts.flags", "response": "what the flag means for this curve and what to do about it"}],
"checks": ["something to verify before acting on the sheet"],
"summary": "two sentences: the shape and the stance, and why"
}
alternatives has 1 to 3 entries (empty only when facts.trades is empty);
risks has 3 to 5, most important first; checks has 3 to 5;
flag_responses has exactly one entry per code in facts.flags, in the same
order. Each why, response, risk, watch and trade
field is at most 60 words, and empty arrays are [], never omitted. When
question is not empty, curve_read or summary answers it
directly. The stance weighs the shape and belly call first, then what the forwards already price,
then carry and roll; best_carry_trade is not automatically the answer, and a trade that
bleeds carry must say so. For no_trade, trade.sizing and
trade.carry describe the leading candidate (or say none applies) and
trade.exit says what would change the view. The review is analysis, not advice: it
describes what a position would look like, never tells you to trade a size.
An illustrative excerpt of a reply for the USD, normal example (the wording of a real run will differ; every figure is copied from the facts above):
{
"lane": "review",
"shape": "normal",
"belly": "rich",
"stance": "flattener_2s10s",
"headline": "The curve is normal at 2s10s +36.0 bp with the 2s5s10s belly rich at -7.5 bp against the line, and the 2s10s flattener is preferred over the steepener.",
"curve_read": "... The 2s10s steepener asked about bleeds -5.77 bp over 3m, -$57,747, because the front is inverted ...",
"spreads_read": "Swap spreads are negative at every supplied tenor, from -7.0 bp at 1Y to -76.0 bp at 30Y. ...",
"trade": {
"construction": "Pay fixed in 2Y and receive fixed in 10Y, DV01-neutral, so the position gains if 2s10s falls.",
"sizing": "$10,000 per bp on each leg: $52.6m notional in 2Y and $12.1m in 10Y.",
"carry": "It earns +5.77 bp over 3m, +$57,747 with the curve unchanged; most of it comes from paying the 2Y, whose receiver carry and roll is -3.72 bp.",
"exit": "..."
},
"alternatives": [
{"id": "steepener_2s10s", "why": "It bleeds -5.77 bp over the horizon and needs the front to reprice lower quickly to pay."},
{"id": "pay_belly_2s5s10s", "why": "The rich belly favours paying it, but it costs -1.99 bp of carry and roll."}
],
"risks": [{"risk": "A bull steepening led by the front end moves against the flattener.", "severity": "high", "tenors": ["2Y"], "watch": "The 1y1y forward at 3.286%."}, ...],
"flag_responses": [
{"code": "inverted_segment", "response": "..."}, {"code": "humped_curve", "response": "..."},
{"code": "negative_swap_spread", "response": "..."}
],
"checks": ["Confirm the rates are mid par swap rates for the same index and fixed-leg convention.", ...],
"summary": "..."
}
The flag codes
Raised by curve.js (in compute, in this order) and sent in facts.flags; the reply must answer each one. tenors lists the tenors or forward periods a flag concerns, and may be empty.
| code | severity | meaning |
|---|---|---|
kinked_curve | high | An inner pillar sits more than 20 bp off the straight line through its two neighbours (skipped when the neighbours are more than 20 years apart). More often a bad quote than a trade: it sets signal_trust to untrusted, and the stance must be no_trade. |
inverted_segment | medium when the shape is inverted, otherwise low | A pillar is more than 5 bp below the one before it; tenors names the lower pillar of each drop. |
humped_curve | low | The highest par rate is at an inner pillar, more than 5 bp above both the first and the last pillar. |
forward_jump | medium | Consecutive 1y forwards differ by more than 75 bp: a sawtooth forward curve points at a bad pillar or at linear interpolation across a wide gap. |
negative_forward | medium | Every par rate is positive but at least one 1y forward is negative. |
negative_swap_spread | low | The swap rate is below the government yield at one or more tenors. |
wide_swap_spread | medium | A swap spread is wider than 100 bp either way; check the government yield is for the same tenor and in percent. |
negative_real_yield | low | The government yield minus the breakeven is below zero at one or more tenors. |
extrapolated_short_end | low | The first pillar is longer than one fixed period; par rates before it are held flat at its rate for the bootstrap. |
interpolated_tenor | low | A tenor used by a slope, fly or trade was not pasted and is interpolated linearly. |
sparse_curve | medium | Fewer than 5 pillars, or two pillars more than 10 years apart: forwards inside a gap are interpolation, not market. |
tenor_not_covered | low | The curve does not span 2s10s or 2s5s10s. The shape then uses the first-to-last slope, and only trades inside the pasted range are sized. |
The trade ids
curve.js sizes a trade only when its slope or fly lies inside the pasted range, so
facts.trades holds at most these eight ids, and the stance and every alternative must be
one of the ids actually present. Every leg's DV01 is the risk per leg times its weight; slope trades
weight both legs 1, flies weight the belly 1 and the wings by maturity (the fly's
wing_weights), so a fly is neutral to a parallel move and to a straight-line slope
change.
| id | legs | makes money when |
|---|---|---|
steepener_2s10s | receive 2Y, pay 10Y | 2s10s rises |
flattener_2s10s | pay 2Y, receive 10Y | 2s10s falls |
steepener_5s30s | receive 5Y, pay 30Y | 5s30s rises |
flattener_5s30s | pay 5Y, receive 30Y | 5s30s falls |
receive_belly_2s5s10s | pay 2Y (weight 0.625 on the example), receive 5Y, pay 10Y (0.375) | the 5Y falls against the line through 2Y and 10Y |
pay_belly_2s5s10s | receive 2Y, pay 5Y, receive 10Y | the 5Y rises against that line |
receive_belly_5s10s30s | pay 5Y (0.800), receive 10Y, pay 30Y (0.200) | the 10Y falls against the line through 5Y and 30Y |
pay_belly_5s10s30s | receive 5Y, pay 10Y, receive 30Y | the 10Y rises against that line |
A trade's carry_roll_bp is the sum over its legs of the receiver carry and roll, with the
sign of the side and the leg weight: receiver carry is the forward swap rate at the horizon minus
spot, and roll is spot minus the rolled-down par rate. A positive figure is also how far the spread
can move against the position over the horizon before it loses.
8. Use it in a daily curve monitor
The stance is built to gate on, once the reply has passed the checks above. Run the
review once a day after the close: rebuild curve.json from end-of-day swap marks, send
it, reconcile the reply, and append one line per day to a log. A no_trade means the
curve supports nothing strongly enough or cannot be trusted yet (a kinked_curve flag
forces it); a trade id is worth a human look, with the risks and checks kept next to the sheet. A
change of shape or belly from the day before is worth a look as well.
#!/bin/sh
# Daily, after the close: rebuild the sheet from today's curve.json, run the review,
# append one line to curve-monitor.log, exit 3 when the stance is a trade.
set -e
node make-body.js curve.json "What changed in the curve today, and does it support a curve trade?" > body.json
INPUT=$(cat body.json)
KEY="curve-desk:review:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
S=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$S" = succeeded ] && break; [ "$S" = failed ] && exit 2; sleep 3
done
LINE=$(printf '%s' "$OUT" | python3 -c 'import sys,json;t=json.load(sys.stdin)["data"]["output"]["output"];r=json.loads(t[t.index("{"):t.rindex("}")+1]);print(r.get("shape",""),r.get("belly",""),r.get("stance") or "no_trade")')
echo "$(date +%F) $LINE" >> curve-monitor.log
echo "today: $LINE"
case "$LINE" in *no_trade) exit 0 ;; *) exit 3 ;; esac
import datetime
facts = json.loads(INPUT["facts"])
trade_ids = [t["id"] for t in facts["trades"]]
codes = {f["code"] for f in facts["flags"]}
problems = []
if r["shape"] != facts["shape"]: problems.append("shape")
if r["belly"] != facts["belly"]: problems.append("belly")
if r["stance"] != "no_trade" and r["stance"] not in trade_ids: problems.append("stance not sized")
if facts["signal_trust"] == "untrusted" and r["stance"] != "no_trade": problems.append("untrusted curve")
if {x["code"] for x in r["flag_responses"]} != codes: problems.append("flags")
if problems:
raise SystemExit("reply disagrees with the sheet (" + ", ".join(problems) + ") - do not use it")
with open("curve-monitor.jsonl", "a") as log:
log.write(json.dumps({"date": datetime.date.today().isoformat(), "shape": r["shape"], "belly": r["belly"],
"stance": r["stance"], "best_carry_trade": facts["best_carry_trade"]}) + "\n")
print("stance:", r["stance"])
raise SystemExit(3 if r["stance"] != "no_trade" else 0)
import { appendFileSync } from "node:fs";
const facts = JSON.parse(INPUT.facts);
const tradeIds = facts.trades.map((t) => t.id);
if (r.shape !== facts.shape || r.belly !== facts.belly) throw new Error("reply disagrees with the sheet");
if (r.stance !== "no_trade" && !tradeIds.includes(r.stance)) throw new Error("stance is not a sized trade");
if (facts.signal_trust === "untrusted" && r.stance !== "no_trade") throw new Error("untrusted curve, stance must be no_trade");
appendFileSync("curve-monitor.jsonl", JSON.stringify({ date: new Date().toISOString().slice(0, 10), shape: r.shape, belly: r.belly, stance: r.stance }) + "\n");
console.log("stance:", r.stance);
process.exitCode = r.stance !== "no_trade" ? 3 : 0;
var facts struct {
Shape string `json:"shape"`
Belly string `json:"belly"`
SignalTrust string `json:"signal_trust"`
Trades []struct {
ID string `json:"id"`
} `json:"trades"`
}
_ = json.Unmarshal([]byte(input["facts"].(string)), &facts)
if r.Shape != facts.Shape || r.Belly != facts.Belly {
panic("reply disagrees with the sheet")
}
if facts.SignalTrust == "untrusted" && r.Stance != "no_trade" {
panic("untrusted curve, stance must be no_trade")
}
log, _ := os.OpenFile("curve-monitor.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
fmt.Fprintf(log, "%s %s %s %s\n", time.Now().Format("2006-01-02"), r.Shape, r.Belly, r.Stance)
log.Close()
if r.Stance != "no_trade" {
fmt.Println("stance:", r.Stance)
os.Exit(3)
}
var om = new com.fasterxml.jackson.databind.ObjectMapper();
var facts = om.readTree(om.readTree(input).get("facts").asText());
if (!shape.equals(facts.get("shape").asText())) throw new IllegalStateException("reply disagrees with the sheet");
if ("untrusted".equals(facts.get("signal_trust").asText()) && !"no_trade".equals(stance))
throw new IllegalStateException("untrusted curve, stance must be no_trade");
java.nio.file.Files.writeString(java.nio.file.Path.of("curve-monitor.log"),
java.time.LocalDate.now() + " " + shape + " " + stance + "\n",
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
if (!"no_trade".equals(stance)) { System.out.println("stance: " + stance); System.exit(3); }
require "date"
facts = JSON.parse(INPUT["facts"])
raise "reply disagrees with the sheet" unless r["shape"] == facts["shape"] && r["belly"] == facts["belly"]
raise "untrusted curve, stance must be no_trade" if facts["signal_trust"] == "untrusted" && r["stance"] != "no_trade"
File.open("curve-monitor.log", "a") { |f| f.puts "#{Date.today} #{r['shape']} #{r['belly']} #{r['stance']}" }
puts "stance: #{r['stance']}"
exit(r["stance"] == "no_trade" ? 0 : 3)
<?php
$facts = json_decode($input["facts"], true);
if ($r["shape"] !== $facts["shape"] || $r["belly"] !== $facts["belly"]) throw new RuntimeException("reply disagrees with the sheet");
if ($facts["signal_trust"] === "untrusted" && $r["stance"] !== "no_trade") throw new RuntimeException("untrusted curve");
file_put_contents("curve-monitor.log", date("Y-m-d") . " {$r["shape"]} {$r["belly"]} {$r["stance"]}\n", FILE_APPEND);
echo "stance: ", $r["stance"], PHP_EOL;
exit($r["stance"] === "no_trade" ? 0 : 3);
var facts = JsonSerializer.Deserialize<JsonElement>(input.GetProperty("facts").GetString()!);
if (r.GetProperty("shape").GetString() != facts.GetProperty("shape").GetString())
throw new Exception("reply disagrees with the sheet");
if (facts.GetProperty("signal_trust").GetString() == "untrusted" && stance != "no_trade")
throw new Exception("untrusted curve, stance must be no_trade");
File.AppendAllText("curve-monitor.log", $"{DateTime.Today:yyyy-MM-dd} {r.GetProperty("shape")} {stance}\n");
Console.WriteLine($"stance: {stance}");
return stance == "no_trade" ? 0 : 3;
Truncation and partial results
When the balance sits between min_credits and hold_credits, the run is not
refused. It executes with a reduced output cap and comes back with truncated: true. What
you hold then is a prefix of the reply: the shape, belly, stance, headline and curve read may be
complete while the alternatives, risks, flag responses, checks and summary are missing. The web page
closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how
many of the nine (headline, curve read, spreads read, trade, alternatives, risks, flag responses,
checks, summary) it recovered; it does the same when a stream ends early. From code, check the flag
before you treat a reply as complete — a truncated reply will usually fail the one-response-per-flag
check — then top up, resubmit and increment the attempt suffix on the Idempotency-Key.