@blockrun/franklin 3.18.0 → 3.20.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/dist/agent/context.js +16 -0
- package/dist/skills-bundled/surf-chain/SKILL.md +92 -0
- package/dist/skills-bundled/surf-chat/SKILL.md +76 -0
- package/dist/skills-bundled/surf-market/SKILL.md +101 -0
- package/dist/skills-bundled/surf-social/SKILL.md +73 -0
- package/dist/skills-bundled/trade-discussion/SKILL.md +70 -0
- package/dist/skills-bundled/trade-signal/SKILL.md +79 -0
- package/dist/skills-bundled/trade-strategy/SKILL.md +91 -0
- package/dist/tools/blockrun.d.ts +21 -0
- package/dist/tools/blockrun.js +257 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/trading-execute.js +79 -7
- package/dist/trading/journal-display.d.ts +16 -0
- package/dist/trading/journal-display.js +53 -0
- package/dist/trading/journal-quality.d.ts +42 -0
- package/dist/trading/journal-quality.js +109 -0
- package/dist/trading/trade-log.d.ts +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlockRun primitive — the generic x402-paid gateway capability.
|
|
3
|
+
*
|
|
4
|
+
* One tool, every BlockRun endpoint. Replaces the per-API hardcoded pattern
|
|
5
|
+
* (ImageGen, VideoGen, Phone tools, etc) for new integrations. Skills in
|
|
6
|
+
* src/skills-bundled/<name>/SKILL.md describe which paths to call for which
|
|
7
|
+
* user intents; this tool just signs the x402 payment and forwards.
|
|
8
|
+
*
|
|
9
|
+
* Why the indirection: BlockRun keeps shipping new partner APIs (Surf,
|
|
10
|
+
* Phone & Voice, future ML/data partners). Hardcoding each as a fresh
|
|
11
|
+
* CapabilityHandler means a Franklin npm release per partner and a bigger
|
|
12
|
+
* tool list for the LLM to reason about. This primitive plus markdown
|
|
13
|
+
* skill files decouples API expansion from agent releases — new partners
|
|
14
|
+
* ship as a new SKILL.md, no code change.
|
|
15
|
+
*
|
|
16
|
+
* Signing pattern mirrors src/tools/modal.ts and src/phone/client.ts; we
|
|
17
|
+
* deliberately keep the copy-paste rather than refactor those into a
|
|
18
|
+
* shared module (out of scope; would touch unrelated tools).
|
|
19
|
+
*/
|
|
20
|
+
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
21
|
+
import { loadChain, API_URLS, USER_AGENT } from '../config.js';
|
|
22
|
+
import { recordUsage } from '../stats/tracker.js';
|
|
23
|
+
import { logger } from '../logger.js';
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
26
|
+
// ─── x402 payment signing (same shape as modal.ts / phone/client.ts) ──────
|
|
27
|
+
async function extractPaymentReq(response) {
|
|
28
|
+
let header = response.headers.get('payment-required');
|
|
29
|
+
if (!header) {
|
|
30
|
+
try {
|
|
31
|
+
const body = (await response.clone().json());
|
|
32
|
+
if (body.x402 || body.accepts)
|
|
33
|
+
header = btoa(JSON.stringify(body));
|
|
34
|
+
}
|
|
35
|
+
catch { /* not JSON, no header */ }
|
|
36
|
+
}
|
|
37
|
+
return header;
|
|
38
|
+
}
|
|
39
|
+
async function signPayment(response, chain, endpoint, resourceDescription) {
|
|
40
|
+
try {
|
|
41
|
+
const paymentHeader = await extractPaymentReq(response);
|
|
42
|
+
if (!paymentHeader)
|
|
43
|
+
return null;
|
|
44
|
+
if (chain === 'solana') {
|
|
45
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
46
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
47
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
48
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
49
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
50
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
51
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
52
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
53
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
54
|
+
extra: details.extra,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
headers: { 'PAYMENT-SIGNATURE': payload },
|
|
58
|
+
amountUsd: Number(details.amount) / 1_000_000,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const wallet = getOrCreateWallet();
|
|
63
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
64
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
65
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
66
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
67
|
+
resourceDescription: details.resource?.description || resourceDescription,
|
|
68
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
69
|
+
extra: details.extra,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
headers: { 'PAYMENT-SIGNATURE': payload },
|
|
73
|
+
amountUsd: Number(details.amount) / 1_000_000,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.warn(`[franklin] BlockRun payment error: ${err.message}`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Pull the settlement tx hash from the gateway's X-Payment-Receipt
|
|
84
|
+
* header. The X-Payment-Response header doesn't carry the amount (only
|
|
85
|
+
* { success, transaction, network, payer }), so we don't parse it here
|
|
86
|
+
* — the amount comes from what signPayment authorized in the 402 retry.
|
|
87
|
+
*/
|
|
88
|
+
function extractTxHash(response) {
|
|
89
|
+
return response.headers.get('x-payment-receipt');
|
|
90
|
+
}
|
|
91
|
+
async function callGateway(url, method, body, resourceDescription, abortSignal, timeoutMs) {
|
|
92
|
+
const start = Date.now();
|
|
93
|
+
const chain = loadChain();
|
|
94
|
+
const headers = {
|
|
95
|
+
'Accept': 'application/json',
|
|
96
|
+
'User-Agent': USER_AGENT,
|
|
97
|
+
};
|
|
98
|
+
if (method === 'POST')
|
|
99
|
+
headers['Content-Type'] = 'application/json';
|
|
100
|
+
const ctrl = new AbortController();
|
|
101
|
+
const onParentAbort = () => ctrl.abort();
|
|
102
|
+
abortSignal.addEventListener('abort', onParentAbort, { once: true });
|
|
103
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
104
|
+
try {
|
|
105
|
+
const payload = method === 'POST' && body !== undefined ? JSON.stringify(body) : undefined;
|
|
106
|
+
let response = await fetch(url, { method, signal: ctrl.signal, headers, body: payload });
|
|
107
|
+
let paidUsd = 0;
|
|
108
|
+
if (response.status === 402) {
|
|
109
|
+
const signed = await signPayment(response, chain, url, resourceDescription);
|
|
110
|
+
if (!signed) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false, status: 402,
|
|
113
|
+
body: { error: 'payment signing failed' }, raw: '',
|
|
114
|
+
paidUsd: 0, txHash: null, latencyMs: Date.now() - start,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
paidUsd = signed.amountUsd;
|
|
118
|
+
response = await fetch(url, {
|
|
119
|
+
method, signal: ctrl.signal,
|
|
120
|
+
headers: { ...headers, ...signed.headers },
|
|
121
|
+
body: payload,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const txHash = extractTxHash(response);
|
|
125
|
+
// If the gateway returned 4xx after we signed, settlement was skipped
|
|
126
|
+
// server-side (per the route's "Payment was NOT charged" pattern). Don't
|
|
127
|
+
// claim a paid amount the wallet didn't actually spend.
|
|
128
|
+
if (!response.ok)
|
|
129
|
+
paidUsd = 0;
|
|
130
|
+
const raw = await response.text().catch(() => '');
|
|
131
|
+
let parsed = {};
|
|
132
|
+
try {
|
|
133
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
134
|
+
}
|
|
135
|
+
catch { /* leave as {} */ }
|
|
136
|
+
return {
|
|
137
|
+
ok: response.ok, status: response.status, body: parsed, raw,
|
|
138
|
+
paidUsd, txHash, latencyMs: Date.now() - start,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
abortSignal.removeEventListener('abort', onParentAbort);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function buildUrl(path, params) {
|
|
147
|
+
const chain = loadChain();
|
|
148
|
+
const base = API_URLS[chain]; // ends in /api
|
|
149
|
+
const clean = path.startsWith('/') ? path : `/${path}`;
|
|
150
|
+
const url = `${base}${clean}`;
|
|
151
|
+
if (!params || Object.keys(params).length === 0)
|
|
152
|
+
return url;
|
|
153
|
+
const usp = new URLSearchParams();
|
|
154
|
+
for (const [key, value] of Object.entries(params)) {
|
|
155
|
+
if (value === undefined || value === null)
|
|
156
|
+
continue;
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
for (const v of value)
|
|
159
|
+
usp.append(key, String(v));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
usp.append(key, String(value));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const qs = usp.toString();
|
|
166
|
+
return qs ? `${url}?${qs}` : url;
|
|
167
|
+
}
|
|
168
|
+
function fmtUsd(n) {
|
|
169
|
+
if (n < 0.01)
|
|
170
|
+
return `$${n.toFixed(4)}`;
|
|
171
|
+
return `$${n.toFixed(2)}`;
|
|
172
|
+
}
|
|
173
|
+
export const blockrunCapability = {
|
|
174
|
+
spec: {
|
|
175
|
+
name: 'BlockRun',
|
|
176
|
+
description: 'Call any BlockRun gateway endpoint. Signs an x402 USDC payment from the user wallet, retries on HTTP 402, and returns the response. ' +
|
|
177
|
+
'Use this for crypto data (Surf — markets, on-chain, social, chat), AI inference (chat / image / video / music), phone numbers and voice calls, ' +
|
|
178
|
+
'prediction markets, DeFi data, and any other API exposed under https://blockrun.ai/marketplace. ' +
|
|
179
|
+
'The path must start with "/v1/" or "/.well-known/". ' +
|
|
180
|
+
'Bundled skills like /surf-market, /surf-chain, /surf-social, /surf-chat document which endpoints to call for common workflows — read those when you are unsure which path serves the user\'s question. ' +
|
|
181
|
+
'Cost is wallet-charged automatically; the response includes the actual USD paid.',
|
|
182
|
+
input_schema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
path: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
description: 'API path under /api, starting with "/v1/" or "/.well-known/". E.g. "/v1/surf/market/fear-greed", "/v1/phone/numbers/list", "/v1/chat/completions".',
|
|
188
|
+
},
|
|
189
|
+
method: {
|
|
190
|
+
type: 'string',
|
|
191
|
+
enum: ['GET', 'POST'],
|
|
192
|
+
description: 'HTTP method. Default: POST if `body` is provided, otherwise GET.',
|
|
193
|
+
},
|
|
194
|
+
params: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
description: 'Query-string parameters. Use for GETs. E.g. { symbol: "BTC" }.',
|
|
197
|
+
},
|
|
198
|
+
body: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
description: 'JSON body. Use for POSTs. E.g. { model: "surf-1.5", messages: [...] }.',
|
|
201
|
+
},
|
|
202
|
+
timeoutMs: {
|
|
203
|
+
type: 'number',
|
|
204
|
+
description: `Optional client-side timeout in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
required: ['path'],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
concurrent: true,
|
|
211
|
+
async execute(input, ctx) {
|
|
212
|
+
const raw = input;
|
|
213
|
+
const path = typeof raw.path === 'string' ? raw.path.trim() : '';
|
|
214
|
+
if (!path) {
|
|
215
|
+
return { output: 'Error: `path` is required (e.g. "/v1/surf/market/fear-greed").', isError: true };
|
|
216
|
+
}
|
|
217
|
+
if (!/^\/(v1|\.well-known)\//.test(path)) {
|
|
218
|
+
return {
|
|
219
|
+
output: `Error: path must start with "/v1/" or "/.well-known/". Got: ${path}`,
|
|
220
|
+
isError: true,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const params = (raw.params && typeof raw.params === 'object') ? raw.params : undefined;
|
|
224
|
+
const body = (raw.body && typeof raw.body === 'object') ? raw.body : undefined;
|
|
225
|
+
// Method resolution: explicit > inferred from body > default GET
|
|
226
|
+
const explicitMethod = typeof raw.method === 'string' ? raw.method.toUpperCase() : '';
|
|
227
|
+
const method = explicitMethod === 'POST' || explicitMethod === 'GET'
|
|
228
|
+
? explicitMethod
|
|
229
|
+
: (body ? 'POST' : 'GET');
|
|
230
|
+
const timeoutMs = Math.min(Math.max(1_000, typeof raw.timeoutMs === 'number' ? raw.timeoutMs : DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
|
|
231
|
+
const url = buildUrl(path, method === 'GET' ? params : undefined);
|
|
232
|
+
const resourceDescription = `BlockRun ${method} ${path}`;
|
|
233
|
+
const result = await callGateway(url, method, method === 'POST' ? body : undefined, resourceDescription, ctx.abortSignal, timeoutMs);
|
|
234
|
+
// Telemetry — show in the panel Audit tab regardless of success
|
|
235
|
+
try {
|
|
236
|
+
recordUsage(`BlockRun:${path}`, 0, 0, result.paidUsd, result.latencyMs);
|
|
237
|
+
}
|
|
238
|
+
catch { /* best-effort */ }
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
const detail = typeof result.body?.error === 'string'
|
|
241
|
+
? result.body.error
|
|
242
|
+
: `HTTP ${result.status}`;
|
|
243
|
+
const fullOutput = result.raw || JSON.stringify(result.body, null, 2);
|
|
244
|
+
return {
|
|
245
|
+
output: `BlockRun ${method} ${path} failed: ${detail} (status ${result.status}). No charge if status is 4xx pre-payment.`,
|
|
246
|
+
fullOutput,
|
|
247
|
+
isError: true,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const head = `BlockRun ${method} ${path} → ${fmtUsd(result.paidUsd)}${result.txHash ? ` · tx ${result.txHash.slice(0, 10)}…` : ''} · ${result.latencyMs}ms`;
|
|
251
|
+
const payload = typeof result.body === 'object' ? JSON.stringify(result.body, null, 2) : String(result.body);
|
|
252
|
+
return {
|
|
253
|
+
output: `${head}\n${payload}`,
|
|
254
|
+
fullOutput: `${head}\n${payload}`,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
};
|
package/dist/tools/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { base0xGaslessSwapCapability } from './zerox-gasless.js';
|
|
|
32
32
|
import { defiLlamaProtocolsCapability, defiLlamaProtocolCapability, defiLlamaChainsCapability, defiLlamaYieldsCapability, defiLlamaPriceCapability, } from './defillama.js';
|
|
33
33
|
import { predictionMarketCapability } from './prediction.js';
|
|
34
34
|
import { modalCapabilities } from './modal.js';
|
|
35
|
+
import { blockrunCapability } from './blockrun.js';
|
|
35
36
|
import { createTradingCapabilities } from './trading-execute.js';
|
|
36
37
|
import { Portfolio } from '../trading/portfolio.js';
|
|
37
38
|
import { RiskEngine } from '../trading/risk.js';
|
|
@@ -163,6 +164,7 @@ export const allCapabilities = [
|
|
|
163
164
|
defiLlamaYieldsCapability,
|
|
164
165
|
defiLlamaPriceCapability,
|
|
165
166
|
predictionMarketCapability, // Polymarket / Kalshi / matching / smart money via Predexon
|
|
167
|
+
blockrunCapability, // Generic x402-paid gateway primitive — Surf, Phone, future partners (see /surf-* skills)
|
|
166
168
|
// Modal GPU sandbox tools — registered but hidden by default (not in
|
|
167
169
|
// CORE_TOOL_NAMES). Agent must `ActivateTool({names:["ModalCreate",...]})`
|
|
168
170
|
// before they appear in its tool inventory. High-cost ($0.40/H100 create)
|
|
@@ -9,7 +9,38 @@
|
|
|
9
9
|
* The split mirrors OpenBB's router/engine/view layering and keeps every
|
|
10
10
|
* layer testable in isolation.
|
|
11
11
|
*/
|
|
12
|
+
import { scoreEntry } from '../trading/journal-quality.js';
|
|
13
|
+
import { renderDisciplineFooter } from '../trading/journal-display.js';
|
|
12
14
|
import { renderOrderBlocked, renderOrderFilled, renderPortfolio, renderPositionClosed, renderTradeHistory, windowToSince, } from './trading-views.js';
|
|
15
|
+
/**
|
|
16
|
+
* Pull a `rationale` object out of the LLM's input safely. Skips fields
|
|
17
|
+
* that don't match the expected shape so a half-filled rationale is still
|
|
18
|
+
* captured (the scorer rewards completeness, doesn't require it).
|
|
19
|
+
*/
|
|
20
|
+
function extractRationale(raw) {
|
|
21
|
+
if (!raw || typeof raw !== 'object')
|
|
22
|
+
return undefined;
|
|
23
|
+
const r = raw;
|
|
24
|
+
const out = {};
|
|
25
|
+
if (r.direction === 'long' || r.direction === 'short' || r.direction === 'neutral')
|
|
26
|
+
out.direction = r.direction;
|
|
27
|
+
if (typeof r.priceTarget === 'number' && r.priceTarget > 0)
|
|
28
|
+
out.priceTarget = r.priceTarget;
|
|
29
|
+
if (typeof r.stopLoss === 'number' && r.stopLoss > 0)
|
|
30
|
+
out.stopLoss = r.stopLoss;
|
|
31
|
+
if (typeof r.timeHorizon === 'string' && r.timeHorizon.trim())
|
|
32
|
+
out.timeHorizon = r.timeHorizon.trim();
|
|
33
|
+
if (typeof r.conviction === 'number' && r.conviction >= 1 && r.conviction <= 5) {
|
|
34
|
+
out.conviction = Math.round(r.conviction);
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(r.evidence))
|
|
37
|
+
out.evidence = r.evidence.filter((x) => typeof x === 'string');
|
|
38
|
+
if (Array.isArray(r.tags))
|
|
39
|
+
out.tags = r.tags.filter((x) => typeof x === 'string');
|
|
40
|
+
if (typeof r.thesis === 'string' && r.thesis.trim())
|
|
41
|
+
out.thesis = r.thesis.trim();
|
|
42
|
+
return Object.keys(out).length ? out : undefined;
|
|
43
|
+
}
|
|
13
44
|
function enginePortfolio(engine) {
|
|
14
45
|
return engine.deps.portfolio;
|
|
15
46
|
}
|
|
@@ -44,7 +75,15 @@ export function createTradingCapabilities(deps) {
|
|
|
44
75
|
concurrent: true,
|
|
45
76
|
async execute(_input, _ctx) {
|
|
46
77
|
const snap = await buildPortfolioSnapshot(engine);
|
|
47
|
-
|
|
78
|
+
const base = renderPortfolio(snap, riskConfig);
|
|
79
|
+
if (!tradeLog)
|
|
80
|
+
return { output: base };
|
|
81
|
+
// Journal discipline footer — last 10 scored entries from the log.
|
|
82
|
+
// If no entries carry qualityScore yet (pre-v3.20 history or no rationale
|
|
83
|
+
// ever recorded), renderDisciplineFooter returns null and we skip silently.
|
|
84
|
+
const recent = tradeLog.recent(10);
|
|
85
|
+
const footer = renderDisciplineFooter(recent);
|
|
86
|
+
return { output: footer ? `${base}\n${footer}` : base };
|
|
48
87
|
},
|
|
49
88
|
};
|
|
50
89
|
const tradingOpenPosition = {
|
|
@@ -53,7 +92,10 @@ export function createTradingCapabilities(deps) {
|
|
|
53
92
|
description: 'Open (buy into) a position. Pre-trade risk checks enforce per-position and total ' +
|
|
54
93
|
'exposure caps; a blocked order returns a normal text result with the reason — the ' +
|
|
55
94
|
'agent should read it and try again with a smaller qty if appropriate. This is paper ' +
|
|
56
|
-
'trading: fills are simulated against the provided price.'
|
|
95
|
+
'trading: fills are simulated against the provided price. ' +
|
|
96
|
+
'Optionally pass a `rationale` object documenting why — direction, price target, stop, ' +
|
|
97
|
+
'time horizon, conviction, evidence, tags, thesis. The journal scores entries on ' +
|
|
98
|
+
'rationale completeness (not P&L) and surfaces the discipline trend in TradingPortfolio.',
|
|
57
99
|
input_schema: {
|
|
58
100
|
type: 'object',
|
|
59
101
|
required: ['symbol', 'qty', 'priceUsd'],
|
|
@@ -61,6 +103,21 @@ export function createTradingCapabilities(deps) {
|
|
|
61
103
|
symbol: { type: 'string', description: 'Ticker (e.g., "BTC", "ETH")' },
|
|
62
104
|
qty: { type: 'number', description: 'Quantity in base units (e.g., 0.01 for 0.01 BTC)' },
|
|
63
105
|
priceUsd: { type: 'number', description: 'Price at which to execute, in USD' },
|
|
106
|
+
rationale: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
description: 'Optional — why you are opening this position. Captured in the trade journal and scored on discipline (verifiability, evidence, specificity, novelty, review).',
|
|
109
|
+
properties: {
|
|
110
|
+
direction: { type: 'string', enum: ['long', 'short', 'neutral'], description: 'Trade direction. For paper-trade longs use "long".' },
|
|
111
|
+
priceTarget: { type: 'number', description: 'Expected exit price (USD).' },
|
|
112
|
+
stopLoss: { type: 'number', description: 'Forced exit floor (USD).' },
|
|
113
|
+
timeHorizon: { type: 'string', description: 'How long you expect to hold: "1h", "1d", "1w", "1m", "3m", etc.' },
|
|
114
|
+
conviction: { type: 'number', description: 'How sure are you, 1 (low) to 5 (high).' },
|
|
115
|
+
evidence: { type: 'array', items: { type: 'string' }, description: 'Sources, links, or indicator names supporting the thesis.' },
|
|
116
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Categorization: "momentum", "macro", "mean-reversion", etc.' },
|
|
117
|
+
thesis: { type: 'string', description: 'Free-text reasoning (≥200 chars scores best).' },
|
|
118
|
+
},
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
},
|
|
64
121
|
},
|
|
65
122
|
additionalProperties: false,
|
|
66
123
|
},
|
|
@@ -84,7 +141,8 @@ export function createTradingCapabilities(deps) {
|
|
|
84
141
|
return { output: `No-op: ${outcome.reason}` };
|
|
85
142
|
}
|
|
86
143
|
if (tradeLog) {
|
|
87
|
-
|
|
144
|
+
const rationale = extractRationale(input.rationale);
|
|
145
|
+
const draftEntry = {
|
|
88
146
|
timestamp: Date.now(),
|
|
89
147
|
symbol,
|
|
90
148
|
side: 'buy',
|
|
@@ -92,7 +150,11 @@ export function createTradingCapabilities(deps) {
|
|
|
92
150
|
priceUsd: outcome.fill.priceUsd,
|
|
93
151
|
feeUsd: outcome.fill.feeUsd,
|
|
94
152
|
realizedPnlUsd: 0,
|
|
95
|
-
|
|
153
|
+
rationale,
|
|
154
|
+
};
|
|
155
|
+
const history = tradeLog.all();
|
|
156
|
+
draftEntry.qualityScore = scoreEntry(draftEntry, history);
|
|
157
|
+
tradeLog.append(draftEntry);
|
|
96
158
|
}
|
|
97
159
|
if (onStateChange)
|
|
98
160
|
await onStateChange();
|
|
@@ -110,7 +172,9 @@ export function createTradingCapabilities(deps) {
|
|
|
110
172
|
name: 'TradingClosePosition',
|
|
111
173
|
description: 'Close (sell) an open position, realizing P&L against the average entry price. ' +
|
|
112
174
|
"Omit qty to flatten the position entirely; pass qty to partially reduce. Uses the " +
|
|
113
|
-
"exchange's current mark — no manual price required."
|
|
175
|
+
"exchange's current mark — no manual price required. " +
|
|
176
|
+
'Optionally pass a `review` note documenting whether the trade hit its plan; that ' +
|
|
177
|
+
'boosts the journal discipline score for this entry.',
|
|
114
178
|
input_schema: {
|
|
115
179
|
type: 'object',
|
|
116
180
|
required: ['symbol'],
|
|
@@ -120,6 +184,10 @@ export function createTradingCapabilities(deps) {
|
|
|
120
184
|
type: 'number',
|
|
121
185
|
description: 'Optional — partial size. Omit to close the full position.',
|
|
122
186
|
},
|
|
187
|
+
review: {
|
|
188
|
+
type: 'string',
|
|
189
|
+
description: 'Optional post-trade note: did the trade hit its target / stop / hypothesis? Boosts the journal "review" score component.',
|
|
190
|
+
},
|
|
123
191
|
},
|
|
124
192
|
additionalProperties: false,
|
|
125
193
|
},
|
|
@@ -145,7 +213,8 @@ export function createTradingCapabilities(deps) {
|
|
|
145
213
|
}
|
|
146
214
|
const tradeRealized = portfolio.realizedPnlUsd - priorRealized;
|
|
147
215
|
if (tradeLog) {
|
|
148
|
-
|
|
216
|
+
const review = typeof input.review === 'string' && input.review.trim() ? input.review.trim() : undefined;
|
|
217
|
+
const draftEntry = {
|
|
149
218
|
timestamp: Date.now(),
|
|
150
219
|
symbol,
|
|
151
220
|
side: 'sell',
|
|
@@ -153,7 +222,10 @@ export function createTradingCapabilities(deps) {
|
|
|
153
222
|
priceUsd: outcome.fill.priceUsd,
|
|
154
223
|
feeUsd: outcome.fill.feeUsd,
|
|
155
224
|
realizedPnlUsd: tradeRealized,
|
|
156
|
-
|
|
225
|
+
review,
|
|
226
|
+
};
|
|
227
|
+
draftEntry.qualityScore = scoreEntry(draftEntry, tradeLog.all());
|
|
228
|
+
tradeLog.append(draftEntry);
|
|
157
229
|
}
|
|
158
230
|
if (onStateChange)
|
|
159
231
|
await onStateChange();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown footer for `TradingPortfolio` — the discipline mirror.
|
|
3
|
+
*
|
|
4
|
+
* Shows the last-N trades' average quality score and flags any component
|
|
5
|
+
* that scored below 3.0 (the threshold AI-Trader uses too). The footer
|
|
6
|
+
* is the only place the discipline metric surfaces today; future
|
|
7
|
+
* releases can drop it into the panel Audit tab too.
|
|
8
|
+
*
|
|
9
|
+
* Pure formatting; takes AggregateScore from journal-quality.ts.
|
|
10
|
+
*/
|
|
11
|
+
import type { TradeLogEntry } from './trade-log.js';
|
|
12
|
+
/**
|
|
13
|
+
* Build the discipline footer markdown for an existing portfolio output.
|
|
14
|
+
* Returns `null` when there's nothing to show (no scored entries yet).
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderDisciplineFooter(entries: TradeLogEntry[]): string | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown footer for `TradingPortfolio` — the discipline mirror.
|
|
3
|
+
*
|
|
4
|
+
* Shows the last-N trades' average quality score and flags any component
|
|
5
|
+
* that scored below 3.0 (the threshold AI-Trader uses too). The footer
|
|
6
|
+
* is the only place the discipline metric surfaces today; future
|
|
7
|
+
* releases can drop it into the panel Audit tab too.
|
|
8
|
+
*
|
|
9
|
+
* Pure formatting; takes AggregateScore from journal-quality.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { aggregateScores } from './journal-quality.js';
|
|
12
|
+
const COMPONENT_WARN_THRESHOLD = 3.0; // out of 5
|
|
13
|
+
function fmt(n) {
|
|
14
|
+
return (Math.round(n * 100) / 100).toFixed(2);
|
|
15
|
+
}
|
|
16
|
+
function flagFor(component) {
|
|
17
|
+
switch (component) {
|
|
18
|
+
case 'averageVerifiability': return 'most trades missing direction or price target';
|
|
19
|
+
case 'averageEvidence': return 'most trades missing thesis or sources';
|
|
20
|
+
case 'averageSpecificity': return 'few tags — trades feel generic';
|
|
21
|
+
case 'averageNovelty': return 'repeating same symbol/direction';
|
|
22
|
+
case 'averageReview': return 'no post-trade notes';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build the discipline footer markdown for an existing portfolio output.
|
|
27
|
+
* Returns `null` when there's nothing to show (no scored entries yet).
|
|
28
|
+
*/
|
|
29
|
+
export function renderDisciplineFooter(entries) {
|
|
30
|
+
const agg = aggregateScores(entries);
|
|
31
|
+
if (!agg)
|
|
32
|
+
return null;
|
|
33
|
+
const lines = [];
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('### Journal discipline');
|
|
36
|
+
lines.push(`Last ${agg.count} scored trade${agg.count === 1 ? '' : 's'}: ` +
|
|
37
|
+
`**${fmt(agg.averageTotal)} / 5**`);
|
|
38
|
+
lines.push('');
|
|
39
|
+
// Each component scaled to 0–5 for display (internal is 0–1).
|
|
40
|
+
const components = [
|
|
41
|
+
{ key: 'averageVerifiability', label: 'verifiability', value: agg.averageVerifiability * 5 },
|
|
42
|
+
{ key: 'averageEvidence', label: 'evidence', value: agg.averageEvidence * 5 },
|
|
43
|
+
{ key: 'averageSpecificity', label: 'specificity', value: agg.averageSpecificity * 5 },
|
|
44
|
+
{ key: 'averageNovelty', label: 'novelty', value: agg.averageNovelty * 5 },
|
|
45
|
+
{ key: 'averageReview', label: 'review', value: agg.averageReview * 5 },
|
|
46
|
+
];
|
|
47
|
+
for (const c of components) {
|
|
48
|
+
const flagged = c.value < COMPONENT_WARN_THRESHOLD;
|
|
49
|
+
const flagText = flagged ? ` ← ${flagFor(c.key)}` : '';
|
|
50
|
+
lines.push(`- ${c.label.padEnd(14)} ${fmt(c.value).padStart(5)}${flagText}`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal quality scorer — non-outcome trade discipline metric.
|
|
3
|
+
*
|
|
4
|
+
* Scores each journal entry on how well it was *justified*, not whether
|
|
5
|
+
* it made money. The five components are weighted to reward the same
|
|
6
|
+
* habits a discretionary trader's playbook teaches:
|
|
7
|
+
*
|
|
8
|
+
* verifiability (30%) did the entry name a direction and a price target?
|
|
9
|
+
* evidence (25%) did it cite sources / thesis / indicators?
|
|
10
|
+
* specificity (20%) symbol, tags — not vague vibes?
|
|
11
|
+
* novelty (15%) not the 4th identical revenge-trade this week?
|
|
12
|
+
* review (10%) did the user write a post-trade note?
|
|
13
|
+
*
|
|
14
|
+
* The total is on a 0–5 scale, presented in the portfolio footer so the
|
|
15
|
+
* agent and the user can see the discipline curve over time.
|
|
16
|
+
*
|
|
17
|
+
* Pure function — no I/O, no clock, deterministic given inputs. Used at
|
|
18
|
+
* append time (TradeLog) and at render time (TradingPortfolio).
|
|
19
|
+
*/
|
|
20
|
+
import type { TradeLogEntry, QualityScore } from './trade-log.js';
|
|
21
|
+
/**
|
|
22
|
+
* Score one journal entry against the prior history (used for novelty).
|
|
23
|
+
* `history` should contain entries chronologically before `entry`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function scoreEntry(entry: TradeLogEntry, history?: TradeLogEntry[]): QualityScore;
|
|
26
|
+
export interface AggregateScore {
|
|
27
|
+
count: number;
|
|
28
|
+
averageTotal: number;
|
|
29
|
+
averageVerifiability: number;
|
|
30
|
+
averageEvidence: number;
|
|
31
|
+
averageSpecificity: number;
|
|
32
|
+
averageNovelty: number;
|
|
33
|
+
averageReview: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Average the qualityScore fields across a set of entries — used by the
|
|
37
|
+
* portfolio footer to show "your last 10 trades scored 3.2 / 5 on average".
|
|
38
|
+
*
|
|
39
|
+
* Entries without a persisted qualityScore are skipped (back-compat with
|
|
40
|
+
* pre-v3.20 trades). Returns null when there's nothing scored to average.
|
|
41
|
+
*/
|
|
42
|
+
export declare function aggregateScores(entries: TradeLogEntry[]): AggregateScore | null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal quality scorer — non-outcome trade discipline metric.
|
|
3
|
+
*
|
|
4
|
+
* Scores each journal entry on how well it was *justified*, not whether
|
|
5
|
+
* it made money. The five components are weighted to reward the same
|
|
6
|
+
* habits a discretionary trader's playbook teaches:
|
|
7
|
+
*
|
|
8
|
+
* verifiability (30%) did the entry name a direction and a price target?
|
|
9
|
+
* evidence (25%) did it cite sources / thesis / indicators?
|
|
10
|
+
* specificity (20%) symbol, tags — not vague vibes?
|
|
11
|
+
* novelty (15%) not the 4th identical revenge-trade this week?
|
|
12
|
+
* review (10%) did the user write a post-trade note?
|
|
13
|
+
*
|
|
14
|
+
* The total is on a 0–5 scale, presented in the portfolio footer so the
|
|
15
|
+
* agent and the user can see the discipline curve over time.
|
|
16
|
+
*
|
|
17
|
+
* Pure function — no I/O, no clock, deterministic given inputs. Used at
|
|
18
|
+
* append time (TradeLog) and at render time (TradingPortfolio).
|
|
19
|
+
*/
|
|
20
|
+
const NOVELTY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
21
|
+
const NOVELTY_PENALTY = 0.2; // per duplicate within window
|
|
22
|
+
const EVIDENCE_KEYWORD_REGEX = /\b(rsi|macd|bollinger|sma|ema|volatility|funding|liquidation|etf|on[-\s]?chain|catalyst|earnings|tvl|because|since|due to|supports?|resistance|breakout|breakdown|divergence|oversold|overbought)\b/i;
|
|
23
|
+
function clamp01(n) {
|
|
24
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
25
|
+
return 0;
|
|
26
|
+
if (n >= 1)
|
|
27
|
+
return 1;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
function hasIndicatorKeyword(text) {
|
|
31
|
+
if (!text)
|
|
32
|
+
return 0;
|
|
33
|
+
return EVIDENCE_KEYWORD_REGEX.test(text) ? 1 : 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Score one journal entry against the prior history (used for novelty).
|
|
37
|
+
* `history` should contain entries chronologically before `entry`.
|
|
38
|
+
*/
|
|
39
|
+
export function scoreEntry(entry, history = []) {
|
|
40
|
+
const r = entry.rationale;
|
|
41
|
+
// ─ verifiability: direction + priceTarget each contribute half ─
|
|
42
|
+
const verifiability = (r?.direction ? 0.5 : 0) +
|
|
43
|
+
(typeof r?.priceTarget === 'number' && r.priceTarget > 0 ? 0.5 : 0);
|
|
44
|
+
// ─ evidence: array length, thesis length, indicator keyword presence ─
|
|
45
|
+
const evidenceArrLen = Array.isArray(r?.evidence) ? r.evidence.length : 0;
|
|
46
|
+
const thesisLen = (r?.thesis ?? '').trim().length;
|
|
47
|
+
const evidence = clamp01(0.4 * Math.min(1, evidenceArrLen / 3) +
|
|
48
|
+
0.4 * Math.min(1, thesisLen / 200) +
|
|
49
|
+
0.2 * hasIndicatorKeyword(r?.thesis));
|
|
50
|
+
// ─ specificity: symbol present + tags present ─
|
|
51
|
+
const tagCount = Array.isArray(r?.tags) ? r.tags.length : 0;
|
|
52
|
+
const specificity = (entry.symbol ? 0.5 : 0) +
|
|
53
|
+
Math.min(1, tagCount / 2) * 0.5;
|
|
54
|
+
// ─ novelty: penalize same symbol + direction within 7d ─
|
|
55
|
+
const sinceCutoff = entry.timestamp - NOVELTY_WINDOW_MS;
|
|
56
|
+
const recentSameCount = history.filter((e) => e.timestamp >= sinceCutoff &&
|
|
57
|
+
e.timestamp < entry.timestamp &&
|
|
58
|
+
e.symbol === entry.symbol &&
|
|
59
|
+
(e.rationale?.direction ?? null) === (r?.direction ?? null)).length;
|
|
60
|
+
const novelty = clamp01(1 - NOVELTY_PENALTY * recentSameCount);
|
|
61
|
+
// ─ review: did the user (or the agent in a follow-up turn) annotate? ─
|
|
62
|
+
const review = entry.review && entry.review.trim().length > 0 ? 1 : 0;
|
|
63
|
+
const total = 5 * (verifiability * 0.30 +
|
|
64
|
+
evidence * 0.25 +
|
|
65
|
+
specificity * 0.20 +
|
|
66
|
+
novelty * 0.15 +
|
|
67
|
+
review * 0.10);
|
|
68
|
+
return {
|
|
69
|
+
total: Math.round(total * 100) / 100, // 2 decimal places
|
|
70
|
+
verifiability,
|
|
71
|
+
evidence,
|
|
72
|
+
specificity,
|
|
73
|
+
novelty,
|
|
74
|
+
review,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Average the qualityScore fields across a set of entries — used by the
|
|
79
|
+
* portfolio footer to show "your last 10 trades scored 3.2 / 5 on average".
|
|
80
|
+
*
|
|
81
|
+
* Entries without a persisted qualityScore are skipped (back-compat with
|
|
82
|
+
* pre-v3.20 trades). Returns null when there's nothing scored to average.
|
|
83
|
+
*/
|
|
84
|
+
export function aggregateScores(entries) {
|
|
85
|
+
const scored = entries.filter((e) => e.qualityScore != null);
|
|
86
|
+
if (scored.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const sum = scored.reduce((acc, e) => {
|
|
89
|
+
const q = e.qualityScore;
|
|
90
|
+
return {
|
|
91
|
+
total: acc.total + q.total,
|
|
92
|
+
v: acc.v + q.verifiability,
|
|
93
|
+
e: acc.e + q.evidence,
|
|
94
|
+
s: acc.s + q.specificity,
|
|
95
|
+
n: acc.n + q.novelty,
|
|
96
|
+
r: acc.r + q.review,
|
|
97
|
+
};
|
|
98
|
+
}, { total: 0, v: 0, e: 0, s: 0, n: 0, r: 0 });
|
|
99
|
+
const n = scored.length;
|
|
100
|
+
return {
|
|
101
|
+
count: n,
|
|
102
|
+
averageTotal: sum.total / n,
|
|
103
|
+
averageVerifiability: sum.v / n,
|
|
104
|
+
averageEvidence: sum.e / n,
|
|
105
|
+
averageSpecificity: sum.s / n,
|
|
106
|
+
averageNovelty: sum.n / n,
|
|
107
|
+
averageReview: sum.r / n,
|
|
108
|
+
};
|
|
109
|
+
}
|