Canvas 기반의 주식 차트 제작기

Droomii·2024년 4월 4일
1

서론

투자 붐이 한창이었던 2021년에 취업을 하게 되면서 자연스럽게 주식 투자에도 관심을 갖게 되었고, 다양한 투자전략들 또한 많이 접하게 되었다.

그 중 2021년부터 Value Rebalancing이라는 퀀트투자전략을 따라서 지금까지 꾸준히 투자하고 있는데, 가끔씩 이게 맞을까 확신이 들지 않을 때가 있다. 퀀트 투자 고수들은 항상 말한다. ‘확신이 서지 않을 때마다 백테스팅을 하라’고… 하지만 수치상으로만 보면 크게 와닿지 않는 경우가 많다. 그래서, 차트상으로 한 눈에 시각적으로 볼 수 있으면 좋을 것 같다는 생각에 개발에 바로 착수했다.


만들어 보자!

일단 차트가 그려지기까지 필요한 요소들을 고민했다.

  • 차트에 표시될 데이터, 캔버스 상태(확대값, 표시 범위 등), 그리기를 관리할 최상위 객체
  • 차트에 그려지는 요소들(캔들, 선 등등)을 관리하는 객체
  • 요소들을 실제 그리는 로직을 담은 객체

최상위 객체 (ChartRoot)

최상위 객체에는 다음과 같은 요소들을 필요로 했다.

  • 데이터 배열 - 주식 데이터를 담고 있는 배열
  • 확대값 - 현재 얼마나 확대가 되어 있는지에 따라 표시되는 범위가 달라질 것
  • 시작 index - 어떤 시간의 데이터부터 표시할지에 대한 index. 맨 왼쪽의 데이터가 해당 index에 해당될 것이다.
  • 해당 객체의 데이터를 사용하는 시각화 요소 관리 객체(ChartController) 목록
    • ChartController를 추가하는 메서드 또한 필요할 것
class ChartRoot {
  zoom = 6;
  offset = 0;
  controllers: Set<ChartController> = new Set();
  private _data: IStockHistory[] = []

	register(controller: ChartController) {
    this.controllers.add(controller);
  }
}

시각화 요소 관리 객체 (ChartController)

ChartController는 시각화 요소들을 관리하는 객체이므로, 다음과 같은 속성이 필요하다.

  • 시각화 요소를 담고 있는 배열 - ChartController에서 draw() 메서드를 호출했을 때, 해당 배열에 있는 요소들이 순차적으로 그려질 것이다.
  • Canvas에 보이는 시계열의 데이터 수 - 해당 개수를 알고 있어야, 각 시각화 요소들의 데이터를 슬라이싱해서 일부만 그릴 수 있다.
class ChartController {
  private _debugName?: string;
  independentRange = false;
  elements: IDrawable[] = [];
  isLog = false; // 로그 범위로 표시
  readonly canvas: HTMLCanvasElement; // 그릴 캔버스 대상

	// 각종 이벤트 핸들러
  onMouseMove?: ((data: IMousePosData) => void) | null = null;
  onMouseOut?: ((data: IMousePosData) => void) | null = null;
  onMouseDown?: ((data: IMousePosData) => void) | null = null;

  constructor(
    public readonly root: ChartRoot,
    readonly ctx: CanvasRenderingContext2D, // 대상 Canvas의 2dContext
		options?: ControllerConstructorOptions) {

    root.register(this); // 인스턴스를 ChartRoot의 Controller 목록에 추가
  }

	get width() {
    return this.ctx.canvas.width;
  }

  get height() {
    return this.ctx.canvas.height;
  }

	// 현재 보이는 시계열의 데이터수
  get visibleDataCount() {
    return Math.floor(this.width / (this.zoom));
  }
}
  • independentRange - 차트에 요소를 그릴 때, 캔버스 상에 표시되는 데이터의 y범위에 영향을 받지 않아야 하는 경우를 위함

‘그릴 수 있는 객체’ 인터페이스(IDrawable) 정의

시각화 요소는 모두 공통된 ‘그리기’, ‘y값의 범위’, ‘독립적인 범위’를 필요로 한다. 해당 속성과 메서드가 있어야 정상적으로 차트에 그릴 수가 있다.

  • 각 시각적 요소들의 y값 범위를 종합하여 차트상의 최소/최대 y범위를 구하고, normalize 작업을 통해 차트에 그리기 때문에 꼭 필요한 항목이다.
interface IDrawable {
    draw(): void;
    independentRange?: boolean;
    range: { lowest: number, highest: number } | null;
}

시각화 요소의 추상 클래스(ChartElement) 정의

차트상에는 주가를 나타내는 Candle, 이동평균선을 나타내는 Line, 거래량을 나타내는 Bar 등 다양한 시각적 요소가 있다.

이 모든 요소들은 동일한 시간의 범위를 X축으로 가지고 있기 때문에, 해당 특성을 기준으로 추상화를 진행했다.

  • draw, range는 각 요소마다 방법이 다르므로 미구현 처리한다.
  • slicedData를 통해 현재 줌인 상태와 데이터 index를 고려한 일부 데이터를 얻고, 그 데이터를 기반으로 요소를 그린다.
abstract class ChartElement<T = IStockHistory> implements IDrawable {
    protected _data: T[] = [];

    constructor(
        protected readonly controller: ChartController,
				// 원하는 값의 배열로 변경하는 함수
        protected readonly convertFunc: (data: IStockHistory[]) => T[]
		) {
        controller.register(this); // 관리 항목으로 추가
        this.setData(); // convertFunc를 사용하여 원하는 값으로 변환한 배열 저장
    }

		// convertFunc를 사용하여 원하는 값으로 변환한 배열 저장
    setData() {
        this._data = this.convertFunc(this.controller.data);
    }

    protected get data() {
        return this._data;
    }
		
		// 요소마다 그리는 방법이 다르므로 미구현 처리
    abstract draw(): void;

    protected get zoom() {
        return this.controller.zoom;
    }

    protected get offset() {
        return this.controller.offset
    }

    protected get width() {
        return this.controller.width;
    }

    protected get height() {
        return this.controller.height;
    }

    protected get visibleDataCount() {
        return this.controller.visibleDataCount;
    }

    get slicedData() {
        return this.data.slice(this.offset, this.visibleDataCount + this.offset)
    }

    abstract get range(): { lowest: number, highest: number } | null;

    protected get isLog() {
        return this.controller.isLog;
    }
}

이 추상 클래스를 활용하여 선(Line) 클래스를 만들면 다음과 같아진다.

interface LineOptions {
    stroke?: string; // 선의 색상
    square?: boolean; // 계단식 선 여부
    excludeRange?: boolean; // 최대/최소 범위 산정에서 제외
}

class Line extends ChartElement<number> { // 숫자의 배열로 주식 데이터를 변환한다
    private _stroke = 'red';
    private _isSquare = false;
    private _excludeRange = false;

    constructor(controller: ChartController,
    convertFunc: (data: IStockHistory[]) => number[], options?: LineOptions) {
        super(controller, convertFunc)

        if (options) {
            options.stroke && (this._stroke = options.stroke);
            this._isSquare = options.square ?? false;
            this._excludeRange = options.excludeRange ?? false;
        }
    }

    setColor(color: string) {
        this._stroke = color;
    }

    draw() {
        const {ctx} = this;
        const {normalize} = this.controller;

				// 데이터 normalize
        const normalizedData = this.slicedData.map(normalize);

        ctx.beginPath();
        ctx.moveTo(0, this.height - normalizedData[0]);
        ctx.strokeStyle = this._stroke;

        normalizedData.forEach((v, i) => {
            if (this._isSquare) {
                ctx.lineTo(i * this.zoom, this.height - v);
                ctx.lineTo((i + 1) * this.zoom, this.height - v);
                return;
            }
            ctx.lineTo((i + 1) * this.zoom - Math.floor((this.zoom / 2)), this.height - v);
        })

        ctx.lineTo(this.width, this.height - (normalizedData.at(-1) ?? 0))
        ctx.stroke();
        ctx.closePath();
    }

    get range() {
        return this._excludeRange ? null : this.slicedData.reduce((acc, v) => {
            acc.highest = Math.max(acc.highest, v);
            acc.lowest = Math.min(acc.lowest, v);
            return acc;
        }, {highest: Number.MIN_VALUE, lowest: Number.MAX_VALUE})
    }
}

잠깐! 데이터를 Normalize 하는 이유?

거래량을 예로 들면, 몇백만이 되는 거래량 값을 차트상에 표시하기 위해서는 그 몇백만의 값을 알맞은 픽셀 개수로 줄이는 작업이 필요하다.

반대로 가격이 0.5달러인 주식을 표시하기 위해서는 잘 보이게 늘려주는 작업이 필요하다.

ChartController는 updateRange, updateNormalizer 메서드를 통해 관리하고 있는 요소의 범위가 적용된 normalizer 함수를 계속 최신 상태로 유지해주어야 한다.

readonly range: { highest: number, lowest: number } = {highest: Number.MAX_VALUE, lowest: Number.MIN_VALUE};

protected updateRange() {
  const {highest, lowest} = this.elements.reduce((acc, {range, independentRange}) => {
    if (!range || independentRange) return acc;
    const {highest, lowest} = range;
    acc.highest = Math.max(acc.highest, highest, lowest);
    acc.lowest = Math.min(acc.lowest, highest, lowest);
    return acc;
  }, {highest: Number.MIN_VALUE, lowest: Number.MAX_VALUE});
  this.range.highest = highest;
  this.range.lowest = lowest;
}

private updateNormalizer() {
  const {highest, lowest} = this.range;
  const {multiplier} = this;
  this.normalize = (val: number) => Math.floor(Util.normalize(multiplier(val), multiplier(lowest * 0.9), this.multiplier(highest / 0.9)) * this.height);
}

시각화 요소 추가 메서드 구현

일련의 과정을 거쳐 원하는 시각화 요소를 구현했다면, 해당 요소들을 Controller에 넣어주는 과정을 거쳐야 한다.

개발한 대로라면 아래의 코드로도 정상적으로 Controller에 주입이 된다.

// Root
const root = new ChartRoot();

// Controller를 Root에 register
const ctrl = new ChartController(root, ctx);
// 시각화 요소를 Controller에 register
new Line(ctrl, (data) => {blablabla})

하지만 매번 시각화 요소를 new 생성자로 만들고, 상위 요소를 지정해주는 등의 불편함이 걸렸고, 요소를 단독적으로 사용할 일이 거의 없어서 굳이 외부에서 생성해서 추가할 필요가 있을까? 라는 생각이 들었다.

그래서 document.createElement 과 비슷한 형태로, 상위 요소 내부에서 하위 요소를 생성하는 방식으로 코드를 변경했다.

  • ChartRoot에 ChartController를 추가하는 것도 동일한 방법으로 개선했다.
const ElementMap = {
  line: Line,
  area: Area,
  candle: Candle,
  lineArea: LineArea,
  stockSplit: Split,
  timeGrid: TimeGrid,
  yTick: YTick,
} as const;

class ChartController implements IDrawable {
	...
  addElement<Element extends keyof typeof ElementMap>(element: Element, ...options: ElementConstructorParams<typeof ElementMap[Element]>): InstanceType<typeof ElementMap[Element]> {
    return new ElementMap[element](this, ...(options as [any])) as InstanceType<typeof ElementMap[Element]>;
  }
}

이렇게 addElement 메서드를 구현해주고, ChartRoot에서도 동일하게 addController를 만들면 아래와 같이 간편하게 요소 추가가 가능하다.

// Root
const root = new ChartRoot();

// Controller를 Root에 register
const ctrl = root.addController(ctx);
// 시각화 요소를 Controller에 register
ctrl.addElement('line', (data) => {blablabla})

Draw 메서드 구현

캔버스에 요소를 그리는 명령은 ChartRoot → ChartController → ChartElement 순으로 진행된다.

그리게 하는 방법은 간단하다. 각 객체가 관리하고 있는 모든 하위 요소들에 그리기 명령을 내리면 된다.

class ChartRoot {
	...
	readonly refresh = () => {
    this.controllers.forEach(v => v.refresh());
  }
}

class ChartController implements IDrawable {
	...
	refresh() {
		// 캔버스 백지화
    this.clear();
		// 확대, 오프셋 상태 최신화
    this.zoom = Math.min(Math.max(this.width / this.data.length, this.zoom), this.width);
    this.offset = Math.max(0, Math.min(this.offset, this.data.length - this.visibleDataCount))
		// 그리기
    this.draw();
  }

	draw() {
		// canvas에서 범위가 벗어나지 않도록 재조정
    this.fitToContainer();

		// 최소/최대 y값 최신화
    this.updateRange();

		// normalizer 최신화
    this.updateNormalizer();
		
		// 관리중인 요소에 그리기 명령
    this.elements.forEach(v => v.draw());
  }
}

시각화를 위한 모든 준비는 완료되었다. 직접 canvas에 물려보자.

const root = new ChartRoot();
const data = [...some stock data]

const ChartMain = () => {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const {current: canvas} = ref;
    if (!canvas) return;

    const wrapper = canvas.parentElement;
    if (!wrapper) return;

    // get the context for the canvas
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const chartCtrl = root.addController(ctx, {log: true, debug: 'main'});

    root.loadData(data, () => {
      chartCtrl.addElement('timeGrid', {unit: 'year'})
      chartCtrl.addElement('yTick');
      chartCtrl.addElement('candle', {
        riseBoxColor: "rgba(203,104,105,0.47)",
        riseWickColor: "rgba(229,26,28,0.45)",
        fallBoxColor: "rgba(15,124,196,0.44)",
        fallWickColor: "rgba(33,140,211,0.42)",
      })
    });

    return root.cleanup;
  }, [])

  return <div className={styles.wrapper}>
    <canvas ref={ref}/>
  </div>
}

캔들 차트, 눈금, 시간 그리드가 아주 예쁘게 성공적으로 그려졌다.

마우스 인터랙션 구현

이제 차트를 드래그해서 범위를 좌우로 옮기고, 휠을 돌려서 줌인/아웃 기능을 구현해야 한다.

드래그를 구현하기 위해서는, 맨 처음 마우스를 누른 위치를 기억하고 있어야 하듯이, 차트의 offset 또한 기억하고 있어야 한다.

따라서 ChartController에 원래 offset을 기준으로 하여 좌우로 움직일 수 있게 해주는 handler를 반환하는 메서드를 추가해준다.

  • inertia 변수는 마우스를 움직이다가 떼었을 때 관성으로 움직이게 해보려고 했는데, 생각보다 잘 되지 않는다.. 이거는 더 연구해 봐야겠다.
class ChartController {
	...
	getOffsetSetter = () => {
    const originalOffset = this.offset;
    let lastOffset = this.offset;
    return (val: number, inertia?: boolean) => {
      const newOffset = Math.min(Math.max(originalOffset + Math.floor(val / this.zoom), 0), this.data.length - this.visibleDataCount);
      if (newOffset < 0 && inertia) return true;
      if (lastOffset === newOffset) return !!inertia;
      this.offset = newOffset;
      lastOffset = newOffset;
      return !inertia;
    }
  }
}

그리고 각종 삽질을 하면서 자연스러운 드래그가 되게 튜닝해준다.

function addChartEventListener(ctrl: ChartController) {
  const {root, canvas} = ctrl;

  let isMouseDown = false;

  // mouse event handler
  const mouseDownHandler = (e: MouseEvent) => {
    isMouseDown = true;

		// 원래 offset을 기억하고 있는 핸들러
    const handleChangeOffset = ctrl.getOffsetSetter();
    const startX = e.x;
    let movementX = 0;
    let lastX = 0;
    let isMoving = false;

    const moveDecay = () => {
      if (Math.abs(movementX) < 5) {
        movementX = 0;
      } else {
        movementX /= 1.2;
      }

      if (isMouseDown) {
				// 과도한 이벤트 호출 제한
        requestAnimationFrame(moveDecay);
      }
    }

    moveDecay();
    const moveHandler = (e: MouseEvent) => {
      if (isMoving) {
        return;
      }
      isMoving = true;
      const changed = handleChangeOffset(startX - e.x)
      movementX = (movementX + e.movementX);
      lastX = e.x;
      changed && root.refresh();
      requestAnimationFrame(() => {
        isMoving = false;
      })
    }

    canvas.addEventListener('mousemove', moveHandler);
    window.addEventListener('mouseup', () => {

      isMouseDown = false;
      canvas.removeEventListener('mousemove', moveHandler)
      let stop = false;
      window.addEventListener('mousedown', (e) => {
        stop = true;
      }, {once: true})

      const inertiaHandler = () => {
        if (Math.abs(movementX) >= 1 && !stop) {
          lastX += movementX
          stop = handleChangeOffset(startX - lastX + Math.floor(movementX), true)
          movementX += movementX > 0 ? -1 : 1;
          root.refresh()
          requestAnimationFrame(inertiaHandler)
        }
      }
      inertiaHandler()
    }, {once: true})
  }

  canvas.addEventListener('mousedown', mouseDownHandler)

	// 윈도우 사이즈 변경에 캔버스 새로고침
  window.addEventListener('resize', root.refresh);

  let moveThrottle = false;
  const mouseMoveHandler = (e: MouseEvent) => {
    if (moveThrottle) return;
    moveThrottle = true;
		
		// 과도한 이벤트 호출 제한
    requestAnimationFrame(() => moveThrottle = false)
  }

  canvas.addEventListener('mousemove', mouseMoveHandler)

	// eventListener 정리해주는 콜백 함수
	// Controller가 destroy 될 때 함께 호출되게 만든다.
  return () => {
    window.removeEventListener('resize', root.refresh);
    canvas.removeEventListener('mousedown', mouseDownHandler)
    canvas.removeEventListener('mousemove', mouseMoveHandler)
  }
}

다음은 확대/축소 기능인데, 이 기능의 경우 마우스 포인터의 x값을 축으로 하여 확대/축소가 되어야 한다. 즉 마우스 밑의 데이터는 확대나 축소가 되어도 항상 그 마우스 밑에 고정되어 있어야 한다.

해당 로직을 ChartController상에 구현해 준다.

class ChartController {
	...
	handleZoom(val: number, x: number) {
    const rolledPos = Math.floor(x / this.zoom);
    this.zoom = Math.min(Math.max(this.width / this.data.length, this.zoom * (1 + (val > 0 ? -0.1 : 0.1))), this.width);
    const newPos = Math.floor(x / this.zoom);
    const posDiff = rolledPos - newPos;
    this.offset = Math.max(0, Math.min(this.offset + posDiff, this.data.length - this.visibleDataCount))
  }
}

이 메서드를 활용하여 핸들러를 만들면 다음과 같이 될 것이다.

const wheelHandler = (e: WheelEvent) => {
    const oldZoom = ctrl.zoom
    ctrl.handleZoom(e.deltaY, e.x - canvas.getBoundingClientRect().left);
    if (ctrl.zoom !== oldZoom) {
      root.refresh();
    }
  }

그리고 ChartController가 생성될 때 함께 이벤트 리스너를 추가해준다.

constructor(
    public readonly root: ChartRoot,
    readonly ctx: CanvasRenderingContext2D, options?: ControllerConstructorOptions) {
    this.canvas = ctx.canvas;

    if (options) {
      this.isLog = options.log ?? false;
      this._debugName = options.debug
    }

    if (options?.isSub) {
      this.independentRange = true;
      return;
    }

    root.register(this);

		// 이벤트 리스너 추가
		// cleanup 콜백을 저장해두었다가 destroy 될 때 함께 호출한다.
    this.cleanup = addChartEventListener(this);
  }

원하는 대로 너무 예쁘게 동작한다!

적용

해당 차트를 활용하여, 목표로 하던 투자전략의 시각화를 완성하였다.

https://droomii.github.io/vr-simul/


확실히 시각적으로 보니 한 눈에 이해가 가고 좋은 것 같다.

제작한 주식 차트 모듈은 추후 라이브러리화 해서 다른 투자 프로젝트에 두고두고 써야겠다.

profile
What, How 이전에 Why를 고민하는 개발자입니다.

0개의 댓글

관련 채용 정보