105: EMC Perps: add instant text filter for positions and wallet groups

This commit is contained in:
2026-10-26 18:08:10 +01:00
parent 805e2e7e6e
commit 1af26cb9a0
8 changed files with 312 additions and 14 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-26
@@ -0,0 +1,27 @@
# Design
## Context
See proposal.md for motivation. PerpsTab reconciles stable TreeItems by sibling ID and renders immutable PositionRows from snapshots. IDs such as venue names repeat across wallets. Destructively removing source children would lose both hidden data and normal expansion state.
## Goals / Non-Goals
**Goals:** Preserve canonical tree identity and financial scope while projecting matching branches on the JavaFX thread.
**Non-Goals:** No new service model, network requests, saved configuration or reusable filtering framework.
## Decisions
- Keep the existing reconciled tree as the full source and normal view. For nonempty queries, reconcile a separate projection sharing immutable row values. This avoids rebuilding all rows or losing hidden state.
- Map source TreeItem identity to projected TreeItem, not globally repeated row IDs. Retain only current matching projections so removed rows do not accumulate.
- Use a text-property listener, with no debounce or Return handler. Search only Position labels and displayed Risk text, plus the full ID for wallet rows whose Position label may be shortened. Other columns, hidden non-wallet IDs and explanatory tooltips do not contribute matches.
- A matching group includes its whole branch; a matching descendant retains its path. Always include the root. Expand projections on query edits; preserve expansion on unchanged polling. Restore the source root on clear.
- Transfer selection through source identity if the selected row remains visible; clear it if filtered away. Capture selection before reconciliation to avoid selecting an unrelated replacement row.
- Keep all metrics and warnings unchanged. Bind an explanatory label's visibility/managed state to nonempty text. Keep the input width bounded and wrap the explanation on narrow windows.
## Risks / Trade-offs
- Shared Cross values cannot describe only matching positions → retain original scope and show the explanation.
- Repeated sibling IDs across wallets → projection cache uses object identity.
- Extra projection nodes cost O(n) memory/work for the owner's snapshot → acceptable for this local overview, without service reads.
- Filtering can reveal a descendant of a collapsed normal branch → expand the filtered branch but retain source expansion for clearing.
@@ -0,0 +1,26 @@
# Proposal
## Why
Issue #105 requests quick discovery of Evelyn positions by instrument or wallet/group text. A local filter reduces manual navigation while preserving the account-wide financial context.
## What Changes
- Add Filter text input and adjacent X button to each Perps tab; update on every text change.
- Show matching rows with ancestors, or whole descendant branches for a matching group, using case-insensitive substring matching in Position and Risk only, including full wallet addresses.
- Keep full-snapshot totals, risk and warnings, with an active-filter explanation.
- Preserve filter state across polling and normal expansion state across filtering.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `evelyn-perps-overview`: instant hierarchical filtering and retained overview state.
## Impact
EMC PerpsTab presentation and its overview specification. No service API, polling, trading or dependency changes. Source: https://git.r35157.com/r35157/com_r35157_nenjim-hubd-impl_ref/issues/105
@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Perps text filtering responds immediately
Each environment's Perps tab SHALL provide a Filter text field and adjacent X clear button above the table. Every text change SHALL immediately apply case-insensitive literal substring matching only to Position and Risk text, including full wallet addresses, without Return, focus loss, polling or service calls. Empty text SHALL restore the normal overview.
#### Scenario: Edit and clear
- **WHEN** the owner types B, BT, BTC, changes it to btc, deletes text or pastes a query
- **THEN** every edit immediately updates the results and BTC and btc give identical matches
- **AND** X clears the field and immediately restores the normal overview
#### Scenario: Search scope is limited to Position and Risk
- **WHEN** a query occurs only in Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL, TWAP or tooltip text
- **THEN** that content does not produce a match
- **WHEN** a query occurs in Position or Risk text, or the full wallet address behind a shortened Position label
- **THEN** the corresponding row matches
### Requirement: Text filtering preserves hierarchical context
Matching positions SHALL retain their ancestors; matching groups SHALL include all descendants. Unrelated branches SHALL be hidden. The root SHALL always remain as context. Changing the query SHALL expand result ancestors so matches are readily visible.
#### Scenario: Symbol and wallet matches
- **WHEN** BTC matches positions only
- **THEN** those positions and their ancestors remain, without unrelated positions
- **WHEN** a substring matches a full wallet address, venue name or Cross group name
- **THEN** its whole branch remains, including descendants with other symbols
#### Scenario: No matches
- **WHEN** no row matches the query
- **THEN** only the contextual root remains
### Requirement: Filtering preserves complete account context
Filtering SHALL preserve original snapshot totals, risk summaries, TWAP warnings and freshness/retention qualifications. While text is nonempty, an explanation near the filter SHALL state that totals include hidden positions; the explanation SHALL disappear when cleared. Shared Cross capital SHALL never be apportioned to visible positions.
#### Scenario: Partial portfolio shown
- **WHEN** the filter hides some positions
- **THEN** root and group values and summaries remain those of their complete scopes
- **AND** the active-filter explanation remains visible
### Requirement: Filter state survives observation updates
Each Perps tab SHALL keep its own query through polling and read failures. Updates SHALL re-evaluate current rows. Unchanged observations SHALL retain visible row identities, selection and expansion. Clearing SHALL restore normal group expansion state from before filtering, with new groups expanded by default.
#### Scenario: Updates and recovery
- **WHEN** positions change or a read fails and recovers while filtering
- **THEN** the query persists and matching additions/removals appear immediately on update
- **AND** retained values keep their original freshness semantics
#### Scenario: Independent views and restoration
- **WHEN** one environment is filtered and groups in the normal overview were previously collapsed
- **THEN** the other environment's filter is unaffected
- **AND** clearing restores those collapsed groups
## MODIFIED Requirements
### Requirement: Venue visibility follows available child rows
EMC SHALL hide venue rows with neither positions, Cross accounts nor TWAP warnings and show them when children reappear. Funded empty Cross accounts and accounts with eligible TWAP warnings SHALL keep their venue visible. Unmatched isolated warnings SHALL remain on the venue with true subaccount identity, never on an invented Cross parent. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Without a text filter, wallet rows and the root SHALL remain visible. With a text filter, only matching branches SHALL remain, with the root always retained as context.
#### Scenario: Empty venue
- **WHEN** Jupiter or Phoenix has no positions, Cross accounts or TWAP warnings
- **THEN** its heading is absent from that wallet's displayed children
- **AND** a successful empty scope still contributes zero to totals
#### Scenario: Funded Cross account without positions
- **WHEN** Phoenix returns a Cross account with capital but no open positions
- **THEN** Phoenix and its Cross account remain visible with that capital counted once
#### Scenario: Positions disappear and return
- **WHEN** a venue loses its last child and later returns a position
- **THEN** its heading disappears and reappears with the correct child
- **AND** unrelated retained rows keep their selection and expansion state
#### Scenario: Hidden scope has not been read successfully
- **WHEN** a childless venue has never been read successfully and its first read is pending or failed
- **THEN** the heading is hidden and its read status is available on the wallet tooltip
- **AND** required totals remain unavailable rather than reporting zero
@@ -0,0 +1,15 @@
# Tasks
## 1. Instant filtering
- [x] 1.1 Add filter controls and identity-preserving hierarchy projection; verify immediate edits, group/position matches, clear and no-match behavior in the JavaFX view.
- [x] 1.2 Verify full metrics/warnings, normal expansion restoration, selection, duplicate IDs across wallets and update/failure/recovery behavior with local JavaFX fixtures; record results in verification.md.
- [x] 1.3 Synchronize the Perps overview specification and verify strict OpenSpec validation.
## 2. Integration verification
- [x] 2.1 Run the normal Gradle build, visually inspect ordinary and narrow layouts, and check the final diff and whitespace; record outcomes and limitations.
## 3. Narrow search scope
- [x] 3.1 Limit matching to Position and Risk while retaining full wallet address matching; verify excluded columns and hidden non-wallet IDs do not match, rerun the build and strict validation, and update verification.md.
@@ -0,0 +1,25 @@
# Verification — issue #105
Verified locally on 2026-10-26 against the issue retrieved from Gitea. Implementation is uncommitted and unpushed; the running Hub has not been deployed or restarted.
## Delivered behavior
- Each Perps tab has Filter input and an adjacent X button. A text-property listener applies changes synchronously without Return, focus loss or polling. Search uses case-insensitive literal substrings of Position and Risk text only, including full wallet addresses behind shortened Position labels. Other columns, hidden non-wallet IDs and explanatory tooltips are excluded, as requested by the owner in the follow-up.
- Matching rows retain their ancestor paths. A matching group includes its whole branch; the root is always present. Query edits expand the filtered view, while clearing restores the original normal tree and its expansion state.
- The filtered projection shares immutable full-scope row values. Totals, risk summaries, warnings and freshness qualifications therefore remain independent of visible children. A visible explanation states that totals include hidden positions.
- Polling and failures preserve the query. Retained projection nodes preserve identity, selection and expansion. New/removed matches are reconciled on snapshot updates; a removed or hidden selection clears. Separate source-object identities keep repeated venue/position IDs in different wallets distinct.
- The main evelyn-perps-overview specification is synchronized: four added requirements and one existing venue-visibility requirement updated to account for filtering. Change artifacts remain active and ready for archive.
## Checks
- `JAVA_HOME=/usr/local/software/java/jfx-25 ./gradlew build`: successful. 42 existing tests, zero failures, errors or skips.
- Temporary `/tmp/Issue105Preview.java` with local synthetic snapshots: 55 JavaFX assertions passed after narrowing search to Position and Risk. This is an assertion count, not 55 separate user scenarios. The fixture reuses utilities from `/tmp/Issue104Preview.java`; no permanent test source or dependency was added.
- Checked actual KEY_TYPED events for B → BT → BTC without Return, lower-case equivalence, deletion and clipboard paste, literal bracket text, clear, no matches, full wallet address hidden by a shortened label, duplicate venue IDs, group matches, independent tabs, projected selection transfer and normal expansion restoration.
- Checked shared full-scope row values, retained root warnings originating on hidden positions, unchanged Cross metrics, unchanged polling identity/selection/collapse, additions/removals, failure retention and recovery. After the scope adjustment, B matches four positions, BT three and BTC two; the root TWAP warning no longer produces matches. Additional checks confirm matches for Risk `Shared` and `25.0%`, and no matches from Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL, TWAP, hidden position IDs or tooltip prose.
- Visually inspected `/tmp/issue105-check/btc.png`, `wallet.png` and `narrow.png`. Normal capture is 1280 × 670; narrow capture is 550 × 430. The input, clear button and totals explanation remain visible; the table retains horizontal scrolling.
- Strict OpenSpec validation succeeds for both `105-perps-instant-filter` and the main `evelyn-perps-overview` specification. Existing long requirement paragraphs produce informational length hints only.
- Final working-tree whitespace and change-artifact whitespace checks pass. Existing staged/deleted artifacts for #97, #99, #103 and #104 were not changed or staged by this task.
## Limits
Verification uses local fixture data, not the owner's live wallets. No deployment, financial transactions, service-side filter, new venue requests or persistent filter configuration are part of this change.
+49 -1
View File
@@ -70,7 +70,7 @@ Tooltips SHALL explain isolated collateral, exposure-based leverage, collateral-
- **AND** no position-only metric is labeled account utilization
### Requirement: Venue visibility follows available child rows
EMC SHALL hide venue rows with neither positions, Cross accounts nor TWAP warnings and show them when children reappear. Funded empty Cross accounts and accounts with eligible TWAP warnings SHALL keep their venue visible. Unmatched isolated warnings SHALL remain on the venue with true subaccount identity, never on an invented Cross parent. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Wallet rows and the root SHALL remain visible.
EMC SHALL hide venue rows with neither positions, Cross accounts nor TWAP warnings and show them when children reappear. Funded empty Cross accounts and accounts with eligible TWAP warnings SHALL keep their venue visible. Unmatched isolated warnings SHALL remain on the venue with true subaccount identity, never on an invented Cross parent. Hidden venues SHALL remain in snapshots and totals, with read status available in wallet tooltips. Unknown scopes SHALL NOT become zero totals; failed reads SHALL preserve last-success totals. Without a text filter, wallet rows and the root SHALL remain visible. With a text filter, only matching branches SHALL remain, with the root always retained as context.
#### Scenario: Empty venue
- **WHEN** Jupiter or Phoenix has no positions, Cross accounts or TWAP warnings
@@ -165,3 +165,51 @@ Available estimated Value SHALL show one `≈` before USD formatting with two de
- **WHEN** Phoenix estimates contribute to a mixed portfolio
- **THEN** available affected totals also explain Phoenix's estimated closing Value
- **AND** a missing required contribution leaves the affected total `—` without `≈`
### Requirement: Perps text filtering responds immediately
Each environment's Perps tab SHALL provide a Filter text field and adjacent X clear button above the table. Every text change SHALL immediately apply case-insensitive literal substring matching only to Position and Risk text, including full wallet addresses, without Return, focus loss, polling or service calls. Empty text SHALL restore the normal overview.
#### Scenario: Edit and clear
- **WHEN** the owner types B, BT, BTC, changes it to btc, deletes text or pastes a query
- **THEN** every edit immediately updates the results and BTC and btc give identical matches
- **AND** X clears the field and immediately restores the normal overview
#### Scenario: Search scope is limited to Position and Risk
- **WHEN** a query occurs only in Direction, Margin, Collateral, Leverage, Exposure, Value, uPnL, TWAP or tooltip text
- **THEN** that content does not produce a match
- **WHEN** a query occurs in Position or Risk text, or the full wallet address behind a shortened Position label
- **THEN** the corresponding row matches
### Requirement: Text filtering preserves hierarchical context
Matching positions SHALL retain their ancestors; matching groups SHALL include all descendants. Unrelated branches SHALL be hidden. The root SHALL always remain as context. Changing the query SHALL expand result ancestors so matches are readily visible.
#### Scenario: Symbol and wallet matches
- **WHEN** BTC matches positions only
- **THEN** those positions and their ancestors remain, without unrelated positions
- **WHEN** a substring matches a full wallet address, venue name or Cross group name
- **THEN** its whole branch remains, including descendants with other symbols
#### Scenario: No matches
- **WHEN** no row matches the query
- **THEN** only the contextual root remains
### Requirement: Filtering preserves complete account context
Filtering SHALL preserve original snapshot totals, risk summaries, TWAP warnings and freshness/retention qualifications. While text is nonempty, an explanation near the filter SHALL state that totals include hidden positions; the explanation SHALL disappear when cleared. Shared Cross capital SHALL never be apportioned to visible positions.
#### Scenario: Partial portfolio shown
- **WHEN** the filter hides some positions
- **THEN** root and group values and summaries remain those of their complete scopes
- **AND** the active-filter explanation remains visible
### Requirement: Filter state survives observation updates
Each Perps tab SHALL keep its own query through polling and read failures. Updates SHALL re-evaluate current rows. Unchanged observations SHALL retain visible row identities, selection and expansion. Clearing SHALL restore normal group expansion state from before filtering, with new groups expanded by default.
#### Scenario: Updates and recovery
- **WHEN** positions change or a read fails and recovers while filtering
- **THEN** the query persists and matching additions/removals appear immediately on update
- **AND** retained values keep their original freshness semantics
#### Scenario: Independent views and restoration
- **WHEN** one environment is filtered and groups in the normal overview were previously collapsed
- **THEN** the other environment's filter is unaffected
- **AND** clearing restores those collapsed groups
@@ -15,6 +15,8 @@ import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Tab;
import javafx.scene.control.Button;
import javafx.scene.control.TextField;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.Tooltip;
@@ -43,6 +45,7 @@ import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -117,12 +120,33 @@ final class PerpsTab {
}
});
var root = new TreeItem<>(group("root", "Perpetual Contracts", Metrics.unknown(), TOTAL_NOTES));
root.setExpanded(true);
table.setRoot(root);
fullRoot.setExpanded(true);
table.setRoot(fullRoot);
table.setShowRoot(true);
VBox content = new VBox(table);
var filterLabel = new Label("Filter:");
filterLabel.setLabelFor(filter);
filter.setPrefColumnCount(18);
filter.setMinWidth(80);
filter.setTooltip(tooltip("Filter Position and Risk, including full wallet addresses; updates as you type."));
var clear = new Button("X");
clear.setAccessibleText("Clear filter");
clear.setTooltip(tooltip("Clear filter"));
clear.setOnAction(event -> {
filter.clear();
filter.requestFocus();
});
var filterControls = new HBox(6, filterLabel, filter, clear);
filterControls.setAlignment(Pos.CENTER_LEFT);
var explanation = new Label("Filtered view — totals include hidden positions.");
explanation.setWrapText(true);
explanation.visibleProperty().bind(filter.textProperty().isNotEmpty());
explanation.managedProperty().bind(explanation.visibleProperty());
var filterBar = new VBox(4, filterControls, explanation);
filter.textProperty().addListener((observable, previous, current) ->
applyFilter(true, selectedSource()));
VBox content = new VBox(8, filterBar, table);
content.setPadding(new Insets(10));
content.setStyle("-fx-background-color: " + environmentColor + ";");
VBox.setVgrow(table, Priority.ALWAYS);
@@ -397,15 +421,15 @@ final class PerpsTab {
// Risk ages during existing polling even if the service snapshot is unchanged.
// Reconciliation below mutates only row values/children that actually differ.
Instant now = clock.instant();
TreeItem<PositionRow> selected = table.getSelectionModel().getSelectedItem();
TreeItem<PositionRow> selected = selectedSource();
var rootRisk = PerpsRisk.Summary.combine(snapshot.wallets().stream().flatMap(wallet -> wallet.venues().stream())
.map(venue -> PerpsRisk.venue(venue, now, snapshotReadFailed)).toList());
PositionRow rootRow = group("root", "Perpetual Contracts", snapshot.totals(), TOTAL_NOTES,
PerpsRiskPresentation.summary(rootRisk), PerpsTwapWarnings.distinct(snapshot.wallets().stream()
.flatMap(wallet -> PerpsTwapWarnings.wallet(wallet, now, snapshotReadFailed).stream()).toList()));
if (!rootRow.equals(table.getRoot().getValue())) table.getRoot().setValue(rootRow);
if (!rootRow.equals(fullRoot.getValue())) fullRoot.setValue(rootRow);
List<TreeItem<PositionRow>> wallets = new ArrayList<>();
Map<String, TreeItem<PositionRow>> existingWallets = childrenById(table.getRoot());
Map<String, TreeItem<PositionRow>> existingWallets = childrenById(fullRoot);
for (PerpsSnapshot.Wallet wallet : snapshot.wallets()) {
String walletNotes = TOTAL_NOTES;
for (PerpsSnapshot.Venue venue : wallet.venues()) {
@@ -461,13 +485,66 @@ final class PerpsTab {
updateChildren(walletItem, venues);
wallets.add(walletItem);
}
updateChildren(table.getRoot(), wallets);
if (selected != null && table.getSelectionModel().getSelectedItem() != selected) {
int selectedRow = table.getRow(selected);
if (selectedRow >= 0) {
table.getSelectionModel().select(selectedRow);
}
updateChildren(fullRoot, wallets);
applyFilter(false, selected);
}
private TreeItem<PositionRow> selectedSource() {
TreeItem<PositionRow> selected = table.getSelectionModel().getSelectedItem();
if (table.getRoot() == fullRoot) return selected;
for (var entry : filteredItems.entrySet()) {
if (entry.getValue() == selected) return entry.getKey();
}
return null;
}
private void applyFilter(boolean queryChanged, TreeItem<PositionRow> selected) {
String query = filter.getText().toLowerCase(Locale.ROOT);
TreeItem<PositionRow> root = fullRoot;
Map<TreeItem<PositionRow>, TreeItem<PositionRow>> next = new IdentityHashMap<>();
if (!query.isEmpty()) {
root = filtered(fullRoot, query, false, queryChanged, next);
}
filteredItems = next;
if (table.getRoot() != root) table.setRoot(root);
TreeItem<PositionRow> visibleSelection = query.isEmpty() ? selected : next.get(selected);
if (visibleSelection != null && table.getRow(visibleSelection) >= 0) {
if (table.getSelectionModel().getSelectedItem() != visibleSelection) {
table.getSelectionModel().select(visibleSelection);
}
} else {
table.getSelectionModel().clearSelection();
}
}
private TreeItem<PositionRow> filtered(TreeItem<PositionRow> source, String query,
boolean ancestorMatches, boolean expand, Map<TreeItem<PositionRow>, TreeItem<PositionRow>> next) {
boolean matches = ancestorMatches || matches(source, query);
List<TreeItem<PositionRow>> children = new ArrayList<>();
for (TreeItem<PositionRow> child : source.getChildren()) {
TreeItem<PositionRow> visible = filtered(child, query, matches, expand, next);
if (visible != null) children.add(visible);
}
if (!matches && children.isEmpty() && source != fullRoot) return null;
TreeItem<PositionRow> visible = filteredItems.get(source);
if (visible == null) {
visible = new TreeItem<>(source.getValue());
visible.setExpanded(true);
} else if (visible.getValue() != source.getValue()) {
visible.setValue(source.getValue());
}
updateChildren(visible, children);
if (expand) visible.setExpanded(true);
next.put(source, visible);
return visible;
}
private boolean matches(TreeItem<PositionRow> source, String query) {
PositionRow row = source.getValue();
// Wallet IDs supply the full Position address when its label is shortened.
String text = String.join("\n", row.label(), row.risk().text(),
source.getParent() == fullRoot ? row.id() : "");
return text.toLowerCase(Locale.ROOT).contains(query);
}
private static String status(PerpsSnapshot.Venue venue) {
@@ -599,6 +676,10 @@ final class PerpsTab {
+ "-fx-control-inner-background-alt: #fce8e6;";
private final Tab tab;
private final TreeTableView<PositionRow> table = new TreeTableView<>();
private final TextField filter = new TextField();
private final TreeItem<PositionRow> fullRoot = new TreeItem<>(
group("root", "Perpetual Contracts", Metrics.unknown(), TOTAL_NOTES));
private Map<TreeItem<PositionRow>, TreeItem<PositionRow>> filteredItems = new IdentityHashMap<>();
private final Clock clock;
private PerpsSnapshot lastSnapshot;
}