Skip to content

♻️ core 지표 nullable 입력 계약 도입#15

Merged
mingi3314 merged 8 commits intomainfrom
core-nullable-foundations
Apr 27, 2026
Merged

♻️ core 지표 nullable 입력 계약 도입#15
mingi3314 merged 8 commits intomainfrom
core-nullable-foundations

Conversation

@mingi3314
Copy link
Copy Markdown
Collaborator

@mingi3314 mingi3314 commented Apr 21, 2026

요약

  • 이 PR은 techr-core의 nullable 입력 마이그레이션을 시작하는 bottom PR입니다.
  • 공개 indicator API의 결측치 계약을 NaN이 아닌 &[Option<f64>] aligned series로 정리하고, 이후 stacked PR인 #16, #17이 이 규칙 위에서 stateful/composite 지표를 순차적으로 올립니다.
  • 범위는 foundation helper와 단일 시계열 기반 지표 중심이며, plugin/polars의 실제 nullable adapter 마이그레이션은 이번 스택에서 제외합니다.

배경

  • 기존 core 함수들은 대부분 dense &[f64] 입력을 전제하고 있었습니다.
  • 하지만 실제 시계열 처리에서는 결측이 섞인 aligned series를 직접 받을 수 있어야 하고, 표준 라이브러리 성격의 API라면 missing을 NaN이 아니라 명시적인 타입으로 다루는 편이 계약이 더 분명합니다.
  • 이 스택의 목표는 techr-core 전체에서 nullable aligned input을 표준 계약으로 만들고, gap을 건너뛰며 series를 압축하지 않는 일관된 의미론을 정착시키는 것입니다.

이 PR에서 하는 일

  • core/src/utils.rs에 nullable rolling/helper primitive를 추가해 foundation semantics를 먼저 고정합니다.
  • sma, ema, wma, bband, env, disparity&[Option<f64>] 입력 기준으로 전환합니다.
  • foundation helper를 사용하는 일부 downstream indicator의 public 시그니처도 같은 계약에 맞게 정렬합니다 (macd, ppo, pvo, massi, sonar 등).
  • 공개 API의 결측치 표현으로 NaN을 새로 도입하지 않도록 정리합니다.

핵심 규칙

  • 출력 길이는 입력 정렬을 그대로 유지합니다.
  • rolling 계산은 필요한 lookback window 안에 gap이 있으면 해당 row를 None으로 반환합니다.
  • recursive smoothing은 gap row에서 output만 None이고 내부 상태는 유지합니다.
  • valid 값만 따로 압축해서 계산한 뒤 다시 원래 위치에 펴는 방식을 public contract로 사용하지 않습니다.

범위 외

  • plugin/polars nullable adapter 마이그레이션은 후속 작업으로 미룹니다.
  • 다만 현재 CI에서 Polars CI가 core-only PR에도 실행되기 때문에, 이 PR에는 non-polars 변경에서 validate를 성공적인 no-op으로 처리하도록 하는 최소한의 workflow 조정이 함께 포함됩니다.
  • 이 workflow 변경은 core nullable 전환 자체의 기능 스코프가 아니라, deferred plugin migration이 현재 스택을 막지 않도록 하는 CI 보정입니다.

리뷰 가이드

  • 먼저 core/src/utils.rs, sma.rs, ema.rs, wma.rs, bband.rs, disparity.rs를 봐주시면 됩니다.
  • 그 다음 downstream 시그니처 정렬과 .github/workflows/polars-ci.yml 변경을 확인하시면 충분합니다.
  • 이 PR만 main 기준으로 리뷰하면 되고, 상위 PR인 #16, #17은 각각 이 PR을 base로 봐주시면 됩니다.

테스트 계획

  • cargo fmt --package techr-core --check
  • cargo test -p techr-core
  • GitHub Actions Polars CI가 non-polars 변경에서 성공적으로 통과하는지 확인

Copy link
Copy Markdown
Collaborator Author

mingi3314 commented Apr 21, 2026

@mingi3314 mingi3314 marked this pull request as draft April 23, 2026 01:27
@mingi3314 mingi3314 marked this pull request as ready for review April 23, 2026 01:27
@mingi3314 mingi3314 changed the title Support nullable core foundation indicators ♻️ core 지표 nullable 입력 계약 도입 Apr 23, 2026
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 당장은 polars plugin쪽에서 non-nullable을 막고 있어서, core쪽만 nullable을 허용하게 되면 CI가 깨지게 됩니다.
그래서 일단 core 작업하는 동안은 임시로 비활성화 해두고, 그 다음 작업으로 바로 plugin쪽으로 넘어가서, 이 파일 변경사항을 되돌리고 작업 재개하려고 합니다

@mingi3314 mingi3314 requested a review from sjquant April 23, 2026 08:57
@mingi3314 mingi3314 self-assigned this Apr 23, 2026
@alphaprime-dev-discord
Copy link
Copy Markdown

Comment thread core/src/indicators/ema.rs Outdated
Comment on lines +46 to +58
/// Computes an exponential moving average over an aligned nullable series.
///
/// The returned vector keeps the same length as the input and emits `None`
/// until the first contiguous run of `period` valid observations has been
/// observed. Once seeded, gaps emit `None` without resetting the EMA state.
pub fn ema(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
ema_impl(data, period)
}

pub(crate) fn ema_aligned(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
ema_impl(data, period)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

똑같아서 ema하나만 남겨도 될거 같네요.

Copy link
Copy Markdown
Collaborator Author

@mingi3314 mingi3314 Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀 주신 방향으로 정리했습니다. 8847f9d 에서 중복 구현은 제거하고 pub(crate) use ema as ema_aligned; alias만 남겨 internal call-site semantics만 유지했습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가로, #17 에서 alias 용도로만 사용되는 얇은 래퍼들을 다 정리하도록 처리해뒀습니다!
40c19a9
801ccdc

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alias도 없어도 될거 같아요

Copy link
Copy Markdown
Collaborator Author

@mingi3314 mingi3314 Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

40c19a9
801ccdc

요기서 alias까지 다 없애뒀어요! (#17에서 한 번에 처리해뒀습니다)

Comment thread core/src/indicators/env.rs Outdated
Comment on lines +36 to +39
let input = testutils::load_data(&format!("../data/{}.json", symbol), "c")
.into_iter()
.map(Some)
.collect::<Vec<_>>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

load_data마다 이 과정을 해주지 않고 load_data의 함수를 바꾸면 좋을거 같네요.

Copy link
Copy Markdown
Collaborator Author

@mingi3314 mingi3314 Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 dense fixture loader는 다른 테스트들과의 호환성 때문에 그대로 유지하고, testutils::load_data_nullable() helper를 추가해서, 이번 nullable 테스트들에서 반복되던 map(Some)를 걷어내도록 처리했습니다! (8847f9d)

Comment thread core/src/indicators/sma.rs Outdated
Comment on lines 14 to 22
/// The returned vector keeps the same length as the input and emits `None`
/// until the first full `period` window has been observed.
pub fn sma(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
sma_impl(data, period)
}

result
pub(crate) fn sma_aligned(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
sma_impl(data, period)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 하나만 남기면 될거 같습니다.

Copy link
Copy Markdown
Collaborator Author

@mingi3314 mingi3314 Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위 코멘트(#15 (comment)) 에서 함께 처리해뒀습니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가로 후속 stack에서 sma_dense가 실제로 dead가 된 뒤에는 제거까지 반영했습니다. #15 범위에서는 dense EOM 경로가 아직 있어서 유지가 필요했지만, 그 경로가 정리된 다음 #16 head b00b2b2와 top stack head 21c77cd에서 sma_dense를 삭제했고 cargo test -p techr-core도 다시 통과했습니다.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pub(crate) use sma as sma_aligned;

이런 alias도 없이 그냥 sma를 외부에서 써도 될 거 같습니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#15 (comment) 요 답변이랑 겹치는 내용이라 멘션드립니다~!

Comment thread core/src/utils.rs Outdated
Comment on lines +156 to +196
/// Computes a rolling mean that only emits a value when the full window contains valid values.
pub fn rolling_mean_strict(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
rolling_mean_strict_impl(data, period)
}

/// Computes a rolling weighted mean that only emits a value when the full window is valid.
fn rolling_weighted_mean_strict_impl(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
let len = data.len();
let mut result = vec![None; len];

if len < period || period == 0 {
return result;
}

let weight_sum = (period * (period + 1)) as f64 / 2.0;

for end in (period - 1)..len {
let start = end + 1 - period;
let mut weighted_sum = 0.0;
let mut valid = true;

for (offset, item) in data[start..=end].iter().enumerate() {
let Some(value) = *item else {
valid = false;
break;
};
weighted_sum += value * (offset + 1) as f64;
}

if valid {
result[end] = Some(weighted_sum / weight_sum);
}
}

result
}

/// Computes a rolling weighted mean that only emits a value when the full window is valid.
pub fn rolling_weighted_mean_strict(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
rolling_weighted_mean_strict_impl(data, period)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sma (sma_aligend)와 차이가 있는지 궁금합니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금은 behavioral difference가 없습니다. 실제 구현은 rolling_mean_strict이고, sma/sma_aligned는 indicator API layer였는데, 최신 head(8847f9d)에서는 sma_aligned도 alias로 정리해서 같은 경로만 타도록 맞췄습니다.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sma와 구현이 같다면 해당 코드 없이 sma를 다른 곳에서 쓰면 어떨까요?
ema나 일부 sma는 그렇게 사용되고 있는거 같아서요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 #15 (comment) 여기서 처리해뒀습니다~!

@sjquant
Copy link
Copy Markdown
Collaborator

sjquant commented Apr 24, 2026

wma가 2배정도 느려져서 한번 개선해보면 좋을듯 합니다.

image

rolling_weighted_mean_strict를 O(n * period)에서 O(n) rolling update로 바꿀 수 있을거 같다고하네요.

@mingi3314
Copy link
Copy Markdown
Collaborator Author

WMA 성능 코멘트도 반영했습니다. 최신 head(8847f9d)에서 rolling_weighted_mean_strict를 full-window 재스캔 방식에서 rolling update 방식으로 바꿔서, nullable strict window semantics는 유지하면서 매 스텝 window 전체를 다시 훑지 않도록 정리했습니다.

Copy link
Copy Markdown
Collaborator

@sjquant sjquant left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!

Copy link
Copy Markdown
Collaborator Author

mingi3314 commented Apr 27, 2026

Merge activity

  • Apr 27, 2:16 AM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Apr 27, 2:16 AM UTC: @mingi3314 merged this pull request with Graphite.

@mingi3314 mingi3314 merged commit 1c802ba into main Apr 27, 2026
5 checks passed
mingi3314 added a commit that referenced this pull request Apr 27, 2026
## 요약
- 이 PR은 `#15` 위에 쌓인 stacked PR입니다. 리뷰는 `core-nullable-foundations` 대비로 봐주시면 됩니다.
- foundation layer에서 정의한 nullable contract를 상태형, pairwise, cumulative 지표에 적용합니다.
- gap을 건너뛰며 series를 압축하지 않고, 현재 row, 이전 row, 내부 state의 유효성에 따라 `None`을 반환하는 규칙을 맞춥니다.

## 변경 사항
- `rsi`, `atr`, `dmi`, `adx`, `adxr`를 nullable 입력 기준으로 전환합니다.
- `ad`, `cmf`, `co`, `efi`, `eom`, `nvi`, `obv`, `pvi`, `ultosc`, `vr` 등 pairwise/cumulative family를 같은 계약으로 정리합니다.
- `psar`를 포함한 stateful indicator가 gap row에서 output만 `None`을 내고, 다음 valid row에서 이전 state를 이어서 재개하도록 맞춥니다.
- 필요한 nullable helper를 `core/src/utils.rs`에 추가합니다.

## 리뷰 포인트
- current row나 required predecessor가 비면 해당 row는 `None`이어야 합니다.
- recursive/stateful 계산은 gap을 사이에 두고 series를 압축하지 않은 채 정렬을 유지해야 합니다.
- accumulator 기반 지표는 invalid row에서 상태를 억지로 갱신하지 않아야 합니다.
- `psar`, `dmi`, `atr`, `rsi`가 대표적인 확인 포인트입니다.

## 범위 외
- `plugin/polars`는 여전히 이번 stack 범위 밖입니다.
- composite/derived indicator의 최종 정리는 상위 PR `#17`에서 다룹니다.

## 테스트 계획
- [x] `cargo fmt --package techr-core --check`
- [x] `cargo test -p techr-core`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants