Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,25 +122,28 @@ void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception

String editBody = """
{"authorizationId":"%s","credentialId":"%s","operations":[
{"action":"ADD_BLOCK","arguments":{"groupId":"buy","blockId":"b1",
"elementCode":"PRICE_CHANGE_PERCENT"}}]}
{"action":"ADD_GROUP","arguments":{"groupId":"buy","container":"BUY",
"evaluationMode":"INDEPENDENT","allocationMode":"EQUAL",
"instrumentIds":["11111111-1111-4111-8111-111111111111"]}}]}
""".formatted(
grant.path("authorizationId").asText(), grant.path("credentialId").asText());

// A freshly created strategy is {"groups":[],"mode":"BASIC"} and the four delegated
// operations cannot create a group, so this edit is refused on its merits — which is the
// assertion that matters here. EDIT_REJECTED means the delegation was accepted and the
// request reached the edit service; a delegation that did not authorize would answer 403
// SCOPE_DENIED, and a missing route would answer 404, which is what it did before this
// change. Applying real blocks needs a valid Basic skeleton and is covered separately.
JsonNode refusal = json.readTree(mvc.perform(
// The strategy is untouched — {"groups":[],"mode":"BASIC"} — and the tool builds its
// container anyway. Before this work the same call answered 404 because the route did not
// exist, and after ADD_GROUP shipped it answered 422 because a new document carries no
// catalogId for the proposed assembly to parse against.
JsonNode preview = json.readTree(mvc.perform(
post("/api/v1/strategies/" + strategyId + "/basic-edits/preview")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(editBody))
.andExpect(status().isUnprocessableEntity())
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
assertThat(refusal.path("code").asText()).isEqualTo("EDIT_REJECTED");
assertThat(preview.path("diff").isArray()).isTrue();
assertThat(preview.path("diff").get(0).asText()).isEqualTo("ADD_GROUP buy BUY");
assertThat(preview.path("previewHash").asText()).isNotBlank();
assertThat(preview.path("expectedEditSequence").isNumber()).isTrue();
assertThat(preview.path("proposedSemanticDocument").path("catalogId").asText()).isNotBlank();

// Revoking takes effect at once: the same call now fails authorization instead of merits.
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
Expand Down
2 changes: 2 additions & 0 deletions apps/idea2strategy-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Supported commands:

```text
tool-contract
catalog elements
catalog instruments [--symbol TICKER[,TICKER...]]
delegation create --name NAME --scopes STRATEGY_EDIT,STRATEGY_VALIDATE --strategy-id ID[,ID...]
[--expires-at ISO_8601_INSTANT]
delegation revoke --authorization-id ID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public final class Idea2StrategyCli {
private static final Set<String> ALLOWED_EDIT_OPERATIONS =
Set.of("ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE");
private static final Set<String> AUTHENTICATED_COMMANDS = Set.of(
"catalog.elements",
"catalog.instruments",
"delegation.create",
"delegation.revoke",
"strategy.list",
Expand Down Expand Up @@ -88,6 +90,8 @@ private static JsonNode execute(Invocation invocation, InputStream stdin, Map<St
? credentials.load()
: invocation.environmentToken();
return switch (commandKey) {
case "catalog.elements" -> catalogElements(arguments, api, token);
case "catalog.instruments" -> catalogInstruments(arguments, api, token);
case "delegation.create" -> delegationCreate(arguments, api, token);
case "delegation.revoke" -> delegationRevoke(arguments, api, token);
case "strategy.list" -> strategyList(arguments, api, token);
Expand Down Expand Up @@ -139,6 +143,45 @@ private static JsonNode login(Arguments args, ApiClient api, CredentialStore cre
return result;
}

/**
* The catalog an edit is validated against.
*
* <p>Without this an external tool cannot turn "use RSI" into an operation: element codes and
* their declared parameters live in the published catalog, and guessing a code produces an
* edit the server refuses. Reading beats guessing.
*/
private static JsonNode catalogElements(Arguments args, ApiClient api, String token) {
args.rejectUnknown();
return api.get("/api/v1/strategy-catalogs/basic", token);
}

/**
* Symbol to instrument id.
*
* <p>A container names the instruments it trades by id, and a person asks for "Apple". Without
* a lookup the tool has no way to cross that gap.
*/
private static JsonNode catalogInstruments(Arguments args, ApiClient api, String token) {
args.rejectUnknown("--symbol");
JsonNode instruments = api.get("/api/v1/strategy-catalogs/basic/instruments", token);
String symbol = args.optional("--symbol");
if (symbol == null || symbol.isBlank()) {
return instruments;
}
ArrayNode matches = JSON.createArrayNode();
for (String requested : symbol.split(",")) {
String wanted = requested.trim();
for (JsonNode instrument : instruments.path("instruments")) {
if (instrument.path("symbol").asText().equalsIgnoreCase(wanted)) {
matches.add(instrument);
}
}
}
ObjectNode filtered = JSON.createObjectNode();
filtered.set("instruments", matches);
return filtered;
}

private static JsonNode delegationCreate(Arguments args, ApiClient api, String token) {
args.rejectUnknown("--name", "--scopes", "--strategy-id", "--expires-at");
ArrayNode scopes = JSON.createArrayNode();
Expand Down Expand Up @@ -359,6 +402,7 @@ static Invocation parse(String[] raw, Map<String, String> environment) {
List<String> words = values.stream().takeWhile(value -> !value.startsWith("--")).toList();
int commandWordCount = switch (words.getFirst()) {
case "login" -> 1;
case "catalog" -> 2;
case "delegation" -> 2;
case "strategy" -> words.size() >= 2 && "edit".equals(words.get(1)) ? 3 : 2;
case "operator" -> 2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,38 @@
"tool": "idea2strategy",
"outputMode": "JSON",
"contractCommand": ["tool-contract"],
"discovery": [
{
"name": "resolve-instruments",
"purpose": "Turn a company or ticker a person named into the instrument ids a container needs.",
"command": ["catalog", "instruments", "--symbol", "<TICKER[,TICKER...]>"],
"requiredOutputFields": ["data.instruments"]
},
{
"name": "read-element-catalog",
"purpose": "Read the official element codes and their declared parameters. Never guess a code; an unpublished code is refused.",
"command": ["catalog", "elements"],
"requiredOutputFields": ["data.elements"]
}
],
"workflow": [
{
"name": "create-strategy",
"purpose": "A new strategy starts empty; the container and blocks are built by the operations below.",
"command": ["strategy", "create", "--name", "<name>"],
"requiredOutputFields": ["data.id"]
},
{
"name": "delegate-editing",
"purpose": "Grant this tool scoped, expiring permission to edit that strategy. The credential is returned once.",
"command": [
"delegation", "create",
"--name", "<tool name>",
"--scopes", "STRATEGY_EDIT",
"--strategy-id", "<data.id from create-strategy>"
],
"requiredOutputFields": ["data.authorizationId", "data.credentialId"]
},
{
"name": "preview-basic-edit",
"command": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ private DelegatedBasicEditPreview prepare(
throw new StrategyDraftConflictException();
}
ObjectNode root = parseRoot(current.semanticDocument());
// A strategy starts as {"groups":[],"mode":"BASIC"} with no catalogId, and every proposed
// document has to parse as an official assembly, which requires one. Without this a
// delegated tool could create a container and still never produce a readable document —
// the first edit would fail on a field no delegated operation can set. The value is not
// invented: it is the catalog this very edit is being validated against.
if (!root.hasNonNull("catalogId") || root.path("catalogId").asText().isBlank()) {
root.put("catalogId", catalog.version().id().toString());
}
Map<String, StrategyElementDefinition> definitions = catalog.elements().stream()
.collect(Collectors.toMap(StrategyElementDefinition::elementCode, Function.identity()));
var changes = new ArrayList<String>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,48 @@ void refusesApplyWhenTheReviewedPreviewHashDoesNotMatch() {
assertThat(commandPort.saved).isNull();
}

/**
* The whole point of delegated container creation: hand a tool an untouched strategy and it
* builds one. This failed on AWS after ADD_GROUP shipped, because a new document carries no
* catalogId and every proposed document must parse as an official assembly. The unit fixtures
* all had a catalogId already, so nothing here noticed.
*/
@Test
void buildsAWholeStrategyFromTheDocumentANewStrategyActuallyStartsWith() {
var commandPort = new RecordingCommandPort();
var service = service(new RecordingAuthorizer(), commandPort, emptyDocument());
var operations = List.of(
new DelegatedBasicEditOperation("ADD_GROUP", Map.of(
"groupId", "buy",
"container", "BUY",
"evaluationMode", "INDEPENDENT",
"allocationMode", "EQUAL",
"instrumentIds", List.of(INSTRUMENT_ID.toString()))),
new DelegatedBasicEditOperation("ADD_BLOCK", Map.of(
"groupId", "buy", "blockId", "trigger", "elementCode", "MARKET_OPEN")),
new DelegatedBasicEditOperation("ADD_BLOCK", Map.of(
"groupId", "buy", "blockId", "condition", "elementCode", "RSI",
"parameters", Map.of("period", 14))),
new DelegatedBasicEditOperation("ADD_BLOCK", Map.of(
"groupId", "buy", "blockId", "order", "elementCode", "BUY_ORDER")),
new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of(
"groupId", "buy", "fromBlockId", "trigger", "outputPort", "signal",
"toBlockId", "condition", "inputPort", "input")),
new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of(
"groupId", "buy", "fromBlockId", "condition", "outputPort", "result",
"toBlockId", "order", "inputPort", "input")));

var preview = service.preview(editor(), STRATEGY_ID, 7, catalog(), operations);

assertThat(preview.proposedSemanticDocument()).contains("\"catalogId\":\"" + CATALOG_ID + "\"");
assertThat(preview.valid()).isTrue();

var applied = service.apply(editor(), STRATEGY_ID, 7, catalog(), operations, preview.previewHash());

assertThat(applied.semanticHash()).isEqualTo(preview.previewHash());
assertThat(commandPort.saved).isEqualTo(applied);
}

@Test
void createsATradeContainerSoADelegatedToolCanStartFromNothing() {
var service = service(new RecordingAuthorizer(), new RecordingCommandPort());
Expand Down Expand Up @@ -197,8 +239,14 @@ void validatesTheCurrentDraftOnlyWithTheDedicatedDelegatedScope() {
private static DelegatedBasicStrategyEditService service(
DelegatedStrategyAuthorizationPort authorizer,
DelegatedBasicEditCommandPort commandPort) {
return service(authorizer, commandPort, document());
}

private static DelegatedBasicStrategyEditService service(
DelegatedStrategyAuthorizationPort authorizer,
DelegatedBasicEditCommandPort commandPort,
StrategyDocument document) {
Strategy strategy = Strategy.createBasic(STRATEGY_ID, ACCOUNT_ID, "Momentum", null, NOW.minusSeconds(60));
StrategyDocument document = document();
StrategyQueryPort strategies = (id, owner) -> Optional.of(strategy)
.filter(value -> id.equals(STRATEGY_ID) && owner.equals(ACCOUNT_ID));
StrategyDocumentQueryPort documents = (id, owner) -> Optional.of(document)
Expand All @@ -211,6 +259,16 @@ private static DelegatedStrategyEditor editor() {
return new DelegatedStrategyEditor(ACCOUNT_ID, AUTHORIZATION_ID, CREDENTIAL_ID);
}

/** Exactly what BasicStrategyDraftCommandService writes for a newly created strategy. */
private static StrategyDocument emptyDocument() {
String semantic = StrategyDocumentJson.canonicalize("{\"groups\":[],\"mode\":\"BASIC\"}");
String presentation = "{\"positions\":{}}";
return new StrategyDocument(
STRATEGY_ID, semantic, presentation, "basic-semantic/v1", "basic-presentation/v1",
StrategyDocumentJson.sha256(semantic), StrategyDocumentJson.sha256(presentation), 7,
NOW.minusSeconds(60), NOW.minusSeconds(1));
}

private static StrategyDocument document() {
String semantic = StrategyDocumentJson.canonicalize("{\"catalogId\":\"" + CATALOG_ID + "\",\"groups\":[{"
+ "\"id\":\"buy\",\"container\":\"BUY\",\"evaluationMode\":\"INDEPENDENT\","
Expand Down