Skip to content

Commit b1e8dbc

Browse files
aaron-jangclaude
andcommitted
test: P1 단위 테스트 73개 추가 (218 → 291)
- test_queue_ledger_core: LIFO, 원자적 쓰기, 자가 치유, 스레드 안전성 (31개) - test_v14_vwap: 퀀트 로직, L1/L2 영속성, 스냅샷, 자전거래 방어 (19개) - test_reversion: 지층 분리 타점, 영속성 캐시, 스냅샷 멱등성 (17개) - test_config_vwap_mode: 수동 VWAP 모드 get/set (6개) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f1ab479 commit b1e8dbc

4 files changed

Lines changed: 681 additions & 0 deletions

File tree

tests/test_config_vwap_mode.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
ConfigManager 수동 VWAP 모드 설정 테스트
3+
4+
V26.00에서 추가된 get/set_manual_vwap_mode 메서드 검증
5+
"""
6+
import pytest
7+
8+
9+
class TestManualVwapMode:
10+
"""수동 VWAP 모드 get/set"""
11+
12+
def test_default_is_false(self, cfg):
13+
assert cfg.get_manual_vwap_mode("SOXL") is False
14+
15+
def test_set_true(self, cfg):
16+
cfg.set_manual_vwap_mode("SOXL", True)
17+
assert cfg.get_manual_vwap_mode("SOXL") is True
18+
19+
def test_set_false(self, cfg):
20+
cfg.set_manual_vwap_mode("SOXL", True)
21+
cfg.set_manual_vwap_mode("SOXL", False)
22+
assert cfg.get_manual_vwap_mode("SOXL") is False
23+
24+
def test_independent_per_ticker(self, cfg):
25+
cfg.set_manual_vwap_mode("SOXL", True)
26+
cfg.set_manual_vwap_mode("TQQQ", False)
27+
assert cfg.get_manual_vwap_mode("SOXL") is True
28+
assert cfg.get_manual_vwap_mode("TQQQ") is False
29+
30+
def test_unknown_ticker_default(self, cfg):
31+
assert cfg.get_manual_vwap_mode("UNKNOWN") is False
32+
33+
def test_persists_to_file(self, cfg):
34+
cfg.set_manual_vwap_mode("SOXL", True)
35+
# 파일에서 직접 읽어서 확인
36+
import json
37+
with open(cfg.FILES["MANUAL_VWAP_CFG"], 'r') as f:
38+
data = json.load(f)
39+
assert data["SOXL"] is True

tests/test_queue_ledger_core.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"""
2+
QueueLedger 핵심 기능 테스트
3+
4+
LIFO 로트 관리, 원자적 쓰기, 자가 치유, 스레드 안전성 검증
5+
"""
6+
import os
7+
import json
8+
import threading
9+
import pytest
10+
11+
12+
@pytest.fixture
13+
def ql(tmp_path):
14+
from trading_bot.strategy.queue_ledger import QueueLedger
15+
file_path = str(tmp_path / "data" / "queue_ledger.json")
16+
return QueueLedger(file_path=file_path)
17+
18+
19+
class TestAddLotBasic:
20+
"""add_lot 기본 동작"""
21+
22+
def test_add_single_lot(self, ql):
23+
ql.add_lot("SOXL", 10, 50.0)
24+
q = ql.get_queue("SOXL")
25+
assert len(q) == 1
26+
assert q[0]["qty"] == 10
27+
assert q[0]["price"] == 50.0
28+
29+
def test_add_lot_returns_none(self, ql):
30+
result = ql.add_lot("SOXL", 5, 30.0)
31+
assert result is None
32+
33+
def test_get_total_qty(self, ql):
34+
ql.add_lot("SOXL", 10, 50.0)
35+
assert ql.get_total_qty("SOXL") == 10
36+
37+
def test_empty_ticker_returns_empty(self, ql):
38+
assert ql.get_queue("TQQQ") == []
39+
assert ql.get_total_qty("TQQQ") == 0
40+
41+
def test_add_zero_qty_ignored(self, ql):
42+
ql.add_lot("SOXL", 0, 50.0)
43+
assert ql.get_queue("SOXL") == []
44+
45+
def test_add_negative_qty_ignored(self, ql):
46+
ql.add_lot("SOXL", -5, 50.0)
47+
assert ql.get_queue("SOXL") == []
48+
49+
def test_add_zero_price_rejected(self, ql):
50+
ql.add_lot("SOXL", 10, 0.0)
51+
assert ql.get_queue("SOXL") == []
52+
53+
def test_add_none_price_rejected(self, ql):
54+
ql.add_lot("SOXL", 10, None)
55+
assert ql.get_queue("SOXL") == []
56+
57+
58+
class TestSameDayMerge:
59+
"""동일 일자 로트 병합"""
60+
61+
def test_same_day_merge_qty(self, ql):
62+
ql.add_lot("SOXL", 10, 50.0)
63+
ql.add_lot("SOXL", 5, 40.0)
64+
q = ql.get_queue("SOXL")
65+
assert len(q) == 1
66+
assert q[0]["qty"] == 15
67+
68+
def test_same_day_merge_weighted_avg_price(self, ql):
69+
ql.add_lot("SOXL", 10, 50.0)
70+
ql.add_lot("SOXL", 10, 40.0)
71+
q = ql.get_queue("SOXL")
72+
assert q[0]["price"] == 45.0
73+
74+
def test_same_day_merge_unequal_qty(self, ql):
75+
ql.add_lot("SOXL", 10, 100.0)
76+
ql.add_lot("SOXL", 5, 50.0)
77+
q = ql.get_queue("SOXL")
78+
expected_price = (10 * 100.0 + 5 * 50.0) / 15
79+
assert q[0]["price"] == pytest.approx(expected_price, abs=0.01)
80+
81+
82+
class TestPopLots:
83+
"""LIFO pop 동작"""
84+
85+
def test_pop_exact_qty(self, ql):
86+
ql.add_lot("SOXL", 10, 50.0)
87+
popped = ql.pop_lots("SOXL", 10)
88+
assert popped == 10
89+
assert ql.get_total_qty("SOXL") == 0
90+
91+
def test_pop_partial(self, ql):
92+
ql.add_lot("SOXL", 10, 50.0)
93+
popped = ql.pop_lots("SOXL", 3)
94+
assert popped == 3
95+
q = ql.get_queue("SOXL")
96+
assert q[0]["qty"] == 7
97+
98+
def test_pop_zero_returns_zero(self, ql):
99+
ql.add_lot("SOXL", 10, 50.0)
100+
assert ql.pop_lots("SOXL", 0) == 0
101+
assert ql.get_total_qty("SOXL") == 10
102+
103+
def test_pop_more_than_available(self, ql):
104+
ql.add_lot("SOXL", 5, 50.0)
105+
popped = ql.pop_lots("SOXL", 10)
106+
assert popped == 5
107+
assert ql.get_total_qty("SOXL") == 0
108+
109+
def test_pop_from_empty_queue(self, ql):
110+
popped = ql.pop_lots("SOXL", 5)
111+
assert popped == 0
112+
113+
114+
class TestGhostLotCleaning:
115+
"""유령 로트(0주) 자동 청소"""
116+
117+
def test_ghost_lot_filtered_on_get(self, ql):
118+
ql.add_lot("SOXL", 10, 50.0)
119+
# 직접 0주 로트 주입
120+
with ql._lock:
121+
data = ql._load_unsafe()
122+
data["SOXL"].append({"qty": 0, "price": 30.0, "date": "2026-01-01", "type": "GHOST"})
123+
ql._save_unsafe(data)
124+
125+
q = ql.get_queue("SOXL")
126+
assert all(int(float(lot.get("qty", 0))) > 0 for lot in q)
127+
128+
129+
class TestSyncWithBroker:
130+
"""브로커 잔고 동기화 (CALIB)"""
131+
132+
def test_sync_no_change_needed(self, ql):
133+
ql.add_lot("SOXL", 10, 50.0)
134+
changed = ql.sync_with_broker("SOXL", 10, 50.0)
135+
assert changed is False
136+
137+
def test_sync_add_missing_qty(self, ql):
138+
ql.add_lot("SOXL", 10, 50.0)
139+
changed = ql.sync_with_broker("SOXL", 15, 48.0)
140+
assert changed is True
141+
assert ql.get_total_qty("SOXL") == 15
142+
143+
def test_sync_remove_excess_qty(self, ql):
144+
ql.add_lot("SOXL", 10, 50.0)
145+
changed = ql.sync_with_broker("SOXL", 7, 50.0)
146+
assert changed is True
147+
assert ql.get_total_qty("SOXL") == 7
148+
149+
def test_sync_add_rejects_zero_price(self, ql):
150+
ql.add_lot("SOXL", 10, 50.0)
151+
changed = ql.sync_with_broker("SOXL", 15, 0.0)
152+
assert changed is True
153+
# 가격 불명이면 기존 로트의 가격을 사용
154+
assert ql.get_total_qty("SOXL") == 15
155+
156+
def test_sync_from_empty_to_new(self, ql):
157+
changed = ql.sync_with_broker("SOXL", 5, 45.0)
158+
assert changed is True
159+
assert ql.get_total_qty("SOXL") == 5
160+
161+
def test_sync_to_zero(self, ql):
162+
ql.add_lot("SOXL", 10, 50.0)
163+
changed = ql.sync_with_broker("SOXL", 0)
164+
assert changed is True
165+
assert ql.get_total_qty("SOXL") == 0
166+
167+
168+
class TestAtomicWriteAndBackup:
169+
"""원자적 쓰기 및 백업 자가 치유"""
170+
171+
def test_file_persists_after_add(self, ql):
172+
ql.add_lot("SOXL", 10, 50.0)
173+
assert os.path.exists(ql.file_path)
174+
with open(ql.file_path, 'r') as f:
175+
data = json.load(f)
176+
assert "SOXL" in data
177+
178+
def test_backup_created_after_save(self, ql):
179+
ql.add_lot("SOXL", 10, 50.0)
180+
assert os.path.exists(ql.file_path + ".bak")
181+
182+
def test_self_healing_from_backup(self, ql):
183+
ql.add_lot("SOXL", 10, 50.0)
184+
# 원본 파일 손상
185+
with open(ql.file_path, 'w') as f:
186+
f.write("{invalid json")
187+
# 백업에서 복원되어야 함
188+
q = ql.get_queue("SOXL")
189+
assert len(q) == 1
190+
assert q[0]["qty"] == 10
191+
192+
def test_both_corrupted_raises_error(self, ql):
193+
ql.add_lot("SOXL", 10, 50.0)
194+
with open(ql.file_path, 'w') as f:
195+
f.write("{bad")
196+
with open(ql.file_path + ".bak", 'w') as f:
197+
f.write("{bad")
198+
with pytest.raises(RuntimeError, match="FATAL ERROR"):
199+
ql.get_queue("SOXL")
200+
201+
202+
class TestThreadSafety:
203+
"""스레드 안전성"""
204+
205+
def test_concurrent_add_lots(self, ql):
206+
errors = []
207+
208+
def add_lots(ticker, count):
209+
try:
210+
for _ in range(count):
211+
ql.add_lot(ticker, 1, 50.0)
212+
except Exception as e:
213+
errors.append(e)
214+
215+
threads = [threading.Thread(target=add_lots, args=("SOXL", 50)) for _ in range(4)]
216+
for t in threads:
217+
t.start()
218+
for t in threads:
219+
t.join()
220+
221+
assert not errors
222+
total = ql.get_total_qty("SOXL")
223+
assert total == 200
224+
225+
def test_concurrent_add_and_pop(self, ql):
226+
ql.add_lot("SOXL", 100, 50.0)
227+
errors = []
228+
229+
def pop_lots():
230+
try:
231+
ql.pop_lots("SOXL", 10)
232+
except Exception as e:
233+
errors.append(e)
234+
235+
threads = [threading.Thread(target=pop_lots) for _ in range(5)]
236+
for t in threads:
237+
t.start()
238+
for t in threads:
239+
t.join()
240+
241+
assert not errors
242+
assert ql.get_total_qty("SOXL") == 50
243+
244+
245+
class TestMultiTicker:
246+
"""다중 종목 격리"""
247+
248+
def test_tickers_independent(self, ql):
249+
ql.add_lot("SOXL", 10, 50.0)
250+
ql.add_lot("TQQQ", 20, 30.0)
251+
assert ql.get_total_qty("SOXL") == 10
252+
assert ql.get_total_qty("TQQQ") == 20
253+
254+
def test_pop_one_ticker_no_affect_other(self, ql):
255+
ql.add_lot("SOXL", 10, 50.0)
256+
ql.add_lot("TQQQ", 20, 30.0)
257+
ql.pop_lots("SOXL", 5)
258+
assert ql.get_total_qty("SOXL") == 5
259+
assert ql.get_total_qty("TQQQ") == 20

0 commit comments

Comments
 (0)