-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
2284 lines (2051 loc) · 103 KB
/
Copy pathmain.py
File metadata and controls
2284 lines (2051 loc) · 103 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
퀀트 트레이더 시스템 - 메인 실행 파일
사용법:
# 백테스팅 (과거 데이터로 전략 검증)
python main.py --mode backtest --strategy scoring --symbol 005930
# 백테스트 리포트 저장 경로 지정
python main.py --mode backtest --strategy scoring --symbol 005930 --output-dir reports
# 페이퍼 트레이딩 (모의 매매, 워치리스트 1회 순회 후 종료)
python main.py --mode paper --strategy scoring
# 모의 24시간 스케줄 루프 (systemd 상시 구동용, config trading.mode=paper 권장)
python main.py --mode schedule --strategy scoring
# 실전 (ENABLE_LIVE_TRADING=true + --confirm-live 필요)
python main.py --mode live --strategy scoring --confirm-live
# 특정 기간 백테스팅
python main.py --mode backtest --strategy scoring --symbol 005930 --start 2023-01-01 --end 2025-12-31
# momentum_top 동일비중 멀티종목 (워치리스트 정의와 동일, KS11 대비)
python main.py --mode backtest_momentum_top --start 2019-01-01 --end 2026-03-23 --rebalance-days 20
# 긴급 전체 청산 (live 설정에서는 ENABLE_LIVE_TRADING=true + --confirm-live 필요)
python main.py --mode liquidate --confirm-live
# 바스켓 리밸런싱 (목표 비중 대비 드리프트 체크 → 주문)
python main.py --mode rebalance --basket kr_blue_chip
python main.py --mode rebalance --basket kr_blue_chip --dry-run
"""
import os
import sys
import argparse
from pathlib import Path
# 프로젝트 루트를 경로에 추가
sys.path.insert(0, str(Path(__file__).parent))
from config.config_loader import Config
from database.models import init_database
from monitoring.logger import setup_logger
from loguru import logger
from core.watchlist_manager import WatchlistManager
def run_portfolio_backtest(args):
"""멀티종목 포트폴리오 백테스팅 모드 실행"""
from backtest.portfolio_backtester import PortfolioBacktester
logger.info("=" * 50)
logger.info("포트폴리오 백테스팅 모드 시작")
logger.info("=" * 50)
symbols_str = getattr(args, "symbols", "") or ""
if not symbols_str:
logger.error(
"--symbols 옵션으로 종목 코드를 지정하세요. "
"예: --symbols 005930,000660,035720,051910,006400"
)
return
symbols = [s.strip() for s in symbols_str.split(",") if s.strip()]
if len(symbols) < 2:
logger.error("포트폴리오 백테스트는 최소 2개 종목이 필요합니다.")
return
logger.info("종목: {} ({}개)", symbols, len(symbols))
pbt = PortfolioBacktester()
result = pbt.run(
symbols=symbols,
strategy_name=args.strategy,
start_date=args.start,
end_date=args.end,
)
if not result:
logger.error("포트폴리오 백테스트 결과가 비어 있습니다.")
return
pbt.print_report(result)
def run_backtest(args):
"""백테스팅 모드 실행"""
from core.data_collector import DataCollector
from backtest.backtester import Backtester
from backtest.report_generator import ReportGenerator
logger.info("=" * 50)
logger.info("📊 백테스팅 모드 시작")
logger.info("=" * 50)
config = Config.get()
universe = (config.risk_params or {}).get("backtest_universe") or {}
u_mode = (universe.get("mode") or "current").strip().lower()
if u_mode == "current":
import sys
logger.warning(
"backtest_universe.mode=current — 현재 상장 종목 기준. "
"생존자 편향으로 수익률이 과대평가될 수 있습니다. "
"mode: historical 또는 kospi200 권장 (설계서 §8.2.1)."
)
print(
"\n" + "=" * 60 + "\n"
" ⚠️ [경고] 생존자 편향 (Survivorship Bias)\n"
+ "=" * 60 + "\n"
" backtest_universe.mode = current (기본)\n"
" → 현재 상장 종목만 사용하므로 상장폐지 종목이 제외됩니다.\n"
" → 백테스트 수익률이 실전보다 과대평가될 수 있습니다.\n"
" → config/risk_params.yaml에서 mode: historical 로 변경 권장.\n"
+ "=" * 60 + "\n",
file=sys.stderr,
)
collector = DataCollector()
symbol = args.symbol or "005930"
logger.info("종목 {} 데이터 수집 중...", symbol)
df = collector.fetch_stock(symbol, args.start, args.end)
if df.empty:
logger.error(
"데이터를 수집할 수 없습니다. 백테스트를 위해 다음 중 하나를 설정하세요:\n"
" 1) pip install FinanceDataReader # 한국 주식 권장\n"
" 2) pip install yfinance # 한국 종목 005930.KS 지원\n"
" 3) KIS API 환경변수 설정 시 일봉 조회로 자동 대체\n"
"지표: pandas-ta는 Python 3.11~3.12 범위에서 사용하는 것을 권장하며, "
"패키지 정책도 >=3.11,<3.13 기준입니다."
)
return
logger.info("수집 완료: {}건 ({} ~ {})", len(df), df.index[0], df.index[-1])
# 백테스팅 실행: strict-lookahead 기본 True. 해제 시에만 아래 명시적 경고 출력
if not args.strict_lookahead:
import sys
border = "=" * 60
msg = (
f"\n{border}\n"
" ⚠️ [경고] Look-Ahead Bias 방지가 해제되었습니다 (--allow-lookahead)\n"
f"{border}\n"
" • 미래 데이터가 신호 생성에 섞여 들어갈 수 있습니다.\n"
" • 백테스트 수익률이 실제보다 훨씬 좋게 나올 수 있습니다.\n"
" • 이 결과를 믿고 실전 투입 시 큰 손실이 날 수 있습니다.\n"
" • 실전 투입 판단 근거로 사용하지 마세요.\n"
" • 기본 동작은 strict-lookahead=True(권장)입니다.\n"
f"{border}\n"
)
logger.warning("Look-Ahead Bias 방지 해제됨 (--allow-lookahead). 실전 판단 근거로 사용 금지.")
print(msg, file=sys.stderr)
backtester = Backtester()
result = backtester.run(
df,
strategy_name=args.strategy,
strict_lookahead=args.strict_lookahead,
notify_overtrading=True,
)
if not result:
logger.error("백테스트 결과가 비어 있습니다.")
return
# 결과 출력
backtester.print_report(result)
report_generator = ReportGenerator(output_dir=args.output_dir)
report_paths = report_generator.generate_all(result)
if report_paths:
logger.info(
"백테스트 리포트 저장 완료 | txt={} | html={}",
report_paths.get("text_path", ""),
report_paths.get("html_path", ""),
)
def run_backtest_momentum_top(args):
"""momentum_top 정의(12개월 수익률 상위 N) 동일비중 멀티종목 백테스트. KS11 등 벤치와 지표 비교."""
from backtest.momentum_top_portfolio import (
print_momentum_top_portfolio_report,
run_momentum_top_portfolio_backtest,
)
logger.info("=" * 50)
logger.info("📊 momentum_top 포트폴리오 백테스트")
logger.info("=" * 50)
start = args.start or "2019-01-01"
end = args.end or "2026-03-23"
bench = getattr(args, "benchmark_symbol", None) or "KS11"
ic = getattr(args, "initial_capital", None)
reb = max(1, int(getattr(args, "rebalance_days", 20)))
result = run_momentum_top_portfolio_backtest(
start_date=start,
end_date=end,
rebalance_every=reb,
initial_capital=ic,
benchmark_symbol=bench,
top_n_override=getattr(args, "top_n", None),
use_market_filter=getattr(args, "market_filter", False),
cash_buffer=float(getattr(args, "cash_buffer", 0.0) or 0.0),
portfolio_stop=float(getattr(args, "portfolio_stop", 0.0) or 0.0),
stop_cooldown=int(getattr(args, "stop_cooldown", 0) or 0),
)
if not result:
logger.error("momentum_top 포트폴리오 백테스트 실패")
return
print_momentum_top_portfolio_report(result, config=Config.get())
def _run_auto_correlation(df, config, threshold=0.7):
"""상관 분석 실행 후 자동 비활성화 대상 가중치 키 반환."""
from core.indicator_correlation import (
run_indicator_correlation_check,
suggest_disable_weights,
)
result = run_indicator_correlation_check(
df,
threshold=threshold,
config=config,
save_report=False,
)
disable = suggest_disable_weights(result.get("high_correlation_pairs", []))
if result.get("high_correlation_pairs"):
logger.info(
"[auto-correlation] 고상관 쌍 {}개 발견: {}",
len(result["high_correlation_pairs"]),
[(p[0], p[1], f"r={p[2]:.2f}") for p in result["high_correlation_pairs"]],
)
if disable:
logger.info("[auto-correlation] 자동 비활성화: {}", disable)
else:
logger.info("[auto-correlation] 고상관 쌍 없음. 모든 지표 유지.")
return disable
def run_param_optimize(args):
"""전략 파라미터 자동 최적화 (Grid Search / Bayesian / 가중치 포함).
--include-weights: 스코어링 전략일 때 가중치도 탐색 (대칭 Grid, OOS 샤프 게이트 포함).
--auto-correlation: 최적화 전 상관 분석 자동 실행, 고상관 지표 비활성화 후 최적화.
권장 파이프라인: check_correlation → optimize --include-weights → validate --walk-forward.
"""
from datetime import datetime, timedelta
from core.data_collector import DataCollector
from backtest.param_optimizer import grid_search, bayesian_optimize, grid_search_scoring_weights
logger.info("=" * 50)
logger.info("전략 파라미터 최적화 ({}), 전략: {}", getattr(args, "optimizer", "grid"), args.strategy)
logger.info("=" * 50)
config = Config.get()
collector = DataCollector()
symbol = args.symbol or "005930"
end_date = args.end
start_date = args.start
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
vy = getattr(args, "validation_years", 5)
start_date = (datetime.now() - timedelta(days=365 * vy)).strftime("%Y-%m-%d")
logger.info(
"최적화 --start 미지정 → check_correlation·validate와 동일하게 최근 {}년 구간 사용",
vy,
)
df = collector.fetch_stock(symbol, start_date, end_date)
if df.empty or len(df) < 252:
logger.error("데이터가 없거나 1년 미만입니다. --start, --end 및 데이터 소스를 확인하세요.")
return
optimizer = getattr(args, "optimizer", "grid")
metric = getattr(args, "optimize_metric", "sharpe_ratio")
train_ratio = getattr(args, "train_ratio", 0.7)
include_weights = getattr(args, "include_weights", False)
# 스코어링 전략 + --include-weights → 가중치 포함 Grid Search
if args.strategy == "scoring" and include_weights:
disabled = [s.strip() for s in (getattr(args, "disable_weights", "") or "").split(",") if s.strip()]
auto_corr = getattr(args, "auto_correlation", False)
if auto_corr:
corr_threshold = getattr(args, "correlation_threshold", 0.7)
auto_disabled = _run_auto_correlation(df, config, threshold=corr_threshold)
merged = sorted(set(disabled) | set(auto_disabled))
if merged != sorted(disabled):
logger.info(
"[auto-correlation] 비활성화 병합: {} → {}",
disabled or "(없음)", merged,
)
disabled = merged
result = grid_search_scoring_weights(
df,
metric=metric,
train_ratio=train_ratio,
strict_lookahead=True,
disabled_weights=disabled or None,
)
if result is None:
logger.warning(
"가중치 최적화 결과 없음: OOS 구간 부족(30거래일 미만)이거나 OOS 샤프가 게이트(기본 1.0) 미달입니다. "
"로그·stdout 메시지를 확인하세요. 기간을 늘리려면 --validation-years 또는 --start/--end 를 지정할 수 있습니다."
)
return
if result.get("message"):
logger.warning(result["message"])
return
logger.info("=" * 60)
logger.info("[가중치 최적화 결과]")
logger.info("평가 조합 수: {}", result["total_evaluated"])
logger.info("최적 가중치 (대칭): {}", result["best_weight_combo"])
logger.info("최적 임계값: buy={}, sell={}", *result["best_threshold"])
logger.info("학습 구간 {} ({}): {:.2f}", metric, result["train_period"], result["best_score"])
if result.get("disabled_weights"):
logger.info("비활성화 지표(0 고정): {}", result["disabled_weights"])
if result.get("oos_metrics"):
oos = result["oos_metrics"]
logger.info(
"OOS 구간 {} ({}): sharpe={}, return={:.1f}%",
metric, result.get("oos_period", ""),
oos.get("sharpe_ratio"), oos.get("total_return", 0),
)
logger.info(
"OOS 샤프 게이트 통과 (≥ {:.1f}). 채택 스니펫은 위 stdout에 출력되었습니다.",
result["oos_sharpe_gate"],
)
if result.get("all_results"):
logger.info("상위 5개 조합:")
for r in result["all_results"][:5]:
logger.info(" {} | threshold={} | {}={:.2f}", r["weight_combo"], r["threshold"], metric, r["score"])
logger.info("=" * 60)
validate_cmd = (
f"python main.py --mode validate --walk-forward --strategy scoring "
f"--symbol {symbol} --validation-years 5"
)
logger.info("다음 단계 (워크포워드 검증):\n {}", validate_cmd)
if result["oos_passed"]:
logger.info(
"YAML 스니펫을 strategies.yaml에 반영한 뒤 위 명령을 실행하세요.\n"
"워크포워드에서도 안정적이면 --mode paper 로 모의투자를 시작합니다."
)
return
# 기존: 임계값만 탐색
if optimizer == "bayesian":
result = bayesian_optimize(
df,
strategy_name=args.strategy,
metric=metric,
train_ratio=train_ratio,
strict_lookahead=True,
n_calls=getattr(args, "optimize_calls", 30),
)
else:
result = grid_search(
df,
strategy_name=args.strategy,
search_space=None,
metric=metric,
train_ratio=train_ratio,
strict_lookahead=True,
)
if result.get("message"):
logger.warning(result["message"])
return
logger.info("최적 파라미터: {}", result["best_params"])
logger.info("학습 구간 {} ({}): {:.2f}", result["metric"], result["train_period"], result["best_score"])
if result.get("oos_metrics"):
logger.info(
"OOS 구간 {} ({}): {} (오버피팅 확인용)",
result["metric"], result.get("oos_period", ""), result["oos_metrics"].get(result["metric"]),
)
if result.get("all_results"):
logger.info("상위 5개: {}", [r["params"] for r in result["all_results"][:5]])
def run_check_indicator_correlation(args):
"""스코어링 지표 독립성 검증: 지표 간 상관계수 계산, 0.7 이상 쌍에 대해 제거/가중치 축소 권고."""
from datetime import datetime, timedelta
from core.data_collector import DataCollector
from core.indicator_correlation import run_indicator_correlation_check, render_correlation_report
symbol = args.symbol or "005930"
years = getattr(args, "validation_years", 5)
end_date = args.end
start_date = args.start
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=365 * years)).strftime("%Y-%m-%d")
collector = DataCollector()
df = collector.fetch_stock(symbol, start_date, end_date)
if df.empty or len(df) < 120:
logger.error("지표 상관 검증용 데이터 부족: {} ({}~{}, {}일)", symbol, start_date, end_date, len(df))
return
threshold = args.correlation_threshold
result = run_indicator_correlation_check(
df,
threshold=threshold,
symbol=symbol,
config=Config.get(),
output_dir=getattr(args, "output_dir", "reports"),
save_report=True,
start=start_date,
end=end_date,
)
report_text = render_correlation_report(result, symbol=symbol, start=start_date, end=end_date)
print(report_text)
logger.info("지표 독립성 검증 리포트 저장: {}", result.get("report_path"))
disable_weights = result.get("disable_weights", [])
if disable_weights:
logger.info(
"고상관 지표 자동 비활성화 대상: {}. "
"optimize --include-weights --disable-weights {} 로 전달하거나 "
"--auto-correlation 플래그를 사용하세요.",
disable_weights, ",".join(disable_weights),
)
def run_check_ensemble_correlation(args):
"""앙상블 구성 전략(technical, momentum_factor, volatility_condition) 신호 간 상관계수 검증.
BUY=1, SELL=-1, HOLD=0 로 수치화한 일별 시리즈 상관계수. |r| >= 0.6 이면 conservative 모드 또는 전략 재구성 권고."""
from datetime import datetime, timedelta
from pathlib import Path
from core.data_collector import DataCollector
from core.ensemble_correlation import run_ensemble_signal_correlation_check, render_ensemble_correlation_report
symbol = args.symbol or "005930"
years = getattr(args, "validation_years", 5)
end_date = args.end
start_date = args.start
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=365 * years)).strftime("%Y-%m-%d")
collector = DataCollector()
df = collector.fetch_stock(symbol, start_date, end_date)
if df.empty or len(df) < 60:
logger.error("앙상블 신호 상관 검증용 데이터 부족: {} ({}~{}, {}일)", symbol, start_date, end_date, len(df))
return
threshold = getattr(args, "ensemble_correlation_threshold", 0.6)
result = run_ensemble_signal_correlation_check(df, threshold=threshold)
report_text = render_ensemble_correlation_report(result)
out_dir = Path(getattr(args, "output_dir", "reports"))
out_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = out_dir / f"ensemble_correlation_{symbol}_{timestamp}.txt"
report_path.write_text(report_text, encoding="utf-8")
print(report_text)
logger.info("앙상블 신호 독립성 검증 리포트 저장: {}", report_path)
def run_strategy_validation(args):
"""전략 검증 모드 실행. --walk-forward 시 슬라이딩 윈도우 워크포워드 검증."""
from backtest.strategy_validator import StrategyValidator
from core.notifier import Notifier
config = Config.get()
universe = (config.risk_params or {}).get("backtest_universe") or {}
u_mode = (universe.get("mode") or "current").strip().lower()
if u_mode == "current":
logger.warning(
"backtest_universe.mode=current — 벤치마크·유니버스가 현재 종목 기준. "
"생존자 편향 위험. mode: historical 또는 kospi200 권장 (§8.2.1)."
)
else:
logger.info("backtest_universe.mode={} — 생존자 편향 완화 적용", u_mode)
validator = StrategyValidator(config=config, output_dir=args.output_dir)
discord = Notifier(config)
if getattr(args, "walk_forward", False):
result = validator.run_walk_forward(
symbol=args.symbol or "005930",
strategy_name=args.strategy,
start_date=args.start,
end_date=args.end,
benchmark_symbol=args.benchmark_symbol,
validation_years=args.validation_years,
train_days=504,
test_days=252,
step_days=252,
min_sharpe=args.min_sharpe,
max_mdd=args.max_mdd,
)
print(validator._render_walk_forward_report(result))
logger.info("워크포워드 검증 리포트 저장 완료: {}", result["report_path"])
warnings = result.get("warnings", [])
else:
result = validator.run(
symbol=args.symbol or "005930",
strategy_name=args.strategy,
start_date=args.start,
end_date=args.end,
benchmark_symbol=args.benchmark_symbol,
validation_years=args.validation_years,
split_ratio=args.split_ratio,
min_sharpe=args.min_sharpe,
max_mdd=args.max_mdd,
use_benchmark_top50=not getattr(args, "no_benchmark_top50", False),
)
validator.print_report(result)
logger.info("전략 검증 리포트 저장 완료: {}", result["report_path"])
warnings = result.get("validation", {}).get("warnings", [])
if warnings:
warn_text = "\n".join([f"⚠️ {w}" for w in warnings])
discord.send_message(
f"📊 전략 검증 경고 ({args.strategy} | {args.symbol or '005930'})\n{warn_text}"
)
# 앙상블 전략일 때 독립성 검증 자동 실행
if args.strategy == "ensemble":
_run_ensemble_independence_check_in_validate(args, config, discord)
def _run_ensemble_independence_check_in_validate(args, config, notifier):
"""validate 모드에서 앙상블 전략의 신호 독립성을 자동 검증한다."""
from datetime import datetime, timedelta
from core.data_collector import DataCollector
from core.ensemble_correlation import (
run_ensemble_signal_correlation_check,
render_ensemble_correlation_report,
)
symbol = args.symbol or "005930"
end_date = args.end or datetime.now().strftime("%Y-%m-%d")
start_date = args.start or (datetime.now() - timedelta(days=365 * 3)).strftime("%Y-%m-%d")
collector = DataCollector(config)
df = collector.fetch_stock(symbol, start_date, end_date)
if df.empty or len(df) < 60:
logger.warning("앙상블 독립성 검증 스킵: 데이터 부족 ({}일)", len(df) if df is not None else 0)
return
threshold = getattr(args, "ensemble_correlation_threshold", 0.6)
result = run_ensemble_signal_correlation_check(df, threshold=threshold, config=config)
report = render_ensemble_correlation_report(result)
logger.info("\n{}", report)
if result.get("should_force_conservative"):
warn_msg = (
f"앙상블 독립성 검증 경고 ({symbol}): "
f"고상관 쌍 {result['n_high']}개 감지 (|r|>={threshold}). "
"majority_vote 모드에서 다수결 의미 퇴색 위험. "
"conservative 전환 또는 전략 재구성 권장."
)
notifier.send_message(warn_msg)
else:
logger.info("앙상블 독립성 검증 통과: 모든 전략 쌍 |r| < {:.1f}", threshold)
# ──────────────────────────────────────────────────────────────
# 바스켓 리밸런싱 모드
# ──────────────────────────────────────────────────────────────
def _rebalance_live_strategy_id(basket_name: str) -> str:
"""live rebalance의 gate/account/order tag를 묶는 승인 단위."""
from core.basket_rebalancer import rebalance_live_strategy_id
return rebalance_live_strategy_id(basket_name)
def run_deploy_check(args) -> int:
"""바스켓 배포 점검 — 계획·예상비용·회전율·활성화 절차를 한눈에.
수익성 결론(분산 보유 buy&hold)을 실제로 굴리기 전, 운영자가 한 명령으로
바스켓 상태를 확인하고 다음 활성화 절차를 받는다. 가격은 최근 거래일 종가를
주입해 "오늘" 시세가 없어도 동작한다. 반환: 0=점검가능, 1=확인 필요.
"""
import json
from datetime import datetime, timedelta
from core.basket_rebalancer import BasketRebalancer
from core.basket_deploy import summarize_basket_deployment
from core.data_collector import DataCollector
config = Config.get()
basket_name = getattr(args, "basket", None) or "kr_diversified_hold"
as_of = getattr(args, "as_of", None)
baskets_cfg = BasketRebalancer._load_baskets_config()
if basket_name not in baskets_cfg:
logger.error("바스켓 '{}' 없음. 가능: {}", basket_name, ", ".join(baskets_cfg.keys()))
return 1
basket_cfg = baskets_cfg[basket_name]
holdings = basket_cfg.get("holdings", {})
# 최근 거래일 종가 주입(오늘 시세 없어도 동작)
dc = DataCollector()
end = as_of or datetime.now().strftime("%Y-%m-%d")
start = (datetime.strptime(end, "%Y-%m-%d") - timedelta(days=20)).strftime("%Y-%m-%d")
prices = {}
for sym in holdings:
try:
df = dc.fetch_stock(sym, start_date=start, end_date=end)
if df is not None and not df.empty:
prices[sym] = float(df["close"].iloc[-1])
except Exception as exc:
logger.debug("가격 조회 실패 {}: {}", sym, exc)
rb = BasketRebalancer(basket_name=basket_name, config=config)
orders = rb.plan_rebalance(prices=prices) if prices else []
summary = summarize_basket_deployment(
basket_name, basket_cfg, orders, prices,
portfolio_value=float(getattr(args, "initial_capital", None) or 10_000_000),
)
if getattr(args, "json", False):
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0 if summary["ready_to_validate"] else 1
icon = "✅" if summary["ready_to_validate"] else "⚠️"
print(f"\n{'=' * 60}")
print(f" {icon} 바스켓 배포 점검: {summary['display_name']} ({basket_name})")
print(f"{'=' * 60}")
print(f" 활성화: {'예' if summary['enabled'] else '아니오 (enabled=false)'} | "
f"종목 {summary['holdings_count']}개 | 비중합 정상: {summary['weights_sum_ok']}")
print(f" 리밸런싱: {summary['rebalance_trigger']} (드리프트 {summary['drift_threshold_pct']}%p, "
f"회전상한 {summary['max_turnover_ratio_pct']}%)")
c = summary["costs"]
print(f"\n 계획 주문: {c['order_count']}건 | 총 거래액 {c['total_trade_amount']:,.0f}원")
print(f" 예상 비용: 수수료 {c['est_commission']:,.0f} + 세금 {c['est_tax']:,.0f} = "
f"{c['est_total_cost']:,.0f}원 ({c['cost_bps_of_trade']}bp)")
if summary["turnover_pct_of_portfolio"] is not None:
print(f" 포트폴리오 대비 회전율: {summary['turnover_pct_of_portfolio']}%")
for p in summary["plan"][:10]:
print(f" {p['action']:4} {p['symbol']} {p['quantity']}주 @ {p['price']:,.0f} = {p['amount']:,.0f}원")
print("\n 다음 절차:")
for i, s in enumerate(summary["next_steps"], 1):
print(f" {i}. {s}")
print()
return 0 if summary["ready_to_validate"] else 1
def run_rebalance(args):
"""바스켓 포트폴리오 리밸런싱 모드."""
from datetime import datetime
from zoneinfo import ZoneInfo
from core.basket_rebalancer import BasketRebalancer
from core.notifier import Notifier
from core.cycle_observability import (
detect_snapshot_gaps_for_account,
format_gap_alert,
record_cycle_event,
)
config = Config.get()
notifier = Notifier(config)
basket_name = getattr(args, "basket", None)
dry_run = getattr(args, "dry_run", False)
force_rebalance = getattr(args, "force_rebalance", False)
mode = str(config.trading.get("mode", "paper")).lower()
live_rebalance_confirmed = False
if basket_name:
basket_names = [basket_name]
else:
basket_names = BasketRebalancer.get_enabled_baskets()
if not basket_names:
logger.warning("enabled=true인 바스켓이 없습니다. --basket으로 지정하거나 baskets.yaml에서 enabled를 true로 설정하세요.")
return
# 여러 바스켓이 같은 계좌(자본 풀)를 공유하면 각자 목표 비중을 독립 배분해
# 과배분(예: 80%+80%)되고, paper에서는 NAV 시계열(트랙레코드)이 섞인다 — fail-closed.
from core.basket_rebalancer import check_basket_account_isolation
isolation_issues = check_basket_account_isolation(basket_names, config, mode)
if isolation_issues:
for issue in isolation_issues:
logger.error("🚫 바스켓 계좌 격리 실패: {}", issue)
notifier.send_message(
"바스켓 리밸런싱 중단 — 계좌 격리 실패:\n" + "\n".join(isolation_issues)
)
return
if mode == "live" and not dry_run:
_require_live_operator_confirmation(
args,
action_label="실전 바스켓 리밸런싱",
example=(
"python main.py --mode rebalance "
f"--basket {basket_name or '<enabled-basket>'} --confirm-live"
),
)
for name in basket_names:
strategy_name = _rebalance_live_strategy_id(name)
gate_issues = _check_live_readiness_gate(config, strategy_name)
if gate_issues:
logger.error("=" * 50)
logger.error(
"🚫 실전 바스켓 리밸런싱 검증 실패 ({}) — 아래 항목 확인 후 재시도하세요:",
strategy_name,
)
for issue in gate_issues:
logger.error(" - {}", issue)
logger.error("리밸런싱 주문도 live gate를 우회할 수 없습니다.")
logger.error("=" * 50)
sys.exit(1)
live_rebalance_confirmed = True
logger.info("=" * 50)
logger.info("🔄 바스켓 리밸런싱 시작 (바스켓: {}, dry_run: {})", basket_names, dry_run)
logger.info("=" * 50)
# 사이클 하트비트: 결측이 나도 '언제 멈췄는지' 추적 가능하게 시작을 남긴다(6/26 교훈).
if not dry_run:
record_cycle_event(
"CYCLE_START", f"리밸런싱 사이클 시작: {basket_names}", mode=mode,
)
cycle_snapshots_saved = 0
for name in basket_names:
try:
live_strategy_name = _rebalance_live_strategy_id(name)
# 계정·귀속 키는 paper/live 공통(basket_rebalance:<name>) — 트랙레코드를
# 바스켓별로 격리·귀속한다(기본 계정 ''는 전 계정 합산 뷰라 오염 위험).
rebalancer = BasketRebalancer(
basket_name=name,
config=config,
account_key=live_strategy_name,
execution_strategy=live_strategy_name,
)
if mode == "live" and not dry_run:
sync_result = rebalancer.portfolio_mgr.sync_with_broker()
if not sync_result.get("ok"):
msg = sync_result.get("message", "sync failed")
logger.error(
"바스켓 '{}' live 리밸런싱 전 포지션 동기화 실패: {}", name, msg,
)
# sys.exit는 SystemExit(BaseException)라 아래 except Exception에 안 걸려
# CYCLE_END가 안 남는다 — 중도 사망 breadcrumb을 여기서 명시로 남긴다.
record_cycle_event(
"CYCLE_ERROR",
f"바스켓 '{name}' live 동기화 실패로 사이클 중단: {msg}",
severity="error", strategy=live_strategy_name, mode=mode,
)
sys.exit(1)
report = rebalancer.get_status_report()
logger.info("\n{}", report)
# 1일 1매매 패스 원칙 강제: 같은 날 두 번째 실행(중복 스케줄·수동 재실행)이
# 회전 상한(1회 15%)을 사실상 2배로 만드는 것을 차단한다 — 6/10 진입 뭉침
# (하루 4회 실행 → 61% 집중 매입, 타이밍 비용 -1%p)의 재발 방지를 코드로.
# 실측: 7/3·7/7 일일 태스크가 사이클을 2회 연속 실행함. 결측 복구 재시도
# 크론은 목적이 스냅샷이라 이 가드로 오히려 더 안전해진다(매매 없이 복구).
# 판정 실패 시 기존 동작 유지(가드는 방어선이지 게이트가 아님).
# 우회: --force-rebalance (운영자 명시 결정).
already_traded_today = False
if not dry_run and not force_rebalance:
try:
from database.repositories import get_trade_history
today0 = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
already_traded_today = bool(get_trade_history(
mode=mode, start_date=today0, account_key=live_strategy_name,
))
except Exception as guard_exc:
logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc)
executed = False
if already_traded_today:
logger.info(
"바스켓 '{}' 오늘 이미 체결 있음 — 1일 1매매 패스 원칙으로 매매 건너뜀 "
"(스냅샷·리포트는 진행, 우회: --force-rebalance)", name,
)
record_cycle_event(
"REBALANCE_SKIPPED_DAILY_LIMIT",
f"바스켓 '{name}' 당일 재실행 — 매매 생략(1일 1매매 패스)",
strategy=live_strategy_name, mode=mode,
)
else:
should, reason = rebalancer.should_rebalance()
if not should and not dry_run:
logger.info("바스켓 '{}' 리밸런싱 불필요: {}", name, reason)
else:
orders = rebalancer.plan_rebalance()
if not orders:
logger.info("바스켓 '{}' 리밸런싱 주문 없음", name)
else:
result = rebalancer.execute(
orders,
dry_run=dry_run,
live_confirmed=live_rebalance_confirmed,
)
executed = True
summary = (
f"바스켓 '{name}' 리밸런싱 {'(DRY RUN) ' if dry_run else ''}"
f"완료: 실행 {result['executed']}건, 스킵 {result['skipped']}건, "
f"실패 {result['failed']}건"
)
logger.info(summary)
notifier.send_message(summary)
# 예외성 실패(status=error)는 가드 거부(보호 동작, 통상 요약)와
# 달리 원장 불일치 가능성이 있는 인프라 실패 — 즉시 critical로
# 승격한다. 7/7 포지션 유실이 통상 요약에 묻혀 3일 지난 교훈.
error_details = [
d for d in result.get("details", [])
if d.get("status") == "error"
]
if error_details and not dry_run:
err_msg = (
f"🚨 바스켓 '{name}' 주문 실행 예외 {len(error_details)}건 "
f"— 원장 정합성 확인 필요: "
+ "; ".join(
str(d.get("reason", ""))[:80]
for d in error_details[:3]
)
)
record_cycle_event(
"ORDER_ERROR", err_msg, severity="critical",
strategy=live_strategy_name, mode=mode,
)
notifier.send_message(err_msg, critical=True)
# 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다.
# 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert.
if not dry_run:
snapshot_saved = rebalancer.save_daily_nav_snapshot()
# 사이클 관측·경보: 스냅샷 저장/스킵을 기록하고, 최근 영업일 결측을
# 당일 경보로 노출한다(6/26류 조용한 누락 재발 방지). best-effort.
try:
if snapshot_saved:
cycle_snapshots_saved += 1
record_cycle_event(
"SNAPSHOT_SAVED", f"바스켓 '{name}' NAV 스냅샷 저장",
strategy=live_strategy_name, mode=mode,
)
else:
record_cycle_event(
"SNAPSHOT_SKIPPED",
f"바스켓 '{name}' NAV 스냅샷 스킵 — 가격 미확보 등",
severity="warning", strategy=live_strategy_name, mode=mode,
)
# 스냅샷 귀속(_nav_attribution_date)이 KST 기준이므로 결측 판정의
# '오늘'도 KST로 맞춘다 — 호스트 TZ가 KST가 아니면(클라우드/CI) 당일
# 결측 경보가 엉뚱한 날을 보거나 안 울리는 것을 방지(적대적 리뷰 medium).
now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None)
gaps = detect_snapshot_gaps_for_account(
config, live_strategy_name, now,
)
if gaps:
alert = format_gap_alert(name, gaps, today=now)
today_missing = now.date() in gaps
# 오늘 결측은 즉시 조치(critical), 과거 결측 재알림은 경고 수준
# — 복구 불가한 과거 결측을 매일 critical로 울리는 피로 방지.
record_cycle_event(
"SNAPSHOT_GAP", alert,
severity="critical" if today_missing else "warning",
strategy=live_strategy_name, mode=mode,
)
notifier.send_message(alert, critical=today_missing)
except Exception as e:
logger.debug("바스켓 '{}' 스냅샷 관측/경보 생략: {}", name, e)
# 일일 디스코드 리포트: 상시 스케줄러의 장마감 리포트는 일일 CLI 운영
# 에서는 돌지 않아 운영자가 받는 푸시가 0건이었다 — 사이클마다 바스켓
# NAV 요약 카드를 보낸다. 실패해도 사이클에는 영향 없음(채널은 보조).
try:
# 시장가 기준으로 평가해야 한다 — current_prices 없이 호출하면 paper
# 포지션이 avg_price로 평가돼 누적수익 −0.0%·MDD 0.0%가 60일 내내
# 표시된다(자기검토 2라운드 HIGH). 스냅샷 단계가 채운 가격 캐시 재사용.
_snap_cache = getattr(rebalancer, "_market_snapshot", None) or {}
_prices = {s: v["price"] for s, v in _snap_cache.items()}
summary_data = rebalancer.portfolio_mgr.get_portfolio_summary(
current_prices=_prices or None,
)
# 일간 수익률: 직전 스냅샷 대비 (summary에는 누적치만 있다).
# 적립식 계정은 입금을 분모에 더해 중화한다 — 입금일에 +33% 같은
# 가짜 일간 수익 방지(docs/POCKET_TRACK_PLAN.md §4).
daily_ret = 0.0
try:
from database.repositories import (
get_portfolio_snapshots,
get_cash_flow_total_between,
)
from core.portfolio_manager import twr_period_return
snaps = get_portfolio_snapshots(
days=7, account_key=live_strategy_name,
)
if snaps is not None and len(snaps) >= 2:
sdf = snaps.sort_values("date")
prev = float(sdf["total_value"].iloc[-2])
last = float(sdf["total_value"].iloc[-1])
# 유입 경계는 자정 귀속(date)이 아니라 실제 측정 시각
# (created_at) — 직전 스냅샷 '이전'의 같은 날 입금을
# 이중 중화하지 않게(적대적 리뷰 medium).
if "created_at" in sdf.columns and sdf["created_at"].iloc[-2] is not None:
prev_boundary = sdf["created_at"].iloc[-2]
else:
prev_boundary = sdf["date"].iloc[-2]
if prev > 0:
flow = 0.0
try:
flow = get_cash_flow_total_between(
live_strategy_name, prev_boundary, datetime.now(),
)
except Exception:
pass
daily_ret = twr_period_return(prev, last, flow) * 100
except Exception:
pass
report_card = {
"total_value": summary_data.get("total_value", 0),
"cash": summary_data.get("cash", 0),
"daily_return": daily_ret,
"cumulative_return": summary_data.get("total_return", 0),
"mdd": summary_data.get("mdd", 0),
"position_count": summary_data.get("position_count", 0),
"total_trades": (result.get("executed", 0) if executed else 0),
"strategy_diagnosis": f"바스켓 {name} · paper 트랙레코드 일일 사이클",
}
# 적립식 계정이면 누적 원금(초기+입금)을 함께 표기 — 평가액과 원금을
# 분리해야 '내가 넣은 돈 대비 얼마'가 한눈에 보인다.
if float(summary_data.get("deposits_total", 0) or 0) > 0:
report_card["principal"] = summary_data.get("principal", 0)
# 리포트 v2 부가 필드(시장/설계/일정 대비) — 실패해도 기본 카드는 발송.
try:
from core.basket_evaluation import (
build_daily_report_extras,
collect_basket_paper_evaluation,
)
deployment = rebalancer.diagnose_deployment(_prices or None)
eval_result, _ = collect_basket_paper_evaluation(basket_name=name)
report_card.update(
build_daily_report_extras(
eval_result=eval_result, deployment=deployment,
# 카드의 '누적 수익률'과 같은 소스(오늘 시가 기준)로 격차 계산
# — 스냅샷 결측일에 📊 수치가 어긋나지 않게.
nav_return_pct=summary_data.get("total_return"),
)
)
except Exception as e:
logger.debug("바스켓 '{}' 리포트 v2 부가필드 생략: {}", name, e)
notifier.send_daily_report(report_card)
except Exception as e:
logger.debug("바스켓 '{}' 일일 리포트 발송 실패(무시): {}", name, e)
except Exception as e:
logger.error("바스켓 '{}' 리밸런싱 실패: {}", name, e)
notifier.send_message(f"바스켓 '{name}' 리밸런싱 오류: {e}")
if not dry_run:
# 사이클이 이 바스켓에서 죽었음을 남긴다 — 결측 원인 추적의 핵심.
record_cycle_event(
"CYCLE_ERROR", f"바스켓 '{name}' 리밸런싱 실패: {e}",
severity="error", strategy=_rebalance_live_strategy_id(name), mode=mode,
)
if not dry_run:
# 사이클 정상 종료 기록: START는 있는데 END가 없으면 중도 사망으로 판별된다.
record_cycle_event(
"CYCLE_END",
f"리밸런싱 사이클 종료: 스냅샷 {cycle_snapshots_saved}/{len(basket_names)} 저장",
mode=mode,
)
# DB 일일 백업: 트랙레코드(거래·포지션·NAV 시계열)가 단일 SQLite 파일이라
# 손상 시 60영업일 증거가 통째로 소실된다. 기존엔 상시 스케줄러 장마감에서만
# 백업했는데, 일일 rebalance CLI 운영에서는 그 경로가 돌지 않으므로 여기서도
# 호출한다. database.backup_path 미설정이면 no-op, 날짜별 파일이라 멱등.
if not dry_run:
try:
from database.backup import run_daily_backup
run_daily_backup(config)
except Exception as e:
logger.warning("리밸런싱 후 DB 백업 실패: {}", e)
def run_paper_trading(args):
"""페이퍼 트레이딩 모드 실행"""
from datetime import datetime
from core.data_collector import DataCollector
from core.order_executor import OrderExecutor
from core.portfolio_manager import PortfolioManager
from core.notifier import Notifier
from database.repositories import get_position, get_all_positions
from strategies import is_strategy_allowed
config = Config.get()
config_mode = str(config.trading.get("mode", "paper")).lower()
if config_mode == "live":
logger.warning(