@blockrun/franklin 3.7.10 → 3.8.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/bash-guard.js +8 -2
- package/dist/agent/compact.d.ts +14 -0
- package/dist/agent/compact.js +57 -1
- package/dist/agent/context.js +6 -4
- package/dist/agent/llm.js +2 -1
- package/dist/agent/loop.js +88 -18
- package/dist/agent/optimize.js +4 -0
- package/dist/agent/tokens.d.ts +7 -3
- package/dist/agent/tokens.js +14 -7
- package/dist/agent/tool-guard.js +64 -26
- package/dist/content/image-pricing.d.ts +14 -0
- package/dist/content/image-pricing.js +32 -0
- package/dist/content/library.d.ts +63 -0
- package/dist/content/library.js +75 -0
- package/dist/content/record-image.d.ts +43 -0
- package/dist/content/record-image.js +50 -0
- package/dist/content/store.d.ts +15 -0
- package/dist/content/store.js +55 -0
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +2 -2
- package/dist/router/index.js +17 -6
- package/dist/tools/bash.d.ts +8 -0
- package/dist/tools/bash.js +13 -0
- package/dist/tools/content-execute.d.ts +26 -0
- package/dist/tools/content-execute.js +212 -0
- package/dist/tools/imagegen.d.ts +14 -0
- package/dist/tools/imagegen.js +164 -101
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.js +91 -5
- package/dist/tools/read.d.ts +13 -0
- package/dist/tools/read.js +17 -0
- package/dist/tools/trading-execute.d.ts +35 -0
- package/dist/tools/trading-execute.js +297 -0
- package/dist/tools/webfetch.d.ts +6 -0
- package/dist/tools/webfetch.js +8 -0
- package/dist/trading/engine.d.ts +51 -0
- package/dist/trading/engine.js +75 -0
- package/dist/trading/live-exchange.d.ts +43 -0
- package/dist/trading/live-exchange.js +48 -0
- package/dist/trading/mock-exchange.d.ts +40 -0
- package/dist/trading/mock-exchange.js +41 -0
- package/dist/trading/portfolio.d.ts +67 -0
- package/dist/trading/portfolio.js +106 -0
- package/dist/trading/risk.d.ts +34 -0
- package/dist/trading/risk.js +64 -0
- package/dist/trading/store.d.ts +9 -0
- package/dist/trading/store.js +32 -0
- package/dist/trading/trade-log.d.ts +39 -0
- package/dist/trading/trade-log.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trading execution capabilities. Exposes Franklin's Portfolio + RiskEngine
|
|
3
|
+
* + Exchange stack to the agent as three tools: TradingPortfolio (read),
|
|
4
|
+
* TradingOpenPosition (buy side), TradingClosePosition (sell side).
|
|
5
|
+
*
|
|
6
|
+
* This is the surface that differentiates Franklin from generic coding
|
|
7
|
+
* agents — Claude Code and Cursor cannot hold a wallet, track positions
|
|
8
|
+
* across sessions, or reason about P&L. Every output here is deliberately
|
|
9
|
+
* information-rich so the agent has the numbers it needs to make the next
|
|
10
|
+
* economic decision (cash left, risk utilization, unrealized vs realized
|
|
11
|
+
* P&L, fill detail) without a follow-up tool call.
|
|
12
|
+
*
|
|
13
|
+
* Factory-style construction (createTradingCapabilities) keeps testing
|
|
14
|
+
* clean: production code calls it with a default disk-backed engine;
|
|
15
|
+
* tests inject a MockExchange-backed engine and assert behavior without
|
|
16
|
+
* touching disk.
|
|
17
|
+
*/
|
|
18
|
+
function formatUsd(n) {
|
|
19
|
+
const sign = n < 0 ? '-' : '';
|
|
20
|
+
const abs = Math.abs(n);
|
|
21
|
+
return `${sign}$${abs.toFixed(2)}`;
|
|
22
|
+
}
|
|
23
|
+
function formatPct(n) {
|
|
24
|
+
return `${(n * 100).toFixed(1)}%`;
|
|
25
|
+
}
|
|
26
|
+
function formatPositionLine(p) {
|
|
27
|
+
const pctReturn = (p.markUsd - p.avgPriceUsd) / p.avgPriceUsd;
|
|
28
|
+
const arrow = p.unrealizedPnlUsd >= 0 ? '↑' : '↓';
|
|
29
|
+
return (`- **${p.symbol}** qty=${p.qty} @ avg ${formatUsd(p.avgPriceUsd)} ` +
|
|
30
|
+
`| mark ${formatUsd(p.markUsd)} ${arrow} ` +
|
|
31
|
+
`| unrealized ${formatUsd(p.unrealizedPnlUsd)} (${formatPct(pctReturn)})`);
|
|
32
|
+
}
|
|
33
|
+
/** Parse a window string (e.g. "24h", "7d", "all") into a lower-bound timestamp. */
|
|
34
|
+
function windowToSince(window, now) {
|
|
35
|
+
const m = /^(\d+)\s*([hdwm])$/i.exec(window.trim());
|
|
36
|
+
if (!m)
|
|
37
|
+
return 0; // "all" or anything unparseable → since epoch
|
|
38
|
+
const n = parseInt(m[1], 10);
|
|
39
|
+
switch (m[2].toLowerCase()) {
|
|
40
|
+
case 'h': return now - n * 3_600_000;
|
|
41
|
+
case 'd': return now - n * 86_400_000;
|
|
42
|
+
case 'w': return now - n * 7 * 86_400_000;
|
|
43
|
+
case 'm': return now - n * 30 * 86_400_000;
|
|
44
|
+
default: return 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function formatTradeLine(entry) {
|
|
48
|
+
const when = new Date(entry.timestamp).toISOString().replace('T', ' ').slice(0, 16);
|
|
49
|
+
const side = entry.side.toUpperCase();
|
|
50
|
+
const pnl = entry.realizedPnlUsd === 0 ? '' : ` → realized ${formatUsd(entry.realizedPnlUsd)}`;
|
|
51
|
+
return `- ${when} ${side} ${entry.qty} ${entry.symbol} @ ${formatUsd(entry.priceUsd)}${pnl}`;
|
|
52
|
+
}
|
|
53
|
+
export function createTradingCapabilities(deps) {
|
|
54
|
+
const { engine, riskConfig, onStateChange, tradeLog } = deps;
|
|
55
|
+
const tradingPortfolio = {
|
|
56
|
+
spec: {
|
|
57
|
+
name: 'TradingPortfolio',
|
|
58
|
+
description: 'Report current paper-trading portfolio: cash, open positions with unrealized P&L, ' +
|
|
59
|
+
'and realized P&L across the session. No inputs. Use this before deciding whether ' +
|
|
60
|
+
'to open, close, or hold a position.',
|
|
61
|
+
input_schema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {},
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
concurrent: true,
|
|
68
|
+
async execute(_input, _ctx) {
|
|
69
|
+
// markToMarket against current exchange prices; fall back to avg price
|
|
70
|
+
// (flat unrealized) when the exchange doesn't know the symbol.
|
|
71
|
+
const priceTable = {};
|
|
72
|
+
for (const p of engine.deps.portfolio.listPositions()) {
|
|
73
|
+
const quote = await engine.deps.exchange.getPrice(p.symbol);
|
|
74
|
+
if (quote != null)
|
|
75
|
+
priceTable[p.symbol] = quote;
|
|
76
|
+
}
|
|
77
|
+
const portfolio = engine.deps.portfolio;
|
|
78
|
+
const snap = portfolio.markToMarket(priceTable);
|
|
79
|
+
const lines = [];
|
|
80
|
+
lines.push('## Portfolio');
|
|
81
|
+
lines.push(`- Cash: ${formatUsd(snap.cashUsd)}`);
|
|
82
|
+
lines.push(`- Equity (cash + positions marked-to-market): ${formatUsd(snap.equityUsd)}`);
|
|
83
|
+
lines.push(`- Unrealized P&L: ${formatUsd(snap.unrealizedPnlUsd)}`);
|
|
84
|
+
lines.push(`- Realized P&L (this session): ${formatUsd(snap.realizedPnlUsd)}`);
|
|
85
|
+
lines.push('');
|
|
86
|
+
if (snap.positions.length === 0) {
|
|
87
|
+
lines.push('_No open positions._');
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
lines.push('### Open positions');
|
|
91
|
+
for (const p of snap.positions)
|
|
92
|
+
lines.push(formatPositionLine(p));
|
|
93
|
+
}
|
|
94
|
+
if (riskConfig) {
|
|
95
|
+
const totalExposure = snap.positions.reduce((a, p) => a + p.qty * p.markUsd, 0);
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('### Risk utilization');
|
|
98
|
+
lines.push(`- Total exposure: ${formatUsd(totalExposure)} / cap ${formatUsd(riskConfig.maxTotalExposureUsd)} ` +
|
|
99
|
+
`(${formatPct(totalExposure / riskConfig.maxTotalExposureUsd)})`);
|
|
100
|
+
}
|
|
101
|
+
return { output: lines.join('\n') };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const tradingOpenPosition = {
|
|
105
|
+
spec: {
|
|
106
|
+
name: 'TradingOpenPosition',
|
|
107
|
+
description: 'Open (buy into) a position. Pre-trade risk checks enforce per-position and total ' +
|
|
108
|
+
'exposure caps; a blocked order returns a normal text result with the reason — the ' +
|
|
109
|
+
'agent should read it and try again with a smaller qty if appropriate. This is paper ' +
|
|
110
|
+
'trading: fills are simulated against the provided price.',
|
|
111
|
+
input_schema: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
required: ['symbol', 'qty', 'priceUsd'],
|
|
114
|
+
properties: {
|
|
115
|
+
symbol: { type: 'string', description: 'Ticker (e.g., "BTC", "ETH")' },
|
|
116
|
+
qty: { type: 'number', description: 'Quantity in base units (e.g., 0.01 for 0.01 BTC)' },
|
|
117
|
+
priceUsd: { type: 'number', description: 'Price at which to execute, in USD' },
|
|
118
|
+
},
|
|
119
|
+
additionalProperties: false,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
concurrent: false,
|
|
123
|
+
async execute(input, _ctx) {
|
|
124
|
+
const symbol = String(input.symbol ?? '').toUpperCase();
|
|
125
|
+
const qty = Number(input.qty);
|
|
126
|
+
const priceUsd = Number(input.priceUsd);
|
|
127
|
+
if (!symbol || !Number.isFinite(qty) || qty <= 0 || !Number.isFinite(priceUsd) || priceUsd <= 0) {
|
|
128
|
+
return {
|
|
129
|
+
output: 'Error: TradingOpenPosition requires symbol (string), qty (>0), priceUsd (>0).',
|
|
130
|
+
isError: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const outcome = await engine.openPosition({ symbol, qty, priceUsd });
|
|
134
|
+
if (outcome.status === 'blocked') {
|
|
135
|
+
// Not an agent error — a legitimate risk decision the agent must read.
|
|
136
|
+
return {
|
|
137
|
+
output: `## Order blocked\n` +
|
|
138
|
+
`- Symbol: ${symbol}\n` +
|
|
139
|
+
`- Attempted: buy ${qty} @ ${formatUsd(priceUsd)}\n` +
|
|
140
|
+
`- Reason: ${outcome.reason}\n\n` +
|
|
141
|
+
`Try a smaller qty, or close other positions first to free up exposure headroom.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (outcome.status === 'noop') {
|
|
145
|
+
return { output: `No-op: ${outcome.reason}` };
|
|
146
|
+
}
|
|
147
|
+
if (tradeLog) {
|
|
148
|
+
tradeLog.append({
|
|
149
|
+
timestamp: Date.now(),
|
|
150
|
+
symbol,
|
|
151
|
+
side: 'buy',
|
|
152
|
+
qty: outcome.fill.qty,
|
|
153
|
+
priceUsd: outcome.fill.priceUsd,
|
|
154
|
+
feeUsd: outcome.fill.feeUsd,
|
|
155
|
+
realizedPnlUsd: 0,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (onStateChange)
|
|
159
|
+
await onStateChange();
|
|
160
|
+
const portfolio = engine.deps.portfolio;
|
|
161
|
+
const pos = portfolio.getPosition(symbol);
|
|
162
|
+
return {
|
|
163
|
+
output: `## Order filled\n` +
|
|
164
|
+
`- Bought ${outcome.fill.qty} ${symbol} @ ${formatUsd(outcome.fill.priceUsd)} ` +
|
|
165
|
+
`(fee ${formatUsd(outcome.fill.feeUsd)})\n` +
|
|
166
|
+
`- Position now: ${pos ? `${pos.qty} ${symbol} @ avg ${formatUsd(pos.avgPriceUsd)}` : '(none)'}\n` +
|
|
167
|
+
`- Cash remaining: ${formatUsd(portfolio.cashUsd)}`,
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const tradingClosePosition = {
|
|
172
|
+
spec: {
|
|
173
|
+
name: 'TradingClosePosition',
|
|
174
|
+
description: 'Close (sell) an open position, realizing P&L against the average entry price. ' +
|
|
175
|
+
'Omit qty to flatten the position entirely; pass qty to partially reduce. Uses the ' +
|
|
176
|
+
'exchange\'s current mark — no manual price required.',
|
|
177
|
+
input_schema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
required: ['symbol'],
|
|
180
|
+
properties: {
|
|
181
|
+
symbol: { type: 'string', description: 'Ticker of the position to close' },
|
|
182
|
+
qty: {
|
|
183
|
+
type: 'number',
|
|
184
|
+
description: 'Optional — partial size. Omit to close the full position.',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
concurrent: false,
|
|
191
|
+
async execute(input, _ctx) {
|
|
192
|
+
const symbol = String(input.symbol ?? '').toUpperCase();
|
|
193
|
+
const qty = input.qty != null ? Number(input.qty) : undefined;
|
|
194
|
+
if (!symbol) {
|
|
195
|
+
return {
|
|
196
|
+
output: 'Error: TradingClosePosition requires symbol.',
|
|
197
|
+
isError: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (qty != null && (!Number.isFinite(qty) || qty <= 0)) {
|
|
201
|
+
return {
|
|
202
|
+
output: 'Error: if qty is provided, it must be > 0.',
|
|
203
|
+
isError: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const portfolio = engine.deps.portfolio;
|
|
207
|
+
const priorRealized = portfolio.realizedPnlUsd;
|
|
208
|
+
const outcome = await engine.closePosition({ symbol, qty });
|
|
209
|
+
if (outcome.status === 'noop') {
|
|
210
|
+
return { output: `No open ${symbol} position to close.` };
|
|
211
|
+
}
|
|
212
|
+
if (outcome.status === 'blocked') {
|
|
213
|
+
return {
|
|
214
|
+
output: `## Close blocked\n- Symbol: ${symbol}\n- Reason: ${outcome.reason}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const tradeRealized = portfolio.realizedPnlUsd - priorRealized;
|
|
218
|
+
if (tradeLog) {
|
|
219
|
+
tradeLog.append({
|
|
220
|
+
timestamp: Date.now(),
|
|
221
|
+
symbol,
|
|
222
|
+
side: 'sell',
|
|
223
|
+
qty: outcome.fill.qty,
|
|
224
|
+
priceUsd: outcome.fill.priceUsd,
|
|
225
|
+
feeUsd: outcome.fill.feeUsd,
|
|
226
|
+
realizedPnlUsd: tradeRealized,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (onStateChange)
|
|
230
|
+
await onStateChange();
|
|
231
|
+
const remaining = portfolio.getPosition(symbol);
|
|
232
|
+
return {
|
|
233
|
+
output: `## Position closed\n` +
|
|
234
|
+
`- Sold ${outcome.fill.qty} ${symbol} @ ${formatUsd(outcome.fill.priceUsd)} ` +
|
|
235
|
+
`(fee ${formatUsd(outcome.fill.feeUsd)})\n` +
|
|
236
|
+
`- Realized on this trade: ${formatUsd(tradeRealized)}\n` +
|
|
237
|
+
`- Remaining ${symbol}: ${remaining ? `${remaining.qty} @ avg ${formatUsd(remaining.avgPriceUsd)}` : '(flat)'}\n` +
|
|
238
|
+
`- Cash: ${formatUsd(portfolio.cashUsd)} · ` +
|
|
239
|
+
`Session realized P&L: ${formatUsd(portfolio.realizedPnlUsd)}`,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const caps = [tradingPortfolio, tradingOpenPosition, tradingClosePosition];
|
|
244
|
+
if (tradeLog) {
|
|
245
|
+
const tradingHistory = {
|
|
246
|
+
spec: {
|
|
247
|
+
name: 'TradingHistory',
|
|
248
|
+
description: 'Show recent trades and realized P&L within a time window. Unlike ephemeral ' +
|
|
249
|
+
'session state, this reads the persistent trade log so it spans every prior ' +
|
|
250
|
+
'session on this machine. Use to answer "am I up this week?", "what was my ' +
|
|
251
|
+
'worst trade?", "how often am I flipping BTC?".',
|
|
252
|
+
input_schema: {
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: {
|
|
255
|
+
window: {
|
|
256
|
+
type: 'string',
|
|
257
|
+
description: 'Time window: "24h", "7d", "30d", "all". Default "7d".',
|
|
258
|
+
},
|
|
259
|
+
limit: {
|
|
260
|
+
type: 'number',
|
|
261
|
+
description: 'Max number of trade rows to list. Default 20.',
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
concurrent: true,
|
|
268
|
+
async execute(input, _ctx) {
|
|
269
|
+
const windowRaw = String(input.window ?? '7d').trim();
|
|
270
|
+
const limit = Number.isFinite(Number(input.limit))
|
|
271
|
+
? Math.max(1, Math.min(200, Number(input.limit)))
|
|
272
|
+
: 20;
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const since = windowRaw.toLowerCase() === 'all' ? 0 : windowToSince(windowRaw, now);
|
|
275
|
+
const entries = tradeLog.recent(limit).filter((e) => e.timestamp >= since);
|
|
276
|
+
const realized = tradeLog.realizedSince(since);
|
|
277
|
+
const opens = entries.filter((e) => e.side === 'buy').length;
|
|
278
|
+
const closes = entries.filter((e) => e.side === 'sell').length;
|
|
279
|
+
const lines = [];
|
|
280
|
+
lines.push(`## Trade history (${windowRaw})`);
|
|
281
|
+
lines.push(`- ${windowRaw} P&L (realized): ${formatUsd(realized)}`);
|
|
282
|
+
lines.push(`- Trades: ${entries.length} (${opens} opens, ${closes} closes)`);
|
|
283
|
+
lines.push('');
|
|
284
|
+
if (entries.length === 0) {
|
|
285
|
+
lines.push('_No trades in this window._');
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
for (const e of entries)
|
|
289
|
+
lines.push(formatTradeLine(e));
|
|
290
|
+
}
|
|
291
|
+
return { output: lines.join('\n') };
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
caps.push(tradingHistory);
|
|
295
|
+
}
|
|
296
|
+
return caps;
|
|
297
|
+
}
|
package/dist/tools/webfetch.d.ts
CHANGED
|
@@ -2,4 +2,10 @@
|
|
|
2
2
|
* WebFetch capability — fetch web page content.
|
|
3
3
|
*/
|
|
4
4
|
import type { CapabilityHandler } from '../agent/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Drop every cached fetch so a fresh session doesn't serve stale content
|
|
7
|
+
* that was fetched under the previous session's intent. The 15-minute TTL
|
|
8
|
+
* would eventually catch this, but we'd rather start clean.
|
|
9
|
+
*/
|
|
10
|
+
export declare function clearSessionState(): void;
|
|
5
11
|
export declare const webFetchCapability: CapabilityHandler;
|
package/dist/tools/webfetch.js
CHANGED
|
@@ -33,6 +33,14 @@ function setCached(key, output) {
|
|
|
33
33
|
}
|
|
34
34
|
fetchCache.set(key, { output, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Drop every cached fetch so a fresh session doesn't serve stale content
|
|
38
|
+
* that was fetched under the previous session's intent. The 15-minute TTL
|
|
39
|
+
* would eventually catch this, but we'd rather start clean.
|
|
40
|
+
*/
|
|
41
|
+
export function clearSessionState() {
|
|
42
|
+
fetchCache.clear();
|
|
43
|
+
}
|
|
36
44
|
// ─── Execute ────────────────────────────────────────────────────────────────
|
|
37
45
|
async function execute(input, ctx) {
|
|
38
46
|
const { url, max_length } = input;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TradingEngine — composes Portfolio + RiskEngine + ExchangeClient into the
|
|
3
|
+
* single surface the Franklin capabilities call into.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Pre-trade risk check (refuse the order if it would breach caps).
|
|
7
|
+
* - Route the order to the Exchange (mock or real adapter).
|
|
8
|
+
* - Apply the resulting Fill to the Portfolio.
|
|
9
|
+
*
|
|
10
|
+
* The engine holds no state itself beyond the injected dependencies; that
|
|
11
|
+
* keeps the class easy to unit-test and lets us swap the ExchangeClient for
|
|
12
|
+
* a real adapter without touching capability plumbing.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExchangeClient } from './mock-exchange.js';
|
|
15
|
+
import type { Portfolio } from './portfolio.js';
|
|
16
|
+
import type { RiskEngine } from './risk.js';
|
|
17
|
+
export interface OpenPositionRequest {
|
|
18
|
+
symbol: string;
|
|
19
|
+
qty: number;
|
|
20
|
+
priceUsd: number;
|
|
21
|
+
}
|
|
22
|
+
export interface CloseRequest {
|
|
23
|
+
symbol: string;
|
|
24
|
+
qty?: number;
|
|
25
|
+
}
|
|
26
|
+
export type Outcome = {
|
|
27
|
+
status: 'filled';
|
|
28
|
+
fill: {
|
|
29
|
+
symbol: string;
|
|
30
|
+
qty: number;
|
|
31
|
+
priceUsd: number;
|
|
32
|
+
feeUsd: number;
|
|
33
|
+
};
|
|
34
|
+
} | {
|
|
35
|
+
status: 'blocked';
|
|
36
|
+
reason: string;
|
|
37
|
+
} | {
|
|
38
|
+
status: 'noop';
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
export interface TradingEngineDeps {
|
|
42
|
+
portfolio: Portfolio;
|
|
43
|
+
risk: RiskEngine;
|
|
44
|
+
exchange: ExchangeClient;
|
|
45
|
+
}
|
|
46
|
+
export declare class TradingEngine {
|
|
47
|
+
private deps;
|
|
48
|
+
constructor(deps: TradingEngineDeps);
|
|
49
|
+
openPosition(req: OpenPositionRequest): Promise<Outcome>;
|
|
50
|
+
closePosition(req: CloseRequest): Promise<Outcome>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TradingEngine — composes Portfolio + RiskEngine + ExchangeClient into the
|
|
3
|
+
* single surface the Franklin capabilities call into.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Pre-trade risk check (refuse the order if it would breach caps).
|
|
7
|
+
* - Route the order to the Exchange (mock or real adapter).
|
|
8
|
+
* - Apply the resulting Fill to the Portfolio.
|
|
9
|
+
*
|
|
10
|
+
* The engine holds no state itself beyond the injected dependencies; that
|
|
11
|
+
* keeps the class easy to unit-test and lets us swap the ExchangeClient for
|
|
12
|
+
* a real adapter without touching capability plumbing.
|
|
13
|
+
*/
|
|
14
|
+
export class TradingEngine {
|
|
15
|
+
deps;
|
|
16
|
+
constructor(deps) {
|
|
17
|
+
this.deps = deps;
|
|
18
|
+
}
|
|
19
|
+
async openPosition(req) {
|
|
20
|
+
const { portfolio, risk, exchange } = this.deps;
|
|
21
|
+
const decision = risk.check(portfolio, {
|
|
22
|
+
symbol: req.symbol,
|
|
23
|
+
side: 'buy',
|
|
24
|
+
qty: req.qty,
|
|
25
|
+
priceUsd: req.priceUsd,
|
|
26
|
+
});
|
|
27
|
+
if (!decision.allowed) {
|
|
28
|
+
return { status: 'blocked', reason: decision.reason ?? 'blocked by risk engine' };
|
|
29
|
+
}
|
|
30
|
+
const fill = await exchange.placeOrder({
|
|
31
|
+
symbol: req.symbol,
|
|
32
|
+
side: 'buy',
|
|
33
|
+
qty: req.qty,
|
|
34
|
+
priceUsd: req.priceUsd,
|
|
35
|
+
});
|
|
36
|
+
portfolio.applyFill(fill);
|
|
37
|
+
return {
|
|
38
|
+
status: 'filled',
|
|
39
|
+
fill: {
|
|
40
|
+
symbol: fill.symbol,
|
|
41
|
+
qty: fill.qty,
|
|
42
|
+
priceUsd: fill.priceUsd,
|
|
43
|
+
feeUsd: fill.feeUsd ?? 0,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
async closePosition(req) {
|
|
48
|
+
const { portfolio, exchange } = this.deps;
|
|
49
|
+
const existing = portfolio.getPosition(req.symbol);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
return { status: 'noop', reason: `No open ${req.symbol} position` };
|
|
52
|
+
}
|
|
53
|
+
const qty = req.qty ?? existing.qty;
|
|
54
|
+
const price = (await exchange.getPrice(req.symbol));
|
|
55
|
+
if (price == null) {
|
|
56
|
+
return { status: 'blocked', reason: `Exchange returned no price for ${req.symbol}` };
|
|
57
|
+
}
|
|
58
|
+
const fill = await exchange.placeOrder({
|
|
59
|
+
symbol: req.symbol,
|
|
60
|
+
side: 'sell',
|
|
61
|
+
qty,
|
|
62
|
+
priceUsd: price,
|
|
63
|
+
});
|
|
64
|
+
portfolio.applyFill(fill);
|
|
65
|
+
return {
|
|
66
|
+
status: 'filled',
|
|
67
|
+
fill: {
|
|
68
|
+
symbol: fill.symbol,
|
|
69
|
+
qty: fill.qty,
|
|
70
|
+
priceUsd: fill.priceUsd,
|
|
71
|
+
feeUsd: fill.feeUsd ?? 0,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveExchange — ExchangeClient backed by a real pricing source (CoinGecko
|
|
3
|
+
* by default) but with *simulated* fills. This is the default adapter the
|
|
4
|
+
* agent uses out of the box: it sees real market prices when valuing
|
|
5
|
+
* positions (so P&L tracks reality) but trades are paper — no real assets
|
|
6
|
+
* are moved, no real USDC is spent on exchange fees.
|
|
7
|
+
*
|
|
8
|
+
* A future commit will add a `RealExchange` that actually routes orders
|
|
9
|
+
* through Coinbase/Kraken; it plugs into the same ExchangeClient contract
|
|
10
|
+
* here. Keep this seam clean: the agent loop, risk engine, and portfolio
|
|
11
|
+
* math never need to know whether they're in paper or live mode.
|
|
12
|
+
*
|
|
13
|
+
* Pricing is injected (not imported directly from `./data.js`) so tests
|
|
14
|
+
* can validate behavior without hitting CoinGecko.
|
|
15
|
+
*/
|
|
16
|
+
import type { ExchangeClient } from './mock-exchange.js';
|
|
17
|
+
import type { Fill, Side } from './portfolio.js';
|
|
18
|
+
/** Subset of src/trading/data.ts's PriceData that we actually consume. */
|
|
19
|
+
export interface PricingClientResponse {
|
|
20
|
+
price: number;
|
|
21
|
+
change24h: number;
|
|
22
|
+
volume24h: number;
|
|
23
|
+
marketCap: number;
|
|
24
|
+
}
|
|
25
|
+
export interface PricingClient {
|
|
26
|
+
/** Returns live price data on success, a string error on failure — matches data.ts. */
|
|
27
|
+
getPrice(ticker: string): Promise<PricingClientResponse | string>;
|
|
28
|
+
}
|
|
29
|
+
export interface LiveExchangeOptions {
|
|
30
|
+
pricing: PricingClient;
|
|
31
|
+
feeBps: number;
|
|
32
|
+
}
|
|
33
|
+
export declare class LiveExchange implements ExchangeClient {
|
|
34
|
+
private opts;
|
|
35
|
+
constructor(opts: LiveExchangeOptions);
|
|
36
|
+
getPrice(symbol: string): Promise<number | null>;
|
|
37
|
+
placeOrder(order: {
|
|
38
|
+
symbol: string;
|
|
39
|
+
side: Side;
|
|
40
|
+
qty: number;
|
|
41
|
+
priceUsd: number;
|
|
42
|
+
}): Promise<Fill>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveExchange — ExchangeClient backed by a real pricing source (CoinGecko
|
|
3
|
+
* by default) but with *simulated* fills. This is the default adapter the
|
|
4
|
+
* agent uses out of the box: it sees real market prices when valuing
|
|
5
|
+
* positions (so P&L tracks reality) but trades are paper — no real assets
|
|
6
|
+
* are moved, no real USDC is spent on exchange fees.
|
|
7
|
+
*
|
|
8
|
+
* A future commit will add a `RealExchange` that actually routes orders
|
|
9
|
+
* through Coinbase/Kraken; it plugs into the same ExchangeClient contract
|
|
10
|
+
* here. Keep this seam clean: the agent loop, risk engine, and portfolio
|
|
11
|
+
* math never need to know whether they're in paper or live mode.
|
|
12
|
+
*
|
|
13
|
+
* Pricing is injected (not imported directly from `./data.js`) so tests
|
|
14
|
+
* can validate behavior without hitting CoinGecko.
|
|
15
|
+
*/
|
|
16
|
+
export class LiveExchange {
|
|
17
|
+
opts;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.opts = opts;
|
|
20
|
+
}
|
|
21
|
+
async getPrice(symbol) {
|
|
22
|
+
try {
|
|
23
|
+
const resp = await this.opts.pricing.getPrice(symbol.toUpperCase());
|
|
24
|
+
if (typeof resp === 'string')
|
|
25
|
+
return null;
|
|
26
|
+
if (typeof resp.price !== 'number' || !Number.isFinite(resp.price))
|
|
27
|
+
return null;
|
|
28
|
+
return resp.price;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Network errors, DNS failures, etc — treat as "price unknown" rather
|
|
32
|
+
// than throwing, so the agent gets a clean "can't close, no price"
|
|
33
|
+
// signal from TradingClosePosition instead of an uncaught exception.
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async placeOrder(order) {
|
|
38
|
+
const notional = order.qty * order.priceUsd;
|
|
39
|
+
const feeUsd = (notional * this.opts.feeBps) / 10_000;
|
|
40
|
+
return {
|
|
41
|
+
symbol: order.symbol,
|
|
42
|
+
side: order.side,
|
|
43
|
+
qty: order.qty,
|
|
44
|
+
priceUsd: order.priceUsd,
|
|
45
|
+
feeUsd,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MockExchange — deterministic in-memory exchange used by tests and dev mode.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same `ExchangeClient` contract a real adapter would, so the
|
|
5
|
+
* agent flow can be verified end-to-end without hitting a network or placing
|
|
6
|
+
* real orders. Fills land at the requested price (no slippage) with a
|
|
7
|
+
* configured taker fee in basis points; no latency is simulated.
|
|
8
|
+
*
|
|
9
|
+
* When a real Coinbase/Kraken adapter lands (follow-up PR), it replaces
|
|
10
|
+
* MockExchange at the ExchangeClient seam — no Portfolio or RiskEngine
|
|
11
|
+
* changes required.
|
|
12
|
+
*/
|
|
13
|
+
import type { Fill, Side } from './portfolio.js';
|
|
14
|
+
export interface ExchangeClient {
|
|
15
|
+
placeOrder(order: {
|
|
16
|
+
symbol: string;
|
|
17
|
+
side: Side;
|
|
18
|
+
qty: number;
|
|
19
|
+
priceUsd: number;
|
|
20
|
+
}): Promise<Fill>;
|
|
21
|
+
getPrice(symbol: string): Promise<number | null>;
|
|
22
|
+
}
|
|
23
|
+
export interface MockExchangeOptions {
|
|
24
|
+
prices: Record<string, number>;
|
|
25
|
+
feeBps: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class MockExchange implements ExchangeClient {
|
|
28
|
+
private prices;
|
|
29
|
+
private feeBps;
|
|
30
|
+
constructor(opts: MockExchangeOptions);
|
|
31
|
+
/** Update the synthetic price book (e.g. to simulate a move in tests). */
|
|
32
|
+
setPrice(symbol: string, priceUsd: number): void;
|
|
33
|
+
placeOrder(order: {
|
|
34
|
+
symbol: string;
|
|
35
|
+
side: Side;
|
|
36
|
+
qty: number;
|
|
37
|
+
priceUsd: number;
|
|
38
|
+
}): Promise<Fill>;
|
|
39
|
+
getPrice(symbol: string): Promise<number | null>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MockExchange — deterministic in-memory exchange used by tests and dev mode.
|
|
3
|
+
*
|
|
4
|
+
* Implements the same `ExchangeClient` contract a real adapter would, so the
|
|
5
|
+
* agent flow can be verified end-to-end without hitting a network or placing
|
|
6
|
+
* real orders. Fills land at the requested price (no slippage) with a
|
|
7
|
+
* configured taker fee in basis points; no latency is simulated.
|
|
8
|
+
*
|
|
9
|
+
* When a real Coinbase/Kraken adapter lands (follow-up PR), it replaces
|
|
10
|
+
* MockExchange at the ExchangeClient seam — no Portfolio or RiskEngine
|
|
11
|
+
* changes required.
|
|
12
|
+
*/
|
|
13
|
+
export class MockExchange {
|
|
14
|
+
prices;
|
|
15
|
+
feeBps;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
this.prices = { ...opts.prices };
|
|
18
|
+
this.feeBps = opts.feeBps;
|
|
19
|
+
}
|
|
20
|
+
/** Update the synthetic price book (e.g. to simulate a move in tests). */
|
|
21
|
+
setPrice(symbol, priceUsd) {
|
|
22
|
+
this.prices[symbol] = priceUsd;
|
|
23
|
+
}
|
|
24
|
+
async placeOrder(order) {
|
|
25
|
+
if (!(order.symbol in this.prices)) {
|
|
26
|
+
throw new Error(`MockExchange has no quote for ${order.symbol}`);
|
|
27
|
+
}
|
|
28
|
+
const notional = order.qty * order.priceUsd;
|
|
29
|
+
const feeUsd = (notional * this.feeBps) / 10_000;
|
|
30
|
+
return {
|
|
31
|
+
symbol: order.symbol,
|
|
32
|
+
side: order.side,
|
|
33
|
+
qty: order.qty,
|
|
34
|
+
priceUsd: order.priceUsd,
|
|
35
|
+
feeUsd,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async getPrice(symbol) {
|
|
39
|
+
return this.prices[symbol] ?? null;
|
|
40
|
+
}
|
|
41
|
+
}
|