@blockrun/franklin 3.8.8 → 3.8.10

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.
Files changed (50) hide show
  1. package/dist/agent/error-classifier.js +1 -0
  2. package/dist/agent/llm.d.ts +7 -0
  3. package/dist/agent/llm.js +48 -7
  4. package/dist/agent/loop.js +66 -3
  5. package/dist/agent/permissions.js +2 -2
  6. package/dist/agent/types.d.ts +7 -0
  7. package/dist/banner.js +15 -0
  8. package/dist/commands/start.d.ts +4 -0
  9. package/dist/commands/start.js +72 -2
  10. package/dist/index.js +11 -3
  11. package/dist/panel/html.js +111 -21
  12. package/dist/panel/server.js +15 -4
  13. package/dist/tools/activate.d.ts +29 -0
  14. package/dist/tools/activate.js +96 -0
  15. package/dist/tools/index.js +2 -0
  16. package/dist/tools/tool-categories.d.ts +22 -0
  17. package/dist/tools/tool-categories.js +44 -0
  18. package/dist/tools/trading-execute.d.ts +11 -21
  19. package/dist/tools/trading-execute.js +43 -130
  20. package/dist/tools/trading-views.d.ts +64 -0
  21. package/dist/tools/trading-views.js +115 -0
  22. package/dist/tools/trading.js +86 -7
  23. package/dist/tools/webhook.d.ts +18 -0
  24. package/dist/tools/webhook.js +185 -0
  25. package/dist/trading/data.d.ts +24 -1
  26. package/dist/trading/data.js +67 -102
  27. package/dist/trading/providers/blockrun/client.d.ts +48 -0
  28. package/dist/trading/providers/blockrun/client.js +253 -0
  29. package/dist/trading/providers/blockrun/price.d.ts +24 -0
  30. package/dist/trading/providers/blockrun/price.js +110 -0
  31. package/dist/trading/providers/coingecko/client.d.ts +20 -0
  32. package/dist/trading/providers/coingecko/client.js +87 -0
  33. package/dist/trading/providers/coingecko/markets.d.ts +3 -0
  34. package/dist/trading/providers/coingecko/markets.js +25 -0
  35. package/dist/trading/providers/coingecko/ohlcv.d.ts +3 -0
  36. package/dist/trading/providers/coingecko/ohlcv.js +29 -0
  37. package/dist/trading/providers/coingecko/price.d.ts +11 -0
  38. package/dist/trading/providers/coingecko/price.js +41 -0
  39. package/dist/trading/providers/coingecko/trending.d.ts +3 -0
  40. package/dist/trading/providers/coingecko/trending.js +22 -0
  41. package/dist/trading/providers/fetcher.d.ts +43 -0
  42. package/dist/trading/providers/fetcher.js +45 -0
  43. package/dist/trading/providers/registry.d.ts +45 -0
  44. package/dist/trading/providers/registry.js +82 -0
  45. package/dist/trading/providers/standard-models.d.ts +94 -0
  46. package/dist/trading/providers/standard-models.js +21 -0
  47. package/dist/trading/providers/telemetry.d.ts +51 -0
  48. package/dist/trading/providers/telemetry.js +115 -0
  49. package/dist/ui/app.js +28 -2
  50. package/package.json +1 -1
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ActivateTool — meta-capability that lets the agent pull on-demand tools
3
+ * into the active toolset per session.
4
+ *
5
+ * Pattern borrowed from OpenBB MCP server's per-session tool visibility:
6
+ * a weak model confronted with 25+ tool definitions starts inventing names
7
+ * or emits role-play "[TOOLCALL]" fragments. Register only the core file/
8
+ * shell tools by default and let the model explicitly opt in to the rest.
9
+ *
10
+ * Contract:
11
+ * - `ActivateTool()` with no args → lists every inactive tool with a
12
+ * one-line description so the model knows what's available.
13
+ * - `ActivateTool({ names: ["ExaSearch", "ExaReadUrls"] })` → adds the
14
+ * named tools to the session's active set; subsequent turns include
15
+ * their full schemas. Returns a concise confirmation.
16
+ *
17
+ * The factory captures the shared `activeTools` Set that the loop filters
18
+ * against and the full `allTools` map used for name resolution. Both live
19
+ * in the session — activation is not durable across restarts on purpose,
20
+ * since the model can always re-activate on the next turn if it needs to.
21
+ */
22
+ import type { CapabilityHandler } from '../agent/types.js';
23
+ export interface ActivateToolDeps {
24
+ /** Mutable set of tool names currently visible to the model. */
25
+ activeTools: Set<string>;
26
+ /** Map of every registered capability, keyed by name. */
27
+ allTools: Map<string, CapabilityHandler>;
28
+ }
29
+ export declare function createActivateToolCapability(deps: ActivateToolDeps): CapabilityHandler;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * ActivateTool — meta-capability that lets the agent pull on-demand tools
3
+ * into the active toolset per session.
4
+ *
5
+ * Pattern borrowed from OpenBB MCP server's per-session tool visibility:
6
+ * a weak model confronted with 25+ tool definitions starts inventing names
7
+ * or emits role-play "[TOOLCALL]" fragments. Register only the core file/
8
+ * shell tools by default and let the model explicitly opt in to the rest.
9
+ *
10
+ * Contract:
11
+ * - `ActivateTool()` with no args → lists every inactive tool with a
12
+ * one-line description so the model knows what's available.
13
+ * - `ActivateTool({ names: ["ExaSearch", "ExaReadUrls"] })` → adds the
14
+ * named tools to the session's active set; subsequent turns include
15
+ * their full schemas. Returns a concise confirmation.
16
+ *
17
+ * The factory captures the shared `activeTools` Set that the loop filters
18
+ * against and the full `allTools` map used for name resolution. Both live
19
+ * in the session — activation is not durable across restarts on purpose,
20
+ * since the model can always re-activate on the next turn if it needs to.
21
+ */
22
+ function shortDesc(desc) {
23
+ // First sentence or first 120 chars, whichever is shorter.
24
+ const firstSentence = desc.split(/[.\n]/)[0]?.trim() ?? '';
25
+ if (firstSentence && firstSentence.length <= 120)
26
+ return firstSentence;
27
+ const trimmed = desc.replace(/\s+/g, ' ').trim();
28
+ return trimmed.length <= 120 ? trimmed : trimmed.slice(0, 117) + '...';
29
+ }
30
+ export function createActivateToolCapability(deps) {
31
+ const { activeTools, allTools } = deps;
32
+ return {
33
+ spec: {
34
+ name: 'ActivateTool',
35
+ description: 'Activate additional tools for this session. Most tools are hidden by default to keep your tool inventory small. ' +
36
+ 'Call with no arguments to see what is available. Call with { "names": ["ToolA", "ToolB"] } to enable specific tools — ' +
37
+ 'they become visible in your tool list on the next turn. Activate only what you need; extra tools crowd the inventory.',
38
+ input_schema: {
39
+ type: 'object',
40
+ properties: {
41
+ names: {
42
+ type: 'array',
43
+ items: { type: 'string' },
44
+ description: 'List of tool names to activate. Omit to list what is available.',
45
+ },
46
+ },
47
+ },
48
+ },
49
+ concurrent: false,
50
+ async execute(input) {
51
+ const raw = input.names;
52
+ const names = Array.isArray(raw) ? raw.filter((n) => typeof n === 'string') : undefined;
53
+ // No args → catalog the inactive tools so the model knows what's there.
54
+ if (!names || names.length === 0) {
55
+ const inactive = [...allTools.values()]
56
+ .filter(t => !activeTools.has(t.spec.name))
57
+ .sort((a, b) => a.spec.name.localeCompare(b.spec.name));
58
+ if (inactive.length === 0) {
59
+ return { output: 'All registered tools are already active.' };
60
+ }
61
+ const lines = inactive.map(t => `- ${t.spec.name}: ${shortDesc(t.spec.description)}`);
62
+ return {
63
+ output: `Available on-demand tools (${inactive.length}). Activate with ` +
64
+ `ActivateTool({ "names": ["<name>", ...] }):\n` +
65
+ lines.join('\n'),
66
+ };
67
+ }
68
+ // Activate each named tool.
69
+ const activated = [];
70
+ const alreadyActive = [];
71
+ const unknown = [];
72
+ for (const name of names) {
73
+ if (!allTools.has(name)) {
74
+ unknown.push(name);
75
+ }
76
+ else if (activeTools.has(name)) {
77
+ alreadyActive.push(name);
78
+ }
79
+ else {
80
+ activeTools.add(name);
81
+ activated.push(name);
82
+ }
83
+ }
84
+ const parts = [];
85
+ if (activated.length)
86
+ parts.push(`Activated: ${activated.join(', ')}`);
87
+ if (alreadyActive.length)
88
+ parts.push(`Already active: ${alreadyActive.join(', ')}`);
89
+ if (unknown.length)
90
+ parts.push(`Unknown (not registered): ${unknown.join(', ')}`);
91
+ const output = parts.length ? parts.join('. ') + '.' : 'No change.';
92
+ const isError = activated.length === 0 && unknown.length > 0;
93
+ return { output, isError };
94
+ },
95
+ };
96
+ }
@@ -22,6 +22,7 @@ import { tradingSignalCapability, tradingMarketCapability } from './trading.js';
22
22
  import { searchXCapability } from './searchx.js';
23
23
  import { postToXCapability } from './posttox.js';
24
24
  import { moaCapability } from './moa.js';
25
+ import { webhookPostCapability } from './webhook.js';
25
26
  import { createTradingCapabilities } from './trading-execute.js';
26
27
  import { Portfolio } from '../trading/portfolio.js';
27
28
  import { RiskEngine } from '../trading/risk.js';
@@ -138,6 +139,7 @@ export const allCapabilities = [
138
139
  searchXCapability,
139
140
  postToXCapability,
140
141
  moaCapability,
142
+ webhookPostCapability,
141
143
  ];
142
144
  export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, };
143
145
  export { createSubAgentCapability } from './subagent.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Tool visibility categories.
3
+ *
4
+ * Franklin ships with ~27 capabilities. Exposing all of them to the model on
5
+ * every turn makes the tool inventory large enough that weak models start
6
+ * hallucinating tool names or emitting role-play "[TOOLCALL]" fragments.
7
+ * The fix: keep a minimal always-on core (file ops, shell, ask) and gate the
8
+ * rest behind an `ActivateTool` meta-tool that the agent pulls on demand —
9
+ * the same per-session visibility pattern that OpenBB's MCP server uses.
10
+ *
11
+ * `CORE_TOOL_NAMES` is the per-session initial active set. Everything else
12
+ * becomes visible only after the agent calls ActivateTool with its name.
13
+ */
14
+ export declare const CORE_TOOL_NAMES: ReadonlySet<string>;
15
+ /** True if this tool is always available without activation. */
16
+ export declare function isCoreTool(name: string): boolean;
17
+ /**
18
+ * Env opt-out: setting `FRANKLIN_DYNAMIC_TOOLS=0` disables the core/on-demand
19
+ * split and exposes every registered tool on every turn (pre-3.8.9 behavior).
20
+ * Kept as a safety valve for users whose workflows depend on the full surface.
21
+ */
22
+ export declare function dynamicToolsEnabled(): boolean;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tool visibility categories.
3
+ *
4
+ * Franklin ships with ~27 capabilities. Exposing all of them to the model on
5
+ * every turn makes the tool inventory large enough that weak models start
6
+ * hallucinating tool names or emitting role-play "[TOOLCALL]" fragments.
7
+ * The fix: keep a minimal always-on core (file ops, shell, ask) and gate the
8
+ * rest behind an `ActivateTool` meta-tool that the agent pulls on demand —
9
+ * the same per-session visibility pattern that OpenBB's MCP server uses.
10
+ *
11
+ * `CORE_TOOL_NAMES` is the per-session initial active set. Everything else
12
+ * becomes visible only after the agent calls ActivateTool with its name.
13
+ */
14
+ export const CORE_TOOL_NAMES = new Set([
15
+ // File operations — nothing else works without these.
16
+ 'Read',
17
+ 'Write',
18
+ 'Edit',
19
+ // Shell execution — needed for running tests, builds, scripts.
20
+ 'Bash',
21
+ // Search — code exploration is table stakes.
22
+ 'Grep',
23
+ 'Glob',
24
+ // User dialogue — the agent must be able to ask for clarification.
25
+ 'AskUser',
26
+ // Sub-agent delegation — the sub-agent has its own tool resolution,
27
+ // so keeping this in the core doesn't leak the full inventory.
28
+ 'Task',
29
+ // The meta-tool itself — must always be callable so the agent can
30
+ // discover and activate the rest.
31
+ 'ActivateTool',
32
+ ]);
33
+ /** True if this tool is always available without activation. */
34
+ export function isCoreTool(name) {
35
+ return CORE_TOOL_NAMES.has(name);
36
+ }
37
+ /**
38
+ * Env opt-out: setting `FRANKLIN_DYNAMIC_TOOLS=0` disables the core/on-demand
39
+ * split and exposes every registered tool on every turn (pre-3.8.9 behavior).
40
+ * Kept as a safety valve for users whose workflows depend on the full surface.
41
+ */
42
+ export function dynamicToolsEnabled() {
43
+ return process.env.FRANKLIN_DYNAMIC_TOOLS !== '0';
44
+ }
@@ -1,19 +1,13 @@
1
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).
2
+ * Trading execution capabilities the three-to-four tools that let the
3
+ * agent inspect its portfolio, open/close paper positions, and (when a
4
+ * persistent trade log is attached) query cross-session history.
5
5
  *
6
- * This is the surface that differentiates Franklin from generic coding
7
- * agents stateless tools can't hold a wallet, track positions across
8
- * 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.
6
+ * This file is now the "router" layer only: it binds the engine to tool
7
+ * handlers and delegates rendering to `trading-views.ts`. The portfolio
8
+ * math, risk math, and exchange simulation all live in `../trading/*`.
9
+ * The split mirrors OpenBB's router/engine/view layering and keeps every
10
+ * layer testable in isolation.
17
11
  */
18
12
  import type { CapabilityHandler } from '../agent/types.js';
19
13
  import type { TradingEngine } from '../trading/engine.js';
@@ -21,15 +15,11 @@ import type { RiskConfig } from '../trading/risk.js';
21
15
  import type { TradeLog } from '../trading/trade-log.js';
22
16
  export interface TradingCapabilitiesDeps {
23
17
  engine: TradingEngine;
24
- /** Risk config used to report "you're using X% of your position cap". */
18
+ /** Risk config used for "you're at X% of your exposure cap" readout. */
25
19
  riskConfig?: RiskConfig;
26
- /** Optional hook run after every state-changing call (e.g., persist to disk). */
20
+ /** Hook run after state-changing calls typically persists to disk. */
27
21
  onStateChange?: () => void | Promise<void>;
28
- /**
29
- * Optional persistent trade log. When provided, opens and closes are
30
- * appended to it and the TradingHistory capability is registered so the
31
- * agent can query cross-session P&L.
32
- */
22
+ /** Persistent trade log; when provided, TradingHistory is registered. */
33
23
  tradeLog?: TradeLog;
34
24
  }
35
25
  export declare function createTradingCapabilities(deps: TradingCapabilitiesDeps): CapabilityHandler[];
@@ -1,54 +1,31 @@
1
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).
2
+ * Trading execution capabilities the three-to-four tools that let the
3
+ * agent inspect its portfolio, open/close paper positions, and (when a
4
+ * persistent trade log is attached) query cross-session history.
5
5
  *
6
- * This is the surface that differentiates Franklin from generic coding
7
- * agents stateless tools can't hold a wallet, track positions across
8
- * 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.
6
+ * This file is now the "router" layer only: it binds the engine to tool
7
+ * handlers and delegates rendering to `trading-views.ts`. The portfolio
8
+ * math, risk math, and exchange simulation all live in `../trading/*`.
9
+ * The split mirrors OpenBB's router/engine/view layering and keeps every
10
+ * layer testable in isolation.
17
11
  */
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)}%`;
12
+ import { renderOrderBlocked, renderOrderFilled, renderPortfolio, renderPositionClosed, renderTradeHistory, windowToSince, } from './trading-views.js';
13
+ function enginePortfolio(engine) {
14
+ return engine.deps.portfolio;
25
15
  }
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)})`);
16
+ function engineExchange(engine) {
17
+ return engine.deps.exchange;
32
18
  }
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;
19
+ async function buildPortfolioSnapshot(engine) {
20
+ const portfolio = enginePortfolio(engine);
21
+ const exchange = engineExchange(engine);
22
+ const priceTable = {};
23
+ for (const p of portfolio.listPositions()) {
24
+ const quote = await exchange.getPrice(p.symbol);
25
+ if (quote != null)
26
+ priceTable[p.symbol] = quote;
45
27
  }
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}`;
28
+ return portfolio.markToMarket(priceTable);
52
29
  }
53
30
  export function createTradingCapabilities(deps) {
54
31
  const { engine, riskConfig, onStateChange, tradeLog } = deps;
@@ -66,39 +43,8 @@ export function createTradingCapabilities(deps) {
66
43
  },
67
44
  concurrent: true,
68
45
  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') };
46
+ const snap = await buildPortfolioSnapshot(engine);
47
+ return { output: renderPortfolio(snap, riskConfig) };
102
48
  },
103
49
  };
104
50
  const tradingOpenPosition = {
@@ -132,14 +78,7 @@ export function createTradingCapabilities(deps) {
132
78
  }
133
79
  const outcome = await engine.openPosition({ symbol, qty, priceUsd });
134
80
  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
- };
81
+ return { output: renderOrderBlocked({ symbol, qty, priceUsd, reason: outcome.reason }) };
143
82
  }
144
83
  if (outcome.status === 'noop') {
145
84
  return { output: `No-op: ${outcome.reason}` };
@@ -157,14 +96,12 @@ export function createTradingCapabilities(deps) {
157
96
  }
158
97
  if (onStateChange)
159
98
  await onStateChange();
160
- const portfolio = engine.deps.portfolio;
161
- const pos = portfolio.getPosition(symbol);
162
99
  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)}`,
100
+ output: renderOrderFilled({
101
+ symbol,
102
+ fill: outcome.fill,
103
+ portfolio: enginePortfolio(engine),
104
+ }),
168
105
  };
169
106
  },
170
107
  };
@@ -172,8 +109,8 @@ export function createTradingCapabilities(deps) {
172
109
  spec: {
173
110
  name: 'TradingClosePosition',
174
111
  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.',
112
+ "Omit qty to flatten the position entirely; pass qty to partially reduce. Uses the " +
113
+ "exchange's current mark — no manual price required.",
177
114
  input_schema: {
178
115
  type: 'object',
179
116
  required: ['symbol'],
@@ -192,27 +129,19 @@ export function createTradingCapabilities(deps) {
192
129
  const symbol = String(input.symbol ?? '').toUpperCase();
193
130
  const qty = input.qty != null ? Number(input.qty) : undefined;
194
131
  if (!symbol) {
195
- return {
196
- output: 'Error: TradingClosePosition requires symbol.',
197
- isError: true,
198
- };
132
+ return { output: 'Error: TradingClosePosition requires symbol.', isError: true };
199
133
  }
200
134
  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
- };
135
+ return { output: 'Error: if qty is provided, it must be > 0.', isError: true };
205
136
  }
206
- const portfolio = engine.deps.portfolio;
137
+ const portfolio = enginePortfolio(engine);
207
138
  const priorRealized = portfolio.realizedPnlUsd;
208
139
  const outcome = await engine.closePosition({ symbol, qty });
209
140
  if (outcome.status === 'noop') {
210
141
  return { output: `No open ${symbol} position to close.` };
211
142
  }
212
143
  if (outcome.status === 'blocked') {
213
- return {
214
- output: `## Close blocked\n- Symbol: ${symbol}\n- Reason: ${outcome.reason}`,
215
- };
144
+ return { output: `## Close blocked\n- Symbol: ${symbol}\n- Reason: ${outcome.reason}` };
216
145
  }
217
146
  const tradeRealized = portfolio.realizedPnlUsd - priorRealized;
218
147
  if (tradeLog) {
@@ -228,15 +157,13 @@ export function createTradingCapabilities(deps) {
228
157
  }
229
158
  if (onStateChange)
230
159
  await onStateChange();
231
- const remaining = portfolio.getPosition(symbol);
232
160
  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)}`,
161
+ output: renderPositionClosed({
162
+ symbol,
163
+ fill: outcome.fill,
164
+ tradeRealized,
165
+ portfolio,
166
+ }),
240
167
  };
241
168
  },
242
169
  };
@@ -274,21 +201,7 @@ export function createTradingCapabilities(deps) {
274
201
  const since = windowRaw.toLowerCase() === 'all' ? 0 : windowToSince(windowRaw, now);
275
202
  const entries = tradeLog.recent(limit).filter((e) => e.timestamp >= since);
276
203
  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') };
204
+ return { output: renderTradeHistory({ windowRaw, entries, realized }) };
292
205
  },
293
206
  };
294
207
  caps.push(tradingHistory);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Trading view/formatter helpers.
3
+ *
4
+ * Anything that turns engine state into human/agent-readable markdown
5
+ * belongs here. Split out of `trading-execute.ts` so the tool handlers in
6
+ * `trading-router.ts` stay focused on request handling and the engine
7
+ * stays free of presentation concerns. This mirrors the view/controller
8
+ * separation OpenBB enforces between `standard_models` (data) and the
9
+ * router-side rendering that happens in their MCP layer.
10
+ */
11
+ import type { Position } from '../trading/portfolio.js';
12
+ import type { Portfolio } from '../trading/portfolio.js';
13
+ import type { RiskConfig } from '../trading/risk.js';
14
+ import type { TradeLogEntry } from '../trading/trade-log.js';
15
+ export declare function formatUsd(n: number): string;
16
+ export declare function formatPct(n: number): string;
17
+ export declare function formatPositionLine(p: Position & {
18
+ markUsd: number;
19
+ unrealizedPnlUsd: number;
20
+ }): string;
21
+ export declare function formatTradeLine(entry: TradeLogEntry): string;
22
+ /** Parse a window string ("24h", "7d", "all") into a lower-bound timestamp. */
23
+ export declare function windowToSince(window: string, now: number): number;
24
+ export interface PortfolioSnapshot {
25
+ cashUsd: number;
26
+ equityUsd: number;
27
+ unrealizedPnlUsd: number;
28
+ realizedPnlUsd: number;
29
+ positions: (Position & {
30
+ markUsd: number;
31
+ unrealizedPnlUsd: number;
32
+ })[];
33
+ }
34
+ export declare function renderPortfolio(snap: PortfolioSnapshot, riskConfig?: RiskConfig): string;
35
+ export declare function renderOrderFilled(params: {
36
+ symbol: string;
37
+ fill: {
38
+ qty: number;
39
+ priceUsd: number;
40
+ feeUsd: number;
41
+ };
42
+ portfolio: Portfolio;
43
+ }): string;
44
+ export declare function renderOrderBlocked(params: {
45
+ symbol: string;
46
+ qty: number;
47
+ priceUsd: number;
48
+ reason: string;
49
+ }): string;
50
+ export declare function renderPositionClosed(params: {
51
+ symbol: string;
52
+ fill: {
53
+ qty: number;
54
+ priceUsd: number;
55
+ feeUsd: number;
56
+ };
57
+ tradeRealized: number;
58
+ portfolio: Portfolio;
59
+ }): string;
60
+ export declare function renderTradeHistory(params: {
61
+ windowRaw: string;
62
+ entries: TradeLogEntry[];
63
+ realized: number;
64
+ }): string;