Chart Visualization Guide
This guide covers rendering visual markers, icons, and custom graphics on Bookmap charts using the Layer1 API.
Overviewβ
Bookmap provides two primary approaches for chart visualization:
| Approach | Class | Use Case | Complexity |
|---|---|---|---|
| Simple Icon Markers | OnlineCalculatable.Marker | Fixed-size icons at specific price levels | Low |
| Data-Bound Graphics | OnlineCalculatable.DataCoordinateMarker | Dynamic graphics spanning price ranges (e.g., candles) | Medium |
When to Use Each Approachβ
Use Marker (Simple Icons) when:
- Displaying trade executions
- Showing order placements/cancellations
- Marking specific price levels
- Icon size is independent of chart zoom
Use DataCoordinateMarker when:
- Drawing OHLC candles or bars
- Creating graphics that span price ranges
- Image height depends on price difference (high - low)
- Graphics need to scale with chart zoom
OnlineCalculatable.Marker - Simple Icon Markersβ
The Marker class represents a fixed-size icon placed at a specific price level on the chart.
Constructorβ
public Marker(double markerY, int iconOffsetX, int iconOffsetY, BufferedImage icon)
Parametersβ
| Parameter | Type | Description |
|---|---|---|
markerY | double | Vertical position (price / pips for PRIMARY chart, raw price for BOTTOM) |
iconOffsetX | int | Horizontal pixel offset from marker position |
iconOffsetY | int | Vertical pixel offset from marker position |
icon | BufferedImage | The image to display |
Coordinate Systemβ
iconOffsetX
Γ’ββΓ’ββ¬Γ’β β¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΒΊ
Γ’βΕΓ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΒ Γ’βΒ²
Γ’ββ Γ’ββ Γ’ββ
Γ’ββ ICON Γ’ββ Γ’ββ iconOffsetY
Γ’ββ Γ’ββ Γ’ββ
Γ’ββΓ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΛ Γ’βΒΌ
Γ’βΒ
(markerY position)
markerY: The price level where the marker anchors (in price/pips units for PRIMARY, raw price for BOTTOM)iconOffsetX: Pixels to shift the icon horizontally (negative = left, positive = right)iconOffsetY: Pixels to shift the icon vertically (negative = up, positive = down)
Icon Positioning Strategiesβ
Centered on Price Levelβ
new Marker(price, -icon.getWidth() / 2, -icon.getHeight() / 2, icon)
Anchored Above Price Levelβ
new Marker(price, -icon.getWidth() / 2, -icon.getHeight(), icon)
Anchored Below Price Levelβ
new Marker(price, -icon.getWidth() / 2, 0, icon)
Anchored to Left of Priceβ
new Marker(price, -icon.getWidth(), -icon.getHeight() / 2, icon)
Trade Marker Exampleβ
From Layer1ApiMarkersDemo.java:
// Create a 16x16 "X" marker icon
BufferedImage tradeIcon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
Graphics graphics = tradeIcon.getGraphics();
graphics.setColor(Color.BLUE);
graphics.drawLine(0, 0, 15, 15);
graphics.drawLine(15, 0, 0, 15);
// Use the marker - centered on the price
@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
if (alias.equals(indicatorAlias)) {
listener.accept(new Marker(
price, // markerY (already in price/pips for real-time)
-tradeIcon.getWidth() / 2, // center horizontally
-tradeIcon.getHeight() / 2, // center vertically
tradeIcon
));
}
}
DataCoordinateMarker - Data-Bound Graphicsβ
The DataCoordinateMarker interface enables creating graphics that span price ranges, such as OHLC candles. The marker dynamically generates its image based on the current chart scale.
Interface Methodsβ
public interface DataCoordinateMarker {
double getMinY(); // Lower edge (for range adjustment)
double getMaxY(); // Upper edge (for range adjustment)
double getValueY(); // Value for widget/line display
Marker makeMarker(Function<Double, Integer> yDataCoordinateToPixelFunction);
}
Method Descriptionsβ
| Method | Purpose |
|---|---|
getMinY() | Returns Y coordinate of lower edge (used for bottom panel range adjustment) |
getMaxY() | Returns Y coordinate of upper edge (used for bottom panel range adjustment) |
getValueY() | Returns value used for widget display and line drawing |
makeMarker() | Creates the visual Marker using the provided coordinate conversion function |
Pixel Coordinate Conversionβ
The yDataCoordinateToPixelFunction converts price values to pixel coordinates:
Function<Double, Integer> yDataCoordinateToPixelFunction
Important Warning:
The function may return extremely large or negative values for prices outside the visible chart range. Always validate and clamp pixel values to prevent memory issues or rendering artifacts (e.g., 10,000 pixels max).
Complete BarEvent Implementationβ
From Layer1ApiBarsDemo.java:
private static class BarEvent implements CustomGeneratedEvent, DataCoordinateMarker {
private static final long serialVersionUID = 1L;
private long time;
double open, low, high, close;
transient int bodyWidthPx; // transient: not serialized
public BarEvent(long time) {
this(time, Double.NaN);
}
public BarEvent(long time, double open) {
this(time, open, open, open, open, -1);
}
public BarEvent(long time, double open, double low, double high, double close, int bodyWidthPx) {
this.time = time;
this.open = open;
this.low = low;
this.high = high;
this.close = close;
this.bodyWidthPx = bodyWidthPx;
}
// Copy constructor (important for cloning)
public BarEvent(BarEvent other) {
this(other.time, other.open, other.low, other.high, other.close, other.bodyWidthPx);
}
@Override
public long getTime() { return time; }
@Override
public Object clone() {
return new BarEvent(time, open, low, high, close, bodyWidthPx);
}
@Override
public double getMinY() { return low; }
@Override
public double getMaxY() { return high; }
@Override
public double getValueY() { return close; }
public void update(double price) {
if (Double.isNaN(price)) return;
if (Double.isNaN(open)) {
open = low = high = price;
} else {
low = Math.min(low, price);
high = Math.max(high, price);
}
close = price;
}
@Override
public Marker makeMarker(Function<Double, Integer> yDataCoordinateToPixelFunction) {
// Convert OHLC prices to pixel coordinates
int top = yDataCoordinateToPixelFunction.apply(high);
int bottom = yDataCoordinateToPixelFunction.apply(low);
int openPx = yDataCoordinateToPixelFunction.apply(open);
int closePx = yDataCoordinateToPixelFunction.apply(close);
// Calculate body bounds
int bodyLow = Math.min(openPx, closePx);
int bodyHigh = Math.max(openPx, closePx);
// Create image sized to contain the full candle
int imageHeight = top - bottom + 1;
BufferedImage bufferedImage = new BufferedImage(bodyWidthPx, imageHeight, BufferedImage.TYPE_INT_ARGB);
int imageCenterX = bufferedImage.getWidth() / 2;
Graphics2D graphics = bufferedImage.createGraphics();
// Clear background (transparent)
graphics.setBackground(new Color(0, 0, 0, 0));
graphics.clearRect(0, 0, bufferedImage.getWidth(), bufferedImage.getHeight());
// Draw wick (shadow)
graphics.setColor(Color.WHITE);
graphics.drawLine(imageCenterX, 0, imageCenterX, imageHeight);
// Draw body (green for bullish, red for bearish)
// Note: BufferedImage Y-axis points downward
graphics.setColor(open < close ? Color.GREEN : Color.RED);
graphics.fillRect(0, top - bodyHigh, bodyWidthPx, bodyHigh - bodyLow + 1);
graphics.dispose();
// Calculate offsets to position the image correctly
// Reference point is 'close', offset so close aligns with markerY
int iconOffsetY = bottom - closePx;
int iconOffsetX = -imageCenterX; // Center horizontally
return new Marker(close, iconOffsetX, iconOffsetY, bufferedImage);
}
/**
* Convert from level numbers to display prices for bottom panel
*/
public void applyPips(double pips) {
open *= pips;
low *= pips;
high *= pips;
close *= pips;
}
}
Candle Coordinate Diagramβ
Γ’βΕΓ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΒ
Γ’ββ BufferedImage Coordinate Space Γ’ββ
Γ’ββ Γ’ββ
Γ’ββ top (high) Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’β¬Òββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬ Y=0 Γ’ββ
Γ’ββ Γ’ββ Γ’ββ
Γ’ββ bodyHigh Γ’ββ¬Γ’ββ¬Γ’β¬Òββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’β´Òββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΒ¬ Γ’ββ
Γ’ββ Γ’ββ BODY Γ’ββ Γ’ββ
Γ’ββ bodyLow Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’β´Òββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΒ΄ Γ’ββ
Γ’ββ Γ’ββ Γ’ββ
Γ’ββ bottom (low) Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’β´Òββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬ Y=imageHeight Γ’ββ
Γ’ββΓ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’βΛ
Chart Space (Y increases upward, pixel Y increases downward)
Dynamic Icon Generationβ
Icons can be generated dynamically based on user settings or runtime conditions.
Pattern: Reload Icon on Color Changeβ
From Layer1ApiMarkersDemo.java:
private Map<String, BufferedImage> orderIcons = Collections.synchronizedMap(new HashMap<>());
private void reloadOrderIcon(String alias) {
BufferedImage orderIcon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
Graphics graphics = orderIcon.getGraphics();
// Clear with transparent background
graphics.setColor(Colors.TRANSPARENT);
graphics.fillRect(0, 0, 15, 15);
// Draw circle with user-selected color
graphics.setColor(getColor(alias, INDICATOR_CIRCLES_COLOR_NAME));
graphics.drawOval(0, 0, 15, 15);
// Cache the icon per alias
orderIcons.put(alias, orderIcon);
}
// Call when color settings change
@Override
public void onColorsChanged() {
reloadOrderIcon(alias);
InvalidateInterface invalidateInterface = invalidateInterfaceMap.get(INDICATOR_NAME_CIRCLES);
if (invalidateInterface != null) {
invalidateInterface.invalidate(); // Trigger redraw
}
}
Icon Caching Best Practicesβ
- Cache per alias: Different instruments may have different settings
- Use
synchronizedmap: Icons may be accessed from multiple threads - Invalidate on change: Call
InvalidateInterface.invalidate()to trigger redraw - Reasonable size: Keep icons small (16x16 to 32x32 typical)
Price Unit Conversionβ
Understanding pipsβ
The pips value from InstrumentInfo converts between:
- Level numbers (integer price levels used internally)
- Display prices (actual dollar/cent values shown to users)
// Store pips per instrument
private Map<String, Double> pipsMap = new ConcurrentHashMap<>();
@Override
public void onInstrumentAdded(String alias, InstrumentInfo instrumentInfo) {
pipsMap.put(alias, instrumentInfo.pips);
}
Conversion Rulesβ
| Context | Formula | Example |
|---|---|---|
| Level Γ’β β Display price | level * pips | 4000 * 0.25 = 1000.00 |
| Display price Γ’β β Level | price / pips | 1000.00 / 0.25 = 4000 |
GraphType and Price Unitsβ
| GraphType | markerY Units | Notes |
|---|---|---|
PRIMARY | Level number (price / pips) | Main heatmap overlay |
BOTTOM | Display price (raw value) | Bottom indicator panel |
Pattern: applyPips for Bottom Panelβ
public void applyPips(double pips) {
open *= pips;
low *= pips;
high *= pips;
close *= pips;
}
// Usage in calculateValuesInRange
@Override
public void calculateValuesInRange(String indicatorName, String indicatorAlias,
long t0, long intervalWidth, int intervalsNumber, CalculatedResultListener listener) {
String userName = indicatorsFullNameToUserName.get(indicatorName);
boolean isBottomChart = userName.equals(INDICATOR_NAME_BARS_BOTTOM);
Double pips = pipsMap.get(indicatorAlias);
// ... fetch data ...
for (int i = 1; i <= intervalsNumber; i++) {
BarEvent value = getBarEvent(result.get(i));
if (value != null) {
value = new BarEvent(value); // Clone before modifying!
value.setBodyWidthPx(bodyWidth);
if (isBottomChart) {
value.applyPips(pips); // Convert to display prices
}
listener.provideResponse(value);
} else {
listener.provideResponse(Double.NaN);
}
}
listener.setCompleted();
}
Multi-Instrument Supportβ
Always use a map to handle multiple instruments:
// Historical data retrieval
Double pips = pipsMap.getOrDefault(alias, 1.0);
double displayPrice = executionInfo.price / pips;
// Real-time callbacks
@Override
public void onOrderExecuted(ExecutionInfo executionInfo) {
String alias = orderIdToAlias.get(executionInfo.orderId);
if (alias != null && alias.equals(indicatorAlias)) {
Double pips = pipsMap.get(alias);
if (pips != null) {
listener.accept(new Marker(
executionInfo.price / pips, // Convert to level number
-orderIcon.getHeight() / 2,
-orderIcon.getWidth() / 2,
orderIcon
));
}
}
}
GraphType and Rendering Locationβ
GraphType.PRIMARYβ
Renders on the main heatmap chart overlay.
Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Main Chart Indicator")
.setIsAdd(true)
.setGraphType(GraphType.PRIMARY)
.setOnlineCalculatable(this)
.build();
Characteristics:
- Overlays the depth/trade heatmap
- Uses level numbers (price / pips) for Y coordinates
- Good for trade markers, price levels, overlaid candles
GraphType.BOTTOMβ
Renders in the bottom indicator panel.
Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Bottom Panel Indicator")
.setIsAdd(true)
.setGraphType(GraphType.BOTTOM)
.setOnlineCalculatable(this)
.build();
Characteristics:
- Displays below the main chart
- Uses raw price values for Y coordinates
- Has its own Y-axis scale
- Good for oscillators, volume, custom metrics
GraphType.NONEβ
No visual indicator graph (widget only).
Layer1ApiUserMessageModifyIndicator.builder(MyStrategy.class, "Widget Only")
.setIsAdd(true)
.setGraphType(GraphType.NONE)
.setIsSupportWidget(true)
.build();
Performance Considerationsβ
Icon Cachingβ
// BAD: Creating new icon for every trade
@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
BufferedImage icon = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB);
// ... draw icon ...
listener.accept(new Marker(price, 0, 0, icon)); // Memory leak!
}
// GOOD: Reuse cached icons
private BufferedImage tradeIcon; // Created once in constructor
@Override
public void onTrade(String alias, double price, int size, TradeInfo tradeInfo) {
listener.accept(new Marker(price, -8, -8, tradeIcon)); // Reuses icon
}
Dynamic Width Based on Zoomβ
For DataCoordinateMarker, adjust body width based on interval width:
private static final int MAX_BODY_WIDTH = 30;
private static final int MIN_BODY_WIDTH = 1;
private static final long CANDLE_INTERVAL_NS = TimeUnit.SECONDS.toNanos(30);
private int getBodyWidth(long intervalWidth) {
long bodyWidth = CANDLE_INTERVAL_NS / intervalWidth;
bodyWidth = Math.max(bodyWidth, MIN_BODY_WIDTH);
bodyWidth = Math.min(bodyWidth, MAX_BODY_WIDTH);
return (int) bodyWidth;
}
@Override
public void onIntervalWidth(long intervalWidth) {
this.bodyWidth = getBodyWidth(intervalWidth);
}
Avoiding Excessive Allocationsβ
From the Layer1ApiBarsDemo.java comment:
Note that caching and reusing markers would improve efficiency, but for simplicity we won't do that here. If you do decide to cache the icons - be mindful of the cache size.
Consider:
- LRU cache for dynamically generated icons
- Object pooling for frequently created markers
- Limiting marker count per visible range
Complete Example: Trade Execution Markersβ
@Layer1Attachable
@Layer1StrategyName("Execution Marker Demo")
@Layer1ApiVersion(Layer1ApiVersionValue.VERSION2)
public class ExecutionMarkerDemo implements
Layer1ApiFinishable,
Layer1ApiAdminAdapter,
Layer1ApiInstrumentListener,
OnlineCalculatable {
private static final String INDICATOR_NAME = "Executions";
private Layer1ApiProvider provider;
private DataStructureInterface dataStructureInterface;
private Map<String, Double> pipsMap = new ConcurrentHashMap<>();
private Map<String, String> indicatorsFullNameToUserName = new HashMap<>();
// Pre-created icons
private BufferedImage buyIcon;
private BufferedImage sellIcon;
public ExecutionMarkerDemo(Layer1ApiProvider provider) {
this.provider = provider;
ListenableHelper.addListeners(provider, this);
// Create buy icon (green up arrow)
buyIcon = createArrowIcon(Color.GREEN, true);
// Create sell icon (red down arrow)
sellIcon = createArrowIcon(Color.RED, false);
}
private BufferedImage createArrowIcon(Color color, boolean pointUp) {
BufferedImage icon = new BufferedImage(12, 12, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = icon.createGraphics();
g.setColor(color);
int[] xPoints = {6, 0, 12};
int[] yPoints = pointUp ? new int[]{0, 12, 12} : new int[]{12, 0, 0};
g.fillPolygon(xPoints, yPoints, 3);
g.dispose();
return icon;
}
@Override
public void onUserMessage(Object data) {
if (data instanceof UserMessageLayersChainCreatedTargeted) {
UserMessageLayersChainCreatedTargeted message = (UserMessageLayersChainCreatedTargeted) data;
if (message.targetClass == getClass()) {
// Request DataStructureInterface
provider.sendUserMessage(new Layer1ApiDataInterfaceRequestMessage(
dsi -> this.dataStructureInterface = dsi
));
// Register indicator
Layer1ApiUserMessageModifyIndicator msg = Layer1ApiUserMessageModifyIndicator
.builder(ExecutionMarkerDemo.class, INDICATOR_NAME)
.setIsAdd(true)
.setGraphType(GraphType.PRIMARY)
.setOnlineCalculatable(this)
.setIndicatorLineStyle(IndicatorLineStyle.NONE)
.build();
indicatorsFullNameToUserName.put(msg.fullName, msg.userName);
provider.sendUserMessage(msg);
}
}
}
@Override
public void onInstrumentAdded(String alias, InstrumentInfo instrumentInfo) {
pipsMap.put(alias, instrumentInfo.pips);
}
@Override
public void calculateValuesInRange(String indicatorName, String indicatorAlias,
long t0, long intervalWidth, int intervalsNumber, CalculatedResultListener listener) {
if (dataStructureInterface == null) {
listener.setCompleted();
return;
}
// Get order events for the range
ArrayList<TreeResponseInterval> intervals = dataStructureInterface.get(
t0, intervalWidth, intervalsNumber, indicatorAlias,
new StandardEvents[] { StandardEvents.ORDER }
);
Double pips = pipsMap.getOrDefault(indicatorAlias, 1.0);
for (int i = 1; i <= intervalsNumber; i++) {
OrderUpdatesExecutionsAggregationEvent orders =
(OrderUpdatesExecutionsAggregationEvent) intervals.get(i).events
.get(StandardEvents.ORDER.toString());
ArrayList<Marker> markers = new ArrayList<>();
for (Object obj : orders.orderUpdates) {
if (obj instanceof OrderExecutedEvent) {
OrderExecutedEvent exec = (OrderExecutedEvent) obj;
double priceLevel = exec.executionInfo.price / pips;
// Determine buy/sell from order info (simplified)
BufferedImage icon = exec.executionInfo.size > 0 ? buyIcon : sellIcon;
markers.add(new Marker(
priceLevel,
-icon.getWidth() / 2,
-icon.getHeight() / 2,
icon
));
}
}
listener.provideResponse(markers.isEmpty() ? Double.NaN : markers);
}
listener.setCompleted();
}
@Override
public OnlineValueCalculatorAdapter createOnlineValueCalculator(
String indicatorName, String indicatorAlias, long time,
Consumer<Object> listener, InvalidateInterface invalidateInterface) {
return new OnlineValueCalculatorAdapter() {
private Map<String, Boolean> orderIdToBuy = new HashMap<>();
@Override
public void onOrderUpdated(OrderInfoUpdate update) {
orderIdToBuy.put(update.orderId, update.isBuy);
}
@Override
public void onOrderExecuted(ExecutionInfo exec) {
Boolean isBuy = orderIdToBuy.get(exec.orderId);
if (isBuy == null) return;
Double pips = pipsMap.get(indicatorAlias);
if (pips == null) return;
BufferedImage icon = isBuy ? buyIcon : sellIcon;
listener.accept(new Marker(
exec.price / pips,
-icon.getWidth() / 2,
-icon.getHeight() / 2,
icon
));
}
};
}
@Override
public void finish() {
provider.sendUserMessage(new Layer1ApiUserMessageModifyIndicator(
ExecutionMarkerDemo.class, INDICATOR_NAME, false
));
}
// Required interface methods
@Override public void onInstrumentRemoved(String alias) {}
@Override public void onInstrumentNotFound(String symbol, String exchange, String type) {}
@Override public void onInstrumentAlreadySubscribed(String symbol, String exchange, String type) {}
}
Cross-Referencesβ
- OnlineCalculatable - Base interface for calculatable indicators
- OnlineCalculatable.Marker - Simple icon marker class
- OnlineCalculatable.DataCoordinateMarker - Interface for data-bound graphics
- Layer1ApiUserMessageModifyIndicator - Indicator registration message
- CustomEventGeneratorGuide - Creating custom events for visualization
- OnlineCalculatableImplementationGuide - Full implementation patterns