@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.
@@ -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
+ };
@@ -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
- return { output: renderPortfolio(snap, riskConfig) };
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
- tradeLog.append({
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
- tradeLog.append({
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
+ }