Skip to content

Commit 1c802ba

Browse files
authored
♻️ core 지표 nullable 입력 계약 도입 (#15)
## 요약 - 이 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로 봐주시면 됩니다. ## 테스트 계획 - [x] `cargo fmt --package techr-core --check` - [x] `cargo test -p techr-core` - [x] GitHub Actions `Polars CI`가 non-polars 변경에서 성공적으로 통과하는지 확인
1 parent 301d08c commit 1c802ba

19 files changed

Lines changed: 400 additions & 202 deletions

File tree

.github/workflows/polars-ci.yml

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ name: Polars CI
22

33
on:
44
pull_request:
5-
types: [review_requested, ready_for_review]
5+
types: [review_requested, ready_for_review, synchronize]
66
paths:
77
- ".github/workflows/polars-*.yml"
88
- "Cargo.toml"
99
- "Makefile"
10-
- "core/**"
1110
- "polars/**"
1211

1312
concurrency:
@@ -18,42 +17,82 @@ permissions:
1817
contents: read
1918

2019
jobs:
20+
preflight:
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
pull-requests: read
25+
outputs:
26+
validate: ${{ steps.filter.outputs.validate }}
27+
steps:
28+
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590
29+
id: filter
30+
with:
31+
filters: |
32+
validate:
33+
- "Cargo.toml"
34+
- "Makefile"
35+
- "polars/**"
36+
2137
validate:
38+
needs: preflight
39+
if: ${{ always() }}
2240
runs-on: ubuntu-latest
2341
defaults:
2442
run:
2543
shell: bash
2644
steps:
27-
- uses: actions/checkout@v6
28-
- uses: actions/setup-python@v6
45+
- name: Fail if preflight did not succeed
46+
if: ${{ needs.preflight.result != 'success' }}
47+
run: |
48+
echo "preflight job did not succeed: ${{ needs.preflight.result }}"
49+
exit 1
50+
51+
- name: Skip validation for non-polars changes
52+
if: ${{ needs.preflight.result == 'success' && needs.preflight.outputs.validate != 'true' }}
53+
run: echo "Skipping Polars CI validate job because this PR does not touch Cargo.toml, Makefile, or polars/**."
54+
55+
- if: ${{ needs.preflight.outputs.validate == 'true' }}
56+
uses: actions/checkout@v6
57+
- if: ${{ needs.preflight.outputs.validate == 'true' }}
58+
uses: actions/setup-python@v6
2959
with:
3060
python-version: "3.10"
31-
- uses: astral-sh/setup-uv@v8.0.0
32-
- uses: dtolnay/rust-toolchain@stable
61+
- if: ${{ needs.preflight.outputs.validate == 'true' }}
62+
uses: astral-sh/setup-uv@v8.0.0
63+
- if: ${{ needs.preflight.outputs.validate == 'true' }}
64+
uses: dtolnay/rust-toolchain@stable
3365

3466
- name: Sync polars dependencies
67+
if: ${{ needs.preflight.outputs.validate == 'true' }}
3568
working-directory: polars
3669
run: uv sync --group dev --no-install-project
3770

3871
- name: Test techr-core
72+
if: ${{ needs.preflight.outputs.validate == 'true' }}
3973
run: cargo test -p techr-core
4074

4175
- name: Build local extension for tests
76+
if: ${{ needs.preflight.outputs.validate == 'true' }}
4277
working-directory: polars
4378
run: uv run maturin develop --uv
4479

4580
- name: Test polars package
81+
if: ${{ needs.preflight.outputs.validate == 'true' }}
4682
working-directory: polars
4783
run: uv run pytest
4884

4985
- name: Build wheel and sdist
86+
if: ${{ needs.preflight.outputs.validate == 'true' }}
5087
working-directory: polars
5188
run: uv run maturin build --release --sdist --out dist
5289

5390
- name: Check artifact contents
91+
if: ${{ needs.preflight.outputs.validate == 'true' }}
5492
run: python polars/scripts/check_artifacts.py polars/dist
5593

5694
- name: Smoke test built wheel
95+
if: ${{ needs.preflight.outputs.validate == 'true' }}
5796
run: |
5897
wheel="$(python - <<'PY'
5998
from pathlib import Path

core/src/indicators/bband.rs

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
use crate::indicators::sma::sma;
2-
3-
use crate::utils::round_scalar;
2+
use crate::utils::{rolling_mean_stddev_strict, round_scalar};
43

54
pub fn bband(
6-
data: &[f64],
5+
data: &[Option<f64>],
76
period: usize,
87
sigma: Option<f64>,
98
) -> (Vec<Option<f64>>, Vec<Option<f64>>, Vec<Option<f64>>) {
@@ -12,55 +11,42 @@ pub fn bband(
1211
(upper_band, center, lower_band)
1312
}
1413

15-
pub fn bband_middle(data: &[f64], period: usize) -> Vec<Option<f64>> {
14+
pub fn bband_middle(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
1615
sma(data, period)
1716
}
1817

19-
pub fn bband_upper(data: &[f64], period: usize, sigma: Option<f64>) -> Vec<Option<f64>> {
18+
pub fn bband_upper(data: &[Option<f64>], period: usize, sigma: Option<f64>) -> Vec<Option<f64>> {
2019
let (upper_band, _) = bband_bands(data, period, sigma);
2120
upper_band
2221
}
2322

24-
pub fn bband_lower(data: &[f64], period: usize, sigma: Option<f64>) -> Vec<Option<f64>> {
23+
pub fn bband_lower(data: &[Option<f64>], period: usize, sigma: Option<f64>) -> Vec<Option<f64>> {
2524
let (_, lower_band) = bband_bands(data, period, sigma);
2625
lower_band
2726
}
2827

2928
fn bband_bands(
30-
data: &[f64],
29+
data: &[Option<f64>],
3130
period: usize,
3231
sigma: Option<f64>,
3332
) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
34-
let mut upper_band = vec![None; data.len()];
35-
let mut lower_band = vec![None; data.len()];
36-
let mut sum = 0.0;
37-
let mut sum_sq = 0.0;
3833
let sigma = sigma.unwrap_or(2.0);
34+
let (means, stddevs) = rolling_mean_stddev_strict(data, period);
3935

40-
if data.len() < period {
41-
return (upper_band, lower_band);
42-
}
43-
44-
for i in 0..data.len() {
45-
sum += data[i];
46-
sum_sq += data[i] * data[i];
47-
48-
if i >= period {
49-
sum -= data[i - period];
50-
sum_sq -= data[i - period] * data[i - period];
51-
}
52-
53-
if i >= period - 1 {
54-
let mean = sum / period as f64;
55-
let variance = (sum_sq / period as f64) - (mean * mean);
56-
let stddev = variance.sqrt();
57-
let deviation = sigma * stddev;
58-
upper_band[i] = Some(round_scalar(mean + deviation, 8));
59-
lower_band[i] = Some(round_scalar(mean - deviation, 8));
60-
}
61-
}
62-
63-
(upper_band, lower_band)
36+
means
37+
.into_iter()
38+
.zip(stddevs)
39+
.map(|(mean, stddev)| match (mean, stddev) {
40+
(Some(mean), Some(stddev)) => {
41+
let deviation = sigma * stddev;
42+
(
43+
Some(round_scalar(mean + deviation, 8)),
44+
Some(round_scalar(mean - deviation, 8)),
45+
)
46+
}
47+
_ => (None, None),
48+
})
49+
.unzip()
6450
}
6551

6652
#[cfg(test)]
@@ -73,7 +59,7 @@ mod tests {
7359
fn test_bband() {
7460
let test_cases = vec!["005930", "TSLA"];
7561
for symbol in test_cases {
76-
let input = testutils::load_data(&format!("../data/{}.json", symbol), "c");
62+
let input = testutils::load_data_nullable(&format!("../data/{}.json", symbol), "c");
7763
let (upper, middle, lower) = bband(&input, 20, None);
7864

7965
let expected_upper = testutils::load_expected::<Option<f64>>(&format!(
@@ -109,4 +95,26 @@ mod tests {
10995
);
11096
}
11197
}
98+
99+
#[test]
100+
fn test_bband_with_interior_gap_invalidates_full_window() {
101+
let input = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)];
102+
103+
let (upper, middle, lower) = bband(&input, 2, Some(2.0));
104+
105+
assert_eq!(middle, vec![None, Some(1.5), None, None, Some(4.5)]);
106+
assert_eq!(upper, vec![None, Some(2.5), None, None, Some(5.5)]);
107+
assert_eq!(lower, vec![None, Some(0.5), None, None, Some(3.5)]);
108+
}
109+
110+
#[test]
111+
fn test_bband_full_window_invalidation() {
112+
let input = vec![None, Some(2.0), None, Some(4.0)];
113+
114+
let (upper, middle, lower) = bband(&input, 2, None);
115+
116+
assert_eq!(upper, vec![None, None, None, None]);
117+
assert_eq!(middle, vec![None, None, None, None]);
118+
assert_eq!(lower, vec![None, None, None, None]);
119+
}
112120
}

core/src/indicators/cv.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::indicators::ema::ema;
1+
use crate::indicators::ema::ema_dense;
22

33
pub fn cv(highs: &[f64], lows: &[f64], period: usize) -> Vec<Option<f64>> {
44
let mut cv = vec![None; highs.len()];
@@ -9,7 +9,7 @@ pub fn cv(highs: &[f64], lows: &[f64], period: usize) -> Vec<Option<f64>> {
99
}
1010

1111
let high_low_diffs: Vec<f64> = highs.iter().zip(lows.iter()).map(|(h, l)| h - l).collect();
12-
let ema_high_low_diffs = ema(&high_low_diffs, period);
12+
let ema_high_low_diffs = ema_dense(&high_low_diffs, period);
1313

1414
for i in period * 2 - 1..len {
1515
if let (Some(current_ema), Some(previous_ema)) =

core/src/indicators/disparity.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
use crate::indicators::sma::sma;
22

3-
pub fn disparity(data: &[f64], period: usize) -> Vec<Option<f64>> {
3+
pub fn disparity(data: &[Option<f64>], period: usize) -> Vec<Option<f64>> {
44
let len = data.len();
55
let mut result = vec![None; len];
66

7-
if len < period {
7+
if len < period || period == 0 {
88
return result;
99
}
1010

1111
let sma = sma(data, period);
1212

1313
for i in period - 1..len {
14+
let Some(value) = data[i] else {
15+
continue;
16+
};
17+
1418
if let Some(sma_value) = sma[i] {
1519
if sma_value != 0.0 {
16-
result[i] = Some((data[i] / sma_value) * 100.0);
20+
result[i] = Some((value / sma_value) * 100.0);
1721
}
1822
}
1923
}
@@ -31,7 +35,7 @@ mod tests {
3135
fn test_disparity() {
3236
let test_cases = vec!["005930", "TSLA"];
3337
for symbol in test_cases {
34-
let close = testutils::load_data(&format!("../data/{}.json", symbol), "c");
38+
let close = testutils::load_data_nullable(&format!("../data/{}.json", symbol), "c");
3539
let result = disparity(&close, 20);
3640
let expected = testutils::load_expected::<Option<f64>>(&format!(
3741
"../data/expected/disparity_{}.json",
@@ -46,4 +50,29 @@ mod tests {
4650
);
4751
}
4852
}
53+
54+
#[test]
55+
fn test_disparity_with_interior_gap_invalidates_window() {
56+
let input = vec![Some(1.0), Some(2.0), None, Some(4.0), Some(5.0)];
57+
let expected = vec![
58+
None,
59+
Some(133.33333333333331),
60+
None,
61+
None,
62+
Some(111.11111111111111),
63+
];
64+
65+
let result = disparity(&input, 2);
66+
67+
assert_eq!(result, expected);
68+
}
69+
70+
#[test]
71+
fn test_disparity_full_window_invalidation() {
72+
let input = vec![None, Some(2.0), None, Some(4.0)];
73+
74+
let result = disparity(&input, 2);
75+
76+
assert_eq!(result, vec![None, None, None, None]);
77+
}
4978
}

core/src/indicators/efi.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::indicators::ema::ema;
1+
use crate::indicators::ema::ema_dense;
22

33
pub fn efi(closes: &[f64], volumes: &[f64], period: usize) -> Vec<Option<f64>> {
44
let len = closes.len();
@@ -22,7 +22,7 @@ pub fn efi(closes: &[f64], volumes: &[f64], period: usize) -> Vec<Option<f64>> {
2222
*efi_val = Some(force_val);
2323
});
2424
} else {
25-
let ema_result = ema(&force, period);
25+
let ema_result = ema_dense(&force, period);
2626
efi.iter_mut()
2727
.skip(1)
2828
.zip(ema_result.into_iter())

0 commit comments

Comments
 (0)