Skip to content

지표·툴을 패키지로 짓는다

@finchart/indicators@finchart/tools코어 커밋 0건으로 지어졌다 — 지표 11종, 드로잉 툴 하나가 전부 코어가 이미 공개한 계약 위에서 만들어졌다. 이 페이지는 그 계약을 보여준다. 서드파티가 같은 자리에서 시작할 수 있게.

플러그인 — 함수 하나, dispose 하나

ts
type Plugin<Host, Api extends PluginApi = PluginApi> = (host: Host) => Api;
interface PluginApi {
  dispose(): void;
  readonly disposed: boolean;
}
ts
const api = pane.use(drawingTools({ plot })); // 설치
api.dispose(); // 해제 — 배선 전부가 한 번에

plot.use()(무대에 매달린다)와 pane.use()(pane에 매달린다) 둘 다 있다 — 기준은 무엇에 매달리는가다. pane을 새로 만들어야 하는 확장(MACD처럼 own pane을 쓰는 지표)은 PaneHost가 필요해 무대의 것이고, 이미 있는 pane에 얹기만 하는 확장(이동평균·볼린저)은 그 pane의 것이다. pane을 떼면 거기 매단 확장도 같이 정리된다.

disposed는 선택이 아니다

ts
interface PluginApi {
  dispose(): void;
  readonly disposed: boolean; // 필수
}

무대가 죽은 플러그인을 놓아주려면, 그리고 뗀 플러그인에 대한 호출을 플러그인 스스로 막으려면 이 플래그가 밖에서 보여야 한다. 직접 구현하지 않고 헬퍼 둘을 쓴다.

ts
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하므로 컴파일러가 짝을 지킨다.

확장요구하는 능력
crosshairDecorationHost & RenderRequester & PlotEventSource
legendOverlayHost & PaneHost & PlotEventSource
tooltipOverlayHost & PlotEventSource & FormatSource
syncX (둘)PlotEventSource & ViewportControl
@finchart/indicatorsPaneHost(대부분은 더 좁은 SeriesHost)
@finchart/toolspane 쪽 PaneDecorationHost & ValueCoordinates & DataProbe, 무대는 옵션으로 — RenderRequester & InputHost & XCoordinates & CursorHost & FocusAreaHost

좁을수록 좋은 이유는 테스트로 드러난다 — Plot 전체가 아니라 최소 가짜 호스트로 플러그인을 돌려 보면, 실제로 쓰지 않는 능력까지 요구하고 있었는지 바로 잡힌다. PlotContext 같은 통짜 컨텍스트 타입은 만들지 않는다 — 만들면 모든 확장이 거기로 모이고, 무엇 하나를 바꿔도 전부 깨지는 단일 결합점이 된다.

계산 노드 — 한 번 계산해서 여러 그림을 먹인다

MACD는 선이 하나가 아니라 셋(macd·signal·histogram)이다. 갈래마다 따로 계산하면 같은 EMA를 세 번 돈다. computation은 입력도 출력도 으로 받는다 — 이름으로 참조하지 않는다.

ts
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/indicatorsrsi()·macd()처럼 attach 없는 이름이 바로 이 계산 노드를 반환한다. 이름 규칙 전체는 이름의 문법에 있다.

재설정 — 뗐다 붙이지 않는다

옵션 하나를 바꾸겠다고 플러그인을 껐다 켜면, 드로잉 툴처럼 상태(그린 선)를 가진 확장은 그 상태를 전부 잃는다. use가 돌려주는 API에 재설정 메서드를 자기 것으로 얹으면 이 문제가 안 생긴다 — crosshair·tooltip· legendapplyOptions가 그 모양이다. 코어가 강제하는 이름은 없다 — 확장 작성자가 자기 API에 맞는 이름을 고른다.

모든 확장이 그래야 하는 것은 아니다. 지표는 일부러 반대로 갔다: attachRsi가 돌려주는 것은 { node, pane } + PluginApi뿐이고 period 세터가 없다. 파라미터를 바꾸는 길은 재설치다 — dispose() 하고 새 옵션으로 다시 use() 한다(@finchart/indicators의 README가 같은 말을 한다). 계산 노드가 값이라 다시 만드는 것이 싸고, 상태가 없어서 잃을 것도 없기 때문이다. 이 문단이 오래 attachRsiperiod 갱신을 예로 들었는데 그런 API는 없었다 — 하필 재설정 패턴이 필요 없는 쪽을 예로 든 셈이다.

최소 예제

@finchart/indicators의 실제 소스가 이 계약이 얼마나 작은지 보여준다 — attachRsi는 계산 노드 하나(rsi()) + 시리즈 등록 하나 + own pane 배선이 전부다. 전체 소스는 API Reference의 attachRsi에서 GitHub 링크로 바로 열린다.

ts
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();
    });
  };
}