Skip to content

Commit 3c47420

Browse files
committed
feat(tools): expose backtest equity curve
The equity curve was fetched from the backend (ResultMap.getEquityCurve) but silently discarded by the MCP layer — invisible to clients. - New get_equity_curve tool returns the curve of a COMPLETED job as compact JSON with parallel arrays t[] (epoch-millis) and equity[] (account equity). Curves longer than maxPoints (default 500, max 5000) are downsampled, always preserving the first/last points and the global min/max so the worst drawdown and the peak survive the reduction. - get_job_status gains an optional includeEquityCurve flag (default false) that appends the curve (downsampled to ~200 points) to the status summary. - JSON is built without Jackson to avoid adding serialized types to the native image; api-client EquityPoint deserialization is already covered by reflect-config. Bumps version to 0.4.0.
1 parent 3b6bf84 commit 3c47420

9 files changed

Lines changed: 253 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.4.0] — 2026-06-16
10+
11+
### Added ✨
12+
13+
- **Equity curve access** — backtest results now expose the equity curve, which was previously fetched from the backend but silently discarded by the MCP layer.
14+
- New **`get_equity_curve`** tool returns the curve of a COMPLETED job as compact JSON (parallel arrays `t[]` = epoch-millis timestamps, `equity[]` = account equity). Curves longer than `maxPoints` (default 500, max 5000) are downsampled, always preserving the first/last points and the global min and max (worst drawdown and peak).
15+
- **`get_job_status`** gains an optional `includeEquityCurve` flag (default `false`) that appends the curve, downsampled to ~200 points, to the status summary.
16+
917
## [0.3.3] — 2026-06-13
1018

1119
### Fixed 🐛

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The installer detects your platform and picks the right delivery:
4141
Pin a version or override the destination:
4242

4343
```bash
44-
VERSION=0.3.3 INSTALL_DIR=~/.local/bin \
44+
VERSION=0.4.0 INSTALL_DIR=~/.local/bin \
4545
curl -fsSL https://raw.githubusercontent.com/QTSurfer/mcp-java/main/install.sh | bash
4646
```
4747

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>com.qtsurfer</groupId>
77
<artifactId>mcp-java</artifactId>
8-
<version>0.3.3</version>
8+
<version>0.4.0</version>
99
<packaging>jar</packaging>
1010
<name>qtsurfer-mcp-java</name>
1111

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.qtsurfer.mcp.model;
2+
3+
/**
4+
* One sample of the running equity curve produced by a backtest.
5+
*
6+
* @param timestamp epoch milliseconds; the first point is anchored at the backtest {@code from},
7+
* subsequent points carry the timestamp of each emitted yield event
8+
* @param equity running equity at this point ({@code initialCapital + cumulativePnl})
9+
*/
10+
public record EquityPoint(long timestamp, double equity) {}

src/main/java/com/qtsurfer/mcp/model/JobResult.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.qtsurfer.mcp.model;
22

3+
import java.util.List;
4+
35
/**
46
* Execution metrics captured when a backtest job reaches COMPLETED state.
57
* All numeric fields are nullable — they are absent when the strategy emitted no trades.
8+
*
9+
* <p>{@code equityCurve} is never null (empty when the run produced no yield events). It is
10+
* not part of the default status summary; callers opt in via the {@code includeEquityCurve}
11+
* flag on {@code get_job_status} or the dedicated {@code get_equity_curve} tool.
612
*/
713
public record JobResult(
814
Double pnlTotal,
@@ -15,5 +21,6 @@ public record JobResult(
1521
Double maxDrawdownPercent,
1622
Long signalCount,
1723
String hostName,
18-
Double iops
24+
Double iops,
25+
List<EquityPoint> equityCurve
1926
) {}

src/main/java/com/qtsurfer/mcp/service/SdkBacktestingService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.qtsurfer.api.sdk.BacktestOptions;
88
import com.qtsurfer.api.sdk.BacktestRequest;
99
import com.qtsurfer.api.sdk.auth.AuthenticatedClient;
10+
import com.qtsurfer.mcp.model.EquityPoint;
1011
import com.qtsurfer.mcp.model.JobResult;
1112
import com.qtsurfer.mcp.model.JobStatus;
1213
import com.qtsurfer.mcp.model.JobSummary;
@@ -137,12 +138,17 @@ public Optional<JobSummary> getJobStatus(String jobId) {
137138
}
138139

139140
private static JobResult toJobResult(ResultMap r) {
141+
List<EquityPoint> curve = r.getEquityCurve() == null ? List.of()
142+
: r.getEquityCurve().stream()
143+
.filter(p -> p.getTimestamp() != null && p.getEquity() != null)
144+
.map(p -> new EquityPoint(p.getTimestamp(), p.getEquity()))
145+
.toList();
140146
return new JobResult(
141147
r.getPnlTotal(), r.getTotalTrades(), r.getWinRate(),
142148
r.getSharpeRatio(), r.getSortinoRatio(), r.getCagr(),
143149
r.getMaxDrawdown(), r.getMaxDrawdownPercent(),
144150
r.getSignalCount() != null ? r.getSignalCount().longValue() : null,
145-
r.getHostName(), r.getIops());
151+
r.getHostName(), r.getIops(), curve);
146152
}
147153

148154
@Override

src/main/java/com/qtsurfer/mcp/tool/McpTools.java

Lines changed: 147 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
import com.qtsurfer.mcp.McpServerRunner;
88
import com.qtsurfer.api.client.model.Exchange;
99
import com.qtsurfer.api.client.model.InstrumentDetail;
10+
import com.qtsurfer.mcp.model.EquityPoint;
1011
import com.qtsurfer.mcp.model.JobResult;
1112
import com.qtsurfer.mcp.model.JobStatus;
1213
import com.qtsurfer.mcp.service.BacktestingService;
1314

15+
import java.util.ArrayList;
1416
import java.util.List;
1517
import java.util.Map;
18+
import java.util.TreeSet;
1619

1720
/**
1821
* Builds the five MCP tools that expose QTSurfer over the Model Context Protocol.
@@ -25,13 +28,21 @@ public final class McpTools {
2528

2629
private McpTools() {}
2730

31+
/** Default sample cap for the inline equity-curve preview in {@code get_job_status}. */
32+
private static final int PREVIEW_POINTS = 200;
33+
/** Default sample cap for the dedicated {@code get_equity_curve} tool. */
34+
private static final int DEFAULT_MAX_POINTS = 500;
35+
/** Hard upper bound on points returned by {@code get_equity_curve}. */
36+
private static final int MAX_POINTS_LIMIT = 5000;
37+
2838
public static List<SyncToolSpecification> build(BacktestingService service, String apiUrl) {
2939
return List.of(
3040
version(apiUrl),
3141
listExchanges(service),
3242
listInstruments(service),
3343
submitBacktest(service),
3444
getJobStatus(service),
45+
getEquityCurve(service),
3546
listJobs(service));
3647
}
3748

@@ -163,22 +174,77 @@ private static SyncToolSpecification submitBacktest(BacktestingService service)
163174
private static SyncToolSpecification getJobStatus(BacktestingService service) {
164175
Tool tool = Tool.builder()
165176
.name("get_job_status")
166-
.description("Get the current status of a submitted backtesting job.")
177+
.description("Get the current status of a submitted backtesting job. "
178+
+ "Set includeEquityCurve=true to append the equity curve (downsampled to ~"
179+
+ PREVIEW_POINTS + " points) as compact JSON; use get_equity_curve for finer control.")
167180
.inputSchema(schema(
168-
Map.of("jobId", prop("string", "Job ID returned by submit_backtest")),
181+
Map.of(
182+
"jobId", prop("string", "Job ID returned by submit_backtest"),
183+
"includeEquityCurve", prop("boolean",
184+
"When true, append the equity curve (downsampled) to the result. Default false.")),
169185
List.of("jobId")))
170186
.build();
171187
return new SyncToolSpecification(tool,
172188
(exchange, request) -> {
173-
String jobId = required(request.arguments(), "jobId");
189+
Map<String, Object> args = request.arguments();
190+
String jobId = required(args, "jobId");
191+
boolean includeCurve = asBool(args == null ? null : args.get("includeEquityCurve"));
174192
return service.getJobStatus(jobId)
175-
.map(j -> text(formatJobStatus(j.jobId(), j.instrument(), j.exchangeId(),
176-
j.status(), j.submittedAt(), j.result())))
193+
.map(j -> {
194+
String body = formatJobStatus(j.jobId(), j.instrument(), j.exchangeId(),
195+
j.status(), j.submittedAt(), j.result());
196+
if (includeCurve && j.result() != null
197+
&& j.result().equityCurve() != null && !j.result().equityCurve().isEmpty()) {
198+
body += "\n\nEquity curve:\n"
199+
+ equityCurveJson(j.jobId(), j.result().equityCurve(), PREVIEW_POINTS);
200+
}
201+
return text(body);
202+
})
177203
.orElse(text("Job not found: " + jobId
178204
+ ". Only jobs submitted in this session are tracked."));
179205
});
180206
}
181207

208+
// ---- get_equity_curve ---------------------------------------------------
209+
210+
private static SyncToolSpecification getEquityCurve(BacktestingService service) {
211+
Tool tool = Tool.builder()
212+
.name("get_equity_curve")
213+
.description("Return the equity curve of a COMPLETED backtest as compact JSON: parallel "
214+
+ "arrays t[] (epoch-millis timestamps) and equity[] (account equity). Curves longer "
215+
+ "than maxPoints are downsampled, always preserving the first/last points and the "
216+
+ "global min and max (worst drawdown and peak).")
217+
.inputSchema(schema(
218+
Map.of(
219+
"jobId", prop("string", "Job ID returned by submit_backtest"),
220+
"maxPoints", prop("integer", "Max points to return (default " + DEFAULT_MAX_POINTS
221+
+ ", max " + MAX_POINTS_LIMIT + "). The curve is downsampled if longer.")),
222+
List.of("jobId")))
223+
.build();
224+
return new SyncToolSpecification(tool,
225+
(exchange, request) -> {
226+
Map<String, Object> args = request.arguments();
227+
String jobId = required(args, "jobId");
228+
int maxPoints = clamp(asInt(args.get("maxPoints"), DEFAULT_MAX_POINTS), 1, MAX_POINTS_LIMIT);
229+
var summary = service.getJobStatus(jobId);
230+
if (summary.isEmpty()) {
231+
return text("Job not found: " + jobId
232+
+ ". Only jobs submitted in this session are tracked.");
233+
}
234+
JobResult result = summary.get().result();
235+
if (result == null) {
236+
return text("Job " + jobId + " has no results yet (status: "
237+
+ summary.get().status() + ").");
238+
}
239+
List<EquityPoint> curve = result.equityCurve();
240+
if (curve == null || curve.isEmpty()) {
241+
return text("Job " + jobId + " produced no equity curve "
242+
+ "(the strategy may have emitted no yield events).");
243+
}
244+
return text(equityCurveJson(jobId, curve, maxPoints));
245+
});
246+
}
247+
182248
// ---- list_jobs ----------------------------------------------------------
183249

184250
private static SyncToolSpecification listJobs(BacktestingService service) {
@@ -263,8 +329,84 @@ private static String formatJobStatus(String jobId, String instrument, String ex
263329
return sb.toString().stripTrailing();
264330
}
265331

332+
// ---- equity curve -------------------------------------------------------
333+
334+
/**
335+
* Render an equity curve as compact JSON with parallel arrays (smaller than an array of
336+
* objects). Curves longer than {@code maxPoints} are downsampled.
337+
*/
338+
private static String equityCurveJson(String jobId, List<EquityPoint> curve, int maxPoints) {
339+
List<EquityPoint> sampled = downsample(curve, maxPoints);
340+
StringBuilder t = new StringBuilder();
341+
StringBuilder eq = new StringBuilder();
342+
for (int i = 0; i < sampled.size(); i++) {
343+
if (i > 0) {
344+
t.append(',');
345+
eq.append(',');
346+
}
347+
t.append(sampled.get(i).timestamp());
348+
eq.append(sampled.get(i).equity());
349+
}
350+
return "{\"jobId\":\"" + jobId + "\",\"unit\":\"epoch_ms\""
351+
+ ",\"points\":" + sampled.size()
352+
+ ",\"totalPoints\":" + curve.size()
353+
+ ",\"downsampled\":" + (sampled.size() < curve.size())
354+
+ ",\"t\":[" + t + "]"
355+
+ ",\"equity\":[" + eq + "]}";
356+
}
357+
358+
/**
359+
* Downsample to ~{@code maxPoints} via uniform striding, always keeping the first and last
360+
* points and the global min/max so the worst drawdown and the peak survive the reduction.
361+
*/
362+
private static List<EquityPoint> downsample(List<EquityPoint> curve, int maxPoints) {
363+
int n = curve.size();
364+
if (maxPoints <= 0 || n <= maxPoints) return curve;
365+
int minI = 0, maxI = 0;
366+
for (int i = 1; i < n; i++) {
367+
double e = curve.get(i).equity();
368+
if (e < curve.get(minI).equity()) minI = i;
369+
if (e > curve.get(maxI).equity()) maxI = i;
370+
}
371+
TreeSet<Integer> idx = new TreeSet<>();
372+
idx.add(0);
373+
idx.add(n - 1);
374+
idx.add(minI);
375+
idx.add(maxI);
376+
double stride = (double) (n - 1) / (maxPoints - 1);
377+
for (int k = 0; k < maxPoints; k++) {
378+
idx.add((int) Math.round(k * stride));
379+
}
380+
List<EquityPoint> out = new ArrayList<>(idx.size());
381+
for (int i : idx) {
382+
out.add(curve.get(i));
383+
}
384+
return out;
385+
}
386+
266387
// ---- helpers ------------------------------------------------------------
267388

389+
private static boolean asBool(Object v) {
390+
if (v instanceof Boolean b) return b;
391+
return v != null && "true".equalsIgnoreCase(v.toString().trim());
392+
}
393+
394+
private static int asInt(Object v, int defaultValue) {
395+
if (v instanceof Number n) return n.intValue();
396+
if (v != null) {
397+
try {
398+
return Integer.parseInt(v.toString().trim());
399+
} catch (NumberFormatException ignored) {
400+
// fall through to default
401+
}
402+
}
403+
return defaultValue;
404+
}
405+
406+
private static int clamp(int v, int lo, int hi) {
407+
return Math.max(lo, Math.min(hi, v));
408+
}
409+
268410
private static CallToolResult text(String content) {
269411
return CallToolResult.builder().addTextContent(content).build();
270412
}

src/test/java/com/qtsurfer/mcp/McpServerIT.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,16 @@ void serverInfoIsCorrect() {
3838
}
3939

4040
@Test
41-
void registersExactlySixTools() {
42-
assertThat(runner.getServer().listTools()).hasSize(6);
41+
void registersExactlySevenTools() {
42+
assertThat(runner.getServer().listTools()).hasSize(7);
4343
}
4444

4545
@Test
4646
void allExpectedToolsPresent() {
4747
List<String> names = runner.getServer().listTools().stream().map(Tool::name).toList();
4848
assertThat(names).containsExactlyInAnyOrder(
49-
"version", "list_exchanges", "list_instruments", "submit_backtest", "get_job_status", "list_jobs");
49+
"version", "list_exchanges", "list_instruments", "submit_backtest",
50+
"get_job_status", "get_equity_curve", "list_jobs");
5051
}
5152

5253
@Test

0 commit comments

Comments
 (0)