지표·툴을 패키지로 짓는다
@finchart/indicators와 @finchart/tools는 코어 커밋 0건으로 지어졌다 — 지표 11종, 드로잉 툴 하나가 전부 코어가 이미 공개한 계약 위에서 만들어졌다. 이 페이지는 그 계약을 보여준다. 서드파티가 같은 자리에서 시작할 수 있게.
플러그인 — 함수 하나, dispose 하나
type Plugin<Host, Api extends PluginApi = PluginApi> = (host: Host) => Api;
interface PluginApi {
dispose(): void;
readonly disposed: boolean;
}const api = pane.use(drawingTools({ plot })); // 설치
api.dispose(); // 해제 — 배선 전부가 한 번에plot.use()(무대에 매달린다)와 pane.use()(pane에 매달린다) 둘 다 있다 — 기준은 무엇에 매달리는가다. pane을 새로 만들어야 하는 확장(MACD처럼 own pane을 쓰는 지표)은 PaneHost가 필요해 무대의 것이고, 이미 있는 pane에 얹기만 하는 확장(이동평균·볼린저)은 그 pane의 것이다. pane을 떼면 거기 매단 확장도 같이 정리된다.
disposed는 선택이 아니다
interface PluginApi {
dispose(): void;
readonly disposed: boolean; // 필수
}무대가 죽은 플러그인을 놓아주려면, 그리고 뗀 플러그인에 대한 호출을 플러그인 스스로 막으려면 이 플래그가 밖에서 보여야 한다. 직접 구현하지 않고 헬퍼 둘을 쓴다.
import { pluginApi, teardown } from "@finchart/core";
// 해제만 있는 API
return teardown(() => {
handle.dispose();
unsubscribe();
});
// 자기 메서드를 가진 API
return pluginApi({ node, pane: paneApi }, () => {
handle.dispose();
disposeOwned();
});스프레드로 합치지 않는다 ({ ...teardown(fn), node }). 스프레드는 disposed 접근자를 그 순간의 값(false)으로 복사해 버려서 정리한 뒤에도 영영 살아 있다고 답하는 API가 만들어진다 — 무대의 청소도 플러그인 자신의 가드도 그 거짓말 위에서 돈다.
능력 인터페이스 — 필요한 만큼만 요구한다
플러그인 시그니처는 Plugin<Plot>이 아니라 자기가 실제로 쓰는 능력만 든 타입으로 좁힌다. Plot이 전부 implements하므로 컴파일러가 짝을 지킨다.
| 확장 | 요구하는 능력 |
|---|---|
crosshair | DecorationHost & RenderRequester & PlotEventSource |
legend | OverlayHost & PaneHost & PlotEventSource |
tooltip | OverlayHost & PlotEventSource & FormatSource |
syncX (둘) | PlotEventSource & ViewportControl |
@finchart/indicators | PaneHost(대부분은 더 좁은 SeriesHost) |
@finchart/tools | pane 쪽 PaneDecorationHost & ValueCoordinates & DataProbe, 무대는 옵션으로 — RenderRequester & InputHost & XCoordinates & CursorHost & FocusAreaHost |
좁을수록 좋은 이유는 테스트로 드러난다 — Plot 전체가 아니라 최소 가짜 호스트로 플러그인을 돌려 보면, 실제로 쓰지 않는 능력까지 요구하고 있었는지 바로 잡힌다. PlotContext 같은 통짜 컨텍스트 타입은 만들지 않는다 — 만들면 모든 확장이 거기로 모이고, 무엇 하나를 바꿔도 전부 깨지는 단일 결합점이 된다.
계산 노드 — 한 번 계산해서 여러 그림을 먹인다
MACD는 선이 하나가 아니라 셋(macd·signal·histogram)이다. 갈래마다 따로 계산하면 같은 EMA를 세 번 돈다. computation은 입력도 출력도 값으로 받는다 — 이름으로 참조하지 않는다.
import { computation } from "@finchart/core";
const price = pane.addSeries({ series: candleSeries(), data: candles });
const macd = computation({
inputs: [price], // 값이다 — 문자열 id가 아니다
calc: (candles) => ({ macd, signal, histogram }), // 갈래 이름은 반환 타입에서
});
lower.addSeries({ series: lineSeries(), input: macd.out.signal });
lower.addSeries({ series: lineSeries(), input: macd.out.histogram });값 참조가 주는 것: 오타(macd.out.signl)가 컴파일 에러가 되고, 없는 것을 참조할 수 없으니 순환이 구조적으로 불가능하다. 대가는 직렬화가 안 된다는 것인데 — 저장할 상태는 보는 상태지 "무엇을 그리는가"가 아니므로 이 대가는 애초에 지지 않아도 된다.
@finchart/indicators의 rsi()·macd()처럼 attach 없는 이름이 바로 이 계산 노드를 반환한다. 이름 규칙 전체는 이름의 문법에 있다.
재설정 — 뗐다 붙이지 않는다
옵션 하나를 바꾸겠다고 플러그인을 껐다 켜면, 드로잉 툴처럼 상태(그린 선)를 가진 확장은 그 상태를 전부 잃는다. use가 돌려주는 API에 재설정 메서드를 자기 것으로 얹으면 이 문제가 안 생긴다 — crosshair·tooltip· legend의 applyOptions가 그 모양이다. 코어가 강제하는 이름은 없다 — 확장 작성자가 자기 API에 맞는 이름을 고른다.
모든 확장이 그래야 하는 것은 아니다. 지표는 일부러 반대로 갔다: attachRsi가 돌려주는 것은 { node, pane } + PluginApi뿐이고 period 세터가 없다. 파라미터를 바꾸는 길은 재설치다 — dispose() 하고 새 옵션으로 다시 use() 한다(@finchart/indicators의 README가 같은 말을 한다). 계산 노드가 값이라 다시 만드는 것이 싸고, 상태가 없어서 잃을 것도 없기 때문이다. 이 문단이 오래 attachRsi의 period 갱신을 예로 들었는데 그런 API는 없었다 — 하필 재설정 패턴이 필요 없는 쪽을 예로 든 셈이다.
최소 예제
@finchart/indicators의 실제 소스가 이 계약이 얼마나 작은지 보여준다 — attachRsi는 계산 노드 하나(rsi()) + 시리즈 등록 하나 + own pane 배선이 전부다. 전체 소스는 API Reference의 attachRsi에서 GitHub 링크로 바로 열린다.
export function attachRsi(
options: AttachRsiOptions,
): Plugin<PaneHost, OwnedPaneIndicatorApi<Rsi>> {
return (plot) => {
const node = rsi(options.source, { period: options.period });
const color = options.color ?? PRIMARY_COLOR;
const { pane, ownedPaneApi, disposeOwned } = ownedPane(plot, options, (owned) =>
wireOscillatorPane(owned, options.levels, { overbought: 70, oversold: 30 }),
);
const handle = pane.addSeries({
series: lineSeries(overlayStyle(color)),
input: node.out.rsi,
name: `RSI(${options.period ?? RSI_DEFAULTS.period})`,
color,
});
return pluginApi({ node, pane: ownedPaneApi }, () => {
handle.dispose();
disposeOwned();
});
};
}