Skip to content

Commit ab29c96

Browse files
authored
♻️ core 상태형 지표 nullable 입력 전환 (#16)
## 요약 - 이 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`
1 parent 1c802ba commit ab29c96

19 files changed

Lines changed: 1743 additions & 382 deletions

File tree

core/src/indicators/ad.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
use crate::utils::calc_clv;
22

3-
pub fn ad(highs: &[f64], lows: &[f64], closes: &[f64], volumes: &[f64]) -> Vec<Option<f64>> {
3+
pub fn ad(
4+
highs: &[Option<f64>],
5+
lows: &[Option<f64>],
6+
closes: &[Option<f64>],
7+
volumes: &[Option<f64>],
8+
) -> Vec<Option<f64>> {
49
let mut ad = vec![None; highs.len()];
510

611
let len = highs.len();
@@ -11,7 +16,13 @@ pub fn ad(highs: &[f64], lows: &[f64], closes: &[f64], volumes: &[f64]) -> Vec<O
1116

1217
let mut ad_point = 0.0;
1318
for i in 0..len {
14-
ad_point += calc_clv(highs[i], lows[i], closes[i]) * volumes[i];
19+
let (Some(high), Some(low), Some(close), Some(volume)) =
20+
(highs[i], lows[i], closes[i], volumes[i])
21+
else {
22+
continue;
23+
};
24+
25+
ad_point += calc_clv(high, low, close) * volume;
1526
ad[i] = Some(ad_point);
1627
}
1728

@@ -28,10 +39,22 @@ mod tests {
2839
fn test_ad() {
2940
let test_cases = vec!["005930"];
3041
for symbol in test_cases {
31-
let high = testutils::load_data(&format!("../data/{}.json", symbol), "h");
32-
let low = testutils::load_data(&format!("../data/{}.json", symbol), "l");
33-
let close = testutils::load_data(&format!("../data/{}.json", symbol), "c");
34-
let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v");
42+
let high = testutils::load_data(&format!("../data/{}.json", symbol), "h")
43+
.into_iter()
44+
.map(Some)
45+
.collect::<Vec<_>>();
46+
let low = testutils::load_data(&format!("../data/{}.json", symbol), "l")
47+
.into_iter()
48+
.map(Some)
49+
.collect::<Vec<_>>();
50+
let close = testutils::load_data(&format!("../data/{}.json", symbol), "c")
51+
.into_iter()
52+
.map(Some)
53+
.collect::<Vec<_>>();
54+
let volume = testutils::load_data(&format!("../data/{}.json", symbol), "v")
55+
.into_iter()
56+
.map(Some)
57+
.collect::<Vec<_>>();
3558

3659
let result = ad(&high, &low, &close, &volume);
3760
let expected = testutils::load_expected::<Option<f64>>(&format!(
@@ -47,4 +70,16 @@ mod tests {
4770
);
4871
}
4972
}
73+
74+
#[test]
75+
fn test_ad_with_gap_preserves_running_total() {
76+
let highs = vec![Some(10.0), Some(12.0), None, Some(14.0)];
77+
let lows = vec![Some(8.0), Some(10.0), None, Some(12.0)];
78+
let closes = vec![Some(9.0), Some(11.0), None, Some(13.0)];
79+
let volumes = vec![Some(100.0), Some(100.0), Some(100.0), Some(100.0)];
80+
81+
let result = ad(&highs, &lows, &closes, &volumes);
82+
83+
assert_eq!(result, vec![Some(0.0), Some(0.0), None, Some(0.0)]);
84+
}
5085
}

core/src/indicators/adx.rs

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,49 @@
11
use crate::indicators::dmi::dmi;
22

33
pub fn adx(
4-
highs: &[f64],
5-
lows: &[f64],
6-
closes: &[f64],
4+
highs: &[Option<f64>],
5+
lows: &[Option<f64>],
6+
closes: &[Option<f64>],
77
dmi_period: usize,
88
adx_period: usize,
99
) -> Vec<Option<f64>> {
1010
let (plus_di, minus_di) = dmi(highs, lows, closes, dmi_period);
11-
let mut adx = Vec::with_capacity(plus_di.len());
11+
let mut adx = vec![None; plus_di.len()];
1212
let mut dx_sum = 0.0;
13-
let mut adx_point = 0.0;
13+
let mut seeded = 0usize;
14+
let mut adx_point = None;
15+
16+
if adx_period == 0 {
17+
return adx;
18+
}
1419

1520
for i in 0..plus_di.len() {
16-
let dx = match (plus_di[i], minus_di[i]) {
21+
let Some(dx) = (match (plus_di[i], minus_di[i]) {
1722
(Some(plus), Some(minus)) if plus != 0.0 || minus != 0.0 => {
18-
(plus - minus).abs() / (plus + minus) * 100.0
23+
Some((plus - minus).abs() / (plus + minus) * 100.0)
24+
}
25+
(Some(_), Some(_)) => Some(0.0),
26+
_ => None,
27+
}) else {
28+
if adx_point.is_none() {
29+
dx_sum = 0.0;
30+
seeded = 0;
1931
}
20-
_ => 0.0,
32+
continue;
2133
};
2234

23-
let initial_period = dmi_period + adx_period - 1;
24-
if i < initial_period {
25-
dx_sum += dx;
26-
adx.push(None);
27-
} else if i == initial_period {
28-
dx_sum += dx;
29-
adx_point = dx_sum / adx_period as f64;
30-
adx.push(Some(adx_point));
35+
if let Some(current_adx) = adx_point {
36+
let next_adx = (current_adx * (adx_period - 1) as f64 + dx) / adx_period as f64;
37+
adx_point = Some(next_adx);
38+
adx[i] = Some(next_adx);
3139
} else {
32-
adx_point = (adx_point * (adx_period - 1) as f64 + dx) / adx_period as f64;
33-
adx.push(Some(adx_point));
40+
dx_sum += dx;
41+
seeded += 1;
42+
if seeded == adx_period {
43+
let initial_adx = dx_sum / adx_period as f64;
44+
adx_point = Some(initial_adx);
45+
adx[i] = Some(initial_adx);
46+
}
3447
}
3548
}
3649

@@ -51,6 +64,10 @@ mod tests {
5164
let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l");
5265
let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c");
5366

67+
let highs = highs.into_iter().map(Some).collect::<Vec<_>>();
68+
let lows = lows.into_iter().map(Some).collect::<Vec<_>>();
69+
let closes = closes.into_iter().map(Some).collect::<Vec<_>>();
70+
5471
let result = adx(&highs, &lows, &closes, 14, 14);
5572
let expected = testutils::load_expected::<Option<f64>>(&format!(
5673
"../data/expected/adx_{}.json",
@@ -65,4 +82,73 @@ mod tests {
6582
);
6683
}
6784
}
85+
86+
#[test]
87+
fn test_adx_requires_contiguous_seed_window_and_resumes_after_gap() {
88+
let highs = vec![
89+
Some(10.0),
90+
Some(12.0),
91+
Some(14.0),
92+
None,
93+
Some(15.0),
94+
Some(16.0),
95+
Some(18.0),
96+
None,
97+
Some(19.0),
98+
Some(20.0),
99+
];
100+
let lows = vec![
101+
Some(8.0),
102+
Some(9.0),
103+
Some(11.0),
104+
None,
105+
Some(13.0),
106+
Some(14.0),
107+
Some(15.0),
108+
None,
109+
Some(17.0),
110+
Some(18.0),
111+
];
112+
let closes = vec![
113+
Some(9.0),
114+
Some(11.0),
115+
Some(13.0),
116+
None,
117+
Some(14.0),
118+
Some(15.0),
119+
Some(17.0),
120+
None,
121+
Some(18.0),
122+
Some(19.0),
123+
];
124+
125+
let result = adx(&highs, &lows, &closes, 2, 2);
126+
127+
assert_eq!(
128+
result,
129+
vec![
130+
None,
131+
None,
132+
None,
133+
None,
134+
None,
135+
None,
136+
Some(100.0),
137+
None,
138+
None,
139+
Some(100.0)
140+
]
141+
);
142+
}
143+
144+
#[test]
145+
fn test_adx_length_mismatch_fails_closed() {
146+
let highs = vec![Some(10.0), Some(12.0), Some(14.0)];
147+
let lows = vec![Some(8.0), Some(9.0)];
148+
let closes = vec![Some(9.0), Some(11.0), Some(13.0)];
149+
150+
let result = adx(&highs, &lows, &closes, 2, 2);
151+
152+
assert_eq!(result, vec![None, None, None]);
153+
}
68154
}

core/src/indicators/adxr.rs

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use crate::indicators::adx::adx;
22

33
pub fn adxr(
4-
highs: &[f64],
5-
lows: &[f64],
6-
closes: &[f64],
4+
highs: &[Option<f64>],
5+
lows: &[Option<f64>],
6+
closes: &[Option<f64>],
77
dmi_period: usize,
88
adx_period: usize,
99
adxr_period: usize,
@@ -35,9 +35,18 @@ mod tests {
3535
fn test_adxr() {
3636
let test_cases = vec!["005930", "TSLA"];
3737
for symbol in test_cases {
38-
let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h");
39-
let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l");
40-
let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c");
38+
let highs = testutils::load_data(&format!("../data/{}.json", symbol), "h")
39+
.into_iter()
40+
.map(Some)
41+
.collect::<Vec<_>>();
42+
let lows = testutils::load_data(&format!("../data/{}.json", symbol), "l")
43+
.into_iter()
44+
.map(Some)
45+
.collect::<Vec<_>>();
46+
let closes = testutils::load_data(&format!("../data/{}.json", symbol), "c")
47+
.into_iter()
48+
.map(Some)
49+
.collect::<Vec<_>>();
4150

4251
let result = adxr(&highs, &lows, &closes, 14, 14, 14);
4352
let expected = testutils::load_expected::<Option<f64>>(&format!(
@@ -53,4 +62,73 @@ mod tests {
5362
);
5463
}
5564
}
65+
66+
#[test]
67+
fn test_adxr_requires_current_and_lagged_adx_after_gaps() {
68+
let highs = vec![
69+
Some(10.0),
70+
Some(12.0),
71+
Some(14.0),
72+
None,
73+
Some(15.0),
74+
Some(16.0),
75+
Some(18.0),
76+
None,
77+
Some(19.0),
78+
Some(20.0),
79+
];
80+
let lows = vec![
81+
Some(8.0),
82+
Some(9.0),
83+
Some(11.0),
84+
None,
85+
Some(13.0),
86+
Some(14.0),
87+
Some(15.0),
88+
None,
89+
Some(17.0),
90+
Some(18.0),
91+
];
92+
let closes = vec![
93+
Some(9.0),
94+
Some(11.0),
95+
Some(13.0),
96+
None,
97+
Some(14.0),
98+
Some(15.0),
99+
Some(17.0),
100+
None,
101+
Some(18.0),
102+
Some(19.0),
103+
];
104+
105+
let result = adxr(&highs, &lows, &closes, 2, 2, 4);
106+
107+
assert_eq!(
108+
result,
109+
vec![
110+
None,
111+
None,
112+
None,
113+
None,
114+
None,
115+
None,
116+
None,
117+
None,
118+
None,
119+
Some(100.0)
120+
]
121+
);
122+
}
123+
124+
#[test]
125+
fn test_adxr_length_mismatch_fails_closed() {
126+
let highs = vec![Some(10.0), Some(12.0), Some(14.0)];
127+
let lows = vec![Some(8.0), Some(9.0)];
128+
let closes = vec![Some(9.0), Some(11.0), Some(13.0)];
129+
130+
let result = adxr(&highs, &lows, &closes, 2, 2, 2);
131+
132+
assert_eq!(result, vec![None, None, None]);
133+
}
56134
}

0 commit comments

Comments
 (0)